diff --git a/e2e-tests/MaestroTestApp/README.md b/e2e-tests/MaestroTestApp/README.md new file mode 100644 index 00000000..1985393c --- /dev/null +++ b/e2e-tests/MaestroTestApp/README.md @@ -0,0 +1,55 @@ +# Maestro E2E Test App + +A minimal Cordova app used by Maestro end-to-end tests to verify RevenueCat SDK integration. + +## Prerequisites + +- Node.js & npm +- Xcode (iOS) / Android Studio (Android) +- [Maestro](https://maestro.mobile.dev/) CLI +- Gradle (for Android builds) + +## Setup + +```bash +npm install +npx cordova prepare +``` + +## Running Locally + +```bash +# iOS +npx cordova run ios --emulator + +# Android +npx cordova run android --emulator +``` + +## API Key + +The app initialises RevenueCat with the placeholder `MAESTRO_TESTS_REVENUECAT_API_KEY`. +In CI, the Fastlane lane replaces this placeholder with the real key from the +`RC_E2E_TEST_API_KEY_PRODUCTION_TEST_STORE` environment variable (provided by the +CircleCI `e2e-tests` context) before building. + +To run locally, either: +- Replace the placeholder in `www/js/app.js` with a valid API key (do **not** commit it), or +- Export the env var and run the same `sed` command the Fastlane lane uses. + +## RevenueCat Project + +The test uses a RevenueCat project configured with: +- A **V2 Paywall** (the test asserts "Paywall V2" is visible) +- A `pro` entitlement (the test checks entitlement status after purchase) +- The **Test Store** environment for purchase confirmation + +## Dependencies + +`cordova-plugin-purchases` is referenced as a local `file:` dependency so the E2E +tests always exercise the code on the current branch, not a published npm version. + +## Note on Cordova + +Cordova does not have a native RevenueCat UI plugin, so the paywall is implemented as +a custom HTML overlay that manually calls `Purchases.purchasePackage()`. diff --git a/e2e-tests/MaestroTestApp/config.xml b/e2e-tests/MaestroTestApp/config.xml new file mode 100644 index 00000000..81b8e3b3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/config.xml @@ -0,0 +1,9 @@ + + + MaestroTestApp + Maestro E2E test app for cordova-plugin-purchases + + + + + diff --git a/e2e-tests/MaestroTestApp/package-lock.json b/e2e-tests/MaestroTestApp/package-lock.json new file mode 100644 index 00000000..640e36f1 --- /dev/null +++ b/e2e-tests/MaestroTestApp/package-lock.json @@ -0,0 +1,1340 @@ +{ + "name": "MaestroTestApp", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "cordova-android": "^13.0.0", + "cordova-ios": "^8.0.0", + "cordova-plugin-add-swift-support": "^2.0.2", + "cordova-plugin-purchases": "file:../../" + } + }, + "../..": { + "version": "7.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "cordova-annotated-plugin-android": "^1.0.4" + }, + "devDependencies": { + "@types/jest": "^27.1.5", + "@types/node": "^12.6.8", + "@types/prettier": "2.6.0", + "jest": "^27.0.0", + "prettier": "1.18.2", + "ts-jest": "^27.0.0-next.12", + "tslint": "^5.20.1", + "tslint-config-prettier": "^1.18.0", + "typedoc": "^0.21.0", + "typescript": "^4.2.0" + } + }, + "../../../../../../../../private/tmp/cordova-plugin-purchases-clean": { + "name": "cordova-plugin-purchases", + "version": "7.3.1", + "extraneous": true, + "license": "MIT", + "dependencies": { + "cordova-annotated-plugin-android": "^1.0.4" + }, + "devDependencies": { + "@types/jest": "^27.1.5", + "@types/node": "^12.6.8", + "@types/prettier": "2.6.0", + "jest": "^27.0.0", + "prettier": "1.18.2", + "ts-jest": "^27.0.0-next.12", + "tslint": "^5.20.1", + "tslint-config-prettier": "^1.18.0", + "typedoc": "^0.21.0", + "typescript": "^4.2.0" + } + }, + "../../../../../../../../tmp/cordova-plugin-purchases-clean": { + "name": "cordova-plugin-purchases", + "version": "7.3.1", + "extraneous": true, + "license": "MIT", + "dependencies": { + "cordova-annotated-plugin-android": "^1.0.4" + }, + "devDependencies": { + "@types/jest": "^27.1.5", + "@types/node": "^12.6.8", + "@types/prettier": "2.6.0", + "jest": "^27.0.0", + "prettier": "1.18.2", + "ts-jest": "^27.0.0-next.12", + "tslint": "^5.20.1", + "tslint-config-prettier": "^1.18.0", + "typedoc": "^0.21.0", + "typescript": "^4.2.0" + } + }, + "node_modules/@netflix/nerror": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@netflix/nerror/-/nerror-1.1.3.tgz", + "integrity": "sha512-b+MGNyP9/LXkapreJzNUzcvuzZslj/RGgdVVJ16P2wSlYatfLycPObImqVJSmNAdyeShvNeM/pl3sVZsObFueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "extsprintf": "^1.4.0", + "lodash": "^4.17.15" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/android-versions": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/android-versions/-/android-versions-2.1.1.tgz", + "integrity": "sha512-dYeO3KHDO81WvEwZFK+OF0dJl/ESvxV3QZE/qo/AAnG/uijco6DOXJJla3CdoC8Eg53YBlbRIyobRGYqIAGw8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.2" + } + }, + "node_modules/ansi": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/ansi/-/ansi-0.3.1.tgz", + "integrity": "sha512-iFY7JCgHbepc0b82yLaw4IMortylNb6wG4kL+4R0C3iv6i+RHGHux/yUX5BTiRvSX/shMnngjR1YyNMnXEFh5A==", + "dev": true, + "license": "MIT" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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" + } + ], + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bplist-creator": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", + "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "stream-buffers": "2.2.x" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cordova-android": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/cordova-android/-/cordova-android-13.0.0.tgz", + "integrity": "sha512-uQG+cSyrB1NMi2aIzihldIupHB9WGpZVvrMMMAAtnyc6tDlEk7gweSSaFsEONyGAnteRYpIvrzg/YwDW08PcUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "android-versions": "^2.0.0", + "cordova-common": "^5.0.0", + "dedent": "^1.5.3", + "execa": "^5.1.1", + "fast-glob": "^3.3.2", + "fs-extra": "^11.2.0", + "is-path-inside": "^3.0.3", + "nopt": "^7.2.1", + "properties-parser": "^0.6.0", + "semver": "^7.6.2", + "string-argv": "^0.3.1", + "untildify": "^4.0.0", + "which": "^4.0.0" + }, + "engines": { + "node": ">=16.13.0" + } + }, + "node_modules/cordova-android/node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/cordova-android/node_modules/cordova-common": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/cordova-common/-/cordova-common-5.0.1.tgz", + "integrity": "sha512-OA2NQ6wvhNz4GytPYwTdlA9xfG7Yf7ufkj4u97m3rUfoL/AECwwj0GVT2CYpk/0Fk6HyuHA3QYCxfDPYsKzI1A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@netflix/nerror": "^1.1.3", + "ansi": "^0.3.1", + "bplist-parser": "^0.3.2", + "cross-spawn": "^7.0.6", + "elementtree": "^0.1.7", + "endent": "^2.1.0", + "fast-glob": "^3.3.3", + "lodash.zip": "^4.2.0", + "plist": "^3.1.0", + "q": "^1.5.1", + "read-chunk": "^3.2.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/cordova-android/node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/cordova-android/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cordova-android/node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/cordova-android/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/cordova-common": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cordova-common/-/cordova-common-6.0.0.tgz", + "integrity": "sha512-16WPC1DuxVdshV3RoQUXqhcJVdhxWGwiFysA4TkYuboqoev6mgt0JuIJFxmQbzR/DuyuONaVe0L0O0Hf1C08Mg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@netflix/nerror": "^1.1.3", + "ansi": "^0.3.1", + "bplist-parser": "^0.3.2", + "elementtree": "^0.1.7", + "endent": "^2.1.0", + "fast-glob": "^3.3.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=20.9.0" + } + }, + "node_modules/cordova-ios": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/cordova-ios/-/cordova-ios-8.0.0.tgz", + "integrity": "sha512-QsynOV8rnRIhDC3qwM1KdRr1gy5RaqzVCzdOaB+DSBPeR5wVEUGpyyJO0FzlCACzY5X25iDc5O3kbn/F06dJ5Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bplist-parser": "^0.3.2", + "cordova-common": "^6.0.0", + "elementtree": "^0.1.7", + "execa": "^5.1.1", + "nopt": "^9.0.0", + "plist": "^3.1.0", + "semver": "^7.7.3", + "simctl": "^3.0.0", + "which": "^6.0.0", + "xcode": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/cordova-plugin-add-swift-support": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/cordova-plugin-add-swift-support/-/cordova-plugin-add-swift-support-2.0.2.tgz", + "integrity": "sha512-K03WDnsD3GT+n7Od3BnS17D8rYnAFZbZjjQJa2r7qW8QLq8+h7hGbFaiF+w5+nUtyAqUNq+HT/d/MdqBGLNzxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "glob": "^7.1.3", + "semver": "^6.0.0", + "xcode": "^2.0.0" + } + }, + "node_modules/cordova-plugin-add-swift-support/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/cordova-plugin-add-swift-support/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/cordova-plugin-add-swift-support/node_modules/xcode": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-2.1.0.tgz", + "integrity": "sha512-uCrmPITrqTEzhn0TtT57fJaNaw8YJs1aCzs+P/QqxsDbvPZSv7XMPPwXrKvHtD6pLjBM/NaVwraWJm8q83Y4iQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "simple-plist": "^1.0.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/cordova-plugin-purchases": { + "resolved": "../..", + "link": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dedent": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", + "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", + "dev": true, + "license": "MIT" + }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/endent": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/endent/-/endent-2.1.0.tgz", + "integrity": "sha512-r8VyPX7XL8U01Xgnb1CjZ3XV+z90cXIJ9JPE/R9SEC9vpw2P6CfsRPJmp20DppC5N7ZAMCmjYkJIa744Iyg96w==", + "dev": true, + "license": "MIT", + "dependencies": { + "dedent": "^0.7.0", + "fast-json-parse": "^1.0.3", + "objectorarray": "^1.0.5" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-parse": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fast-json-parse/-/fast-json-parse-1.0.3.tgz", + "integrity": "sha512-FRWsaZRWEJ1ESVNbDWmsAlqDk96gPQezzLghafp5J4GUKjbCz3OkAHuZs5TuPEtkbVQERysLp9xv6c24fBm8Aw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "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, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.zip": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz", + "integrity": "sha512-C7IOaBBK/0gMORRBd8OETNx3kmOkgIWIPvyDpZSCTwUrpYmgZwJkjZeOD8ww4xbOUOs4/attY+pciKvadNfFbg==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/objectorarray": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/objectorarray/-/objectorarray-1.0.5.tgz", + "integrity": "sha512-eJJDYkhJFFbBBAxeh8xW+weHlkI28n2ZdQV/J/DNfWfSKlGEf2xcfAbZTv3riEXHAhL9SVOTs2pRmXiSTf78xg==", + "dev": true, + "license": "ISC" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/properties-parser": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/properties-parser/-/properties-parser-0.6.0.tgz", + "integrity": "sha512-qvr2cSmoA0dln0MARAKwBzPkkXn7FqwX+RVVNpMdMJc7rt9mqO2cXwluxtux9fHrLhjnPFaQkS8BM0kFrTCnSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.3.1" + } + }, + "node_modules/q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "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" + } + ], + "license": "MIT" + }, + "node_modules/read-chunk": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/read-chunk/-/read-chunk-3.2.0.tgz", + "integrity": "sha512-CEjy9LCzhmD7nUpJ1oVOE6s/hBkejlcJEgLQHVnQznOSilOPb+kpKktlLfFDK3/WP43+F80xkUTM2VOkYoSYvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^4.0.1", + "with-open-file": "^0.1.6" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "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" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "dev": true, + "license": "ISC" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simctl": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/simctl/-/simctl-3.0.0.tgz", + "integrity": "sha512-AoScwFvWCLU7lx9AlqmooHlxQDQDjGdSGAdyLySDeR2IP6ji5L4Z6DdHxmBLAQM61ftaHylXfnF1KWCR56P58w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.17.0" + } + }, + "node_modules/simple-plist": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", + "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bplist-creator": "0.1.0", + "bplist-parser": "0.3.1", + "plist": "^3.0.5" + } + }, + "node_modules/simple-plist/node_modules/bplist-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", + "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/with-open-file": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/with-open-file/-/with-open-file-0.1.7.tgz", + "integrity": "sha512-ecJS2/oHtESJ1t3ZfMI3B7KIDKyfN0O16miWxdn30zdh66Yd3LsRFebXZXq6GU4xfxLf6nVxp9kIqElb5fqczA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0", + "p-try": "^2.1.0", + "pify": "^4.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xcode": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", + "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "simple-plist": "^1.1.0", + "uuid": "^7.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + } + } +} diff --git a/e2e-tests/MaestroTestApp/package.json b/e2e-tests/MaestroTestApp/package.json new file mode 100644 index 00000000..72d0dd47 --- /dev/null +++ b/e2e-tests/MaestroTestApp/package.json @@ -0,0 +1,17 @@ +{ + "devDependencies": { + "cordova-android": "^13.0.0", + "cordova-ios": "^8.0.0", + "cordova-plugin-add-swift-support": "^2.0.2", + "cordova-plugin-purchases": "file:../../" + }, + "cordova": { + "plugins": { + "cordova-plugin-purchases": {} + }, + "platforms": [ + "android", + "ios" + ] + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/.gitignore b/e2e-tests/MaestroTestApp/platforms/ios/.gitignore new file mode 100644 index 00000000..cc76483f --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/.gitignore @@ -0,0 +1,5 @@ +*.mode1v3 +*.perspectivev3 +*.pbxuser +.DS_Store +build/ diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App.xcodeproj/project.pbxproj b/e2e-tests/MaestroTestApp/platforms/ios/App.xcodeproj/project.pbxproj new file mode 100644 index 00000000..80104154 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App.xcodeproj/project.pbxproj @@ -0,0 +1,625 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + 0CB5E348C05247CAEE939B53 /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 09640854FF895B7C113C3716 /* Pods_App.framework */; }; + 2A1DDBFE06EC450FBC8DF701 /* PurchasesPlugin+PurchasesDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FCCD916FF6C45A4BC25F173 /* PurchasesPlugin+PurchasesDelegate.swift */; }; + 4B08DAD49AB14F109E42DB93 /* PurchasesPlugin+Purchasing.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8BE520BB23439DAF66EDF5 /* PurchasesPlugin+Purchasing.swift */; }; + 79D76B9807BD485A863C57DD /* PurchasesPlugin+Users.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3253E690675E4E6D96CA62F7 /* PurchasesPlugin+Users.swift */; }; + 8E9DA0A02AC64B639392D642 /* PurchasesPlugin+VirtualCurrencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB9A397BB3C45F69F26C79A /* PurchasesPlugin+VirtualCurrencies.swift */; }; + 907F98562C06B87200D2D242 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 907F98552C06B87200D2D242 /* PrivacyInfo.xcprivacy */; }; + 907F98662C06BC1B00D2D242 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 907F98652C06BC1B00D2D242 /* config.xml */; }; + 907F986A2C06BCD300D2D242 /* www in Resources */ = {isa = PBXBuildFile; fileRef = 907F98692C06BCD300D2D242 /* www */; }; + 90A914592CA3D370003DB979 /* Cordova in Frameworks */ = {isa = PBXBuildFile; productRef = 90A914582CA3D370003DB979 /* Cordova */; }; + 90BD9B7A2C06907D000DEBAB /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 90BD9B792C06907D000DEBAB /* Base */; }; + 90BD9B7C2C06907E000DEBAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 90BD9B7B2C06907E000DEBAB /* Assets.xcassets */; }; + 90BD9B7F2C06907E000DEBAB /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 90BD9B7E2C06907E000DEBAB /* Base */; }; + 90CBB5282C06968500B805A2 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90CBB5272C06968500B805A2 /* AppDelegate.swift */; }; + 90CBB52A2C06968500B805A2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90CBB5292C06968500B805A2 /* SceneDelegate.swift */; }; + 90CBB52C2C06968500B805A2 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90CBB52B2C06968500B805A2 /* ViewController.swift */; }; + 90D82ABD2CF19AEA001383CF /* CordovaPlugins in Frameworks */ = {isa = PBXBuildFile; productRef = 90D82ABC2CF19AEA001383CF /* CordovaPlugins */; }; + E4267458D4B0452CB160BBD5 /* PurchasesPlugin+APIHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF74F250D2A54F9E8D4A661B /* PurchasesPlugin+APIHelpers.swift */; }; + EF8F04B76828495FB4511E60 /* PurchasesPlugin+Attribution.swift in Sources */ = {isa = PBXBuildFile; fileRef = A270727C1B3E49B6BAFCD943 /* PurchasesPlugin+Attribution.swift */; }; + F67BFA19F7EF4CB3A8C25C72 /* PurchasesPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FDDB3AE37604B77A3405EA3 /* PurchasesPlugin.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 902AE2152C6C059A0041150F /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 09640854FF895B7C113C3716 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 2B33D34D851713793D2AB14A /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = ""; }; + 2FCCD916FF6C45A4BC25F173 /* PurchasesPlugin+PurchasesDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "PurchasesPlugin+PurchasesDelegate.swift"; path = "cordova-plugin-purchases/PurchasesPlugin+PurchasesDelegate.swift"; sourceTree = ""; }; + 3253E690675E4E6D96CA62F7 /* PurchasesPlugin+Users.swift */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "PurchasesPlugin+Users.swift"; path = "cordova-plugin-purchases/PurchasesPlugin+Users.swift"; sourceTree = ""; }; + 4B7CBDB8FAECC7AB4A9E66D7 /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; + 6FDDB3AE37604B77A3405EA3 /* PurchasesPlugin.swift */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = PurchasesPlugin.swift; path = "cordova-plugin-purchases/PurchasesPlugin.swift"; sourceTree = ""; }; + 8BB9A397BB3C45F69F26C79A /* PurchasesPlugin+VirtualCurrencies.swift */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "PurchasesPlugin+VirtualCurrencies.swift"; path = "cordova-plugin-purchases/PurchasesPlugin+VirtualCurrencies.swift"; sourceTree = ""; }; + 9040B1872C6DD3EB00662C5D /* Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "Bridging-Header.h"; sourceTree = ""; }; + 9040B1882C6DD41B00662C5D /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; name = www; path = ../../www; sourceTree = ""; }; + 907F98552C06B87200D2D242 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 907F98622C06B97000D2D242 /* Entitlements-Debug.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Entitlements-Debug.plist"; sourceTree = ""; }; + 907F98632C06B9C800D2D242 /* Entitlements-Release.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Entitlements-Release.plist"; sourceTree = ""; }; + 907F98652C06BC1B00D2D242 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; + 907F98692C06BCD300D2D242 /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; path = www; sourceTree = SOURCE_ROOT; }; + 9080B40F2C6DD7EC00078F33 /* config.xml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; name = config.xml; path = ../../config.xml; sourceTree = ""; }; + 90BD9B6C2C06907D000DEBAB /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 90BD9B792C06907D000DEBAB /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 90BD9B7B2C06907E000DEBAB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 90BD9B7E2C06907E000DEBAB /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/CDVLaunchScreen.storyboard; sourceTree = ""; }; + 90BD9B802C06907E000DEBAB /* App-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "App-Info.plist"; sourceTree = ""; }; + 90CBB5272C06968500B805A2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 90CBB5292C06968500B805A2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 90CBB52B2C06968500B805A2 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; + 90EB303B2C6DD83300CEEB2F /* build.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = build.xcconfig; path = cordova/build.xcconfig; sourceTree = SOURCE_ROOT; }; + 90EB303C2C6DD83300CEEB2F /* build-extras.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = "build-extras.xcconfig"; path = "cordova/build-extras.xcconfig"; sourceTree = SOURCE_ROOT; }; + 90EB303F2C6DD87600CEEB2F /* build-debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = "build-debug.xcconfig"; path = "cordova/build-debug.xcconfig"; sourceTree = SOURCE_ROOT; }; + 90EB30402C6DD87600CEEB2F /* build-release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = "build-release.xcconfig"; path = "cordova/build-release.xcconfig"; sourceTree = SOURCE_ROOT; }; + 90F7E7002C6EB78900AD84C2 /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; + 90F7E7012C6EB78900AD84C2 /* MainViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MainViewController.h; sourceTree = ""; }; + A270727C1B3E49B6BAFCD943 /* PurchasesPlugin+Attribution.swift */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "PurchasesPlugin+Attribution.swift"; path = "cordova-plugin-purchases/PurchasesPlugin+Attribution.swift"; sourceTree = ""; }; + AF74F250D2A54F9E8D4A661B /* PurchasesPlugin+APIHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "PurchasesPlugin+APIHelpers.swift"; path = "cordova-plugin-purchases/PurchasesPlugin+APIHelpers.swift"; sourceTree = ""; }; + DD8BE520BB23439DAF66EDF5 /* PurchasesPlugin+Purchasing.swift */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "PurchasesPlugin+Purchasing.swift"; path = "cordova-plugin-purchases/PurchasesPlugin+Purchasing.swift"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 90BD9B692C06907D000DEBAB /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 90A914592CA3D370003DB979 /* Cordova in Frameworks */, + 90D82ABD2CF19AEA001383CF /* CordovaPlugins in Frameworks */, + 0CB5E348C05247CAEE939B53 /* Pods_App.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 905D2F6D2C6DDEE100117937 /* Resources */ = { + isa = PBXGroup; + children = ( + ); + path = Resources; + sourceTree = ""; + }; + 907F98542C06B85800D2D242 /* Plugins */ = { + isa = PBXGroup; + children = ( + 6FDDB3AE37604B77A3405EA3 /* PurchasesPlugin.swift */, + AF74F250D2A54F9E8D4A661B /* PurchasesPlugin+APIHelpers.swift */, + A270727C1B3E49B6BAFCD943 /* PurchasesPlugin+Attribution.swift */, + 2FCCD916FF6C45A4BC25F173 /* PurchasesPlugin+PurchasesDelegate.swift */, + DD8BE520BB23439DAF66EDF5 /* PurchasesPlugin+Purchasing.swift */, + 3253E690675E4E6D96CA62F7 /* PurchasesPlugin+Users.swift */, + 8BB9A397BB3C45F69F26C79A /* PurchasesPlugin+VirtualCurrencies.swift */, + ); + path = Plugins; + sourceTree = ""; + }; + 907F98602C06B8F000D2D242 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 09640854FF895B7C113C3716 /* Pods_App.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9080B40D2C6DD79000078F33 /* config */ = { + isa = PBXGroup; + children = ( + 90EB303F2C6DD87600CEEB2F /* build-debug.xcconfig */, + 90EB30402C6DD87600CEEB2F /* build-release.xcconfig */, + 90EB303C2C6DD83300CEEB2F /* build-extras.xcconfig */, + 90EB303B2C6DD83300CEEB2F /* build.xcconfig */, + ); + name = config; + sourceTree = ""; + }; + 9080B40E2C6DD7A100078F33 /* staging */ = { + isa = PBXGroup; + children = ( + 907F98652C06BC1B00D2D242 /* config.xml */, + 907F98692C06BCD300D2D242 /* www */, + ); + name = staging; + sourceTree = ""; + }; + 90BD9B632C06907D000DEBAB = { + isa = PBXGroup; + children = ( + 9080B40F2C6DD7EC00078F33 /* config.xml */, + 9040B1882C6DD41B00662C5D /* www */, + 90BD9B6E2C06907D000DEBAB /* App */, + 90BD9B6D2C06907D000DEBAB /* Products */, + 907F98602C06B8F000D2D242 /* Frameworks */, + A6912107363F0EFADF230000 /* Pods */, + ); + name = CustomTemplate; + sourceTree = ""; + }; + 90BD9B6D2C06907D000DEBAB /* Products */ = { + isa = PBXGroup; + children = ( + 90BD9B6C2C06907D000DEBAB /* App.app */, + ); + name = Products; + sourceTree = ""; + }; + 90BD9B6E2C06907D000DEBAB /* App */ = { + isa = PBXGroup; + children = ( + 90CBB5272C06968500B805A2 /* AppDelegate.swift */, + 90CBB5292C06968500B805A2 /* SceneDelegate.swift */, + 90CBB52B2C06968500B805A2 /* ViewController.swift */, + 90BD9B7D2C06907E000DEBAB /* CDVLaunchScreen.storyboard */, + 90BD9B782C06907D000DEBAB /* Main.storyboard */, + 90BD9B7B2C06907E000DEBAB /* Assets.xcassets */, + 90BD9B802C06907E000DEBAB /* App-Info.plist */, + 907F98552C06B87200D2D242 /* PrivacyInfo.xcprivacy */, + 907F98622C06B97000D2D242 /* Entitlements-Debug.plist */, + 907F98632C06B9C800D2D242 /* Entitlements-Release.plist */, + 90F7E7002C6EB78900AD84C2 /* AppDelegate.h */, + 9040B1872C6DD3EB00662C5D /* Bridging-Header.h */, + 90F7E7012C6EB78900AD84C2 /* MainViewController.h */, + 907F98542C06B85800D2D242 /* Plugins */, + 905D2F6D2C6DDEE100117937 /* Resources */, + 9080B40D2C6DD79000078F33 /* config */, + 9080B40E2C6DD7A100078F33 /* staging */, + ); + path = App; + sourceTree = ""; + }; + A6912107363F0EFADF230000 /* Pods */ = { + isa = PBXGroup; + children = ( + 4B7CBDB8FAECC7AB4A9E66D7 /* Pods-App.debug.xcconfig */, + 2B33D34D851713793D2AB14A /* Pods-App.release.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 90BD9B6B2C06907D000DEBAB /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 90BD9B852C06907E000DEBAB /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( + 0A45A8977B29DD960F0F9C4B /* [CP] Check Pods Manifest.lock */, + 90BD9B682C06907D000DEBAB /* Sources */, + 90BD9B692C06907D000DEBAB /* Frameworks */, + 90BD9B6A2C06907D000DEBAB /* Resources */, + 902AE2152C6C059A0041150F /* Embed Frameworks */, + BF5328AE5E49ECFA5B02E68D /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = App; + packageProductDependencies = ( + 90A914582CA3D370003DB979 /* Cordova */, + 90D82ABC2CF19AEA001383CF /* CordovaPlugins */, + ); + productName = App; + productReference = 90BD9B6C2C06907D000DEBAB /* App.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 90BD9B642C06907D000DEBAB /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 1540; + LastUpgradeCheck = 2600; + TargetAttributes = { + 90BD9B6B2C06907D000DEBAB = { + CreatedOnToolsVersion = 15.4; + }; + }; + }; + buildConfigurationList = 90BD9B672C06907D000DEBAB /* Build configuration list for PBXProject "App" */; + compatibilityVersion = "Xcode 15.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 90BD9B632C06907D000DEBAB; + packageReferences = ( + 90A914572CA3D370003DB979 /* XCLocalSwiftPackageReference "cordova-ios" */, + 90D82ABB2CF19AEA001383CF /* XCLocalSwiftPackageReference "cordova-ios-plugins" */, + ); + productRefGroup = 90BD9B6D2C06907D000DEBAB /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 90BD9B6B2C06907D000DEBAB /* App */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 90BD9B6A2C06907D000DEBAB /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 907F986A2C06BCD300D2D242 /* www in Resources */, + 90BD9B7C2C06907E000DEBAB /* Assets.xcassets in Resources */, + 90BD9B7F2C06907E000DEBAB /* Base in Resources */, + 907F98562C06B87200D2D242 /* PrivacyInfo.xcprivacy in Resources */, + 90BD9B7A2C06907D000DEBAB /* Base in Resources */, + 907F98662C06BC1B00D2D242 /* config.xml in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 0A45A8977B29DD960F0F9C4B /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-App-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + BF5328AE5E49ECFA5B02E68D /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 90BD9B682C06907D000DEBAB /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 90CBB52C2C06968500B805A2 /* ViewController.swift in Sources */, + 90CBB5282C06968500B805A2 /* AppDelegate.swift in Sources */, + 90CBB52A2C06968500B805A2 /* SceneDelegate.swift in Sources */, + F67BFA19F7EF4CB3A8C25C72 /* PurchasesPlugin.swift in Sources */, + E4267458D4B0452CB160BBD5 /* PurchasesPlugin+APIHelpers.swift in Sources */, + EF8F04B76828495FB4511E60 /* PurchasesPlugin+Attribution.swift in Sources */, + 2A1DDBFE06EC450FBC8DF701 /* PurchasesPlugin+PurchasesDelegate.swift in Sources */, + 4B08DAD49AB14F109E42DB93 /* PurchasesPlugin+Purchasing.swift in Sources */, + 79D76B9807BD485A863C57DD /* PurchasesPlugin+Users.swift in Sources */, + 8E9DA0A02AC64B639392D642 /* PurchasesPlugin+VirtualCurrencies.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 90BD9B782C06907D000DEBAB /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 90BD9B792C06907D000DEBAB /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 90BD9B7D2C06907E000DEBAB /* CDVLaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 90BD9B7E2C06907E000DEBAB /* Base */, + ); + name = CDVLaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 90BD9B832C06907E000DEBAB /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + 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++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + "DEPLOYMENT_LOCATION[sdk=iphonesimulator*]" = YES; + "DEPLOYMENT_LOCATION[sdk=macosx*]" = YES; + "DEPLOYMENT_LOCATION[sdk=xrsimulator*]" = YES; + DSTROOT = "$(SRCROOT)/build"; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + "INSTALL_PATH[sdk=iphonesimulator*]" = "$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)"; + "INSTALL_PATH[sdk=macosx*]" = "$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)"; + "INSTALL_PATH[sdk=xrsimulator*]" = "$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_WORKSPACE = NO; + }; + name = Debug; + }; + 90BD9B842C06907E000DEBAB /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + 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++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + "DEPLOYMENT_LOCATION[sdk=iphonesimulator*]" = YES; + "DEPLOYMENT_LOCATION[sdk=macosx*]" = YES; + "DEPLOYMENT_LOCATION[sdk=xrsimulator*]" = YES; + DSTROOT = "$(SRCROOT)/build"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + "INSTALL_PATH[sdk=iphonesimulator*]" = "$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)"; + "INSTALL_PATH[sdk=macosx*]" = "$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)"; + "INSTALL_PATH[sdk=xrsimulator*]" = "$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_COMPILATION_MODE = wholemodule; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 90BD9B862C06907E000DEBAB /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 90EB303F2C6DD87600CEEB2F /* build-debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = "$(TARGET_NAME)/Entitlements-$(CONFIGURATION).plist"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1.0.0; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "$(TARGET_NAME)/$(TARGET_NAME)-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "$(PRODUCT_NAME)"; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = CDVLaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.revenuecat.maestro.e2e"; + PRODUCT_NAME = "MaestroTestApp"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "$(TARGET_NAME)/Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,6,7"; + }; + name = Debug; + }; + 90BD9B872C06907E000DEBAB /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 90EB30402C6DD87600CEEB2F /* build-release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = "$(TARGET_NAME)/Entitlements-$(CONFIGURATION).plist"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1.0.0; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "$(TARGET_NAME)/$(TARGET_NAME)-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "$(PRODUCT_NAME)"; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = CDVLaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.revenuecat.maestro.e2e"; + PRODUCT_NAME = "MaestroTestApp"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "$(TARGET_NAME)/Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,6,7"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 90BD9B672C06907D000DEBAB /* Build configuration list for PBXProject "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 90BD9B832C06907E000DEBAB /* Debug */, + 90BD9B842C06907E000DEBAB /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 90BD9B852C06907E000DEBAB /* Build configuration list for PBXNativeTarget "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 90BD9B862C06907E000DEBAB /* Debug */, + 90BD9B872C06907E000DEBAB /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 90A914572CA3D370003DB979 /* XCLocalSwiftPackageReference "cordova-ios" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "packages/cordova-ios"; + }; + 90D82ABB2CF19AEA001383CF /* XCLocalSwiftPackageReference "cordova-ios-plugins" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "packages/cordova-ios-plugins"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 90A914582CA3D370003DB979 /* Cordova */ = { + isa = XCSwiftPackageProductDependency; + productName = Cordova; + }; + 90D82ABC2CF19AEA001383CF /* CordovaPlugins */ = { + isa = XCSwiftPackageProductDependency; + productName = CordovaPlugins; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 90BD9B642C06907D000DEBAB /* Project object */; +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/e2e-tests/MaestroTestApp/platforms/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..21fc716e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,25 @@ + + + + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/e2e-tests/MaestroTestApp/platforms/ios/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App.xcworkspace/contents.xcworkspacedata b/e2e-tests/MaestroTestApp/platforms/ios/App.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..b301e824 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/e2e-tests/MaestroTestApp/platforms/ios/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App.xcworkspace/xcshareddata/xcschemes/App.xcscheme b/e2e-tests/MaestroTestApp/platforms/ios/App.xcworkspace/xcshareddata/xcschemes/App.xcscheme new file mode 100644 index 00000000..3f54405f --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App.xcworkspace/xcshareddata/xcschemes/App.xcscheme @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/.gitignore b/e2e-tests/MaestroTestApp/platforms/ios/App/.gitignore new file mode 100644 index 00000000..cc76483f --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/.gitignore @@ -0,0 +1,5 @@ +*.mode1v3 +*.perspectivev3 +*.pbxuser +.DS_Store +build/ diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/App-Info.plist b/e2e-tests/MaestroTestApp/platforms/ios/App/App-Info.plist new file mode 100644 index 00000000..83e3bd5b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/App-Info.plist @@ -0,0 +1,45 @@ + + + + + UTImportedTypeDeclarations + + + UTTypeConformsTo + + public.data + + UTTypeDescription + WebAssembly + UTTypeIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + UTTypeTagSpecification + + public.filename-extension + wasm + public.mime-type + application/wasm + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + + \ No newline at end of file diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/AppDelegate.h b/e2e-tests/MaestroTestApp/platforms/ios/App/AppDelegate.h new file mode 100644 index 00000000..62a77631 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/AppDelegate.h @@ -0,0 +1,29 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +#import + +#ifndef __CORDOVA_SILENCE_HEADER_DEPRECATIONS + #warning It is unsafe to rely on the AppDelegate class as an extension point. \ + Update your code to extend CDVAppDelegate instead -- \ + This code will stop working in Cordova iOS 9! +#endif + +@interface AppDelegate : CDVAppDelegate +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/AppDelegate.swift b/e2e-tests/MaestroTestApp/platforms/ios/App/AppDelegate.swift new file mode 100644 index 00000000..d0ec40f4 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/AppDelegate.swift @@ -0,0 +1,33 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +import UIKit + +@main +#if compiler(>=6.1) +@objc @implementation +#else +@_objcImplementation +#endif +extension AppDelegate { + open override func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } +} + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/e2e-tests/MaestroTestApp/platforms/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..2c8c083a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,20 @@ +{ + "images" : [ + { + "filename" : "icon.png", + "idiom" : "universal", + "platform": "ios", + "size" : "1024x1024" + }, + { + "filename" : "icon.png", + "idiom" : "universal", + "platform": "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/Assets.xcassets/AppIcon.appiconset/icon.png b/e2e-tests/MaestroTestApp/platforms/ios/App/Assets.xcassets/AppIcon.appiconset/icon.png new file mode 100644 index 00000000..b9250b12 Binary files /dev/null and b/e2e-tests/MaestroTestApp/platforms/ios/App/Assets.xcassets/AppIcon.appiconset/icon.png differ diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/Assets.xcassets/BackgroundColor.colorset/Contents.json b/e2e-tests/MaestroTestApp/platforms/ios/App/Assets.xcassets/BackgroundColor.colorset/Contents.json new file mode 100644 index 00000000..38ce5e7c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/Assets.xcassets/BackgroundColor.colorset/Contents.json @@ -0,0 +1,15 @@ +{ + "colors": [ + { + "idiom": "universal", + "color": { + "platform": "ios", + "reference": "systemBackgroundColor" + } + } + ], + "info": { + "author": "Xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/Assets.xcassets/Contents.json b/e2e-tests/MaestroTestApp/platforms/ios/App/Assets.xcassets/Contents.json new file mode 100644 index 00000000..da4a164c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/Assets.xcassets/LaunchStoryboard.imageset/Contents.json b/e2e-tests/MaestroTestApp/platforms/ios/App/Assets.xcassets/LaunchStoryboard.imageset/Contents.json new file mode 100644 index 00000000..c0ac4835 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/Assets.xcassets/LaunchStoryboard.imageset/Contents.json @@ -0,0 +1,872 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x", + "width-class": "compact", + "height-class": "compact" + }, + { + "idiom": "universal", + "scale": "1x", + "width-class": "compact", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "universal", + "scale": "1x", + "width-class": "compact", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "universal", + "scale": "1x", + "width-class": "compact" + }, + { + "idiom": "universal", + "scale": "1x", + "width-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "universal", + "scale": "1x", + "width-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "universal", + "scale": "1x", + "height-class": "compact" + }, + { + "idiom": "universal", + "scale": "1x", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "universal", + "scale": "1x", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "universal", + "scale": "1x" + }, + { + "idiom": "universal", + "scale": "1x", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "universal", + "scale": "1x", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "universal", + "scale": "2x", + "width-class": "compact", + "height-class": "compact" + }, + { + "idiom": "universal", + "scale": "2x", + "width-class": "compact", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "universal", + "scale": "2x", + "width-class": "compact", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "universal", + "scale": "2x", + "width-class": "compact" + }, + { + "idiom": "universal", + "scale": "2x", + "width-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "universal", + "scale": "2x", + "width-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "universal", + "scale": "2x", + "height-class": "compact" + }, + { + "idiom": "universal", + "scale": "2x", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "universal", + "scale": "2x", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "2x", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "universal", + "scale": "2x", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "universal", + "scale": "3x", + "width-class": "compact", + "height-class": "compact" + }, + { + "idiom": "universal", + "scale": "3x", + "width-class": "compact", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "universal", + "scale": "3x", + "width-class": "compact", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "universal", + "scale": "3x", + "width-class": "compact" + }, + { + "idiom": "universal", + "scale": "3x", + "width-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "universal", + "scale": "3x", + "width-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "universal", + "scale": "3x", + "height-class": "compact" + }, + { + "idiom": "universal", + "scale": "3x", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "universal", + "scale": "3x", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "universal", + "scale": "3x" + }, + { + "idiom": "universal", + "scale": "3x", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "universal", + "scale": "3x", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "ipad", + "scale": "1x", + "width-class": "compact", + "height-class": "compact" + }, + { + "idiom": "ipad", + "scale": "1x", + "width-class": "compact", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "ipad", + "scale": "1x", + "width-class": "compact", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "ipad", + "scale": "1x", + "width-class": "compact" + }, + { + "idiom": "ipad", + "scale": "1x", + "width-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "ipad", + "scale": "1x", + "width-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "ipad", + "scale": "1x", + "height-class": "compact" + }, + { + "idiom": "ipad", + "scale": "1x", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "ipad", + "scale": "1x", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "ipad", + "scale": "1x" + }, + { + "idiom": "ipad", + "scale": "1x", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "ipad", + "scale": "1x", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "ipad", + "scale": "2x", + "width-class": "compact", + "height-class": "compact" + }, + { + "idiom": "ipad", + "scale": "2x", + "width-class": "compact", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "ipad", + "scale": "2x", + "width-class": "compact", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "ipad", + "scale": "2x", + "width-class": "compact" + }, + { + "idiom": "ipad", + "scale": "2x", + "width-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "ipad", + "scale": "2x", + "width-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "ipad", + "scale": "2x", + "height-class": "compact" + }, + { + "idiom": "ipad", + "scale": "2x", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "ipad", + "scale": "2x", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "ipad", + "scale": "2x" + }, + { + "idiom": "ipad", + "scale": "2x", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "ipad", + "scale": "2x", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "iphone", + "scale": "1x", + "width-class": "compact", + "height-class": "compact" + }, + { + "idiom": "iphone", + "scale": "1x", + "width-class": "compact", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "iphone", + "scale": "1x", + "width-class": "compact", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "iphone", + "scale": "1x", + "width-class": "compact" + }, + { + "idiom": "iphone", + "scale": "1x", + "width-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "iphone", + "scale": "1x", + "width-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "iphone", + "scale": "1x", + "height-class": "compact" + }, + { + "idiom": "iphone", + "scale": "1x", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "iphone", + "scale": "1x", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "iphone", + "scale": "1x" + }, + { + "idiom": "iphone", + "scale": "1x", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "iphone", + "scale": "1x", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "iphone", + "scale": "2x", + "width-class": "compact", + "height-class": "compact" + }, + { + "idiom": "iphone", + "scale": "2x", + "width-class": "compact", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "iphone", + "scale": "2x", + "width-class": "compact", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "iphone", + "scale": "2x", + "width-class": "compact" + }, + { + "idiom": "iphone", + "scale": "2x", + "width-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "iphone", + "scale": "2x", + "width-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "iphone", + "scale": "2x", + "height-class": "compact" + }, + { + "idiom": "iphone", + "scale": "2x", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "iphone", + "scale": "2x", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "iphone", + "scale": "2x" + }, + { + "idiom": "iphone", + "scale": "2x", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "iphone", + "scale": "2x", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "iphone", + "scale": "3x", + "width-class": "compact", + "height-class": "compact" + }, + { + "idiom": "iphone", + "scale": "3x", + "width-class": "compact", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "iphone", + "scale": "3x", + "width-class": "compact", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "iphone", + "scale": "3x", + "width-class": "compact" + }, + { + "idiom": "iphone", + "scale": "3x", + "width-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "iphone", + "scale": "3x", + "width-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "iphone", + "scale": "3x", + "height-class": "compact" + }, + { + "idiom": "iphone", + "scale": "3x", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "iphone", + "scale": "3x", + "height-class": "compact", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "idiom": "iphone", + "scale": "3x" + }, + { + "idiom": "iphone", + "scale": "3x", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "idiom": "iphone", + "scale": "3x", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + } + ], + "info": { + "author": "Xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/Assets.xcassets/SplashScreenBackgroundColor.colorset/Contents.json b/e2e-tests/MaestroTestApp/platforms/ios/App/Assets.xcassets/SplashScreenBackgroundColor.colorset/Contents.json new file mode 100644 index 00000000..38ce5e7c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/Assets.xcassets/SplashScreenBackgroundColor.colorset/Contents.json @@ -0,0 +1,15 @@ +{ + "colors": [ + { + "idiom": "universal", + "color": { + "platform": "ios", + "reference": "systemBackgroundColor" + } + } + ], + "info": { + "author": "Xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/Assets.xcassets/StatusBarBackgroundColor.colorset/Contents.json b/e2e-tests/MaestroTestApp/platforms/ios/App/Assets.xcassets/StatusBarBackgroundColor.colorset/Contents.json new file mode 100644 index 00000000..38ce5e7c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/Assets.xcassets/StatusBarBackgroundColor.colorset/Contents.json @@ -0,0 +1,15 @@ +{ + "colors": [ + { + "idiom": "universal", + "color": { + "platform": "ios", + "reference": "systemBackgroundColor" + } + } + ], + "info": { + "author": "Xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/Base.lproj/CDVLaunchScreen.storyboard b/e2e-tests/MaestroTestApp/platforms/ios/App/Base.lproj/CDVLaunchScreen.storyboard new file mode 100644 index 00000000..be89abe1 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/Base.lproj/CDVLaunchScreen.storyboard @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/Base.lproj/Main.storyboard b/e2e-tests/MaestroTestApp/platforms/ios/App/Base.lproj/Main.storyboard new file mode 100644 index 00000000..3372d5d2 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/Base.lproj/Main.storyboard @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/Bridging-Header.h b/e2e-tests/MaestroTestApp/platforms/ios/App/Bridging-Header.h new file mode 100644 index 00000000..9dec5295 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/Bridging-Header.h @@ -0,0 +1,25 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +#define __CORDOVA_SILENCE_HEADER_DEPRECATIONS +#import "AppDelegate.h" +#import "MainViewController.h" +#undef __CORDOVA_SILENCE_HEADER_DEPRECATIONS diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/Entitlements-Debug.plist b/e2e-tests/MaestroTestApp/platforms/ios/App/Entitlements-Debug.plist new file mode 100644 index 00000000..1ed4ae5b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/Entitlements-Debug.plist @@ -0,0 +1,24 @@ + + + + + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/Entitlements-Release.plist b/e2e-tests/MaestroTestApp/platforms/ios/App/Entitlements-Release.plist new file mode 100644 index 00000000..1ed4ae5b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/Entitlements-Release.plist @@ -0,0 +1,24 @@ + + + + + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/MainViewController.h b/e2e-tests/MaestroTestApp/platforms/ios/App/MainViewController.h new file mode 100644 index 00000000..ddb37c4a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/MainViewController.h @@ -0,0 +1,29 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +#import + +#ifndef __CORDOVA_SILENCE_HEADER_DEPRECATIONS + #warning It is unsafe to rely on the MainViewController class as an extension point. \ + Update your code to extend CDVViewController instead -- \ + This code will stop working in Cordova iOS 9! +#endif + +@interface MainViewController : CDVViewController +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/README b/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/README new file mode 100644 index 00000000..87df09f2 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/README @@ -0,0 +1,20 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +Put the .h and .m files of your plugin here. The .js files of your plugin belong in the www folder. diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/cordova-plugin-purchases/PurchasesPlugin+APIHelpers.swift b/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/cordova-plugin-purchases/PurchasesPlugin+APIHelpers.swift new file mode 100644 index 00000000..6b62762e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/cordova-plugin-purchases/PurchasesPlugin+APIHelpers.swift @@ -0,0 +1,54 @@ +// +// PurchasesPlugin+APIHelpers.swift +// PurchasesPlugin +// +// Created by Joshua Liebowitz on 7/5/22. +// + +import Foundation +import PurchasesHybridCommon +import RevenueCat + +extension CDVPurchasesPlugin { + + func sendOKFor(command: CDVInvokedUrlCommand, messageAsArray: [Any]? = nil) { + let pluginResult = CDVPluginResult(status: .ok, messageAs: messageAsArray ?? []) + self.commandDelegate.send(pluginResult, callbackId: command.callbackId) + } + + func sendBadParameterFor(command: CDVInvokedUrlCommand, parameterNamed: String, expectedType: Any.Type) { + self.sendBadParametersFor(command: command, parametersNamed: [parameterNamed], expectedTypes: [expectedType]) + } + + func sendBadParametersFor(command: CDVInvokedUrlCommand, + parametersNamed: [String], + expectedTypes: [Any.Type]) { + + let args = zip(parametersNamed, expectedTypes) + .map { name, type in "parameter: \(name), type: \(type)" } + .joined(separator: ", ") + + let pluginResult = CDVPluginResult(status: .error, messageAs: "Invalid or missing parameter(s): \(args)") + self.commandDelegate.send(pluginResult, callbackId: command.callbackId) + } + + func responseCompletion(forCommand command: CDVInvokedUrlCommand) -> HybridResponseBlock { + let callback: HybridResponseBlock = { response, error in + let result: CDVPluginResult + if let error = error { + result = CDVPluginResult(status: .error, messageAs: error.info) + } else { + result = CDVPluginResult(status: .ok, messageAs: response ?? [:]) + } + self.commandDelegate.send(result, callbackId: command.callbackId) + } + return callback + } + + func sendUnsupportedErrorFor(command: CDVInvokedUrlCommand) { + let error = ErrorContainer(error: ErrorCode.unsupportedError, extraPayload: [:]) + let result = CDVPluginResult(status: .error, messageAs: error.info) + self.commandDelegate.send(result, callbackId: command.callbackId) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/cordova-plugin-purchases/PurchasesPlugin+Attribution.swift b/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/cordova-plugin-purchases/PurchasesPlugin+Attribution.swift new file mode 100644 index 00000000..f33a3c40 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/cordova-plugin-purchases/PurchasesPlugin+Attribution.swift @@ -0,0 +1,173 @@ +// +// PurchasesPlugin+Attribution.swift +// PurchasesPlugin +// +// Created by Joshua Liebowitz on 7/5/22. +// + +import Foundation +import PurchasesHybridCommon + +@objc public extension CDVPurchasesPlugin { + + @objc(enableAdServicesAttributionTokenCollection:) + func enableAdServicesAttributionTokenCollection(command: CDVInvokedUrlCommand) { + if #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) { + CommonFunctionality.enableAdServicesAttributionTokenCollection() + } else { + NSLog("[Purchases] Warning: tried to enable AdServices attribution token collection, but it's only available on iOS 14.3 or greater or macOS 11.1 or greater."); + } + self.sendOKFor(command: command) + } + + @objc(setAttributes:) + func setAttributes(command: CDVInvokedUrlCommand) { + guard let attributes = command.arguments[0] as? [String: String] else { + self.sendBadParameterFor(command: command, parameterNamed: "attributes", expectedType: NSDictionary.self) + return + } + + CommonFunctionality.setAttributes(attributes) + self.sendOKFor(command: command) + } + + @objc(setEmail:) + func setEmail(command: CDVInvokedUrlCommand) { + self.setSubscriberAttribute(command: command, name: "email", setFunction: CommonFunctionality.setEmail) + } + + @objc(setPhoneNumber:) + func setPhoneNumber(command: CDVInvokedUrlCommand) { + self.setSubscriberAttribute(command: command, + name: "phoneNumber", + setFunction: CommonFunctionality.setPhoneNumber) + } + + @objc(setDisplayName:) + func setDisplayName(command: CDVInvokedUrlCommand) { + self.setSubscriberAttribute(command: command, + name: "displayName", + setFunction: CommonFunctionality.setDisplayName) + } + + @objc(setPushToken:) + func setPushToken(command: CDVInvokedUrlCommand) { + self.setSubscriberAttribute(command: command, + name: "pushToken", + setFunction: CommonFunctionality.setPushToken) + } + + @objc(setAdjustID:) + func setAdjustID(command: CDVInvokedUrlCommand) { + self.setSubscriberAttribute(command: command, name: "adjustID", setFunction: CommonFunctionality.setAdjustID) + } + + @objc(setCleverTapID:) + func setCleverTapID(command: CDVInvokedUrlCommand) { + self.setSubscriberAttribute(command: command, name: "cleverTapID", setFunction: CommonFunctionality.setCleverTapID) + } + + @objc(setFirebaseAppInstanceID:) + func setFirebaseAppInstanceID(command: CDVInvokedUrlCommand) { + self.setSubscriberAttribute(command: command, name: "firebaseAppInstanceID", setFunction: CommonFunctionality.setFirebaseAppInstanceID) + } + + @objc(setMixpanelDistinctID:) + func setMixpanelDistinctID(command: CDVInvokedUrlCommand) { + self.setSubscriberAttribute(command: command, name: "mixpanelDistinctID", setFunction: CommonFunctionality.setMixpanelDistinctID) + } + + @objc(setAppsflyerID:) + func setAppsflyerID(command: CDVInvokedUrlCommand) { + self.setSubscriberAttribute(command: command, + name: "appsFlyerID", + setFunction: CommonFunctionality.setAppsflyerID) + } + + @objc(setFBAnonymousID:) + func setFBAnonymousID(command: CDVInvokedUrlCommand) { + self.setSubscriberAttribute(command: command, + name: "fbAnonymousID", + setFunction: CommonFunctionality.setFBAnonymousID) + } + + @objc(setMparticleID:) + func setMparticleID(command: CDVInvokedUrlCommand) { + self.setSubscriberAttribute(command: command, + name: "mparticleID", + setFunction: CommonFunctionality.setMparticleID) + } + + @objc(setOnesignalID:) + func setOnesignalID(command: CDVInvokedUrlCommand) { + self.setSubscriberAttribute(command: command, + name: "onesignalID", + setFunction: CommonFunctionality.setOnesignalID) + } + + @objc(setAirshipChannelID:) + func setAirshipChannelID(command: CDVInvokedUrlCommand) { + self.setSubscriberAttribute(command: command, + name: "airshipChannelID", + setFunction: CommonFunctionality.setAirshipChannelID) + } + + @objc(setMediaSource:) + func setMediaSource(command: CDVInvokedUrlCommand) { + self.setSubscriberAttribute(command: command, + name: "mediaSource", + setFunction: CommonFunctionality.setMediaSource) + } + + @objc(setCampaign:) + func setCampaign(command: CDVInvokedUrlCommand) { + self.setSubscriberAttribute(command: command, name: "campaign", setFunction: CommonFunctionality.setCampaign) + } + + @objc(setAdGroup:) + func setAdGroup(command: CDVInvokedUrlCommand) { + self.setSubscriberAttribute(command: command, name: "adGroup", setFunction: CommonFunctionality.setAdGroup) + } + + @objc(setAd:) + func setAd(command: CDVInvokedUrlCommand) { + self.setSubscriberAttribute(command: command, name: "ad", setFunction: CommonFunctionality.setAd) + } + + @objc(setKeyword:) + func setKeyword(command: CDVInvokedUrlCommand) { + self.setSubscriberAttribute(command: command, name: "keyword", setFunction: CommonFunctionality.setKeyword) + } + + @objc(setCreative:) + func setCreative(command: CDVInvokedUrlCommand) { + self.setSubscriberAttribute(command: command, name: "creative", setFunction: CommonFunctionality.setCreative) + } + + @objc(setProxyURLString:) + func setProxyURLString(command: CDVInvokedUrlCommand) { + CommonFunctionality.proxyURLString = command.arguments[0] as? String + self.sendOKFor(command: command) + } + + @objc(collectDeviceIdentifiers:) + func collectDeviceIdentifiers(command: CDVInvokedUrlCommand) { + CommonFunctionality.collectDeviceIdentifiers() + self.sendOKFor(command: command) + } + +} + +private extension CDVPurchasesPlugin { + + func setSubscriberAttribute(command: CDVInvokedUrlCommand, name: String, setFunction: (String) -> Void) { + guard let setValue = command.arguments[0] as? NSString else { + self.sendBadParameterFor(command: command, parameterNamed: name, expectedType: NSString.self) + return + } + + setFunction(setValue as String) + self.sendOKFor(command: command) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/cordova-plugin-purchases/PurchasesPlugin+PurchasesDelegate.swift b/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/cordova-plugin-purchases/PurchasesPlugin+PurchasesDelegate.swift new file mode 100644 index 00000000..d3af1026 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/cordova-plugin-purchases/PurchasesPlugin+PurchasesDelegate.swift @@ -0,0 +1,31 @@ +// +// PurchasesPlugin+PurchasesDelegate.swift +// PurchasesPlugin +// +// Created by Joshua Liebowitz on 7/5/22. +// + +import Foundation +import PurchasesHybridCommon +import RevenueCat + +extension CDVPurchasesPlugin: PurchasesDelegate { + + public func purchases(_ purchases: Purchases, receivedUpdated customerInfo: CustomerInfo) { + let result = CDVPluginResult(status: .ok, messageAs: CommonFunctionality.encode(customerInfo: customerInfo)) + result.setKeepCallbackAs(true) + self.commandDelegate.send(result, callbackId: self.updatedCustomerInfoCallbackID) + } + + public func purchases(_ purchases: Purchases, + readyForPromotedProduct product: StoreProduct, + purchase makeDeferredPurchase: @escaping DeferredPromotionalPurchaseBlock) { + // TODO: This is not threadsafe. + self.defermentBlocks.append(makeDeferredPurchase) + let position = self.defermentBlocks.count - 1 + let result = CDVPluginResult(status: .ok, messageAs: ["callbackID": NSNumber(value: position)]) + result.setKeepCallbackAs(true) + self.commandDelegate.send(result, callbackId: self.shouldPurchasePromoProductCallbackID ?? "") + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/cordova-plugin-purchases/PurchasesPlugin+Purchasing.swift b/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/cordova-plugin-purchases/PurchasesPlugin+Purchasing.swift new file mode 100644 index 00000000..a591af24 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/cordova-plugin-purchases/PurchasesPlugin+Purchasing.swift @@ -0,0 +1,322 @@ +// +// PurchasesPlugin+Purchasing.swift +// PurchasesPlugin +// +// Created by Joshua Liebowitz on 7/5/22. +// + +import Foundation +import PurchasesHybridCommon + +@objc public extension CDVPurchasesPlugin { + + @objc(getOfferings:) + func getOfferings(command: CDVInvokedUrlCommand) { + + CommonFunctionality.getOfferings(completion: self.responseCompletion(forCommand: command)) + } + + @objc(getProducts:) + func getProducts(command: CDVInvokedUrlCommand) { + guard let products = command.arguments[0] as? [String] else { + self.sendBadParameterFor(command: command, parameterNamed: "products", expectedType: NSArray.self) + return + } + + CommonFunctionality.getProductInfo(products) { + self.sendOKFor(command: command, messageAsArray: $0) + } + } + + @objc(getEligibleWinBackOffersForProduct:) + func getEligibleWinBackOffersForProduct(command: CDVInvokedUrlCommand) { + guard let productIdentifier = command.arguments[0] as? String else { + self.sendBadParameterFor(command: command, parameterNamed: "productIdentifier", expectedType: String.self) + return + } + + if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) { + CommonFunctionality.eligibleWinBackOffers(for: productIdentifier) { eligibleOffers, error in + if let error = error { + let result = CDVPluginResult(status: .error, messageAs: error.info) + self.commandDelegate.send(result, callbackId: command.callbackId) + } else { + self.sendOKFor(command: command, messageAsArray: eligibleOffers) + } + } + } else { + NSLog("[Purchases] Warning: tried to call fetch eligible win-back offers, but it's only available on iOS 18.0+") + sendUnsupportedErrorFor(command: command) + } + } + + @objc(purchaseProductWithWinBackOffer:) + func purchaseProductWithWinBackOffer(command: CDVInvokedUrlCommand) { + guard let productIdentifier = command.arguments[0] as? String else { + self.sendBadParameterFor(command: command, parameterNamed: "productIdentifier", expectedType: String.self) + return + } + + guard let winBackOfferIdentifier = command.arguments[1] as? String else { + self.sendBadParameterFor(command: command, parameterNamed: "winBackOfferIdentifier", expectedType: String.self) + return + } + + if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) { + CommonFunctionality.purchase( + product: productIdentifier, + winBackOfferID: winBackOfferIdentifier, + completion: self.responseCompletion(forCommand: command) + ) + } else { + NSLog("[Purchases] Warning: tried to purchase a product with a win-back offer, but win-back offers are only available on iOS 18.0+") + sendUnsupportedErrorFor(command: command) + } + } + + @objc(purchasePackageWithWinBackOffer:) + func purchasePackageWithWinBackOffer(command: CDVInvokedUrlCommand) { + guard let packageIdentifier = command.arguments[0] as? String else { + self.sendBadParameterFor(command: command, parameterNamed: "packageIdentifier", expectedType: String.self) + return + } + + guard let offeringIdentifier = command.arguments[1] as? String else { + self.sendBadParameterFor(command: command, parameterNamed: "offeringIdentifier", expectedType: String.self) + return + } + + guard let winBackOfferIdentifier = command.arguments[2] as? String else { + self.sendBadParameterFor(command: command, parameterNamed: "winBackOfferIdentifier", expectedType: String.self) + return + } + + if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) { + CommonFunctionality.purchase( + package: packageIdentifier, + presentedOfferingContext: ["offeringIdentifier": offeringIdentifier], + winBackOfferID: winBackOfferIdentifier, + completion: self.responseCompletion(forCommand: command) + ) + } else { + NSLog("[Purchases] Warning: tried to purchase a package with a win-back offer, but win-back offers are only available on iOS 18.0+") + sendUnsupportedErrorFor(command: command) + } + } + + @objc(purchaseProduct:) + func purchaseProduct(command: CDVInvokedUrlCommand) { + guard let productIdentifier = command.arguments[0] as? String else { + self.sendBadParameterFor(command: command, parameterNamed: "productIdentifier", expectedType: String.self) + return + } + + CommonFunctionality.purchase(product: productIdentifier, + signedDiscountTimestamp: nil, + completion: self.responseCompletion(forCommand: command)) + } + + @objc(purchasePackage:) + func purchasePackage(command: CDVInvokedUrlCommand) { + guard let packageIdentifier = command.arguments[0] as? String, + let offeringIdentifier = command.arguments[1] as? String else { + self.sendBadParametersFor(command: command, + parametersNamed: ["packageIdentifier", "offeringIdentifier"], + expectedTypes: [String.self, String.self]) + return + } + + CommonFunctionality.purchase(package: packageIdentifier, + presentedOfferingContext: ["offeringIdentifier": offeringIdentifier], + signedDiscountTimestamp: nil, + completion: self.responseCompletion(forCommand: command)) + } + + @objc(restorePurchases:) + func restorePurchases(command: CDVInvokedUrlCommand) { + CommonFunctionality.restorePurchases(completion: self.responseCompletion(forCommand: command)) + } + + @objc(syncPurchases:) + func syncPurchases(command: CDVInvokedUrlCommand) { + CommonFunctionality.syncPurchases(completion: self.responseCompletion(forCommand: command)) + } + + @objc(recordPurchase:) + func recordPurchase(command: CDVInvokedUrlCommand) { + guard let productIdentifier = command.arguments[0] as? String else { + self.sendBadParameterFor(command: command, + parameterNamed: "productIdentifier", + expectedType: String.self) + return + } + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) { + CommonFunctionality.recordPurchase(productID: productIdentifier, + completion: self.responseCompletion(forCommand: command)) + } else { + NSLog("[Purchases] Warning: tried to record purchase, but it's only available on iOS 15.0+") + sendUnsupportedErrorFor(command: command) + } + } + + @objc(setSimulatesAskToBuyInSandbox:) + func setSimulatesAskToBuyInSandbox(command: CDVInvokedUrlCommand) { + guard let askToBuyInSandbox = command.arguments[0] as? Bool else { + self.sendBadParameterFor(command: command, + parameterNamed: "setSimulatesAskToBuyInSandbox", + expectedType: Bool.self) + return + } + + CommonFunctionality.simulatesAskToBuyInSandbox = askToBuyInSandbox + self.sendOKFor(command: command) + } + + @objc(presentCodeRedemptionSheet:) + func presentCodeRedemptionSheet(command: CDVInvokedUrlCommand) { + func logPresentCodeRedemptionSheetNotAvailable() { + NSLog("%@", "[Purchases] Warning: tried to present codeRedemptionSheet, but it's only available on iOS 14.0 or greater.") + } + + #if targetEnvironment(macCatalyst) + logPresentCodeRedemptionSheetNotAvailable() + #else + if #available(iOS 14.0, *) { + CommonFunctionality.presentCodeRedemptionSheet() + } else { + logPresentCodeRedemptionSheetNotAvailable() + } + #endif + + self.sendOKFor(command: command) + } + + @objc(canMakePayments:) + func canMakePayments(command: CDVInvokedUrlCommand) { + let canMakePayments = CommonFunctionality.canMakePaymentsWithFeatures([]) + let result = CDVPluginResult(status: .ok, messageAs: canMakePayments) + self.commandDelegate.send(result, callbackId: command.callbackId) + } + + @objc(checkTrialOrIntroductoryPriceEligibility:) + func checkTrialOrIntroductoryPriceEligibility(command: CDVInvokedUrlCommand) { + guard let products = command.arguments[0] as? [String] else { + self.sendBadParameterFor(command: command, parameterNamed: "productIdentifiers", expectedType: NSArray.self) + return + } + + CommonFunctionality.checkTrialOrIntroductoryPriceEligibility(for: products) { + let result = CDVPluginResult(status: .ok, messageAs: $0) + self.commandDelegate.send(result, callbackId: command.callbackId) + } + } + + @objc(makeDeferredPurchase:) + func makeDeferredPurchase(command: CDVInvokedUrlCommand) { + let callbackID = command.arguments[0] as! Int + assert(callbackID >= 0) + let defermentBlock = self.defermentBlocks[callbackID] + CommonFunctionality.makeDeferredPurchase(defermentBlock, + completion: self.responseCompletion(forCommand: command)) + } + + @objc(beginRefundRequestForActiveEntitlement:) + func beginRefundRequestForActiveEntitlement(command: CDVInvokedUrlCommand) { +#if os(iOS) + if #available(iOS 15.0, *) { + let completion = beginRefundRequestCompletionFor(command: command) + CommonFunctionality.beginRefundRequestForActiveEntitlement(completion: completion) + } else { + sendUnsupportedErrorFor(command: command) + } +#else + sendUnsupportedErrorFor(command: command) +#endif + } + + @objc(beginRefundRequestForEntitlementId:) + func beginRefundRequestForEntitlementId(command: CDVInvokedUrlCommand) { + guard let entitlementIdentifier = command.arguments[0] as? String else { + self.sendBadParameterFor(command: command, parameterNamed: "entitlementIdentifier", expectedType: String.self) + return + } +#if os(iOS) + if #available(iOS 15.0, *) { + let completion = beginRefundRequestCompletionFor(command: command) + CommonFunctionality.beginRefundRequest(entitlementId: entitlementIdentifier, completion: completion) + } else { + sendUnsupportedErrorFor(command: command) + } +#else + sendUnsupportedErrorFor(command: command) +#endif + } + + @objc(beginRefundRequestForProductId:) + func beginRefundRequestForProductId(command: CDVInvokedUrlCommand) { + guard let productIdentifier = command.arguments[0] as? String else { + self.sendBadParameterFor(command: command, parameterNamed: "productIdentifier", expectedType: String.self) + return + } +#if os(iOS) + if #available(iOS 15.0, *) { + let completion = beginRefundRequestCompletionFor(command: command) + CommonFunctionality.beginRefundRequest(productId: productIdentifier, completion: completion) + } else { + sendUnsupportedErrorFor(command: command) + } +#else + sendUnsupportedErrorFor(command: command) +#endif + } + + @objc(showInAppMessages:) + func showInAppMessages(command: CDVInvokedUrlCommand) { + let intMessageTypes = command.argument(at: 0, withDefault: nil) as? [Int] +#if os(iOS) || targetEnvironment(macCatalyst) + if #available(iOS 16.0, *) { + if let intMessageTypes { + let messageTypes = intMessageTypes.map({ intNumber in + NSNumber(integerLiteral: intNumber) + }) + CommonFunctionality.showStoreMessages(forRawValues: Set(messageTypes)) { [weak self] in + self?.sendOKFor(command: command) + } + } else { + CommonFunctionality.showStoreMessages { [weak self] in + self?.sendOKFor(command: command) + } + } + } else { + NSLog("[Purchases] Warning: tried to show in app messages, but it's only available on iOS 16.0+") + self.sendOKFor(command: command) + } +#else + NSLog("[Purchases] Warning: tried to show in app messages, but it's only available on iOS or macCatalyst") + self.sendOKFor(command: command) +#endif + } + + private func beginRefundRequestCompletionFor(command: CDVInvokedUrlCommand) -> (ErrorContainer?) -> Void { + return { error in + let result: CDVPluginResult + guard let error = error else { + result = CDVPluginResult(status: .ok, messageAs: 0) + self.commandDelegate.send(result, callbackId: command.callbackId) + return + } + if ((error.info["userCancelled"]) != nil) { + result = CDVPluginResult(status: .ok, messageAs: 1) + } else { + result = CDVPluginResult(status: .error, messageAs: error.info) + } + self.commandDelegate.send(result, callbackId: command.callbackId) + } + } + + @objc(purchaseSubscriptionOption:) + func purchaseSubscriptionOption(command: CDVInvokedUrlCommand) { + sendUnsupportedErrorFor(command: command) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/cordova-plugin-purchases/PurchasesPlugin+Users.swift b/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/cordova-plugin-purchases/PurchasesPlugin+Users.swift new file mode 100644 index 00000000..b437b8ae --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/cordova-plugin-purchases/PurchasesPlugin+Users.swift @@ -0,0 +1,51 @@ +// +// PurchasesPlugin+Users.swift +// PurchasesPlugin +// +// Created by Joshua Liebowitz on 7/5/22. +// + +import Foundation +import PurchasesHybridCommon + +@objc public extension CDVPurchasesPlugin { + + @objc(getAppUserID:) + func getAppUserID(command: CDVInvokedUrlCommand) { + let result = CDVPluginResult(status: .ok, messageAs: CommonFunctionality.appUserID) + self.commandDelegate.send(result, callbackId: command.callbackId) + } + + @objc(logIn:) + func logIn(command: CDVInvokedUrlCommand) { + guard let appUserID = command.arguments[0] as? String else { + self.sendBadParameterFor(command: command, parameterNamed: "appUserID", expectedType: String.self) + return + } + + CommonFunctionality.logIn(appUserID: appUserID, completion: self.responseCompletion(forCommand: command)) + } + + @objc(logOut:) + func logOut(command: CDVInvokedUrlCommand) { + CommonFunctionality.logOut(completion: self.responseCompletion(forCommand: command)) + } + + @objc(getCustomerInfo:) + func getCustomerInfo(command: CDVInvokedUrlCommand) { + CommonFunctionality.customerInfo(completion: self.responseCompletion(forCommand: command)) + } + + @objc(isAnonymous:) + func isAnonymous(command: CDVInvokedUrlCommand) { + let result = CDVPluginResult(status: .ok, messageAs: CommonFunctionality.isAnonymous) + self.commandDelegate.send(result, callbackId: command.callbackId) + } + + @objc(invalidateCustomerInfoCache:) + func invalidateCustomerInfoCache(command: CDVInvokedUrlCommand) { + CommonFunctionality.invalidateCustomerInfoCache() + self.sendOKFor(command: command) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/cordova-plugin-purchases/PurchasesPlugin+VirtualCurrencies.swift b/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/cordova-plugin-purchases/PurchasesPlugin+VirtualCurrencies.swift new file mode 100644 index 00000000..5c3c7de9 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/cordova-plugin-purchases/PurchasesPlugin+VirtualCurrencies.swift @@ -0,0 +1,30 @@ +// +// PurchasesPlugin+Users.swift +// PurchasesPlugin +// +// Created by Will Taylor on 8/6/2025. +// + +import Foundation +import PurchasesHybridCommon + +@objc public extension CDVPurchasesPlugin { + + @objc(getVirtualCurrencies:) + func getVirtualCurrencies(command: CDVInvokedUrlCommand) { + CommonFunctionality.getVirtualCurrencies(completion: self.responseCompletion(forCommand: command)) + } + + @objc(invalidateVirtualCurrenciesCache:) + func invalidateVirtualCurrenciesCache(command: CDVInvokedUrlCommand) { + CommonFunctionality.invalidateVirtualCurrenciesCache() + self.sendOKFor(command: command) + } + + @objc(getCachedVirtualCurrencies:) + func getCachedVirtualCurrencies(command: CDVInvokedUrlCommand) { + let cachedVirtualCurrencies = CommonFunctionality.getCachedVirtualCurrencies() + let result = CDVPluginResult(status: .ok, messageAs: cachedVirtualCurrencies ?? [:]) + self.commandDelegate.send(result, callbackId: command.callbackId) + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/cordova-plugin-purchases/PurchasesPlugin.swift b/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/cordova-plugin-purchases/PurchasesPlugin.swift new file mode 100644 index 00000000..2a0e514a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/Plugins/cordova-plugin-purchases/PurchasesPlugin.swift @@ -0,0 +1,109 @@ +// +// PurchasesPlugin.swift +// PurchasesPlugin +// +// Created by Joshua Liebowitz on 6/27/22 +// +// the code for the plugin lives in CDVPurchasesPlugin + +import Foundation +import PurchasesHybridCommon +import RevenueCat + +@objc(CDVPurchasesPlugin) public class CDVPurchasesPlugin : CDVPlugin { + + public typealias DeferredPromotionalPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void + typealias HybridResponseBlock = ([String: Any]?, ErrorContainer?) -> Void + + var updatedCustomerInfoCallbackID: String! + var shouldPurchasePromoProductCallbackID: String? + var defermentBlocks: [DeferredPromotionalPurchaseBlock] = [] + + private var purchases: Purchases! + + @objc(setupDelegateCallback:) + func setupDelegateCallback(command: CDVInvokedUrlCommand) { + self.updatedCustomerInfoCallbackID = command.callbackId + let pluginResult = CDVPluginResult(status: .noResult) + pluginResult.setKeepCallbackAs(true) + self.commandDelegate.send(pluginResult, callbackId: command.callbackId) + } + + @objc(configure:) + func configure(command: CDVInvokedUrlCommand) { + guard let apiKey = command.arguments[0] as? String else { + self.sendBadParameterFor(command: command, parameterNamed: "apiKey", expectedType: String.self) + return + } + let appUserID = command.arguments[1] as? String + let purchasesAreCompletedBy = command.arguments[2] as? String ?? nil + let userDefaultsSuiteName = command.arguments[3] as? String + let storeKitVersion = command.arguments[4] as? String ?? "DEFAULT" + let shouldShowInAppMessagesAutomatically = command.arguments[6] as? Bool ?? true + + self.purchases = Purchases.configure(apiKey: apiKey, + appUserID: appUserID, + purchasesAreCompletedBy: purchasesAreCompletedBy, + userDefaultsSuiteName: userDefaultsSuiteName, + platformFlavor: self.platformFlavor, + platformFlavorVersion: self.platformFlavorVersion, + storeKitVersion: storeKitVersion, + dangerousSettings: nil, + shouldShowInAppMessagesAutomatically: shouldShowInAppMessagesAutomatically) + self.purchases.delegate = self + self.sendOKFor(command: command) + } + + @objc(setupShouldPurchasePromoProductCallback:) + func setupShouldPurchasePromoProductCallback(command: CDVInvokedUrlCommand) { + self.shouldPurchasePromoProductCallbackID = command.callbackId + } + + @objc(setDebugLogsEnabled:) + func setDebugLogsEnabled(command: CDVInvokedUrlCommand) { + guard let debugLogsEnabled = command.arguments[0] as? Bool else { + self.sendBadParameterFor(command: command, parameterNamed: "debugLogsEnabled", expectedType: Bool.self) + return + } + + CommonFunctionality.setDebugLogsEnabled(debugLogsEnabled) + self.sendOKFor(command: command) + } + + @objc(setLogLevel:) + func setLogLevel(command: CDVInvokedUrlCommand) { + guard let level = command.arguments[0] as? String else { + self.sendBadParameterFor(command: command, parameterNamed: "level", expectedType: String.self) + return + } + + CommonFunctionality.setLogLevel(level) + self.sendOKFor(command: command) + } + + @objc(setLogHandler:) + func setLogHandler(command: CDVInvokedUrlCommand) { + CommonFunctionality.setLogHander { logDetails in + let pluginResult = CDVPluginResult(status: .ok, messageAs: logDetails) + pluginResult.setKeepCallbackAs(true) + self.commandDelegate.send(pluginResult, callbackId: command.callbackId) + } + let pluginResult = CDVPluginResult(status: .noResult) + pluginResult.setKeepCallbackAs(true) + self.commandDelegate.send(pluginResult, callbackId: command.callbackId) + } + +} + + +extension CDVPurchasesPlugin { + + var platformFlavor: String { + return "cordova" + } + + var platformFlavorVersion: String { + return "7.3.1" + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/PrivacyInfo.xcprivacy b/e2e-tests/MaestroTestApp/platforms/ios/App/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..4b2e70fd --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/PrivacyInfo.xcprivacy @@ -0,0 +1,32 @@ + + + + + + NSPrivacyTracking + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + NSPrivacyTrackingDomains + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/Resources/README b/e2e-tests/MaestroTestApp/platforms/ios/App/Resources/README new file mode 100644 index 00000000..1872c8e9 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/Resources/README @@ -0,0 +1,20 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +Put resource files to embed in the app bundle here. diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/SceneDelegate.swift b/e2e-tests/MaestroTestApp/platforms/ios/App/SceneDelegate.swift new file mode 100644 index 00000000..82b592e7 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/SceneDelegate.swift @@ -0,0 +1,24 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +import Cordova + +class SceneDelegate: CDVSceneDelegate { +} + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/ViewController.swift b/e2e-tests/MaestroTestApp/platforms/ios/App/ViewController.swift new file mode 100644 index 00000000..0f29fed2 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/ViewController.swift @@ -0,0 +1,31 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +import Cordova + +#if compiler(>=6.1) +@objc @implementation +#else +@_objcImplementation +#endif +extension MainViewController { +} + +class ViewController: MainViewController { +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/App/config.xml b/e2e-tests/MaestroTestApp/platforms/ios/App/config.xml new file mode 100644 index 00000000..09797200 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/App/config.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MaestroTestApp + Maestro E2E test app for cordova-plugin-purchases + + + + + + + + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Podfile b/e2e-tests/MaestroTestApp/platforms/ios/Podfile new file mode 100644 index 00000000..e46af40e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Podfile @@ -0,0 +1,8 @@ +# DO NOT MODIFY -- auto-generated by Apache Cordova +source 'https://github.com/CocoaPods/Specs.git' +platform :ios, '16.0' +use_frameworks! +target 'App' do + project 'App.xcodeproj' + pod 'PurchasesHybridCommon', '17.41.1' +end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Podfile.lock b/e2e-tests/MaestroTestApp/platforms/ios/Podfile.lock new file mode 100644 index 00000000..a3129bf1 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Podfile.lock @@ -0,0 +1,20 @@ +PODS: + - PurchasesHybridCommon (17.41.1): + - RevenueCat (= 5.59.2) + - RevenueCat (5.59.2) + +DEPENDENCIES: + - PurchasesHybridCommon (= 17.41.1) + +SPEC REPOS: + https://github.com/CocoaPods/Specs.git: + - PurchasesHybridCommon + - RevenueCat + +SPEC CHECKSUMS: + PurchasesHybridCommon: 8eb130ab98f8dab600dea932a2599dee79304bb5 + RevenueCat: f352b3661288080bd8bf30bbb96e331940e73c88 + +PODFILE CHECKSUM: 7cb67c74098032c0a69396211bc157735e7d9ba6 + +COCOAPODS: 1.16.2 diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Manifest.lock b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Manifest.lock new file mode 100644 index 00000000..a3129bf1 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Manifest.lock @@ -0,0 +1,20 @@ +PODS: + - PurchasesHybridCommon (17.41.1): + - RevenueCat (= 5.59.2) + - RevenueCat (5.59.2) + +DEPENDENCIES: + - PurchasesHybridCommon (= 17.41.1) + +SPEC REPOS: + https://github.com/CocoaPods/Specs.git: + - PurchasesHybridCommon + - RevenueCat + +SPEC CHECKSUMS: + PurchasesHybridCommon: 8eb130ab98f8dab600dea932a2599dee79304bb5 + RevenueCat: f352b3661288080bd8bf30bbb96e331940e73c88 + +PODFILE CHECKSUM: 7cb67c74098032c0a69396211bc157735e7d9ba6 + +COCOAPODS: 1.16.2 diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Pods.xcodeproj/project.pbxproj b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Pods.xcodeproj/project.pbxproj new file mode 100644 index 00000000..b067f8b5 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Pods.xcodeproj/project.pbxproj @@ -0,0 +1,2791 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + 001602D3680D1D798A55CDA7C8806989 /* CachingTrialOrIntroPriceEligibilityChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22F6DD5AE3228D84A03651163F045D95 /* CachingTrialOrIntroPriceEligibilityChecker.swift */; }; + 01BFB44EEA988D5C06CF190F898AD5CA /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = E6C69D0347CD9870EDC9B9E2A36E2E16 /* PrivacyInfo.xcprivacy */; }; + 02E1542278A8D2D5859BFFA8C2813164 /* OfferingStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD6B3724C693795D04A81FBDDCA37D /* OfferingStrings.swift */; }; + 030C4AB920D4945E144BDDFB3796CBFD /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 421EAA5204753DA3639CE19B2FB2752D /* Configuration.swift */; }; + 03183A3B2358AA4112BC58439084EDA1 /* PurchasesHybridCommon-umbrella.h in Headers */ = {isa = PBXBuildFile; fileRef = A617AD237C24722AE93154558C4BD548 /* PurchasesHybridCommon-umbrella.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 033121D335DAB7DE8C1097A0A157A355 /* WebBillingProduct+SimulatedStoreProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 416DB5A58251BC0BA7DA6C8F7BE5E68C /* WebBillingProduct+SimulatedStoreProduct.swift */; }; + 03B6CCFD551AA712027E915231F2D5E3 /* CommonFunctionality.swift in Sources */ = {isa = PBXBuildFile; fileRef = 093422977BA2834C3C9EE9B19661FF77 /* CommonFunctionality.swift */; }; + 043AE6B23610A87FB2FD32380FD24DF9 /* ProductPaidPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8B8D1442C20FE45C844F1A8CF2FC978 /* ProductPaidPrice.swift */; }; + 04CDB63D6B40DCFBB65C16B951F53BFF /* AttributionStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C107B155D13EA52F7FFE15268FC1567 /* AttributionStrings.swift */; }; + 05A8D362305FBD5E683851F6838B0B56 /* PaywallFontManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2603375AE11B4D193BD0781F8C9E31F /* PaywallFontManagerType.swift */; }; + 05AA3E46F27FCB5D62907A5A687C70DC /* WebOfferingProductsCallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D8C79631CF5388121CCCFAAD1C90129 /* WebOfferingProductsCallback.swift */; }; + 07A59F29E59B77C88F03B6ECE606B3D6 /* HealthReportAvailabilityResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93CA4450613241C9258EF62F5E9908B6 /* HealthReportAvailabilityResponse.swift */; }; + 07F85EE88D25BE60F0E332D642D204F4 /* EmptyFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4146B5A85087D99E08A1A3BD37BD8606 /* EmptyFile.swift */; }; + 08186A0136D03D87561D6E38C3F4A663 /* PurchasesDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E572374C713EAE0F6925B0C4177016B /* PurchasesDiagnostics.swift */; }; + 091D391F1C499F73AA36F42F345A00CF /* SynchronizedUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1B2587339CB18DD341DDAD7C5BAD1E /* SynchronizedUserDefaults.swift */; }; + 09BA0309D2BACF6583F84B8E5DFB407D /* PaywallTabsComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AD5E61E36E46150F5EB885D9379D202 /* PaywallTabsComponent.swift */; }; + 09DC7E85F60D7186ECA3B9729BE7EB33 /* SubscriberAttribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC23020549490D059E953FE9D3A1EF9 /* SubscriberAttribute.swift */; }; + 0ADB6FCD9734BB9B0F11B62ACC65F715 /* UInt8+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C95818A043218391EF6DDF699C76F1 /* UInt8+Extensions.swift */; }; + 0C9437A2E830FCFD7769A6299724B3E6 /* AdEventStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 244DB81E8FD0371C546E639D39A9F21A /* AdEventStore.swift */; }; + 106641AB276CBAFF92012750D0211C75 /* StoredFeatureEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF9F718CE997930CE32118B4B91C4B82 /* StoredFeatureEvent.swift */; }; + 10A3B1D0006E841479F7899966C53BFE /* CustomerCenterConfigAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C2D7B43E931C1C9B757627A675B90B /* CustomerCenterConfigAPI.swift */; }; + 10EC64BFB17FECD8B875C8CB843CD363 /* SK1StoreProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D46CB95801AB522A4036DEC508DC5B6 /* SK1StoreProduct.swift */; }; + 10F4FD55E13BF23D01BBD6C3D188CCA4 /* AttributionTypeFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36C45E16C55EA2AE8F0D1E7CFF16B685 /* AttributionTypeFactory.swift */; }; + 1264A93DE7517ACD4F98008BC4B3BEBF /* StoredAdEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 609E66457F5C58D8FDEDD2F9C08CE662 /* StoredAdEvent.swift */; }; + 12B4AE6D86BD110458267EE6548EB932 /* PaywallColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC398DE3CBF2CF951D9F1956821D9D92 /* PaywallColor.swift */; }; + 131859884B3C435DFA786DDFB550A8C6 /* SimulatedStorePurchaseHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E4E5667EA6813DACB860344998405E /* SimulatedStorePurchaseHandler.swift */; }; + 13ECC7EC33ECA8C85DC2811C69CC2E19 /* ASIdManagerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73E16ABFCE212CEDA5D3816F74A918ED /* ASIdManagerProxy.swift */; }; + 1409E78A536830E13709AD12EAFD1D20 /* FeatureEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DF9870E61DE1C5D9647AF5CCB7BD04B /* FeatureEvent.swift */; }; + 148D0FDFA60D4FC5D3610A080C7E79BD /* TrialOrIntroPriceEligibilityChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31E998A2F775507890CED89B91DA99CC /* TrialOrIntroPriceEligibilityChecker.swift */; }; + 14E381357117FB5E25B3CB2D4A6070A7 /* PaywallComponentBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CFEAB3D036DEBADB4C70917B3971425 /* PaywallComponentBase.swift */; }; + 14E4C48C41BBA672B94EAAE59F45AABD /* PurchasesAreCompletedBy+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDE5AC53E0E96375CC1225DD7E66C583 /* PurchasesAreCompletedBy+HybridAdditions.swift */; }; + 1515DF0219C03EFA63EFCDE2A575096E /* LoggerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59F0EFFF8F045F27D410C4438F766BD3 /* LoggerType.swift */; }; + 152F5160ED3F8B7530021E3BC9B0B885 /* PaywallAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28302208340DDFACF06CF1E952BCD375 /* PaywallAnimation.swift */; }; + 1566C952453E9745B770FE20B8CA8DB3 /* PurchasesAreCompletedBy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDCED199AD06DAFF4A82B6206F88905 /* PurchasesAreCompletedBy.swift */; }; + 156B39BB4CAAA1CC6CA823BEE8DD84B8 /* IgnoreHashable.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17AE553BB4DF6C497F6AD3AE734D2F5 /* IgnoreHashable.swift */; }; + 15742E67D3780DD5E1ED310B56F9D192 /* Locale+Comparison.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BE26AC956D611F452BF266415E186C7 /* Locale+Comparison.swift */; }; + 16BDDF5C12987345DF60FEA70303FC34 /* PaywallsStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 493EE7C69C52E3CE4E013AAD381662DB /* PaywallsStrings.swift */; }; + 17AF2784A076FECBD67D0C12206718CA /* PurchaseParamsBuilder+HybridExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD2798C0929EA1B458ADB701AE1FFD5 /* PurchaseParamsBuilder+HybridExtensions.swift */; }; + 180D839229B3B662F65D8B8838D5B690 /* NSDate+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D31C9077CE472B5356BE6196B56FFE3F /* NSDate+HybridAdditions.swift */; }; + 1A81A29142D26A1B8EAF60C5D5BB2F28 /* SubscriptionInfo+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C189A64874CE704A5EB5F9E4814B82AF /* SubscriptionInfo+HybridAdditions.swift */; }; + 1BB0D75317F0646F5F673D0AF4473C6A /* PurchaseParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6C6D3C09F444D3FD7D1A51DB59C6DD /* PurchaseParams.swift */; }; + 1BBABCCF8C05DBA0BF23313C4EED1ECD /* PromotionalOffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E382909CD71BFADC9E5050D833E866 /* PromotionalOffer.swift */; }; + 1CCCDFE36E423640BB45B58EC5D3B6BE /* TimeInterval+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C223355B1D7BA7EF7055BA32F0132078 /* TimeInterval+Extensions.swift */; }; + 1CEFEF736D8E92EC0A86ED83DC62F947 /* RevenueCat-umbrella.h in Headers */ = {isa = PBXBuildFile; fileRef = 05CE0A318507A1B03E71FC3AE3B8A616 /* RevenueCat-umbrella.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 1DD7DC56178A4753AB7CD93B74860D01 /* VirtualCurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = A47F72C2370452B72036EC0A6F194747 /* VirtualCurrency.swift */; }; + 1F17AD87208F203FB6966A49AFE59E39 /* TestStoreProductDiscount.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACEEBBF233CE279392D2E23CD2EB2B5E /* TestStoreProductDiscount.swift */; }; + 1FCAA2B2EE9DEB70AD020C866AC0737B /* WebOfferingProductsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = E06D51866D5E4D037B1DF0A7248BB569 /* WebOfferingProductsResponse.swift */; }; + 1FDA6BD059FE84BC7AB93AF0A3B8C7FD /* PaywallVideoComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA9C293797882DD49FABBACE0A9A4C7A /* PaywallVideoComponent.swift */; }; + 20573DE31015C3DFEEF2C9073840BB09 /* StoreKit2ObserverModePurchaseDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7117D2E3139747D4C0AF1A2007E49862 /* StoreKit2ObserverModePurchaseDetector.swift */; }; + 206FF8E3E673450A193CD90B95D0EA72 /* EntitlementInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE57945CF7FB4E615C08C02571AEA631 /* EntitlementInfo.swift */; }; + 208340C06D4DA3F40D229407246FD4A1 /* EntitlementVerificationMode+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27B3A3294778ED9BEE5035B2AE722969 /* EntitlementVerificationMode+HybridAdditions.swift */; }; + 20B615542DF3C519130C52650E9DE1CB /* ProcessInfo+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB52161BBB21E3DB944AAD6127619230 /* ProcessInfo+Extensions.swift */; }; + 21041155EA9AAC704A2C275E02133F4E /* Dimension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982DD257A0E6F11A61B10C78C75219D1 /* Dimension.swift */; }; + 213005A27193AF89E6C69108C18E0454 /* ProductsFetcherSK1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BDA3EC3179F2D6F9366E6B08F62428E /* ProductsFetcherSK1.swift */; }; + 213F980DD8DA00BC54F6590CC4DA68D3 /* AnyEncodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5496A5BB6EFD4B1E2F477B637CB4E48D /* AnyEncodable.swift */; }; + 21691E8A6A56203ABEBB5644BD232747 /* Package+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 669737EF3E7F2EBAFEF056A15A64F9BC /* Package+HybridAdditions.swift */; }; + 2191A6742168C17FB9632D624A206768 /* DebugContentViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640C6F5ADC38815FB221C4355EAC255C /* DebugContentViews.swift */; }; + 21F61D7BDA1AEBDFD9FEC5DE13A74307 /* CommonPurchaseParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9667579EAAB94933AEA8860ECB03D7FD /* CommonPurchaseParams.swift */; }; + 2206DFEB66D51DB4A4B80502EDF906F6 /* Offering.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6722B0341B3AF578C8A665E4C784C884 /* Offering.swift */; }; + 2218ED4EBA9CE52918838D34B8EA4F3B /* Deprecations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E1E4328DE00E9A112E7B280A00D700 /* Deprecations.swift */; }; + 223D8C5B39CA7D94879522EEDEBF9EC0 /* SigningStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F37C08824D3E2BC8EFAD684F8DD2D30 /* SigningStrings.swift */; }; + 23640BE7C9C4E7874AED750478FC9A04 /* SimulatedStoreProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E844CEC04570752215BC22D972BE5D /* SimulatedStoreProduct.swift */; }; + 2377BEA5A8937BA519CBCA176F399C83 /* SystemInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF759005470A151F5C92C1ABCF08A1C /* SystemInfo.swift */; }; + 23BF085B8F093E4DCC370ECD4461CAE9 /* Pods-App-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 6D748E1EB3CD22A59197E91481E3FBE9 /* Pods-App-dummy.m */; }; + 2471BB2C5F6475EA1D566E721F7AD571 /* AnalyticsStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0A3E95AB022B7D010C4ECA6DCE48491 /* AnalyticsStrings.swift */; }; + 24D59F614C7F7DF7BF6F5BFE7E7798F7 /* ProductsManagerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43982CC961296BCB9016F25DF5636068 /* ProductsManagerFactory.swift */; }; + 25060BEFF76C4B7369DF4E8F4D7A36EC /* PaywallViewMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F15050F581CBB4C04E20BB331FDA3A /* PaywallViewMode.swift */; }; + 2579B7B799C41C0CECACDCA597517BF1 /* Dictionary+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCBBBCD0B4137EC4520A77CE9DB8616 /* Dictionary+Extensions.swift */; }; + 276F8B6366A20E819F8B35BCBC0B3A83 /* FeatureEventStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 953A994B046DCFCD2FEE652AD8AD2103 /* FeatureEventStore.swift */; }; + 278374A2154BB820AD45C86AE5C8C001 /* SK2StoreProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7802D37966EE1823520BCEF311C699 /* SK2StoreProduct.swift */; }; + 29DE1B55467F0D04EC4A300C21A0D88A /* LogInCallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CDB60101318931DC0BC33B504865A0B /* LogInCallback.swift */; }; + 2A0D491FC575852938DCC01611B838D4 /* EventsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04640A438AEB80D9CDC05F4498C45246 /* EventsManager.swift */; }; + 2A1344CCB1AB136C7ADCC7E1D3438B21 /* SKError+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A1ECCA9B0AFF30A5B3239C7337FEA0 /* SKError+Extensions.swift */; }; + 2A4DDD439C25E03965FB12619124E959 /* StoreMessageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA34C54BB65ECDFACD48E340F35CFAD /* StoreMessageType.swift */; }; + 2AE918F82916DD9EB251134CE888B904 /* ProductEntitlementMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57CF47D0307513EBACAECEEFE998F34D /* ProductEntitlementMapping.swift */; }; + 2B5236D888BF52D775DF0CCFF7BE3DB8 /* HealthOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ACD1A5A68737844A45D1D0FCB388D9A /* HealthOperation.swift */; }; + 2BEEDE76AB0D00EFBD78E793B071D7B3 /* IntroEligibility+HybridExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72F24EE29B2E7AA79A16E34349BD4FE7 /* IntroEligibility+HybridExtensions.swift */; }; + 2CDAE1C27C590A918B0FCD78AB1913B2 /* ISODurationFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66395F21F3D3732A876F3989EFDAACAB /* ISODurationFormatter.swift */; }; + 2D356B2490FDBBE760935EE7F2ECC501 /* FakeSigning.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EFE56FF515BDDBC227CB67D1730EFF /* FakeSigning.swift */; }; + 2E43C8EE5DE7DE000CA8132A2BF4195D /* WinBackOffer+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73A88F340A4914A1B38585A668F787ED /* WinBackOffer+HybridAdditions.swift */; }; + 2EDE750731D055CE89039F9DE66E2176 /* CustomerInfo+ActiveDates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 920E9A6F1FBF9A3E24676E196676231C /* CustomerInfo+ActiveDates.swift */; }; + 2F0B92E488BE14E847168B6E4ECE9E5F /* WebBillingHTTPRequestPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8951ED7C8061999BC1457F7F8F15F9E /* WebBillingHTTPRequestPath.swift */; }; + 2F49EC74AA36DBB0D436B9B790A92E78 /* Optional+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D73AAC4299967BF45F9FF01CB8787F /* Optional+Extensions.swift */; }; + 3076382EF535E9F1060CFC140617348F /* ReceiptParsingError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6F53A0BB095AE1E03AF662A04BDA52 /* ReceiptParsingError.swift */; }; + 308296D6BB22879826E82F568F6A6420 /* RevenueCat-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5CEC8A93A161ABEAA5C71216AAF12613 /* RevenueCat-dummy.m */; }; + 30E7269EB51FF197416794A96A97481A /* FeatureEventHTTPRequestPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF2593C6653218A284C93AB9622A4E53 /* FeatureEventHTTPRequestPath.swift */; }; + 3107ACB6D1A55D8A8C8DB0192DB69A03 /* ProductsManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C07C024B48CD014121D93FFE67D317D /* ProductsManagerType.swift */; }; + 3246AD757F5D3E065B6E22396BEB57BC /* DebugViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1309DABE4238A9917EB560A21A7DA63A /* DebugViewModel.swift */; }; + 32758E91A629958F6F9597D7F8B0BC14 /* NetworkStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 163559D4E96350598D8E7A1511432D47 /* NetworkStrings.swift */; }; + 327B6DE2691BA35C6FCC8FBC380A403F /* ProductEntitlementMappingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86DA289E7F22C32D00FC998DA306C51 /* ProductEntitlementMappingResponse.swift */; }; + 33F8A0FF2F9F88E58EADB444432FA656 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C21042E9373E06C1FF2749EEB2023EC3 /* StoreKit.framework */; }; + 34BD2C8AE79882E9CD1E4B20F3B83AD7 /* GetCustomerInfoOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 219AB6D5487157E08F7CE7DC21CBC1FF /* GetCustomerInfoOperation.swift */; }; + 34D26CD371C4C4A0C9413A96A2D06D1C /* Locale+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67DB6BB03C169A5A099A774B7A97BC20 /* Locale+Extensions.swift */; }; + 34D795D69C1896928A9844E252BB8552 /* OperationQueue+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA56B412BDF2C937EFB1687014EC5990 /* OperationQueue+Extensions.swift */; }; + 36235CC944CE2FC473A71E5BF5948A0E /* OfflineCustomerInfoCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 620A422511B4CA19BF6E2009C98AE691 /* OfflineCustomerInfoCreator.swift */; }; + 36A15A464BF447DD40144709F5ADDFB6 /* Storefront.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFE092025639D2E60A58D4B9686537B2 /* Storefront.swift */; }; + 36AB5D7CB2009E336A8B5AC837B261A1 /* EventsRequest+CustomerCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050860AE16CF6518B71B90904CA8F627 /* EventsRequest+CustomerCenter.swift */; }; + 378F1FB4146DC53F3E38FE12B83A4A23 /* PriceFormatterProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FDDFFADA25052A655640C88A443EC44 /* PriceFormatterProvider.swift */; }; + 3847043C02DE3BA5AED254C37AE3FCF3 /* AttributionData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DEBD207C132DF1952C479926EF40A2 /* AttributionData.swift */; }; + 3920B1552E7225DDF97B799DB049E613 /* VirtualCurrenciesAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD09B3518B3902E39C9B44C28578B656 /* VirtualCurrenciesAPI.swift */; }; + 39A7159CCC5FE2E6402B841C11F21A57 /* StoreKit1Wrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9885CBEA1078E82811ED1AB2167DAFCA /* StoreKit1Wrapper.swift */; }; + 3A0AB7D40C480569B52BB0C568F9A09B /* DispatchTimeInterval+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCDB0AFAEE59344FC83922BAFAA8F84F /* DispatchTimeInterval+Extensions.swift */; }; + 3AEBA4EAF76EE56781B8C11EFA181843 /* NonSubscriptionTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFC6733DC521170E9EC0E84187ABAC82 /* NonSubscriptionTransaction.swift */; }; + 3AFC91C477936253792D4D67F9929D0C /* CallbackCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C748BCC3EB0BE2C32357DD969EEC2957 /* CallbackCache.swift */; }; + 3B00006AFCB08F69DCFB35B6A74C62C1 /* PostRedeemWebPurchaseOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92CFDC0C567E64E8CD81EC9BE7A59DC7 /* PostRedeemWebPurchaseOperation.swift */; }; + 3CA663B0D64A682B6E10835DCD3CF6ED /* Signing.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6997D4CD82F1DBB88676A21436D1B00 /* Signing.swift */; }; + 3D072DD0150F8C6220A34AC6FBA4897A /* IdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E64E1C26D494FBF0137B5E18D5B02B5D /* IdentityManager.swift */; }; + 3D43F3A991DDCBF702DAFB921D0E045C /* Integer+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B5E9919901D1A85E2C8CE70E94B48B /* Integer+Extensions.swift */; }; + 3DCD237C98945FF5237F779681167768 /* EnsureNonEmptyCollectionDecodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 402596F229D0A94560681BDDA848E0B4 /* EnsureNonEmptyCollectionDecodable.swift */; }; + 3E88E03632FC38E5F995E32D8C901050 /* Store+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F52B96394F66BDADDF3D23F148FA8C4D /* Store+Extensions.swift */; }; + 3F6EC5931284554C8723F2136C18B2D6 /* TransactionPoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581324E62C9B9C66F986C485D64EC0C6 /* TransactionPoster.swift */; }; + 3F8316FB0AE284C8A7F3B0B1A1BD1567 /* LargeItemCacheType.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88512127CC7038A2B20295A1BE47EDA /* LargeItemCacheType.swift */; }; + 405E859EE0416E12CBCE6FD40BB3993E /* SwiftVersionCheck.swift in Sources */ = {isa = PBXBuildFile; fileRef = B862D15197CF3107C7B56884695C03BF /* SwiftVersionCheck.swift */; }; + 41B66027F6EB3ECD1889410068488926 /* OfflineEntitlementsStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45B358AABC35CB2D00DF8167FA1693D9 /* OfflineEntitlementsStrings.swift */; }; + 4245D221CFEF6AAB8D9F4EA719DE8396 /* StoreKit2TransactionListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8230177979D569F3B9611599288B690E /* StoreKit2TransactionListener.swift */; }; + 42693FAE530586342A09CF9EC588E5B2 /* PostOfferResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ECE680BBFCE5A638EAF2E3100B14519 /* PostOfferResponse.swift */; }; + 42BEDE9FED3C4B88BBB111407DEB9285 /* UIConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA708953532C337EB9E943F1E130927 /* UIConfig.swift */; }; + 4351539D4BCCD9C914D9F53BFBC07584 /* HTTPResponseBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FF3D640A2B8E39DDEB652E3C42552FE /* HTTPResponseBody.swift */; }; + 43870D37B89CBD1F87144783926160D7 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC9BC9A8E600BC4B32CFBD0FBA5B222F /* Atomic.swift */; }; + 44970CF5CBACB735A2E96D73CFC8D4F1 /* ErrorUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F5CC5B8C02683C98E9A43443D3C0005 /* ErrorUtils.swift */; }; + 44C7EF3324C7975E053E620CC0BEDF9C /* PaywallComponentLocalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB1B62E71CD509474F1D600D45F213A /* PaywallComponentLocalization.swift */; }; + 4537CCA3F7D854F8423527D1AD861FC6 /* DebugViewSheetPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5DE0B7AD6EE4A13EEDBA37EF4239ED3 /* DebugViewSheetPresentation.swift */; }; + 45D06B4201AC9DD5A44F16D0025773C0 /* PostAttributionDataOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C6ED52688C7A48D11AF18260782774E /* PostAttributionDataOperation.swift */; }; + 45D4F61E2302D7AD6F8DDA325F912E45 /* LocalReceiptFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE2736B26E9D8B3CF17719478794A9DB /* LocalReceiptFetcher.swift */; }; + 477F337DBB860F7FA41141DA6E8D2CAF /* StoreKitError+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57A8AE57A74B292C1510B351C293C0CA /* StoreKitError+Extensions.swift */; }; + 492EFD12496C5BBBFC5749C88C4071B3 /* VirtualCurrencyStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A835CD3320BF4219C95068A6A91A85 /* VirtualCurrencyStrings.swift */; }; + 4995A36CE81246962C7A1E55F3A1AC85 /* StoreKitWorkarounds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419AFCB98F8B32E3B67CBE20C5AD78EE /* StoreKitWorkarounds.swift */; }; + 49A9A167B76A20D10074132B1C53BEE8 /* SynchronizedLargeItemCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0163E5B4BF6BDC1A473065C2478040B /* SynchronizedLargeItemCache.swift */; }; + 4A30A63B65AF75F662364BA66ECA3DE3 /* DescribableError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBF8ECB2D76A2E8A1CA77B8CE8169924 /* DescribableError.swift */; }; + 4A480AA741A5F40A2D5078A3B5B079FB /* CacheStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D4E5CAC728FFED4E79837CA237069E2 /* CacheStatus.swift */; }; + 4AF96C30871F7CB233FCF1B47E5C654B /* PaywallIconComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8E77E31E19C324E81B04C28117B1A99 /* PaywallIconComponent.swift */; }; + 4AFCE3BDDAA0B1B4BE39C95DF95F35D6 /* AttributionFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66616998505D0C66379137483045A4DF /* AttributionFetcher.swift */; }; + 4C5D08E7EEE4B916161F07C0AAE6D5C4 /* SubscriptionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E38DB9CE7A8E23D6C8B7FEE1C4DA92 /* SubscriptionInfo.swift */; }; + 4C9CC1416B0122CB6519DF3A15A41029 /* ETagStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79F69394BA6293A6E77539088AB6A35B /* ETagStrings.swift */; }; + 4CCE38129034AAEB9608AA5800C7EAD9 /* StoreKit2StorefrontListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35158BFC494344C16B5ABBC1099786BF /* StoreKit2StorefrontListener.swift */; }; + 4D0111C816C4202EBCABD84588611821 /* SK1StoreProductDiscount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 276E2BC6FC3AF1829144103AC86A4FEC /* SK1StoreProductDiscount.swift */; }; + 4D34A508E2D2714BAFE6E4D2119A74DA /* Box.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89575BF68A8CEE956D14067908790200 /* Box.swift */; }; + 4D50158AF043606EAA5ADED80F15ED23 /* WebBillingProductsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E870720B12A477833EC662153EAA94 /* WebBillingProductsResponse.swift */; }; + 4D5C54021F5BC6B250F65AC2FFB2431F /* StoredAdEventSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4429BAC03814B14CCA05FA621E7945B4 /* StoredAdEventSerializer.swift */; }; + 4D6E07073A8D14E4AFA27A6475007323 /* CustomerInfoStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D16F31D095A5E8B7668451E9766B3C8B /* CustomerInfoStrings.swift */; }; + 4D6EC71482A650EAA558F7D1A228BAF4 /* PaywallStickyFooterComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267F75A0D2086BDC6DCC403A0CF82358 /* PaywallStickyFooterComponent.swift */; }; + 4DF1428C9BBD4E84EFB82EDDAE9709C2 /* ProductEntitlementMappingCallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AF8A07013B121F68476D9577FD026EC /* ProductEntitlementMappingCallback.swift */; }; + 4EDB5F0189CA9D0ED02DDE39F806AF5B /* RateLimiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E0093159D58F9F03FE9A34BDCDCE831 /* RateLimiter.swift */; }; + 4EF17FEDC16DDA63618ADAE6F884E77C /* CustomerInfoResponseHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54C6E8D902D8921B25981F3137A22646 /* CustomerInfoResponseHandler.swift */; }; + 4F1D869A61AC9895817A0D1E186516B1 /* PurchasesOrchestrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE040262DF475BDC6D9F04D91E9F6071 /* PurchasesOrchestrator.swift */; }; + 501533E437DC8A1D3358AE04129C3322 /* IdentityStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 678FD19DD03283F7C4DE61D0D2AC2379 /* IdentityStrings.swift */; }; + 515A661C6F29D2093AE703E342A4080C /* StoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3829F4F6709CEF1D465E1B090726BF /* StoreTransaction.swift */; }; + 517CCB855DCDD46EC3D8ED0DA41C2B96 /* EntitlementInfos.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21C6634D66B5478C5D81E580547D16F /* EntitlementInfos.swift */; }; + 51900CD62D52368F2A4624C539E755CD /* SK2BeginRefundRequestHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C453716CD6B42DA4DACF0707ACD5DD1B /* SK2BeginRefundRequestHelper.swift */; }; + 52810FA18198B6C56C3EF93B32F51080 /* SimpleNetworkServiceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D41ADD45E9FC908CBB9064A6CB0FE5C1 /* SimpleNetworkServiceType.swift */; }; + 52D7645AEFCC26A59FB1610F154FF794 /* PaywallEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43D3F8ED7D4AE425544CEF8B68DADFD /* PaywallEvent.swift */; }; + 53202F238900EBC88086410C61471CD5 /* PostIsPurchaseAllowedByRestoreBehaviorOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E068784BF9CDD02905819DA9274C3E /* PostIsPurchaseAllowedByRestoreBehaviorOperation.swift */; }; + 5351D5668E2B378389BB09AB962DABAD /* RedeemWebPurchaseAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95531205282ED304C416F800F98EA53F /* RedeemWebPurchaseAPI.swift */; }; + 53B4B42F7FD86E3F31C226E61261C96D /* OfferingsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B60F293050E52C352755F0B7DDBCDB2 /* OfferingsFactory.swift */; }; + 53CE3CF2A4F3A055984E482FA53D27B4 /* GetCustomerCenterConfigOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A743E94DDA53FDEB5D7426089CB278 /* GetCustomerCenterConfigOperation.swift */; }; + 5522B6C4005A1080C598A41E60C1D678 /* Lock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46428AF439B212CAE8C44D1B2187C9E9 /* Lock.swift */; }; + 55D8028893DADE1FA8510654182DBB25 /* OfferingsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DBA6A441AA1F122717DFA33959AACE3 /* OfferingsResponse.swift */; }; + 56C2B5FBB55D0C700D9C0C164AE07D37 /* StoreProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C458C2BE7CC01A36C8235C83BC8EBC /* StoreProduct.swift */; }; + 56D9EC3F3BDE13F44396F49859BE37AA /* PurchasesHybridCommon.h in Headers */ = {isa = PBXBuildFile; fileRef = FD0E802DBD924DC3546667A598B442B0 /* PurchasesHybridCommon.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 586114F07AFA49A0BD3D2B25689EA539 /* OfferingsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 574D90A224BA283F29C96807AD089D4D /* OfferingsAPI.swift */; }; + 586EE3D92C7EF6FBBBE671BD0849BC0B /* PurchasesType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05373132568C97719C8133092E0A62CC /* PurchasesType.swift */; }; + 5925A21D702BA8CB8C9A1DD7EFF6CA06 /* CustomerCenterEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E54FD6EBD1D6AF85E66D46923BEFDC /* CustomerCenterEvent.swift */; }; + 5987821275DA7D2D54545DE990E43E3F /* CustomerCenterConfigResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B3CC18E70A7CEC9AA5D543C14B079B /* CustomerCenterConfigResponse.swift */; }; + 59D4C3938D6B2424BD231E14777D0427 /* DangerousSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE6D62CB5B0507D61F0943DC5B7803F /* DangerousSettings.swift */; }; + 5ADC8C584D7D620CF86FCAC95A63761E /* SDKHealthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC5DED6775202924EF0C04B2E2A8F425 /* SDKHealthManager.swift */; }; + 5C05E3117F5DBD93C0C76B9A643EEBC8 /* LogIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC42359A1CA61861ADE99307393F1E0 /* LogIntent.swift */; }; + 5C0F7B99BB5D06D5681CC84531B894AA /* OfferingsCallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71FFD0EB89D5CDF617AF3EB7C1AC6DD /* OfferingsCallback.swift */; }; + 5CA1FC8A23AE6F619CE711089F093C39 /* CustomerInfo+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7106C3714AC8B3947AAF7682205D64DC /* CustomerInfo+HybridAdditions.swift */; }; + 5CAF071FB8EDFF15366291521C86F4F6 /* StoreKitRequestFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 741F1FE82F5563A51C18047143318D51 /* StoreKitRequestFetcher.swift */; }; + 5E659DB78ACF16037276DEDF6AAD3A5D /* PaymentAuthorizationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A53E1B1FF57A749F9042B85E80B999E /* PaymentAuthorizationProvider.swift */; }; + 5EC08752843008B7FA2C77868785B2E1 /* ErrorContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E7FA372B2126E960DD9345ACD310EC /* ErrorContainer.swift */; }; + 5EED0714A88E74BFCA2EA2114331CD65 /* PurchasesDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5259A4BCC0961A44A79FD7A37E3201F1 /* PurchasesDelegate.swift */; }; + 5F38C55DD408F6339712D08BB4A6279F /* URL+WebPurchaseRedemption.swift in Sources */ = {isa = PBXBuildFile; fileRef = C62B0FEC9381399A0CF313D658B62FB9 /* URL+WebPurchaseRedemption.swift */; }; + 6057874615E5DFDF77819375000B318C /* AsyncExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 411B45CA57486D588D69BE38012EBC60 /* AsyncExtensions.swift */; }; + 60C9149A5F902CEB43020283532E7B15 /* ExitOffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A92823154D5BF0558321E066DD375B81 /* ExitOffer.swift */; }; + 61011136C7896358E6EE825B52E44EC1 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 190C1838CF4C354E2B7F9EC2FD60CCA0 /* Logger.swift */; }; + 61D0AB83A1BF334C4F44E532C30AE0E5 /* HTTPRequest+Signing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F4600CE4F86683888FA412DD3200ABE /* HTTPRequest+Signing.swift */; }; + 61DE9725A7F168F8B27ECE5A6AEF263E /* PaywallPurchaseButtonComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A724FFF66F3706AF6B6F9F03DDCBD30 /* PaywallPurchaseButtonComponent.swift */; }; + 61E359193242F00C15B62EDA65305196 /* HTTPRequestTimeoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B782EEACC35423EE8396C87C2A44FDBD /* HTTPRequestTimeoutManager.swift */; }; + 624E5CDEA0823014638AA663F7BDB89D /* SK2AppTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DFF719C20AC527A6BC2DC98C963E13 /* SK2AppTransaction.swift */; }; + 625BD2AFC9EFFCD1F39237B27254B9F9 /* VirtualCurrencyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACF413EA2CEFBC52AFDBF08039BC2B1A /* VirtualCurrencyManager.swift */; }; + 62691C2B39C26024F110B743D49D35D5 /* Checksum.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4461612DEFB559B4AF58297EE6A4E5BF /* Checksum.swift */; }; + 626F76E0D8E45C0C562BAD8E06E43A5A /* EventsRequest+Paywall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 167781201460DD988699AD9F132B8239 /* EventsRequest+Paywall.swift */; }; + 6455060844D0E34AEF3B3EE3DEFBC38F /* AdEventsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0C17CB45538B27EDBE538A2174480E /* AdEventsRequest.swift */; }; + 65633024139A171F23654CD4E335976E /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 87A71B489ED684C9F56752C38BF66DF8 /* Foundation.framework */; }; + 65F39BF1ED8ADF7900D8E1A27AD22A0B /* DebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2A1A9A4FD00BFC410C13FDDD69ED238 /* DebugView.swift */; }; + 65F402692D84E0334C9BEF775C8A8799 /* RevenueCat-RevenueCat in Resources */ = {isa = PBXBuildFile; fileRef = D83AE5D93C653BDBFDDF59AC3D1E32BB /* RevenueCat-RevenueCat */; }; + 66D8491B3A36D4C420D5ABEB74EDC4C6 /* StoreKitVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55C188AC2C1A94092CD8E91790E6C08 /* StoreKitVersion.swift */; }; + 6783F4EB72088C6CDE3F2CD7E72A6DEA /* URLWithValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C3AA691053AC1526EA7AD7112D87EB9 /* URLWithValidation.swift */; }; + 67FA180A0D518DDCD2F3A499C9895F15 /* ProductsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F6ECC6C4DD98E625F6E37A6F6C399C8 /* ProductsManager.swift */; }; + 6883F9EBCA20083096B2D52AD3530F05 /* EntitlementInfos+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5377D542CC6A8DEE15A5FF76640C8D0D /* EntitlementInfos+HybridAdditions.swift */; }; + 68C2CA5FD79C893D82CF145A749D52E2 /* HTTPRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9577237390742DC703110594A2987D /* HTTPRequest.swift */; }; + 6911A2866BA68E26F43227BBB14332F7 /* NetworkOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A66AD16E450533FF112985315A1A6117 /* NetworkOperation.swift */; }; + 699F09290490B89C7ADA9A61DE216485 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7743565FD090970EC752509A9331B24 /* String+Extensions.swift */; }; + 6A4AE283A715A3537A23D34CC178CE48 /* ProductEntitlementMappingFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9878330F2E5A509C95464B5A9BDF996 /* ProductEntitlementMappingFetcher.swift */; }; + 6A883EC0F7E1C3C42185398701DB0187 /* NonSubscriptionTransaction+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF9D0CF26D635488D863AE5E82D2E617 /* NonSubscriptionTransaction+HybridAdditions.swift */; }; + 6BC16D6C4EF5D73855198617F3C570FE /* InternalAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6321C6CAE67AD0B90C77BDE9108FE3B2 /* InternalAPI.swift */; }; + 6C590FD333D3C1FA9D221016E847A422 /* Purchases+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12DE160C6746F68C2CD64AC5F7170E14 /* Purchases+HybridAdditions.swift */; }; + 6D421A2A914E6A0303FAF92EEB0C5A80 /* OfflineEntitlementsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFA6135F25B7790E77FF9616C066DB77 /* OfflineEntitlementsManager.swift */; }; + 6D86FE4F481EF154900291B74D4A8499 /* HTTPRequestPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB05F7D7A24B3D579504E3878AEA3208 /* HTTPRequestPath.swift */; }; + 6DA0F9994A547B5FFA7A205F4192AFD2 /* FileRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CFF995830964954DCE98FA86C0769AC /* FileRepository.swift */; }; + 6F16EBFB28BD79915C768BD45DFD8F94 /* PaymentQueueWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF6A2D87D8C4049AC7CDFE088303F699 /* PaymentQueueWrapper.swift */; }; + 6F29229B7DAF07FE2BD52D41A48EBCA6 /* IntroEligibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76DCD34563B705E96DD53FE3CC8659EB /* IntroEligibility.swift */; }; + 70425D2CF9B0EB920FA5274A3694E587 /* AdTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49962DEFE7B07C16FBD2C37C37CBBE1F /* AdTracker.swift */; }; + 70F9FFE974854A0332AFEFF01AFFD806 /* VirtualCurrenciesCallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0D5E64BCD5A731B05D799C3899BD45 /* VirtualCurrenciesCallback.swift */; }; + 71A4E7B1ABBA2010596B4796BD205747 /* PostSubscriberAttributesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B4C221F9E26239D362F074E8B3AB200 /* PostSubscriberAttributesOperation.swift */; }; + 71AAB546724AAAEC9F32A0827B397009 /* PaywallData+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0399EA39C6ECD0B8E9FF2EAC45A595A1 /* PaywallData+Localization.swift */; }; + 7256E12C68AAC32714327F166B7F22B4 /* EncodedAppleReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = C72682CB26A8A8798A45276B14DCD6FE /* EncodedAppleReceipt.swift */; }; + 72B32094C7DD9E4EC0B6809D97C3ED38 /* PurchaseOwnershipType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B27429EA2264D28F334AF7BA26B273E /* PurchaseOwnershipType+Extensions.swift */; }; + 737C86EBA24A886E53EE6DCCC2C986BF /* DiagnosticsHTTPRequestPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F16A164D387C89633751E0685FF7E70 /* DiagnosticsHTTPRequestPath.swift */; }; + 74419E4695126A2956F0510CFA6BE611 /* ReceiptFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9291FD1CC3EDC9A696B0E9D715A228CF /* ReceiptFetcher.swift */; }; + 747618F3DD2FF5CFF3923AA87F4184DD /* CustomerInfo+NonSubscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB9C59CAF6D0718768842AFA6937ACB7 /* CustomerInfo+NonSubscriptions.swift */; }; + 74FDA5758CB9026D3C1269DF1D925927 /* TransactionReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18BCDD139ADDE82AB46C6785541DFA6D /* TransactionReason.swift */; }; + 763E2F69BD19993134A8B6AEB1D89994 /* IdentityAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 794983798EEBEA8147747C6AA24D41A4 /* IdentityAPI.swift */; }; + 76AE94A1A86A32AD29413EAA5CA7A1BD /* Attribution.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB1A30CEB1B8DE6971D0078A39D1608D /* Attribution.swift */; }; + 76FF8B4502362A884D951F14109F5F10 /* WebPurchaseRedemptionResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 724E4C9353C10489676F511523A082CA /* WebPurchaseRedemptionResult.swift */; }; + 772206C12DD0B5CE7BD2E2A5FE46197E /* Assertions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43177DE9BFE35EAF4DDF828B198ED0F /* Assertions.swift */; }; + 7742BBC1F5D4443E3E1EBDCDE250A77E /* DiagnosticsTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14073EAA11882BFEA66F9D5446A20C01 /* DiagnosticsTracker.swift */; }; + 77C3355901AE40A27B546502B75E0129 /* VirtualCurrencies+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A738020BF03183667C17B8A12819E7E6 /* VirtualCurrencies+HybridAdditions.swift */; }; + 780EE2DE01CD3710169FE4D9D47BEEDD /* WinBackOfferEligibilityCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F52AACBDDB4E40E83FF56FA1DA1B314F /* WinBackOfferEligibilityCalculator.swift */; }; + 7819B2F212D80297276A833E670E9C8E /* GetWebBillingProductsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39085DB222F7F991FA160FDD2EA404DD /* GetWebBillingProductsOperation.swift */; }; + 784BF0E8882593223EACA3FF833E85CB /* DebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6727B22C2DD81436B87F5B07744FFED /* DebugViewController.swift */; }; + 7A301B3E33A2B101D7872CC170310629 /* RedirectLoggerTaskDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D758AE9F572BFC5F198EA01E9DF975E /* RedirectLoggerTaskDelegate.swift */; }; + 7AF61D3301BF23C426CBF119C8EFA692 /* SubscriberAttributesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EEF5C7982E9B875772855739D85E3A1 /* SubscriberAttributesManager.swift */; }; + 7B5198CCF1CFA02ED61EEB40CAA26E34 /* SDKHealthCheckStatus+Icon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9689278B1C8252427CB02806EDD0966C /* SDKHealthCheckStatus+Icon.swift */; }; + 7B81B7AAC93B9AD9A4A3BCD5577D9E30 /* PaywallExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171EF18DDC6787FAF20A5F9BF6D4BD8F /* PaywallExtensions.swift */; }; + 7BE399E51C92F93CFFE1EE6D5F913365 /* CodableStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585A42B7A612959DAFDF99089F7076E2 /* CodableStrings.swift */; }; + 7BF461163C11F166126C05049E220874 /* ManageSubscriptionsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE1F9C9F63501650001C13DA131E2616 /* ManageSubscriptionsHelper.swift */; }; + 7CAD224349EB3588B0A73F1464D5E580 /* StoreKit2PromotionalOfferPurchaseOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9F5CD71E870407DC34DB7812BF0C48 /* StoreKit2PromotionalOfferPurchaseOptions.swift */; }; + 7CBECC8C698BCBB1049FA8278BBE6D37 /* TransactionMetadataStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 861F711AED94FA2C6115C3A7757FD1A6 /* TransactionMetadataStrings.swift */; }; + 7DBBFC773DA0A00D66A543FED131F14F /* IsPurchaseAllowedByRestoreBehaviorResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370718947692D51A24D4CE246D0158C9 /* IsPurchaseAllowedByRestoreBehaviorResponse.swift */; }; + 7DFB68F5928E7F081CFEDF21659A8C66 /* StoreMessagesHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2E8626622CAC40EF643A18775639D0D /* StoreMessagesHelper.swift */; }; + 7E26F9F95EDE7747AB14F7BD5FF55340 /* InAppPurchase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 807C132E2A49B7544A75717CD942AE59 /* InAppPurchase.swift */; }; + 7E92EA37828F4E9F88E37497092D246F /* CustomerInfoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DDEB9434DD6BAA4198327BE0524964 /* CustomerInfoManager.swift */; }; + 7EB2E4F9725D1CFB15D63E6D62257B7E /* FrameworkDisambiguation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A79718881EF41876F0ACC9A8C44AB73 /* FrameworkDisambiguation.swift */; }; + 7F793D6609B0771DAB205DC80413F8A0 /* DiagnosticsEventsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09349528089AE3BF1C1ADA84D9101E55 /* DiagnosticsEventsRequest.swift */; }; + 7F923D4B265D853318B659E8C94EAFB0 /* InAppPurchaseBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29F9D1208D1C4BB939FC2F38F8B5E866 /* InAppPurchaseBuilder.swift */; }; + 8009EFBD68459BD7F154554CDAF0C476 /* BackendErrorStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38001264FD37589AF13374D1B55C3580 /* BackendErrorStrings.swift */; }; + 80C5E97875B2415C462721211CFCB32C /* DeepLinkParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2260E01F523D44DA02E6F1C279B3324B /* DeepLinkParser.swift */; }; + 822B45B6091245E60A73AAD509430F59 /* PaywallTextComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2A25D4575F6BE627919815E3E1F7DC /* PaywallTextComponent.swift */; }; + 829CF3270E4E5F05FE0CE9ADA8B12822 /* PurchasesReceiptParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E473F9E55BE0F0931A5F499C838EB5F /* PurchasesReceiptParser.swift */; }; + 83D077583C157AF74986453F33B08073 /* HealthReport+Validate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F72B95D42A565758D0A54C319A7A37A5 /* HealthReport+Validate.swift */; }; + 83DD040E6E7F68E92C206BE88A7B45A9 /* GetVirtualCurrenciesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3091166AE010D315AFE23BA999429369 /* GetVirtualCurrenciesOperation.swift */; }; + 853F20C7F5DCA7D4EC281364F5E277A5 /* DiagnosticsFileHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4675D69D85060AD0CCF95F2F433C4CB6 /* DiagnosticsFileHandler.swift */; }; + 862570941311AA81CEC0E70D64715D65 /* ReceiptParserLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC9EDB04491D60DB27AEF0131F56 /* ReceiptParserLogger.swift */; }; + 8665003E6691051A04F90065D92C03A5 /* AttributionNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF7E8C10125597CD5C46CA7F0A69A95 /* AttributionNetwork.swift */; }; + 86C07CAD53C2BD9945ADB4522C193583 /* DateExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3413C64E09BB73FF739C2F58A9CCB69B /* DateExtensions.swift */; }; + 8708176D793338D813D8DEE81E4C696E /* WebPurchaseRedemption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 472FC5C89285A9712BEABCDDD91FAAF5 /* WebPurchaseRedemption.swift */; }; + 88200664E20C83D20178541A941DDAEC /* HealthReportResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7746B40EE5186834473EF0C65F15A607 /* HealthReportResponse.swift */; }; + 897C85D1CFB8BE60EF2DA468FEC0A233 /* HTTPRequestBody+Signing.swift in Sources */ = {isa = PBXBuildFile; fileRef = A916D6FBB0F53B12D9BC92722540B52D /* HTTPRequestBody+Signing.swift */; }; + 89CC55C8ED64E48C2D7A72EBBC47963C /* StoreKit2PurchaseIntentListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCEA109D76F5730F49E697444344B367 /* StoreKit2PurchaseIntentListener.swift */; }; + 8A7D431240AFF8546554FC76593ACA98 /* HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22CD3659713C78546E489A2A66815B86 /* HTTPClient.swift */; }; + 8AEEC4076B85C1ED87BA1D0E028A3001 /* AttributionKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6618B807930840144456BE060ECF4A6 /* AttributionKey.swift */; }; + 8AF8CA1ECAA366030F181DBFAA3539B2 /* Signing+ResponseVerification.swift in Sources */ = {isa = PBXBuildFile; fileRef = B585AC5FD94D011C8EAB750F531DD9D4 /* Signing+ResponseVerification.swift */; }; + 8AF928B7B5596A441C8F6488EE215714 /* WebPurchaseRedemptionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 154AAA9A3B0432A54E8802E6C7AD0CAB /* WebPurchaseRedemptionHelper.swift */; }; + 8B1D04C144AC73F5B3B44393CA784D59 /* InMemoryCachedObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A3AB7080B77B6F0334F03A9C8B2D84 /* InMemoryCachedObject.swift */; }; + 8B93842F2A0FA79826FFB176092CA68A /* AttributionDataMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E068FB1DD1D8B6F0726EB4AF89FDAB3E /* AttributionDataMigrator.swift */; }; + 8D068435B740743AFC141359A635C6A6 /* PackageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4A49717BD402DBC31C202C38543A009 /* PackageType.swift */; }; + 8E41613FD4C11C71F3B715E34966553A /* ManageSubscriptionsStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C6DCE5AA49AC6AD95CAD94D34DC7107 /* ManageSubscriptionsStrings.swift */; }; + 8F1F6D04F6AF938E4148E9DD473D433A /* CustomerCenterPresentationMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E42E9F22C0F3B953366046EB72B1452A /* CustomerCenterPresentationMode.swift */; }; + 8F5121FB350068C2AAD309B7717E5E14 /* PurchasesHybridCommon-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5E636DE5DDB3B5BDF5FFC68F773CC609 /* PurchasesHybridCommon-dummy.m */; }; + 8F86481D857009B299B6076E890F2386 /* Result+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1334C5F2A9F962CA1F7F2F47425A447F /* Result+Extensions.swift */; }; + 8F88765D915734873DB5C3AEFF7D236E /* WinBackOffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69AEABD735DE880752B3A57DA9663F93 /* WinBackOffer.swift */; }; + 8F957D5CBF21994528EDE0E92C695455 /* PeriodType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C16D040BEC10E29CB27DE018724380 /* PeriodType+Extensions.swift */; }; + 90C75EA6052F479E1B4E3B755C52B5E4 /* SandboxEnvironmentDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0406D4D0D251FD0240084DDDD47A2AAC /* SandboxEnvironmentDetector.swift */; }; + 90ECDA89BFA33C09C0EA7CACB705E38D /* OperationDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D45B91C86E29354B695E56BD1795F6E0 /* OperationDispatcher.swift */; }; + 917EE0107F13098DFA3C601C16F4439D /* CacheFetchPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DF1E3F994BD3D68530C7CA1F5172F71 /* CacheFetchPolicy.swift */; }; + 91D926DFE01EE8CDE6598B81CFE8ABBD /* Enums+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A893E27001C650745F283602FF9C5 /* Enums+HybridAdditions.swift */; }; + 929B2D1E9201C129D1F99911AD4E4BC6 /* ETagManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37235A9E7F563EA5613B6127EEF9071 /* ETagManager.swift */; }; + 92A7CAA793F08187EFD059636A0F0B8A /* SimulatedStoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1C3EFF43297F918D2F35BA617C14C8 /* SimulatedStoreTransaction.swift */; }; + 9348952875E729527572646A4E96F13F /* StoreProduct+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CFCD5D3EF7A3B23BE90664F4C137688 /* StoreProduct+HybridAdditions.swift */; }; + 939417F0CD8A6316FD00164A9354FA7A /* Purchases+async.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB8B8C9C0633CE6C25F0B33F42EC298D /* Purchases+async.swift */; }; + 954021FD040EE33AD0E79B993266CF6E /* WebBillingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E405D92F1496D54E211947AE2BF80437 /* WebBillingAPI.swift */; }; + 9541631B9653458879686FCBAB3C62EF /* ConnectionErrorReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B60220F8AF0AD933C4600331FFC21F1 /* ConnectionErrorReason.swift */; }; + 954D0E7EC83418A5BA496C10200DC885 /* Border.swift in Sources */ = {isa = PBXBuildFile; fileRef = 254D224EE53AC495F856B7B6FAFE64FA /* Border.swift */; }; + 9630DBC2BEAF472CF6A6849A5ADD3FD0 /* WinBackOfferEligibilityCalculatorType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C80098D91C52427263BECAB1F82D20F /* WinBackOfferEligibilityCalculatorType.swift */; }; + 963E80788E5ADD9CDC5E340AE6A6C024 /* VirtualCurrenciesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3769014434C82BEC6EC59E4F1156A10 /* VirtualCurrenciesResponse.swift */; }; + 966AE973DE8439C5CB230AB8309AA9B3 /* Obsoletions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55FD547023F2A20FF4544B62F0E586CF /* Obsoletions.swift */; }; + 967E930166FD79D29E7F5948FA8ED5A4 /* PlatformInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4064ABA2F049CCB130535FF67203494 /* PlatformInfo.swift */; }; + 96A612B7F976E0B4D8E641ED758987BE /* StoredFeatureEventSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA26904737F608373C9C7B92857448C4 /* StoredFeatureEventSerializer.swift */; }; + 9722BD750D05A2A5760EDBC4FCAF9611 /* Offering+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 153B8FA4E5198A2A285226070C62493D /* Offering+HybridAdditions.swift */; }; + 9A1F93903A42A477256B361727DE8DB4 /* GetIntroEligibilityResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10B1AA5292666C89BB23EAA215885DDF /* GetIntroEligibilityResponse.swift */; }; + 9A6A9D2FEDA333096CC65CE0A824CC0F /* StoreKitVersion+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C61AF345273EF54AC3D469D70F0E0A8 /* StoreKitVersion+HybridAdditions.swift */; }; + 9A780CB6C011F7FB63EAC26167D9617E /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ADC5CF5FB80DF59ABBB2AA88301A337 /* NetworkError.swift */; }; + 9B3C4DE8E8018B995E6837023D39ECAF /* ErrorCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECB122B30DFFF8EA3C1D320B8CFA0735 /* ErrorCode.swift */; }; + 9D229E91428C0F33D6FC67A756522D71 /* ReceiptRefreshPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 181E1EE6E30B8FC9E1493E8579F6148D /* ReceiptRefreshPolicy.swift */; }; + 9EC4720EF15F220A22D6624FC065D7D9 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 87A71B489ED684C9F56752C38BF66DF8 /* Foundation.framework */; }; + 9EC557C2B69D43E3754F865D87FBBA25 /* TestStoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9ECBD5A10BECFD6D6B78D9B18815437 /* TestStoreTransaction.swift */; }; + 9EE03FCDD1577723000E62E384E389F8 /* DateProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DAEC63578C194A8F591F33C0A58342 /* DateProvider.swift */; }; + 9F3C9F43E51710EAB7D3D4C91EE3C7C6 /* Either.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EA2D22D2E6CA3B7F5956EC6500603A5 /* Either.swift */; }; + 9F45845EC907C7D3E8EC5CBF358C9F65 /* ErrorResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884B518EF2556A1388CDEBB756A896B /* ErrorResponse.swift */; }; + 9F60DC3031BEE3F5F12C70726312349B /* DiagnosticsPostOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51058E8152DEB2BF828A53F4AD450B01 /* DiagnosticsPostOperation.swift */; }; + A05D19095E9514CD12EEA8B7DE67FAA1 /* PurchasesHybridCommon-PurchasesHybridCommon in Resources */ = {isa = PBXBuildFile; fileRef = 3AA9124C018EA7BF6460091D86216692 /* PurchasesHybridCommon-PurchasesHybridCommon */; }; + A093A62DF992EB27B7113BC2016C6982 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 87A71B489ED684C9F56752C38BF66DF8 /* Foundation.framework */; }; + A0C13F33ACA277E525CB93548C576AB5 /* EntitlementInfo+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB6178E26439BA8FBAAB00CFD0DD1CE /* EntitlementInfo+HybridAdditions.swift */; }; + A11EBD6CCE3111CE42181721BB84A904 /* PaywallV2CacheWarming.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34416EDC539D29D5CF431CD65CEE0A3C /* PaywallV2CacheWarming.swift */; }; + A13D3A2C9C9DF25E4DC35F306F82915C /* IntroEligibilityCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C19E63EBCB8B1A3E5DC6B1417EC36C8 /* IntroEligibilityCalculator.swift */; }; + A1B28F49322BEED2B0403265DE934BBD /* Array+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 042E2E05C8AAA27935046E9B1CC28EEE /* Array+Extensions.swift */; }; + A1EF5406367450EA2FA41E0E79E9A0D1 /* BackendErrorCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60AE353D45C0E32FD77A16F2525C134 /* BackendErrorCode.swift */; }; + A31A78934D6EDB7302F56AC436DAED00 /* UserDefaults+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 741E28294C6E366139D9B1A98FF4D201 /* UserDefaults+Extensions.swift */; }; + A39B25F4B8AC63AF7A0DD44B03199E33 /* AnyDecodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E58C73BFF7155DD4AB1509DC65F6F6AA /* AnyDecodable.swift */; }; + A3E05972292D1D8C3574B2A158737E7F /* ProductRequestData+Initialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2403A13AAAE0FE4A1CEA7184A02C573 /* ProductRequestData+Initialization.swift */; }; + A46F46AA15270BCAD792746BB0D04CC0 /* WebBillingProductsCallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA50CB4C6E0ABB414E82FBFBC3B5B259 /* WebBillingProductsCallback.swift */; }; + A494E13FA39145F50323095B61A9F57C /* PostAdServicesTokenOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56A90A644DA1F65D4BABA53C91FB3839 /* PostAdServicesTokenOperation.swift */; }; + A4E7E2ABFDDB45A490DB0BDE92DAAEDB /* PaywallTimelineComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E69D0ED836DC2584E63FEA604B3AA390 /* PaywallTimelineComponent.swift */; }; + A630B3770983BFA4E1062A4A206AAB98 /* OfferingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65CAC8117AD855E94F9AD43DE1CEE746 /* OfferingsManager.swift */; }; + A71B299E41E020C2BC475091C4320F38 /* CustomerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614C8BDF4EF615DC21C0252EC9C68B1A /* CustomerInfo.swift */; }; + A7FD09C1F62919A6631C9447C9FFA244 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A65AECEC43CBADA572E58C2084B63AF0 /* Data+Extensions.swift */; }; + A915CFF4FC0215FE996E2CF88D55B3FE /* ASN1ContainerBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA3121E4FE255F17E267E7B7E5D9DE0 /* ASN1ContainerBuilder.swift */; }; + A99D9A97FBCF14F226E24723A7910C8A /* SubscriptionPeriod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067849C6531AF8172F2B92E8C70E55AC /* SubscriptionPeriod.swift */; }; + AA2393F2E17333DAA72DD5003E79B56D /* ProductStatus+Icon.swift in Sources */ = {isa = PBXBuildFile; fileRef = C48FDD55070B9A0A8C52F0337FEAB59F /* ProductStatus+Icon.swift */; }; + AA77866411E19FB9D6D9B78F214667B8 /* AppleReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89B36287D3878611A40796176083DBE4 /* AppleReceipt.swift */; }; + AA7A0B547083A7449975B1845E17D74A /* PurchasedProductsFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1898596839397C9AD5DA180A888003F5 /* PurchasedProductsFetcher.swift */; }; + AB7713C6FC578FE6FB5E0ED725B6C823 /* ProductRequestData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B109616F0DBB8B553145755A69337FE8 /* ProductRequestData.swift */; }; + AC007C5CC26D3C43D3979B33C60C3CD4 /* CachingProductsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B02F3C6E2F57EE78C8EF72930F25E8A /* CachingProductsManager.swift */; }; + AC342B627EC87B9B653555D36B18EEA4 /* LogInOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12CCC30F9D4787599E16E9D03D5AA781 /* LogInOperation.swift */; }; + ACC3BE0CDB49D4B1FF8C850B90E8368D /* SK2StoreProductDiscount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86F3ED86A3C76F0AA6BED070C70CF4BE /* SK2StoreProductDiscount.swift */; }; + ACD871A1CA99981795758CBC85256AE8 /* StoreKitErrorHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 464B54FC3CD735207FE9083CB9904FED /* StoreKitErrorHelper.swift */; }; + ADDC57AE1CCD0B526EAADE6ADB77AC48 /* EventsHTTPRequestPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B8724774D8EAA3D595E2AD2D6F0A2DA /* EventsHTTPRequestPath.swift */; }; + AEC3A5F2ECBB090DCE4B0A5ABEB3DA9F /* Clock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 339746A8D84F4A4528121114106A6473 /* Clock.swift */; }; + AF979601CEF7E72C1239B2C5C0C0073E /* DNSChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13771028F9A83D1381BAB98A92991E48 /* DNSChecker.swift */; }; + AF9833D45A51C28186500A67ECBE5BEC /* PaywallStackComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0988BF1A4E391F5D1E424FD6ECFF537 /* PaywallStackComponent.swift */; }; + AF9854824B9384417638A854FFDE4790 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 50A6A3B1299C884DECA9398CA5434A53 /* PrivacyInfo.xcprivacy */; }; + B12F5B7D39A17ADFEA70C32D5EC30CE5 /* ArraySlice_UInt8+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A5A36BE17EDC4996FA118995EFF95A3 /* ArraySlice_UInt8+Extensions.swift */; }; + B1DB0CF5F53E8E4FF5197EBEF12C0308 /* RawDataContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 492DC2973A8438B3FD8BC91EE2D1C3AF /* RawDataContainer.swift */; }; + B3126D5E3BFB1E308624F3DF43C4D248 /* PurchasesError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36B3F59C973860A8269FBAC688EF48C8 /* PurchasesError.swift */; }; + B36A384567B248BE57D25650874C60EE /* HealthReportOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2942D1070605FA57789397BE3734694 /* HealthReportOperation.swift */; }; + B43EC17C209109CEAA80252ACAB47DF8 /* HealthReportAvailabilityOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F929B4C2C5B3A0269A40D220994CFB9 /* HealthReportAvailabilityOperation.swift */; }; + B46898EA7DC1D219FB751D276307F4FA /* IsPurchaseAllowedByRestoreBehaviorCallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 125FD7AF9118CF5729AF7B557AF935A8 /* IsPurchaseAllowedByRestoreBehaviorCallback.swift */; }; + B5911DA4A44191BF54F3884779787DB4 /* IOSAPIAvailabilityChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECABB8B06C508A1B0CFD0A60244244B5 /* IOSAPIAvailabilityChecker.swift */; }; + B63B5E6E34ED0F380A4AFB04069E2B18 /* AdHTTPRequestPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C74858ACCB6718D09F9A76A94E45922 /* AdHTTPRequestPath.swift */; }; + B7B57DEE52B2B092F409EB6F1E227D2D /* TransactionsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 881041EB30032A832A0F458D97E93162 /* TransactionsFactory.swift */; }; + B8B7151332606D3D4807004DFDEDA602 /* CacheStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FFBAFA262DFF87E003DA4F5E7D85D01 /* CacheStrings.swift */; }; + B8CB1C1FC3F33EECBBD296220920E42A /* GetProductEntitlementMappingOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFD39CF476F2A473004A7DDCD750F056 /* GetProductEntitlementMappingOperation.swift */; }; + B9B9D6096020353B530D6CC1D4852FB8 /* ReservedSubscriberAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72D231BD7B9A3D4F83AF06454B15416 /* ReservedSubscriberAttributes.swift */; }; + BB9EB99AB72DACF1697085BD308515A9 /* TrackingManagerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28909F20999A48234075586D77F3D448 /* TrackingManagerProxy.swift */; }; + BC06E4B60F818A12740499225E78A417 /* GetWebOfferingProductsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA54EC4A66CAF29728AD641B5EC7462 /* GetWebOfferingProductsOperation.swift */; }; + BC337DB759014BA9A848C8A127A8E000 /* PurchaseOwnershipType.swift in Sources */ = {isa = PBXBuildFile; fileRef = B313A8CDB736DB52EDDD3634F8131A94 /* PurchaseOwnershipType.swift */; }; + BD48C95A7EA78365D974A0124E1BE62E /* PaywallTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614A2D3670D47E6D1CA8428911162808 /* PaywallTransition.swift */; }; + BDBD3F8531DA20BA63A631D71D209B5E /* NonEmptyStringDecodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FDBADD2C97E2A55FDF2261BA910C2D2 /* NonEmptyStringDecodable.swift */; }; + BE842972F21FE22B5803128040E2A0CA /* ProductsRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1AE29494FF679ED68FC6191B1007F9 /* ProductsRequestFactory.swift */; }; + BFE5D0420E2FBBDFE558DAF5C03498C4 /* PurchaseStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A23286ED5558C93B7CD6515FDFF13D6B /* PurchaseStrings.swift */; }; + C0787A171FA2D751F4946AC3EBE99EF0 /* HTTPStatusCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BA2B0446F8676CF6AFB955BC90BCF40 /* HTTPStatusCode.swift */; }; + C08BD9D29FF795235836F0DB1B140307 /* ReceiptStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC56A666004484EE48980FB66564EA4 /* ReceiptStrings.swift */; }; + C0EBC01260388290B43203BA88D0D011 /* CustomerInfo+OfflineEntitlements.swift in Sources */ = {isa = PBXBuildFile; fileRef = 204CC1573F6C6826DCCCCADDFE4D2458 /* CustomerInfo+OfflineEntitlements.swift */; }; + C10AFAFC73FC999742DC38C505C4ED4C /* OfflineEntitlementsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0022D458214DD88C567CC6ADA1AA796 /* OfflineEntitlementsAPI.swift */; }; + C19C4F122086D1B2EC60182C91C3EF4A /* PaywallComponentPropertyTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D58E95383516C9168038CFA612FAB5 /* PaywallComponentPropertyTypes.swift */; }; + C1FF83EF8C0E2E49AD87DEA5EBD0154D /* FileRepositoryStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38EDA47D097A5C9EB5899D446B2AB23B /* FileRepositoryStrings.swift */; }; + C37877E7BE08E313E0239948775E8604 /* DateFormatter+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3CED652BC3338C72625BF4EC37592C /* DateFormatter+Extensions.swift */; }; + C40942B092E7382C3D80DC5C389D736C /* EligibilityStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 609D91E64EC4D4A6D042D33246E4A6AD /* EligibilityStrings.swift */; }; + C4486D13B4187D2F6631CEF197088440 /* PostFeatureEventsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92791C0DE028C10279B0A9C37325E3BB /* PostFeatureEventsOperation.swift */; }; + C497D9F2D7CF9A3139F931212AAA229E /* Offerings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 965338A4A31115163DF10EB0DAB5A35F /* Offerings.swift */; }; + C4C5A2513784F2D139190C7C2779238A /* TimingUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = A22F90A4358F5D13F7E0BF83F7DABD18 /* TimingUtil.swift */; }; + C4DFEBD184F7D3E5DBC21DA205AB5FF3 /* DeviceCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD2628DBF8716B69F39514E51E899E8 /* DeviceCache.swift */; }; + C51BF6C3E3CE4B055CAD1DFD243F4501 /* CustomerInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92CFD404DC47FA588A5FCDBD87FE890A /* CustomerInfoResponse.swift */; }; + C524453D31C15223446D9DF4E856A117 /* PromotionalOffer+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A6CDA6B79CEF8E8C41348883E4B7D6B /* PromotionalOffer+HybridAdditions.swift */; }; + C5B982498BFBD84F5D0AD4EC5E7FBEA1 /* ConfigureStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DFE5F44BA4F88D8736526122B43005 /* ConfigureStrings.swift */; }; + C5E92926FE1B42120709EDA8524842B9 /* LocalTransactionMetadataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0903967F6943A1CFA693D640698292 /* LocalTransactionMetadataStore.swift */; }; + C61D4C05EC2F02FF6157FBB228784A19 /* DiagnosticsStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA483A3A60C364D45C2DC333D452445 /* DiagnosticsStrings.swift */; }; + C6B34F3A3347FC317ABBFB2DBF7962CE /* UIApplication+RCExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA9B90A92058E60129DF12B69DC9B22C /* UIApplication+RCExtensions.swift */; }; + C71A17355E7F729ACE56A36F76FFB396 /* Purchases+nonasync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D6433F205FDA9C6CA5BF3413B1A6544 /* Purchases+nonasync.swift */; }; + C9911D0E33DB5ACA693F5161033F688B /* PaywallCarouselComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC22E86D5F7D7434FC244609913CDA0F /* PaywallCarouselComponent.swift */; }; + C9C3DBB60EDD836FE5542EACF92603A3 /* StorefrontProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBF1CC7C6CDEDA70A5B1B4A698B43255 /* StorefrontProvider.swift */; }; + CA33BC7DD153E27579F689CDB273E6D6 /* FatalErrorUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 573585F29EDFC095922D625F0D3B18D8 /* FatalErrorUtil.swift */; }; + CB7BCFB33043BAFEDD6C9CE15A47ED42 /* Backend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12090462D9731132865898CF0C5FB3FD /* Backend.swift */; }; + CC1DA1DCA4BEA977787BCC17C039CB7D /* PaywallCacheWarming.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DA853BC3FDB08FEDFB505FA5E0C6173 /* PaywallCacheWarming.swift */; }; + CC76A950588A6C13FCCF7AFB6FEDB475 /* StoreEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 910374F9FAEC162807F13EA3A8C85814 /* StoreEnvironment.swift */; }; + CCB5D0C444055BFDC81D53B54A30ACC6 /* ErrorDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EE0CE490CF7D279BCAC2D9008011A53 /* ErrorDetails.swift */; }; + CD027B2DD4808DA282F2F36C61D210A2 /* CustomerAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A64F0DFB604EFF49C3D9E0B8E4237B /* CustomerAPI.swift */; }; + CD608C8E934B7B25F1EFA643D1DBC531 /* FileHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF15DBECBD34E96E716BA9E933D2A96 /* FileHandler.swift */; }; + CFAD65732AA47E2E7B6078E390F9B3BA /* ASN1ObjectIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C826229AC0D01A9A1DCAFD3FA49D664 /* ASN1ObjectIdentifier.swift */; }; + CFC0971372AF616EFDB66BBD3ED1D554 /* ProductType.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D077092FF4842866C6ADEAEA1F7656 /* ProductType.swift */; }; + D0BDD146E036E53D6C4377121470A0AE /* SimulatedStoreProductsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C639366CDC49C8E10C63953916B94A /* SimulatedStoreProductsManager.swift */; }; + D14222F2BE3D20ADD09C6B163B80877D /* TestStoreProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48ED6D60D075E6F7AEF9F00648E6578B /* TestStoreProduct.swift */; }; + D1EBE83909E908C2FE17803BBD1D8749 /* PaywallCountdownComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6258085318619BB7C3D5AE8E2F820D /* PaywallCountdownComponent.swift */; }; + D21CA64CD17B1D2F4F9DD1C6D1E5A1E4 /* DiagnosticsSynchronizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9551D15B300B9E49AB65E0AD100281DB /* DiagnosticsSynchronizer.swift */; }; + D2A2D1F3F6F9284AAF2CE0F0CC9652DF /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C21042E9373E06C1FF2749EEB2023EC3 /* StoreKit.framework */; }; + D2EA911BF05E202B311D755DB5C43D81 /* GetOfferingsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DCDC2ECAE3A42D908B510F6E822DB23 /* GetOfferingsOperation.swift */; }; + D30B4DB5DB2894428DD65EE18C73E57D /* ISOPeriodFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F49C84CFA1863E7868E02361FBB88D1 /* ISOPeriodFormatter.swift */; }; + D32850FA660E05D72D8600D49FDC45A9 /* VerificationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CC8A563BB74D631FDCBB5C81A713BD /* VerificationResult.swift */; }; + D3CE6AB8A9F13F5F6CFCA33483BD26F4 /* StoreKit2Receipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235323C5CE7B865F32673CFFCD7AABAF /* StoreKit2Receipt.swift */; }; + D47A2A68E3C44BE5D93D241403171340 /* GetIntroEligibilityOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A22950F419E154E9F7E62484B3CC02 /* GetIntroEligibilityOperation.swift */; }; + D4DC5EF6A39ABF5419B24DA4719BA0D6 /* Background.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20084C04DD4D0456F699564398A40991 /* Background.swift */; }; + D5724DED5E59DB0B69944DD19A7A76FD /* SDKHealthStatus+Icon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7807B1C6EB2D20BFF2529D4C0FF0040E /* SDKHealthStatus+Icon.swift */; }; + D5A739E830743C21564B04F620C23262 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8992CDB73A6B912E0847E88CDC2A50E4 /* Strings.swift */; }; + D5CDAAE0B97EB7C65DD5B77F004AC24F /* BeginRefundRequestHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A355796FDA7429F6C3F8AEECF26D13B /* BeginRefundRequestHelper.swift */; }; + D676EC928CF04E162D3B97B0623901D1 /* PostAdEventsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA4FE0D817A656D07A8BECEA1FA4048 /* PostAdEventsOperation.swift */; }; + D78890D9555AC822C78851ADFD587D27 /* LocalTransactionMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75F61E994F0997F8CE394AAC208B4DC8 /* LocalTransactionMetadata.swift */; }; + D7A4AAE763DE5C21E9320332071185CF /* Decoder+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C72F237269BCD25DF95B5E2164B13E /* Decoder+Extensions.swift */; }; + D7A4E34586ECC6E5D9DC63D6E9F61649 /* SK2StoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C18D1196A56E0AFB6F74E4B0D3575CB /* SK2StoreTransaction.swift */; }; + D8A65A63D27980274ABF72BC3C6A46FC /* StoreProductDiscount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EDEA4635193B221F9EE2E133BC48482 /* StoreProductDiscount.swift */; }; + D8CE88E583458C9A6FFE816B81DC3FA6 /* PostOfferForSigningOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66FEA9280C5974DC10249711D910CB10 /* PostOfferForSigningOperation.swift */; }; + D9751900FB7AED06B4C1C181793CB8A4 /* DiagnosticsEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D57A0A13A7C4FE366352708CEC295C5 /* DiagnosticsEvent.swift */; }; + D97FD83BB13F2936D4D159C25D47B11D /* PaywallData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39331A8229EED7942827BCE62A01F4D /* PaywallData.swift */; }; + D9D214FCD2F31E8B3A9286E2D6408142 /* SDKHealthError+CustomNSError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFC2C7BF166DA2E780161AEDA0440880 /* SDKHealthError+CustomNSError.swift */; }; + DA43B7486BD9A21FBCD6B73FF4C10777 /* PaywallPackageComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515D62CFA3C38D397A27B63D5F56FB63 /* PaywallPackageComponent.swift */; }; + DA5265CB90A53B414716CF394DD60332 /* AdEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D9DD7E59B0917282E2931D14844DE58 /* AdEvent.swift */; }; + DA75E9E46C62030FF02C65FD64436F1F /* TransactionMetadataSyncHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE6C8B273904DBD8CF1DCAD8E07F42C /* TransactionMetadataSyncHelper.swift */; }; + DB3C51563385F086DB26C27EBB3D5D7E /* ProductsFetcherSK2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F62083A4B0658AE77BFA307A544DBD2 /* ProductsFetcherSK2.swift */; }; + DC047090760CE5550E9E06946441EFDD /* Purchases.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FA5026DD91A2DED9B052E3E1198B3A6 /* Purchases.swift */; }; + DCF7D0917F451C871DE8F3BF00E7A719 /* StoreKitStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94BCD2231C5EFE24C396F26F671628F4 /* StoreKitStrings.swift */; }; + DD0FCF9D527DD230105686159BC701E6 /* VirtualCurrency+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F5DABF3AB2AFACD4C62AF610BCACD83 /* VirtualCurrency+HybridAdditions.swift */; }; + DD1CC65A882933401221C4B69BEEB1E7 /* AttributionPoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C76ACA0029FE791E430477CF5488BF /* AttributionPoster.swift */; }; + DEFC0D6136E06F4C84440534FE74CB99 /* Codable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C09FB99B96B83ECDA4C9A114BF61B515 /* Codable+Extensions.swift */; }; + E00E7FEC38BA0C560A4A1D7E8CD8E847 /* PostReceiptDataOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49C2C260F2FB279A9C18BB6EB020510 /* PostReceiptDataOperation.swift */; }; + E09CBD1852D8718B8A97CE089CC13162 /* BackendError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 240B1A0BC46B3F7DCC0B4F0A790AC972 /* BackendError.swift */; }; + E106FBD688BB86B42FF7DA63820A2DB0 /* CustomerCenterConfigData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C729B739E6AE47DBF843097F81DEB596 /* CustomerCenterConfigData.swift */; }; + E1210474FADC31DCB701CCF595BCB678 /* HTTPResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09B5AA42CE08338CF0FC191DB815A38F /* HTTPResponse.swift */; }; + E192696EF2F37D23D3A366595667D7F5 /* TransactionsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85CBFB8711101557F06EE0748753997E /* TransactionsManager.swift */; }; + E2295E3C99C8E9ED05E9CE2F0BF2DFD1 /* PaywallComponentsData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DADB2F738C652A10125B3D891E321E4 /* PaywallComponentsData.swift */; }; + E245DB241936662149239F251BAF8F76 /* Operators+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE96A3D55B67224E00ED86E5867DD3C3 /* Operators+Extensions.swift */; }; + E265123CB9C97B72F2CC272BC5D98737 /* DefaultDecodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C51AE102965B03FA08BD6F7BEC4950A /* DefaultDecodable.swift */; }; + E2E63416874647E29FABFD5F4457703B /* TransactionNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B99AC8998CBD5E39EAF55D3A285403C /* TransactionNotifications.swift */; }; + E38883BAA6278EAECB9299E1B38AC69C /* PreferredLocalesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382B9CE21DD4DC115C8E591FCD6FD124 /* PreferredLocalesProvider.swift */; }; + E5485009A0D5F2CDA6ED0177ECAFE746 /* PurchasedSK2Product.swift in Sources */ = {isa = PBXBuildFile; fileRef = C546CE2528372F2E3CE7F7FF44AA8B0C /* PurchasedSK2Product.swift */; }; + E69CAD7E81C16443125E4DA3E4D96480 /* PaywallButtonComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6559E0AA4A21488E5AF0280AAA41E469 /* PaywallButtonComponent.swift */; }; + E6DC692C7D87A5FCA6C447C51F583129 /* StoreProductDiscount+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FAB0193EAC1911F1FE8D3DE4E149552 /* StoreProductDiscount+HybridAdditions.swift */; }; + E78F0CCC5F66A632EDC1CAE094657BDA /* Set+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B46B23738D8EAE9DD716F061861A47B2 /* Set+Extensions.swift */; }; + E8C2B760491C92809D413D143B2A03D1 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEB9F17B978BC7E226389FA816CC7123 /* Date+Extensions.swift */; }; + E91DE9EA53A5C841C09CB76AA923F1CA /* HTTPRequestBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DA7A78EFA238D1D51ACA5C7A723974E /* HTTPRequestBody.swift */; }; + E9247C0420E6655DD68EC62404FAE64C /* SK1Storefront.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600CC8EB2F5188ABB89DEF7BB7838C86 /* SK1Storefront.swift */; }; + E9447A299FD6DB996759DCE4B4B609F9 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25E25D4FBC2376DD3A5F3B4723A6669E /* Constants.swift */; }; + E98EBDDB7D5A2D571E4A37E0AC218C68 /* KeyedDeferredValueStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 291662A72F8724CA0C2022AA2744585E /* KeyedDeferredValueStore.swift */; }; + E999DEC784AE613847A2B4E3960A613D /* BackendConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A60B144B9CE8E6512A831EAC32E6089 /* BackendConfiguration.swift */; }; + EB43921987558ADBB6654583453FC747 /* CallbackCacheStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDABBB513EBF5A01231357F6511E84A /* CallbackCacheStatus.swift */; }; + EB992F6DEC2F138549EF43B672E0C455 /* RefundRequestStatus+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761291AD85C6761A0D19DEDABFC56053 /* RefundRequestStatus+HybridAdditions.swift */; }; + EEB95AE9933FF2BC0D559D7FEF448755 /* WebRedemptionStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A19C133F59B1808E6D3932D56DC8CEB /* WebRedemptionStrings.swift */; }; + EF9BDB5B7D0AC3BFF6E78446CB8CDCBA /* ComponentOverrides.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369C96C96A059C108C26F63EF4C096B2 /* ComponentOverrides.swift */; }; + EFAD008F7E4995E7CE79291FC3FE4652 /* StoreTransaction+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A10F8EA83C029E9B6802BB90F1F9816 /* StoreTransaction+HybridAdditions.swift */; }; + F0FA12C9742296F7418CF1A04C6FE48A /* FileReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB41E7346EFDE57E402723AE013355EF /* FileReader.swift */; }; + F1497A9480B7D8A32541AF9A8D1944D5 /* ASN1ObjectIdentifierBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64CD77CEDC8C3185F1FE264816ADF9D8 /* ASN1ObjectIdentifierBuilder.swift */; }; + F17C543C5A5BF7613D6EE05E5F7C2D39 /* AppleReceiptBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9D9683F758C52576E83A191DF57B0A /* AppleReceiptBuilder.swift */; }; + F1ED2FFF6A85A43643AB68A99CE6DCC5 /* MacDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 299974220C820FA3089AE7228473EFB7 /* MacDevice.swift */; }; + F545CEA115FD66C136DE436CFA9459BA /* SK1StoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 705AC44DA1E8E98E0B441DB0948BFF0B /* SK1StoreTransaction.swift */; }; + F552CB4E0A203E85A72D0C1C9D42B38A /* Error+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4400C64812BBD064D273842F65A53DD /* Error+Extensions.swift */; }; + F589F55B8508CD5B519CE739E4A3C6C0 /* ASN1Container.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA682E9FE7118CE9B9C8FF66FBCA9D3A /* ASN1Container.swift */; }; + F695AA75AF3DEECCD9345CAD9DF7151E /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13FB5753CF3B322E6D6ED418E012E061 /* Package.swift */; }; + F7D16DA69B13B1FE0142A981CC6E2677 /* Pods-App-umbrella.h in Headers */ = {isa = PBXBuildFile; fileRef = 8E75B29AE1472628C750F928572B5885 /* Pods-App-umbrella.h */; settings = {ATTRIBUTES = (Public, ); }; }; + F7E14028DA7AD0C0C71C5402B168185E /* MapAppStoreDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF174732015CC1C1E5DDCBD5C1014D3 /* MapAppStoreDetector.swift */; }; + F815DAEFCBA4D5597D9DF77055028858 /* SimulatedStorePurchaseUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BF2BB4EAD6110F50B678BEEA212AFFA /* SimulatedStorePurchaseUI.swift */; }; + F8217905363A5DC8876064053E3042C1 /* CustomerInfoCallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = E64A488F942CAB289E50838D99391CA8 /* CustomerInfoCallback.swift */; }; + F973398F934D30C07E6092C82785F74F /* Offerings+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9934FEA0DF74CCE48B0639282EF2C87 /* Offerings+HybridAdditions.swift */; }; + F9FFFA8CD4376820B19F4E6AC416955C /* DirectoryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41B231852638AC98276D9882F5B8942F /* DirectoryHelper.swift */; }; + FA015721ACEC320497D11EC381E6C83A /* SK2Storefront.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22D26B7714838694A449B7595D4FF67 /* SK2Storefront.swift */; }; + FA74D8A2EAB60D6B069EA30E2DCD8872 /* FeatureEventsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7638F462DE697A04D926423C8BFBE1 /* FeatureEventsRequest.swift */; }; + FB44DCEBCA144D51E8C0879F09B194F3 /* StoreKit2TransactionFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82F811A0E6D79CE9AE93569A1B189427 /* StoreKit2TransactionFetcher.swift */; }; + FBF04E2554B6F5DD1830A1C8F8B41CF5 /* CustomerCenterConfigCallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A75172A4413D9B31F18ACDB27F8365F /* CustomerCenterConfigCallback.swift */; }; + FC6E16AF548205ED2D10E2B6A0B034A6 /* VirtualCurrencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E767D12636C059F2945360E3798A816 /* VirtualCurrencies.swift */; }; + FDBB3A93EE41E43FF91E07FFD4DBD8C9 /* PaywallImageComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE17C82E7C2192C4C5E7AD92ABF19E6A /* PaywallImageComponent.swift */; }; + FF0BF4F77972ED239E5A3262FBC85761 /* SubscriptionHistoryTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 481504BF7369B000939A050011E64363 /* SubscriptionHistoryTracker.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 1CE178B10C550E37F0749E7A5188F684 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = BFDFE7DC352907FC980B868725387E98 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E3F5D7A4C3AB3CFEB8B1C429405FED63; + remoteInfo = "PurchasesHybridCommon-PurchasesHybridCommon"; + }; + 79B4B6C291361F77C5AE178A64078EE0 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = BFDFE7DC352907FC980B868725387E98 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A9FD6F34305C03A1CC3A10B207522C48; + remoteInfo = RevenueCat; + }; + 8BE5ABBF2AA986C8A26F97BDB17C2873 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = BFDFE7DC352907FC980B868725387E98 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A9FD6F34305C03A1CC3A10B207522C48; + remoteInfo = RevenueCat; + }; + BC049305D5A9B9D50C0F3961ECB7D8FB /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = BFDFE7DC352907FC980B868725387E98 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 58084B0686015596789324D0C42368C5; + remoteInfo = "RevenueCat-RevenueCat"; + }; + BC8C6F246651D6565B3363A0602268C4 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = BFDFE7DC352907FC980B868725387E98 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D47CB7C8CD3E8F81E812E1BF4156FE15; + remoteInfo = PurchasesHybridCommon; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 01E1E4328DE00E9A112E7B280A00D700 /* Deprecations.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Deprecations.swift; path = Sources/Misc/Deprecations.swift; sourceTree = ""; }; + 02E068784BF9CDD02905819DA9274C3E /* PostIsPurchaseAllowedByRestoreBehaviorOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PostIsPurchaseAllowedByRestoreBehaviorOperation.swift; path = Sources/Networking/Operations/PostIsPurchaseAllowedByRestoreBehaviorOperation.swift; sourceTree = ""; }; + 0399EA39C6ECD0B8E9FF2EAC45A595A1 /* PaywallData+Localization.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "PaywallData+Localization.swift"; path = "Sources/Paywalls/PaywallData+Localization.swift"; sourceTree = ""; }; + 0406D4D0D251FD0240084DDDD47A2AAC /* SandboxEnvironmentDetector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SandboxEnvironmentDetector.swift; path = Sources/Misc/SandboxEnvironmentDetector.swift; sourceTree = ""; }; + 042E2E05C8AAA27935046E9B1CC28EEE /* Array+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Array+Extensions.swift"; path = "Sources/FoundationExtensions/Array+Extensions.swift"; sourceTree = ""; }; + 04640A438AEB80D9CDC05F4498C45246 /* EventsManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EventsManager.swift; path = Sources/Events/EventsManager.swift; sourceTree = ""; }; + 050860AE16CF6518B71B90904CA8F627 /* EventsRequest+CustomerCenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "EventsRequest+CustomerCenter.swift"; path = "Sources/CustomerCenter/Events/Networking/EventsRequest+CustomerCenter.swift"; sourceTree = ""; }; + 05373132568C97719C8133092E0A62CC /* PurchasesType.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PurchasesType.swift; path = Sources/Purchasing/Purchases/PurchasesType.swift; sourceTree = ""; }; + 05CE0A318507A1B03E71FC3AE3B8A616 /* RevenueCat-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "RevenueCat-umbrella.h"; sourceTree = ""; }; + 067849C6531AF8172F2B92E8C70E55AC /* SubscriptionPeriod.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SubscriptionPeriod.swift; path = Sources/Purchasing/StoreKitAbstractions/SubscriptionPeriod.swift; sourceTree = ""; }; + 07C76ACA0029FE791E430477CF5488BF /* AttributionPoster.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttributionPoster.swift; path = Sources/Attribution/AttributionPoster.swift; sourceTree = ""; }; + 08DEBD207C132DF1952C479926EF40A2 /* AttributionData.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttributionData.swift; path = Sources/Attribution/AttributionData.swift; sourceTree = ""; }; + 09209143938B2386BB3906033655559D /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Pods-App.debug.xcconfig"; sourceTree = ""; }; + 093422977BA2834C3C9EE9B19661FF77 /* CommonFunctionality.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CommonFunctionality.swift; path = ios/PurchasesHybridCommon/PurchasesHybridCommon/CommonFunctionality.swift; sourceTree = ""; }; + 09349528089AE3BF1C1ADA84D9101E55 /* DiagnosticsEventsRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DiagnosticsEventsRequest.swift; path = Sources/Diagnostics/Networking/DiagnosticsEventsRequest.swift; sourceTree = ""; }; + 09B5AA42CE08338CF0FC191DB815A38F /* HTTPResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HTTPResponse.swift; path = Sources/Networking/HTTPClient/HTTPResponse.swift; sourceTree = ""; }; + 0A1C3EFF43297F918D2F35BA617C14C8 /* SimulatedStoreTransaction.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SimulatedStoreTransaction.swift; path = Sources/Purchasing/SimulatedStore/SimulatedStoreTransaction.swift; sourceTree = ""; }; + 0AC56A666004484EE48980FB66564EA4 /* ReceiptStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ReceiptStrings.swift; path = Sources/LocalReceiptParsing/Helpers/ReceiptStrings.swift; sourceTree = ""; }; + 0ACD1A5A68737844A45D1D0FCB388D9A /* HealthOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HealthOperation.swift; path = Sources/Networking/Operations/HealthOperation.swift; sourceTree = ""; }; + 0ADC5CF5FB80DF59ABBB2AA88301A337 /* NetworkError.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NetworkError.swift; path = Sources/Networking/HTTPClient/NetworkError.swift; sourceTree = ""; }; + 0BA54EC4A66CAF29728AD641B5EC7462 /* GetWebOfferingProductsOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = GetWebOfferingProductsOperation.swift; path = Sources/Networking/Operations/GetWebOfferingProductsOperation.swift; sourceTree = ""; }; + 0BE26AC956D611F452BF266415E186C7 /* Locale+Comparison.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Locale+Comparison.swift"; path = "Sources/Paywalls/Locale+Comparison.swift"; sourceTree = ""; }; + 0C18D1196A56E0AFB6F74E4B0D3575CB /* SK2StoreTransaction.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SK2StoreTransaction.swift; path = Sources/Purchasing/StoreKitAbstractions/SK2StoreTransaction.swift; sourceTree = ""; }; + 0C51AE102965B03FA08BD6F7BEC4950A /* DefaultDecodable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DefaultDecodable.swift; path = Sources/Misc/Codable/DefaultDecodable.swift; sourceTree = ""; }; + 0C80098D91C52427263BECAB1F82D20F /* WinBackOfferEligibilityCalculatorType.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WinBackOfferEligibilityCalculatorType.swift; path = "Sources/Purchasing/StoreKit2/Win-Back Offers/WinBackOfferEligibilityCalculatorType.swift"; sourceTree = ""; }; + 0CCBBBCD0B4137EC4520A77CE9DB8616 /* Dictionary+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Dictionary+Extensions.swift"; path = "Sources/FoundationExtensions/Dictionary+Extensions.swift"; sourceTree = ""; }; + 0D46CB95801AB522A4036DEC508DC5B6 /* SK1StoreProduct.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SK1StoreProduct.swift; path = Sources/Purchasing/StoreKitAbstractions/SK1StoreProduct.swift; sourceTree = ""; }; + 0DFBA4B190EFE203B3F2586C1BD3D910 /* RevenueCat.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = RevenueCat.release.xcconfig; sourceTree = ""; }; + 0F4600CE4F86683888FA412DD3200ABE /* HTTPRequest+Signing.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "HTTPRequest+Signing.swift"; path = "Sources/Security/HTTPRequest+Signing.swift"; sourceTree = ""; }; + 10B1AA5292666C89BB23EAA215885DDF /* GetIntroEligibilityResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = GetIntroEligibilityResponse.swift; path = Sources/Networking/Responses/GetIntroEligibilityResponse.swift; sourceTree = ""; }; + 11DFE5F44BA4F88D8736526122B43005 /* ConfigureStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConfigureStrings.swift; path = Sources/Logging/Strings/ConfigureStrings.swift; sourceTree = ""; }; + 12090462D9731132865898CF0C5FB3FD /* Backend.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Backend.swift; path = Sources/Networking/Backend.swift; sourceTree = ""; }; + 125FD7AF9118CF5729AF7B557AF935A8 /* IsPurchaseAllowedByRestoreBehaviorCallback.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = IsPurchaseAllowedByRestoreBehaviorCallback.swift; path = Sources/Networking/Caching/IsPurchaseAllowedByRestoreBehaviorCallback.swift; sourceTree = ""; }; + 12CCC30F9D4787599E16E9D03D5AA781 /* LogInOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LogInOperation.swift; path = Sources/Networking/Operations/LogInOperation.swift; sourceTree = ""; }; + 12DE160C6746F68C2CD64AC5F7170E14 /* Purchases+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Purchases+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/Purchases+HybridAdditions.swift"; sourceTree = ""; }; + 1309DABE4238A9917EB560A21A7DA63A /* DebugViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DebugViewModel.swift; path = Sources/Support/DebugUI/DebugViewModel.swift; sourceTree = ""; }; + 1334C5F2A9F962CA1F7F2F47425A447F /* Result+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Result+Extensions.swift"; path = "Sources/FoundationExtensions/Result+Extensions.swift"; sourceTree = ""; }; + 13771028F9A83D1381BAB98A92991E48 /* DNSChecker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DNSChecker.swift; path = Sources/Networking/HTTPClient/DNSChecker.swift; sourceTree = ""; }; + 13FB5753CF3B322E6D6ED418E012E061 /* Package.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Package.swift; path = Sources/Purchasing/Package.swift; sourceTree = ""; }; + 14073EAA11882BFEA66F9D5446A20C01 /* DiagnosticsTracker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DiagnosticsTracker.swift; path = Sources/Diagnostics/DiagnosticsTracker.swift; sourceTree = ""; }; + 153B8FA4E5198A2A285226070C62493D /* Offering+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Offering+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/Offering+HybridAdditions.swift"; sourceTree = ""; }; + 154AAA9A3B0432A54E8802E6C7AD0CAB /* WebPurchaseRedemptionHelper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WebPurchaseRedemptionHelper.swift; path = Sources/WebPurchaseRedemption/WebPurchaseRedemptionHelper.swift; sourceTree = ""; }; + 163559D4E96350598D8E7A1511432D47 /* NetworkStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NetworkStrings.swift; path = Sources/Logging/Strings/NetworkStrings.swift; sourceTree = ""; }; + 167781201460DD988699AD9F132B8239 /* EventsRequest+Paywall.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "EventsRequest+Paywall.swift"; path = "Sources/Paywalls/Events/Networking/EventsRequest+Paywall.swift"; sourceTree = ""; }; + 16DDEB9434DD6BAA4198327BE0524964 /* CustomerInfoManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CustomerInfoManager.swift; path = Sources/Identity/CustomerInfoManager.swift; sourceTree = ""; }; + 171EF18DDC6787FAF20A5F9BF6D4BD8F /* PaywallExtensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallExtensions.swift; path = Sources/Support/PaywallExtensions.swift; sourceTree = ""; }; + 181E1EE6E30B8FC9E1493E8579F6148D /* ReceiptRefreshPolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ReceiptRefreshPolicy.swift; path = Sources/Purchasing/ReceiptRefreshPolicy.swift; sourceTree = ""; }; + 1898596839397C9AD5DA180A888003F5 /* PurchasedProductsFetcher.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PurchasedProductsFetcher.swift; path = Sources/OfflineEntitlements/PurchasedProductsFetcher.swift; sourceTree = ""; }; + 18BCDD139ADDE82AB46C6785541DFA6D /* TransactionReason.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TransactionReason.swift; path = Sources/Purchasing/StoreKitAbstractions/TransactionReason.swift; sourceTree = ""; }; + 190C1838CF4C354E2B7F9EC2FD60CCA0 /* Logger.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Logger.swift; path = Sources/Logging/Logger.swift; sourceTree = ""; }; + 1A19C133F59B1808E6D3932D56DC8CEB /* WebRedemptionStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WebRedemptionStrings.swift; path = Sources/Logging/Strings/WebRedemptionStrings.swift; sourceTree = ""; }; + 1B27429EA2264D28F334AF7BA26B273E /* PurchaseOwnershipType+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "PurchaseOwnershipType+Extensions.swift"; path = "Sources/CodableExtensions/PurchaseOwnershipType+Extensions.swift"; sourceTree = ""; }; + 1B60F293050E52C352755F0B7DDBCDB2 /* OfferingsFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OfferingsFactory.swift; path = Sources/Purchasing/OfferingsFactory.swift; sourceTree = ""; }; + 1B8724774D8EAA3D595E2AD2D6F0A2DA /* EventsHTTPRequestPath.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EventsHTTPRequestPath.swift; path = Sources/Events/Networking/EventsHTTPRequestPath.swift; sourceTree = ""; }; + 1BF2BB4EAD6110F50B678BEEA212AFFA /* SimulatedStorePurchaseUI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SimulatedStorePurchaseUI.swift; path = Sources/Purchasing/SimulatedStore/SimulatedStorePurchaseUI.swift; sourceTree = ""; }; + 1CF759005470A151F5C92C1ABCF08A1C /* SystemInfo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SystemInfo.swift; path = Sources/Misc/SystemInfo.swift; sourceTree = ""; }; + 1CFEAB3D036DEBADB4C70917B3971425 /* PaywallComponentBase.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallComponentBase.swift; path = Sources/Paywalls/Components/Common/PaywallComponentBase.swift; sourceTree = ""; }; + 1CFF995830964954DCE98FA86C0769AC /* FileRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FileRepository.swift; path = Sources/Caching/FileRepository.swift; sourceTree = ""; }; + 1D57A0A13A7C4FE366352708CEC295C5 /* DiagnosticsEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DiagnosticsEvent.swift; path = Sources/Diagnostics/DiagnosticsEvent.swift; sourceTree = ""; }; + 1D758AE9F572BFC5F198EA01E9DF975E /* RedirectLoggerTaskDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RedirectLoggerTaskDelegate.swift; path = Sources/Networking/HTTPClient/RedirectLoggerTaskDelegate.swift; sourceTree = ""; }; + 1DCDC2ECAE3A42D908B510F6E822DB23 /* GetOfferingsOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = GetOfferingsOperation.swift; path = Sources/Networking/Operations/GetOfferingsOperation.swift; sourceTree = ""; }; + 1ECE680BBFCE5A638EAF2E3100B14519 /* PostOfferResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PostOfferResponse.swift; path = Sources/Networking/Responses/PostOfferResponse.swift; sourceTree = ""; }; + 1EE0CE490CF7D279BCAC2D9008011A53 /* ErrorDetails.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ErrorDetails.swift; path = "Sources/Error Handling/ErrorDetails.swift"; sourceTree = ""; }; + 20084C04DD4D0456F699564398A40991 /* Background.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Background.swift; path = Sources/Paywalls/Components/Common/Background.swift; sourceTree = ""; }; + 204CC1573F6C6826DCCCCADDFE4D2458 /* CustomerInfo+OfflineEntitlements.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "CustomerInfo+OfflineEntitlements.swift"; path = "Sources/OfflineEntitlements/CustomerInfo+OfflineEntitlements.swift"; sourceTree = ""; }; + 219AB6D5487157E08F7CE7DC21CBC1FF /* GetCustomerInfoOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = GetCustomerInfoOperation.swift; path = Sources/Networking/Operations/GetCustomerInfoOperation.swift; sourceTree = ""; }; + 2260E01F523D44DA02E6F1C279B3324B /* DeepLinkParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DeepLinkParser.swift; path = Sources/DeepLink/DeepLinkParser.swift; sourceTree = ""; }; + 22CD3659713C78546E489A2A66815B86 /* HTTPClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HTTPClient.swift; path = Sources/Networking/HTTPClient/HTTPClient.swift; sourceTree = ""; }; + 22F6DD5AE3228D84A03651163F045D95 /* CachingTrialOrIntroPriceEligibilityChecker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CachingTrialOrIntroPriceEligibilityChecker.swift; path = Sources/Purchasing/CachingTrialOrIntroPriceEligibilityChecker.swift; sourceTree = ""; }; + 235323C5CE7B865F32673CFFCD7AABAF /* StoreKit2Receipt.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoreKit2Receipt.swift; path = Sources/Purchasing/StoreKit2/StoreKit2Receipt.swift; sourceTree = ""; }; + 240B1A0BC46B3F7DCC0B4F0A790AC972 /* BackendError.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BackendError.swift; path = "Sources/Error Handling/BackendError.swift"; sourceTree = ""; }; + 244DB81E8FD0371C546E639D39A9F21A /* AdEventStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AdEventStore.swift; path = Sources/Ads/Events/AdEventStore.swift; sourceTree = ""; }; + 254D224EE53AC495F856B7B6FAFE64FA /* Border.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Border.swift; path = Sources/Paywalls/Components/Common/Border.swift; sourceTree = ""; }; + 2582F971E65634F0CF024A3785204906 /* RevenueCat.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = RevenueCat.modulemap; sourceTree = ""; }; + 25E25D4FBC2376DD3A5F3B4723A6669E /* Constants.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Constants.swift; path = ios/PurchasesHybridCommon/PurchasesHybridCommon/Constants.swift; sourceTree = ""; }; + 267F75A0D2086BDC6DCC403A0CF82358 /* PaywallStickyFooterComponent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallStickyFooterComponent.swift; path = Sources/Paywalls/Components/PaywallStickyFooterComponent.swift; sourceTree = ""; }; + 276E2BC6FC3AF1829144103AC86A4FEC /* SK1StoreProductDiscount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SK1StoreProductDiscount.swift; path = Sources/Purchasing/StoreKitAbstractions/SK1StoreProductDiscount.swift; sourceTree = ""; }; + 27B3A3294778ED9BEE5035B2AE722969 /* EntitlementVerificationMode+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "EntitlementVerificationMode+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/EntitlementVerificationMode+HybridAdditions.swift"; sourceTree = ""; }; + 28302208340DDFACF06CF1E952BCD375 /* PaywallAnimation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallAnimation.swift; path = Sources/Paywalls/Components/Transitions/PaywallAnimation.swift; sourceTree = ""; }; + 28909F20999A48234075586D77F3D448 /* TrackingManagerProxy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TrackingManagerProxy.swift; path = Sources/Attribution/TrackingManagerProxy.swift; sourceTree = ""; }; + 291662A72F8724CA0C2022AA2744585E /* KeyedDeferredValueStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = KeyedDeferredValueStore.swift; path = Sources/Caching/KeyedDeferredValueStore.swift; sourceTree = ""; }; + 299974220C820FA3089AE7228473EFB7 /* MacDevice.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MacDevice.swift; path = Sources/Misc/MacDevice.swift; sourceTree = ""; }; + 29F9D1208D1C4BB939FC2F38F8B5E866 /* InAppPurchaseBuilder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = InAppPurchaseBuilder.swift; path = Sources/LocalReceiptParsing/Builders/InAppPurchaseBuilder.swift; sourceTree = ""; }; + 2A724FFF66F3706AF6B6F9F03DDCBD30 /* PaywallPurchaseButtonComponent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallPurchaseButtonComponent.swift; path = Sources/Paywalls/Components/PaywallPurchaseButtonComponent.swift; sourceTree = ""; }; + 2B3CED652BC3338C72625BF4EC37592C /* DateFormatter+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "DateFormatter+Extensions.swift"; path = "Sources/LocalReceiptParsing/DataConverters/DateFormatter+Extensions.swift"; sourceTree = ""; }; + 2B4C221F9E26239D362F074E8B3AB200 /* PostSubscriberAttributesOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PostSubscriberAttributesOperation.swift; path = Sources/Networking/Operations/PostSubscriberAttributesOperation.swift; sourceTree = ""; }; + 2BA2B0446F8676CF6AFB955BC90BCF40 /* HTTPStatusCode.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HTTPStatusCode.swift; path = Sources/Networking/HTTPClient/HTTPStatusCode.swift; sourceTree = ""; }; + 2CA4FE0D817A656D07A8BECEA1FA4048 /* PostAdEventsOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PostAdEventsOperation.swift; path = Sources/Ads/Events/Networking/PostAdEventsOperation.swift; sourceTree = ""; }; + 2CDB60101318931DC0BC33B504865A0B /* LogInCallback.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LogInCallback.swift; path = Sources/Networking/Caching/LogInCallback.swift; sourceTree = ""; }; + 2CFCD5D3EF7A3B23BE90664F4C137688 /* StoreProduct+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "StoreProduct+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/StoreProduct+HybridAdditions.swift"; sourceTree = ""; }; + 2D7638F462DE697A04D926423C8BFBE1 /* FeatureEventsRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FeatureEventsRequest.swift; path = Sources/Events/FeatureEvents/Networking/FeatureEventsRequest.swift; sourceTree = ""; }; + 2DE6D62CB5B0507D61F0943DC5B7803F /* DangerousSettings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DangerousSettings.swift; path = Sources/Misc/DangerousSettings.swift; sourceTree = ""; }; + 2E9D9683F758C52576E83A191DF57B0A /* AppleReceiptBuilder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AppleReceiptBuilder.swift; path = Sources/LocalReceiptParsing/Builders/AppleReceiptBuilder.swift; sourceTree = ""; }; + 2F5CC5B8C02683C98E9A43443D3C0005 /* ErrorUtils.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ErrorUtils.swift; path = "Sources/Error Handling/ErrorUtils.swift"; sourceTree = ""; }; + 2F957AC5C000E03E731AFBC2C95A1B32 /* RevenueCat-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "RevenueCat-Info.plist"; sourceTree = ""; }; + 3091166AE010D315AFE23BA999429369 /* GetVirtualCurrenciesOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = GetVirtualCurrenciesOperation.swift; path = Sources/Networking/Operations/GetVirtualCurrenciesOperation.swift; sourceTree = ""; }; + 31E998A2F775507890CED89B91DA99CC /* TrialOrIntroPriceEligibilityChecker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TrialOrIntroPriceEligibilityChecker.swift; path = Sources/Purchasing/TrialOrIntroPriceEligibilityChecker.swift; sourceTree = ""; }; + 339746A8D84F4A4528121114106A6473 /* Clock.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Clock.swift; path = Sources/Misc/DateAndTime/Clock.swift; sourceTree = ""; }; + 3413C64E09BB73FF739C2F58A9CCB69B /* DateExtensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DateExtensions.swift; path = Sources/Misc/DateAndTime/DateExtensions.swift; sourceTree = ""; }; + 34416EDC539D29D5CF431CD65CEE0A3C /* PaywallV2CacheWarming.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallV2CacheWarming.swift; path = Sources/Paywalls/Components/PaywallV2CacheWarming.swift; sourceTree = ""; }; + 35158BFC494344C16B5ABBC1099786BF /* StoreKit2StorefrontListener.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoreKit2StorefrontListener.swift; path = Sources/Purchasing/StoreKit2/StoreKit2StorefrontListener.swift; sourceTree = ""; }; + 369C96C96A059C108C26F63EF4C096B2 /* ComponentOverrides.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ComponentOverrides.swift; path = Sources/Paywalls/Components/Common/ComponentOverrides.swift; sourceTree = ""; }; + 36B3F59C973860A8269FBAC688EF48C8 /* PurchasesError.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PurchasesError.swift; path = "Sources/Error Handling/PurchasesError.swift"; sourceTree = ""; }; + 36C45E16C55EA2AE8F0D1E7CFF16B685 /* AttributionTypeFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttributionTypeFactory.swift; path = Sources/Attribution/AttributionTypeFactory.swift; sourceTree = ""; }; + 370718947692D51A24D4CE246D0158C9 /* IsPurchaseAllowedByRestoreBehaviorResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = IsPurchaseAllowedByRestoreBehaviorResponse.swift; path = Sources/Networking/Responses/IsPurchaseAllowedByRestoreBehaviorResponse.swift; sourceTree = ""; }; + 37F15050F581CBB4C04E20BB331FDA3A /* PaywallViewMode.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallViewMode.swift; path = Sources/Paywalls/PaywallViewMode.swift; sourceTree = ""; }; + 38001264FD37589AF13374D1B55C3580 /* BackendErrorStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BackendErrorStrings.swift; path = Sources/Logging/Strings/BackendErrorStrings.swift; sourceTree = ""; }; + 382B9CE21DD4DC115C8E591FCD6FD124 /* PreferredLocalesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PreferredLocalesProvider.swift; path = Sources/Misc/Locale/PreferredLocalesProvider.swift; sourceTree = ""; }; + 38EDA47D097A5C9EB5899D446B2AB23B /* FileRepositoryStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FileRepositoryStrings.swift; path = Sources/Logging/Strings/FileRepositoryStrings.swift; sourceTree = ""; }; + 39085DB222F7F991FA160FDD2EA404DD /* GetWebBillingProductsOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = GetWebBillingProductsOperation.swift; path = Sources/Networking/Operations/GetWebBillingProductsOperation.swift; sourceTree = ""; }; + 3A10F8EA83C029E9B6802BB90F1F9816 /* StoreTransaction+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "StoreTransaction+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/StoreTransaction+HybridAdditions.swift"; sourceTree = ""; }; + 3A79718881EF41876F0ACC9A8C44AB73 /* FrameworkDisambiguation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FrameworkDisambiguation.swift; path = Sources/Support/FrameworkDisambiguation.swift; sourceTree = ""; }; + 3AA9124C018EA7BF6460091D86216692 /* PurchasesHybridCommon-PurchasesHybridCommon */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; name = "PurchasesHybridCommon-PurchasesHybridCommon"; path = PurchasesHybridCommon.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; + 3BDA3EC3179F2D6F9366E6B08F62428E /* ProductsFetcherSK1.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProductsFetcherSK1.swift; path = Sources/Purchasing/StoreKit1/ProductsFetcherSK1.swift; sourceTree = ""; }; + 3CA34C54BB65ECDFACD48E340F35CFAD /* StoreMessageType.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoreMessageType.swift; path = Sources/Support/StoreMessageType.swift; sourceTree = ""; }; + 3D4E5CAC728FFED4E79837CA237069E2 /* CacheStatus.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CacheStatus.swift; path = Sources/Caching/CacheStatus.swift; sourceTree = ""; }; + 3DF1E3F994BD3D68530C7CA1F5172F71 /* CacheFetchPolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CacheFetchPolicy.swift; path = Sources/Networking/Caching/CacheFetchPolicy.swift; sourceTree = ""; }; + 3F3829F4F6709CEF1D465E1B090726BF /* StoreTransaction.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoreTransaction.swift; path = Sources/Purchasing/StoreKitAbstractions/StoreTransaction.swift; sourceTree = ""; }; + 3F929B4C2C5B3A0269A40D220994CFB9 /* HealthReportAvailabilityOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HealthReportAvailabilityOperation.swift; path = Sources/Networking/Operations/HealthReportAvailabilityOperation.swift; sourceTree = ""; }; + 3FF3D640A2B8E39DDEB652E3C42552FE /* HTTPResponseBody.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HTTPResponseBody.swift; path = Sources/Networking/HTTPClient/HTTPResponseBody.swift; sourceTree = ""; }; + 402596F229D0A94560681BDDA848E0B4 /* EnsureNonEmptyCollectionDecodable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EnsureNonEmptyCollectionDecodable.swift; path = Sources/Misc/Codable/EnsureNonEmptyCollectionDecodable.swift; sourceTree = ""; }; + 411B45CA57486D588D69BE38012EBC60 /* AsyncExtensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AsyncExtensions.swift; path = Sources/FoundationExtensions/AsyncExtensions.swift; sourceTree = ""; }; + 4146B5A85087D99E08A1A3BD37BD8606 /* EmptyFile.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EmptyFile.swift; path = Sources/DocCDocumentation/EmptyFile.swift; sourceTree = ""; }; + 416DB5A58251BC0BA7DA6C8F7BE5E68C /* WebBillingProduct+SimulatedStoreProduct.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "WebBillingProduct+SimulatedStoreProduct.swift"; path = "Sources/Purchasing/SimulatedStore/WebBillingProduct+SimulatedStoreProduct.swift"; sourceTree = ""; }; + 419AFCB98F8B32E3B67CBE20C5AD78EE /* StoreKitWorkarounds.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoreKitWorkarounds.swift; path = Sources/Purchasing/StoreKitAbstractions/StoreKitWorkarounds.swift; sourceTree = ""; }; + 41A835CD3320BF4219C95068A6A91A85 /* VirtualCurrencyStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = VirtualCurrencyStrings.swift; path = Sources/Logging/Strings/VirtualCurrencyStrings.swift; sourceTree = ""; }; + 41B231852638AC98276D9882F5B8942F /* DirectoryHelper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DirectoryHelper.swift; path = Sources/Caching/DirectoryHelper.swift; sourceTree = ""; }; + 421EAA5204753DA3639CE19B2FB2752D /* Configuration.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Configuration.swift; path = Sources/Purchasing/Configuration.swift; sourceTree = ""; }; + 42A3AB7080B77B6F0334F03A9C8B2D84 /* InMemoryCachedObject.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = InMemoryCachedObject.swift; path = Sources/Caching/InMemoryCachedObject.swift; sourceTree = ""; }; + 42C2D7B43E931C1C9B757627A675B90B /* CustomerCenterConfigAPI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CustomerCenterConfigAPI.swift; path = Sources/Networking/CustomerCenterConfigAPI.swift; sourceTree = ""; }; + 43982CC961296BCB9016F25DF5636068 /* ProductsManagerFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProductsManagerFactory.swift; path = Sources/Purchasing/ProductsManagerFactory.swift; sourceTree = ""; }; + 43CC8A563BB74D631FDCBB5C81A713BD /* VerificationResult.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = VerificationResult.swift; path = Sources/Security/VerificationResult.swift; sourceTree = ""; }; + 43D73AAC4299967BF45F9FF01CB8787F /* Optional+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Optional+Extensions.swift"; path = "Sources/FoundationExtensions/Optional+Extensions.swift"; sourceTree = ""; }; + 4429BAC03814B14CCA05FA621E7945B4 /* StoredAdEventSerializer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoredAdEventSerializer.swift; path = Sources/Ads/Events/StoredAdEventSerializer.swift; sourceTree = ""; }; + 4461612DEFB559B4AF58297EE6A4E5BF /* Checksum.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Checksum.swift; path = Sources/Caching/Checksum.swift; sourceTree = ""; }; + 45B358AABC35CB2D00DF8167FA1693D9 /* OfflineEntitlementsStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OfflineEntitlementsStrings.swift; path = Sources/Logging/Strings/OfflineEntitlementsStrings.swift; sourceTree = ""; }; + 46428AF439B212CAE8C44D1B2187C9E9 /* Lock.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Lock.swift; path = Sources/Misc/Concurrency/Lock.swift; sourceTree = ""; }; + 464B54FC3CD735207FE9083CB9904FED /* StoreKitErrorHelper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoreKitErrorHelper.swift; path = "Sources/Error Handling/StoreKitErrorHelper.swift"; sourceTree = ""; }; + 4675D69D85060AD0CCF95F2F433C4CB6 /* DiagnosticsFileHandler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DiagnosticsFileHandler.swift; path = Sources/Diagnostics/DiagnosticsFileHandler.swift; sourceTree = ""; }; + 472FC5C89285A9712BEABCDDD91FAAF5 /* WebPurchaseRedemption.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WebPurchaseRedemption.swift; path = Sources/WebPurchaseRedemption/WebPurchaseRedemption.swift; sourceTree = ""; }; + 481504BF7369B000939A050011E64363 /* SubscriptionHistoryTracker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SubscriptionHistoryTracker.swift; path = Sources/Paywalls/SubscriptionHistoryTracker.swift; sourceTree = ""; }; + 48E7FA372B2126E960DD9345ACD310EC /* ErrorContainer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ErrorContainer.swift; path = ios/PurchasesHybridCommon/PurchasesHybridCommon/ErrorContainer.swift; sourceTree = ""; }; + 48ED6D60D075E6F7AEF9F00648E6578B /* TestStoreProduct.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TestStoreProduct.swift; path = "Sources/Purchasing/StoreKitAbstractions/Test Data/TestStoreProduct.swift"; sourceTree = ""; }; + 492DC2973A8438B3FD8BC91EE2D1C3AF /* RawDataContainer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RawDataContainer.swift; path = Sources/Misc/Codable/RawDataContainer.swift; sourceTree = ""; }; + 493EE7C69C52E3CE4E013AAD381662DB /* PaywallsStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallsStrings.swift; path = Sources/Logging/Strings/PaywallsStrings.swift; sourceTree = ""; }; + 49962DEFE7B07C16FBD2C37C37CBBE1F /* AdTracker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AdTracker.swift; path = Sources/Ads/AdTracker.swift; sourceTree = ""; }; + 4A355796FDA7429F6C3F8AEECF26D13B /* BeginRefundRequestHelper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BeginRefundRequestHelper.swift; path = Sources/Support/BeginRefundRequestHelper.swift; sourceTree = ""; }; + 4A53E1B1FF57A749F9042B85E80B999E /* PaymentAuthorizationProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaymentAuthorizationProvider.swift; path = Sources/Support/PaymentAuthorizationProvider.swift; sourceTree = ""; }; + 4AA483A3A60C364D45C2DC333D452445 /* DiagnosticsStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DiagnosticsStrings.swift; path = Sources/Logging/Strings/DiagnosticsStrings.swift; sourceTree = ""; }; + 4C0C17CB45538B27EDBE538A2174480E /* AdEventsRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AdEventsRequest.swift; path = Sources/Ads/Events/Networking/AdEventsRequest.swift; sourceTree = ""; }; + 4C826229AC0D01A9A1DCAFD3FA49D664 /* ASN1ObjectIdentifier.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ASN1ObjectIdentifier.swift; path = Sources/LocalReceiptParsing/BasicTypes/ASN1ObjectIdentifier.swift; sourceTree = ""; }; + 4D9DD7E59B0917282E2931D14844DE58 /* AdEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AdEvent.swift; path = Sources/Ads/Events/AdEvent.swift; sourceTree = ""; }; + 4DA7A78EFA238D1D51ACA5C7A723974E /* HTTPRequestBody.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HTTPRequestBody.swift; path = Sources/Networking/HTTPClient/HTTPRequestBody.swift; sourceTree = ""; }; + 4F62083A4B0658AE77BFA307A544DBD2 /* ProductsFetcherSK2.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProductsFetcherSK2.swift; path = Sources/Purchasing/StoreKit2/ProductsFetcherSK2.swift; sourceTree = ""; }; + 4FAB0193EAC1911F1FE8D3DE4E149552 /* StoreProductDiscount+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "StoreProductDiscount+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/StoreProductDiscount+HybridAdditions.swift"; sourceTree = ""; }; + 50A6A3B1299C884DECA9398CA5434A53 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Sources/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 51058E8152DEB2BF828A53F4AD450B01 /* DiagnosticsPostOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DiagnosticsPostOperation.swift; path = Sources/Diagnostics/Networking/DiagnosticsPostOperation.swift; sourceTree = ""; }; + 515D62CFA3C38D397A27B63D5F56FB63 /* PaywallPackageComponent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallPackageComponent.swift; path = Sources/Paywalls/Components/PaywallPackageComponent.swift; sourceTree = ""; }; + 5259A4BCC0961A44A79FD7A37E3201F1 /* PurchasesDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PurchasesDelegate.swift; path = Sources/Purchasing/Purchases/PurchasesDelegate.swift; sourceTree = ""; }; + 52A64F0DFB604EFF49C3D9E0B8E4237B /* CustomerAPI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CustomerAPI.swift; path = Sources/Networking/CustomerAPI.swift; sourceTree = ""; }; + 5377D542CC6A8DEE15A5FF76640C8D0D /* EntitlementInfos+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "EntitlementInfos+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/EntitlementInfos+HybridAdditions.swift"; sourceTree = ""; }; + 5496A5BB6EFD4B1E2F477B637CB4E48D /* AnyEncodable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AnyEncodable.swift; path = Sources/Misc/Codable/AnyEncodable.swift; sourceTree = ""; }; + 54C6E8D902D8921B25981F3137A22646 /* CustomerInfoResponseHandler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CustomerInfoResponseHandler.swift; path = Sources/Networking/Operations/Handling/CustomerInfoResponseHandler.swift; sourceTree = ""; }; + 55FD547023F2A20FF4544B62F0E586CF /* Obsoletions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Obsoletions.swift; path = Sources/Misc/Obsoletions.swift; sourceTree = ""; }; + 56A90A644DA1F65D4BABA53C91FB3839 /* PostAdServicesTokenOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PostAdServicesTokenOperation.swift; path = Sources/Networking/Operations/PostAdServicesTokenOperation.swift; sourceTree = ""; }; + 573585F29EDFC095922D625F0D3B18D8 /* FatalErrorUtil.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FatalErrorUtil.swift; path = ios/PurchasesHybridCommon/PurchasesHybridCommon/FatalErrorUtil.swift; sourceTree = ""; }; + 574D90A224BA283F29C96807AD089D4D /* OfferingsAPI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OfferingsAPI.swift; path = Sources/Networking/OfferingsAPI.swift; sourceTree = ""; }; + 57A8AE57A74B292C1510B351C293C0CA /* StoreKitError+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "StoreKitError+Extensions.swift"; path = "Sources/Error Handling/StoreKitError+Extensions.swift"; sourceTree = ""; }; + 57CF47D0307513EBACAECEEFE998F34D /* ProductEntitlementMapping.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProductEntitlementMapping.swift; path = Sources/OfflineEntitlements/ProductEntitlementMapping.swift; sourceTree = ""; }; + 581324E62C9B9C66F986C485D64EC0C6 /* TransactionPoster.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TransactionPoster.swift; path = Sources/Purchasing/Purchases/TransactionPoster.swift; sourceTree = ""; }; + 585A42B7A612959DAFDF99089F7076E2 /* CodableStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CodableStrings.swift; path = Sources/Logging/Strings/CodableStrings.swift; sourceTree = ""; }; + 58E54FD6EBD1D6AF85E66D46923BEFDC /* CustomerCenterEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CustomerCenterEvent.swift; path = Sources/CustomerCenter/Events/CustomerCenterEvent.swift; sourceTree = ""; }; + 59F0EFFF8F045F27D410C4438F766BD3 /* LoggerType.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LoggerType.swift; path = Sources/LocalReceiptParsing/Helpers/LoggerType.swift; sourceTree = ""; }; + 5AF8A07013B121F68476D9577FD026EC /* ProductEntitlementMappingCallback.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProductEntitlementMappingCallback.swift; path = Sources/Networking/Caching/ProductEntitlementMappingCallback.swift; sourceTree = ""; }; + 5B60220F8AF0AD933C4600331FFC21F1 /* ConnectionErrorReason.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConnectionErrorReason.swift; path = Sources/Networking/ConnectionErrorReason.swift; sourceTree = ""; }; + 5BEF4602752E47C46E8C10FB8B4B57F2 /* Pods-App */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = "Pods-App"; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 5C19E63EBCB8B1A3E5DC6B1417EC36C8 /* IntroEligibilityCalculator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = IntroEligibilityCalculator.swift; path = Sources/Purchasing/IntroEligibilityCalculator.swift; sourceTree = ""; }; + 5CEC8A93A161ABEAA5C71216AAF12613 /* RevenueCat-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "RevenueCat-dummy.m"; sourceTree = ""; }; + 5E473F9E55BE0F0931A5F499C838EB5F /* PurchasesReceiptParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PurchasesReceiptParser.swift; path = Sources/LocalReceiptParsing/PurchasesReceiptParser.swift; sourceTree = ""; }; + 5E636DE5DDB3B5BDF5FFC68F773CC609 /* PurchasesHybridCommon-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "PurchasesHybridCommon-dummy.m"; sourceTree = ""; }; + 5EDEA4635193B221F9EE2E133BC48482 /* StoreProductDiscount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoreProductDiscount.swift; path = Sources/Purchasing/StoreKitAbstractions/StoreProductDiscount.swift; sourceTree = ""; }; + 5FDBADD2C97E2A55FDF2261BA910C2D2 /* NonEmptyStringDecodable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NonEmptyStringDecodable.swift; path = Sources/Misc/Codable/NonEmptyStringDecodable.swift; sourceTree = ""; }; + 600CC8EB2F5188ABB89DEF7BB7838C86 /* SK1Storefront.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SK1Storefront.swift; path = Sources/Purchasing/StoreKitAbstractions/SK1Storefront.swift; sourceTree = ""; }; + 609D91E64EC4D4A6D042D33246E4A6AD /* EligibilityStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EligibilityStrings.swift; path = Sources/Logging/Strings/EligibilityStrings.swift; sourceTree = ""; }; + 609E66457F5C58D8FDEDD2F9C08CE662 /* StoredAdEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoredAdEvent.swift; path = Sources/Ads/Events/StoredAdEvent.swift; sourceTree = ""; }; + 611DF8B4F21CB669239775C22230859C /* PurchasesHybridCommon-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "PurchasesHybridCommon-Info.plist"; sourceTree = ""; }; + 614A2D3670D47E6D1CA8428911162808 /* PaywallTransition.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallTransition.swift; path = Sources/Paywalls/Components/Transitions/PaywallTransition.swift; sourceTree = ""; }; + 614C8BDF4EF615DC21C0252EC9C68B1A /* CustomerInfo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CustomerInfo.swift; path = Sources/Identity/CustomerInfo.swift; sourceTree = ""; }; + 61B12281162B2669A9CD4FDB2800B3FF /* Pods-App-frameworks.sh */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.script.sh; path = "Pods-App-frameworks.sh"; sourceTree = ""; }; + 620A422511B4CA19BF6E2009C98AE691 /* OfflineCustomerInfoCreator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OfflineCustomerInfoCreator.swift; path = Sources/OfflineEntitlements/OfflineCustomerInfoCreator.swift; sourceTree = ""; }; + 6321C6CAE67AD0B90C77BDE9108FE3B2 /* InternalAPI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = InternalAPI.swift; path = Sources/Networking/InternalAPI.swift; sourceTree = ""; }; + 6324CE2644473AD9256FF61329E8E281 /* ResourceBundle-PurchasesHybridCommon-PurchasesHybridCommon-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "ResourceBundle-PurchasesHybridCommon-PurchasesHybridCommon-Info.plist"; sourceTree = ""; }; + 640C6F5ADC38815FB221C4355EAC255C /* DebugContentViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DebugContentViews.swift; path = Sources/Support/DebugUI/DebugContentViews.swift; sourceTree = ""; }; + 64CD77CEDC8C3185F1FE264816ADF9D8 /* ASN1ObjectIdentifierBuilder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ASN1ObjectIdentifierBuilder.swift; path = Sources/LocalReceiptParsing/Builders/ASN1ObjectIdentifierBuilder.swift; sourceTree = ""; }; + 64EFE56FF515BDDBC227CB67D1730EFF /* FakeSigning.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FakeSigning.swift; path = Sources/Security/FakeSigning.swift; sourceTree = ""; }; + 6559E0AA4A21488E5AF0280AAA41E469 /* PaywallButtonComponent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallButtonComponent.swift; path = Sources/Paywalls/Components/PaywallButtonComponent.swift; sourceTree = ""; }; + 65CAC8117AD855E94F9AD43DE1CEE746 /* OfferingsManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OfferingsManager.swift; path = Sources/Purchasing/OfferingsManager.swift; sourceTree = ""; }; + 66395F21F3D3732A876F3989EFDAACAB /* ISODurationFormatter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ISODurationFormatter.swift; path = Sources/Misc/DateAndTime/ISODurationFormatter.swift; sourceTree = ""; }; + 66616998505D0C66379137483045A4DF /* AttributionFetcher.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttributionFetcher.swift; path = Sources/Attribution/AttributionFetcher.swift; sourceTree = ""; }; + 669737EF3E7F2EBAFEF056A15A64F9BC /* Package+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Package+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/Package+HybridAdditions.swift"; sourceTree = ""; }; + 66FEA9280C5974DC10249711D910CB10 /* PostOfferForSigningOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PostOfferForSigningOperation.swift; path = Sources/Networking/Operations/PostOfferForSigningOperation.swift; sourceTree = ""; }; + 6722B0341B3AF578C8A665E4C784C884 /* Offering.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Offering.swift; path = Sources/Purchasing/Offering.swift; sourceTree = ""; }; + 678FD19DD03283F7C4DE61D0D2AC2379 /* IdentityStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = IdentityStrings.swift; path = Sources/Logging/Strings/IdentityStrings.swift; sourceTree = ""; }; + 67DB6BB03C169A5A099A774B7A97BC20 /* Locale+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Locale+Extensions.swift"; path = "Sources/FoundationExtensions/Locale+Extensions.swift"; sourceTree = ""; }; + 69AEABD735DE880752B3A57DA9663F93 /* WinBackOffer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WinBackOffer.swift; path = Sources/Purchasing/StoreKitAbstractions/WinBackOffer.swift; sourceTree = ""; }; + 6A5A36BE17EDC4996FA118995EFF95A3 /* ArraySlice_UInt8+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "ArraySlice_UInt8+Extensions.swift"; path = "Sources/LocalReceiptParsing/DataConverters/ArraySlice_UInt8+Extensions.swift"; sourceTree = ""; }; + 6B02F3C6E2F57EE78C8EF72930F25E8A /* CachingProductsManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CachingProductsManager.swift; path = Sources/Purchasing/CachingProductsManager.swift; sourceTree = ""; }; + 6C107B155D13EA52F7FFE15268FC1567 /* AttributionStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttributionStrings.swift; path = Sources/Logging/Strings/AttributionStrings.swift; sourceTree = ""; }; + 6D748E1EB3CD22A59197E91481E3FBE9 /* Pods-App-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "Pods-App-dummy.m"; sourceTree = ""; }; + 6D8C79631CF5388121CCCFAAD1C90129 /* WebOfferingProductsCallback.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WebOfferingProductsCallback.swift; path = Sources/Networking/Caching/WebOfferingProductsCallback.swift; sourceTree = ""; }; + 6E572374C713EAE0F6925B0C4177016B /* PurchasesDiagnostics.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PurchasesDiagnostics.swift; path = Sources/Support/PurchasesDiagnostics.swift; sourceTree = ""; }; + 6E767D12636C059F2945360E3798A816 /* VirtualCurrencies.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = VirtualCurrencies.swift; path = "Sources/Virtual Currencies/VirtualCurrencies.swift"; sourceTree = ""; }; + 6FDDFFADA25052A655640C88A443EC44 /* PriceFormatterProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PriceFormatterProvider.swift; path = Sources/Misc/PriceFormatterProvider.swift; sourceTree = ""; }; + 705AC44DA1E8E98E0B441DB0948BFF0B /* SK1StoreTransaction.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SK1StoreTransaction.swift; path = Sources/Purchasing/StoreKitAbstractions/SK1StoreTransaction.swift; sourceTree = ""; }; + 7106C3714AC8B3947AAF7682205D64DC /* CustomerInfo+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "CustomerInfo+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/CustomerInfo+HybridAdditions.swift"; sourceTree = ""; }; + 7117D2E3139747D4C0AF1A2007E49862 /* StoreKit2ObserverModePurchaseDetector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoreKit2ObserverModePurchaseDetector.swift; path = "Sources/Purchasing/StoreKit2/Observer Mode/StoreKit2ObserverModePurchaseDetector.swift"; sourceTree = ""; }; + 724E4C9353C10489676F511523A082CA /* WebPurchaseRedemptionResult.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WebPurchaseRedemptionResult.swift; path = Sources/WebPurchaseRedemption/WebPurchaseRedemptionResult.swift; sourceTree = ""; }; + 72F24EE29B2E7AA79A16E34349BD4FE7 /* IntroEligibility+HybridExtensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "IntroEligibility+HybridExtensions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/IntroEligibility+HybridExtensions.swift"; sourceTree = ""; }; + 73A88F340A4914A1B38585A668F787ED /* WinBackOffer+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "WinBackOffer+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/WinBackOffer+HybridAdditions.swift"; sourceTree = ""; }; + 73E16ABFCE212CEDA5D3816F74A918ED /* ASIdManagerProxy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ASIdManagerProxy.swift; path = Sources/Attribution/ASIdManagerProxy.swift; sourceTree = ""; }; + 741E28294C6E366139D9B1A98FF4D201 /* UserDefaults+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "UserDefaults+Extensions.swift"; path = "Sources/FoundationExtensions/UserDefaults+Extensions.swift"; sourceTree = ""; }; + 741F1FE82F5563A51C18047143318D51 /* StoreKitRequestFetcher.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoreKitRequestFetcher.swift; path = Sources/Purchasing/StoreKit1/StoreKitRequestFetcher.swift; sourceTree = ""; }; + 74A3C88613D4EB0E63038367698A81E9 /* RevenueCat */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = RevenueCat; path = RevenueCat.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 75F61E994F0997F8CE394AAC208B4DC8 /* LocalTransactionMetadata.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LocalTransactionMetadata.swift; path = Sources/Purchasing/Purchases/LocalTransactionMetadata.swift; sourceTree = ""; }; + 761291AD85C6761A0D19DEDABFC56053 /* RefundRequestStatus+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "RefundRequestStatus+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/RefundRequestStatus+HybridAdditions.swift"; sourceTree = ""; }; + 76836226476D35BC62A098CF501DF10B /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Pods-App.release.xcconfig"; sourceTree = ""; }; + 76DCD34563B705E96DD53FE3CC8659EB /* IntroEligibility.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = IntroEligibility.swift; path = Sources/Purchasing/IntroEligibility.swift; sourceTree = ""; }; + 7746B40EE5186834473EF0C65F15A607 /* HealthReportResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HealthReportResponse.swift; path = Sources/Networking/Responses/HealthReportResponse.swift; sourceTree = ""; }; + 77E382909CD71BFADC9E5050D833E866 /* PromotionalOffer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PromotionalOffer.swift; path = Sources/Purchasing/StoreKitAbstractions/PromotionalOffer.swift; sourceTree = ""; }; + 7807B1C6EB2D20BFF2529D4C0FF0040E /* SDKHealthStatus+Icon.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "SDKHealthStatus+Icon.swift"; path = "Sources/Support/DebugUI/SDKHealthStatus+Icon.swift"; sourceTree = ""; }; + 7884B518EF2556A1388CDEBB756A896B /* ErrorResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ErrorResponse.swift; path = Sources/Networking/HTTPClient/ErrorResponse.swift; sourceTree = ""; }; + 794983798EEBEA8147747C6AA24D41A4 /* IdentityAPI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = IdentityAPI.swift; path = Sources/Networking/IdentityAPI.swift; sourceTree = ""; }; + 79D7A380E2CBB08C55B18415CD1215CB /* Pods-App-acknowledgements.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "Pods-App-acknowledgements.plist"; sourceTree = ""; }; + 79F69394BA6293A6E77539088AB6A35B /* ETagStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ETagStrings.swift; path = Sources/Logging/Strings/ETagStrings.swift; sourceTree = ""; }; + 7A75172A4413D9B31F18ACDB27F8365F /* CustomerCenterConfigCallback.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CustomerCenterConfigCallback.swift; path = Sources/Networking/Caching/CustomerCenterConfigCallback.swift; sourceTree = ""; }; + 7B99AC8998CBD5E39EAF55D3A285403C /* TransactionNotifications.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TransactionNotifications.swift; path = Sources/Purchasing/Purchases/TransactionNotifications.swift; sourceTree = ""; }; + 7C3AA691053AC1526EA7AD7112D87EB9 /* URLWithValidation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = URLWithValidation.swift; path = Sources/Caching/URLWithValidation.swift; sourceTree = ""; }; + 7C61AF345273EF54AC3D469D70F0E0A8 /* StoreKitVersion+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "StoreKitVersion+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/StoreKitVersion+HybridAdditions.swift"; sourceTree = ""; }; + 7C6DCE5AA49AC6AD95CAD94D34DC7107 /* ManageSubscriptionsStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ManageSubscriptionsStrings.swift; path = Sources/Logging/Strings/ManageSubscriptionsStrings.swift; sourceTree = ""; }; + 7C6ED52688C7A48D11AF18260782774E /* PostAttributionDataOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PostAttributionDataOperation.swift; path = Sources/Networking/Operations/PostAttributionDataOperation.swift; sourceTree = ""; }; + 7CC23020549490D059E953FE9D3A1EF9 /* SubscriberAttribute.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SubscriberAttribute.swift; path = Sources/SubscriberAttributes/SubscriberAttribute.swift; sourceTree = ""; }; + 7D7B6D8D2D0FB1D62E64B8C51A82A79B /* ResourceBundle-RevenueCat-RevenueCat-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "ResourceBundle-RevenueCat-RevenueCat-Info.plist"; sourceTree = ""; }; + 7DBA6A441AA1F122717DFA33959AACE3 /* OfferingsResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OfferingsResponse.swift; path = Sources/Networking/Responses/OfferingsResponse.swift; sourceTree = ""; }; + 7E1B2587339CB18DD341DDAD7C5BAD1E /* SynchronizedUserDefaults.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SynchronizedUserDefaults.swift; path = Sources/Misc/Concurrency/SynchronizedUserDefaults.swift; sourceTree = ""; }; + 7F5DABF3AB2AFACD4C62AF610BCACD83 /* VirtualCurrency+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "VirtualCurrency+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/VirtualCurrency+HybridAdditions.swift"; sourceTree = ""; }; + 7F6ECC6C4DD98E625F6E37A6F6C399C8 /* ProductsManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProductsManager.swift; path = Sources/Purchasing/ProductsManager.swift; sourceTree = ""; }; + 7FA5026DD91A2DED9B052E3E1198B3A6 /* Purchases.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Purchases.swift; path = Sources/Purchasing/Purchases/Purchases.swift; sourceTree = ""; }; + 807C132E2A49B7544A75717CD942AE59 /* InAppPurchase.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = InAppPurchase.swift; path = Sources/LocalReceiptParsing/BasicTypes/InAppPurchase.swift; sourceTree = ""; }; + 80B4EAC55FDCA2A0D3FC58E15C98A22E /* PurchasesHybridCommon */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = PurchasesHybridCommon; path = PurchasesHybridCommon.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8230177979D569F3B9611599288B690E /* StoreKit2TransactionListener.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoreKit2TransactionListener.swift; path = Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift; sourceTree = ""; }; + 82F811A0E6D79CE9AE93569A1B189427 /* StoreKit2TransactionFetcher.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoreKit2TransactionFetcher.swift; path = Sources/Purchasing/StoreKit2/StoreKit2TransactionFetcher.swift; sourceTree = ""; }; + 83B31470F770EB32564A13857A8BE25A /* RevenueCat-prefix.pch */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "RevenueCat-prefix.pch"; sourceTree = ""; }; + 85CBFB8711101557F06EE0748753997E /* TransactionsManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TransactionsManager.swift; path = Sources/Purchasing/TransactionsManager.swift; sourceTree = ""; }; + 861F711AED94FA2C6115C3A7757FD1A6 /* TransactionMetadataStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TransactionMetadataStrings.swift; path = Sources/Logging/Strings/TransactionMetadataStrings.swift; sourceTree = ""; }; + 86F3ED86A3C76F0AA6BED070C70CF4BE /* SK2StoreProductDiscount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SK2StoreProductDiscount.swift; path = Sources/Purchasing/StoreKitAbstractions/SK2StoreProductDiscount.swift; sourceTree = ""; }; + 8773B2E5D7F8A99B50F36D80B9AF21E1 /* Pods-App.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = "Pods-App.modulemap"; sourceTree = ""; }; + 87A71B489ED684C9F56752C38BF66DF8 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + 881041EB30032A832A0F458D97E93162 /* TransactionsFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TransactionsFactory.swift; path = Sources/Purchasing/TransactionsFactory.swift; sourceTree = ""; }; + 88E4E5667EA6813DACB860344998405E /* SimulatedStorePurchaseHandler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SimulatedStorePurchaseHandler.swift; path = Sources/Purchasing/SimulatedStore/SimulatedStorePurchaseHandler.swift; sourceTree = ""; }; + 89575BF68A8CEE956D14067908790200 /* Box.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Box.swift; path = Sources/Misc/Box.swift; sourceTree = ""; }; + 8992CDB73A6B912E0847E88CDC2A50E4 /* Strings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Strings.swift; path = Sources/Logging/Strings/Strings.swift; sourceTree = ""; }; + 89B36287D3878611A40796176083DBE4 /* AppleReceipt.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AppleReceipt.swift; path = Sources/LocalReceiptParsing/BasicTypes/AppleReceipt.swift; sourceTree = ""; }; + 8A60B144B9CE8E6512A831EAC32E6089 /* BackendConfiguration.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BackendConfiguration.swift; path = Sources/Networking/BackendConfiguration.swift; sourceTree = ""; }; + 8A6CDA6B79CEF8E8C41348883E4B7D6B /* PromotionalOffer+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "PromotionalOffer+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/PromotionalOffer+HybridAdditions.swift"; sourceTree = ""; }; + 8AD5E61E36E46150F5EB885D9379D202 /* PaywallTabsComponent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallTabsComponent.swift; path = Sources/Paywalls/Components/PaywallTabsComponent.swift; sourceTree = ""; }; + 8AE6C8B273904DBD8CF1DCAD8E07F42C /* TransactionMetadataSyncHelper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TransactionMetadataSyncHelper.swift; path = Sources/Purchasing/Purchases/TransactionMetadataSyncHelper.swift; sourceTree = ""; }; + 8BF7E8C10125597CD5C46CA7F0A69A95 /* AttributionNetwork.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttributionNetwork.swift; path = Sources/Attribution/AttributionNetwork.swift; sourceTree = ""; }; + 8CB6178E26439BA8FBAAB00CFD0DD1CE /* EntitlementInfo+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "EntitlementInfo+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/EntitlementInfo+HybridAdditions.swift"; sourceTree = ""; }; + 8DA853BC3FDB08FEDFB505FA5E0C6173 /* PaywallCacheWarming.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallCacheWarming.swift; path = Sources/Paywalls/PaywallCacheWarming.swift; sourceTree = ""; }; + 8DADB2F738C652A10125B3D891E321E4 /* PaywallComponentsData.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallComponentsData.swift; path = Sources/Networking/Responses/RevenueCatUI/PaywallComponentsData.swift; sourceTree = ""; }; + 8DF9870E61DE1C5D9647AF5CCB7BD04B /* FeatureEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FeatureEvent.swift; path = Sources/Events/FeatureEvents/FeatureEvent.swift; sourceTree = ""; }; + 8E0093159D58F9F03FE9A34BDCDCE831 /* RateLimiter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RateLimiter.swift; path = Sources/Misc/RateLimiter.swift; sourceTree = ""; }; + 8E75B29AE1472628C750F928572B5885 /* Pods-App-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "Pods-App-umbrella.h"; sourceTree = ""; }; + 8F16A164D387C89633751E0685FF7E70 /* DiagnosticsHTTPRequestPath.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DiagnosticsHTTPRequestPath.swift; path = Sources/Diagnostics/Networking/DiagnosticsHTTPRequestPath.swift; sourceTree = ""; }; + 8F49C84CFA1863E7868E02361FBB88D1 /* ISOPeriodFormatter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ISOPeriodFormatter.swift; path = Sources/Misc/DateAndTime/ISOPeriodFormatter.swift; sourceTree = ""; }; + 910374F9FAEC162807F13EA3A8C85814 /* StoreEnvironment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoreEnvironment.swift; path = Sources/Purchasing/StoreKitAbstractions/StoreEnvironment.swift; sourceTree = ""; }; + 920E9A6F1FBF9A3E24676E196676231C /* CustomerInfo+ActiveDates.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "CustomerInfo+ActiveDates.swift"; path = "Sources/Identity/CustomerInfo+ActiveDates.swift"; sourceTree = ""; }; + 92254E4924A2E640CA1B355E0D19EFCC /* RevenueCat.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = RevenueCat.debug.xcconfig; sourceTree = ""; }; + 92791C0DE028C10279B0A9C37325E3BB /* PostFeatureEventsOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PostFeatureEventsOperation.swift; path = Sources/Events/FeatureEvents/Networking/PostFeatureEventsOperation.swift; sourceTree = ""; }; + 9291FD1CC3EDC9A696B0E9D715A228CF /* ReceiptFetcher.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ReceiptFetcher.swift; path = Sources/Purchasing/ReceiptFetcher.swift; sourceTree = ""; }; + 92CFD404DC47FA588A5FCDBD87FE890A /* CustomerInfoResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CustomerInfoResponse.swift; path = Sources/Networking/Responses/CustomerInfoResponse.swift; sourceTree = ""; }; + 92CFDC0C567E64E8CD81EC9BE7A59DC7 /* PostRedeemWebPurchaseOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PostRedeemWebPurchaseOperation.swift; path = Sources/Networking/Operations/PostRedeemWebPurchaseOperation.swift; sourceTree = ""; }; + 93CA4450613241C9258EF62F5E9908B6 /* HealthReportAvailabilityResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HealthReportAvailabilityResponse.swift; path = Sources/Networking/Responses/HealthReportAvailabilityResponse.swift; sourceTree = ""; }; + 94BCD2231C5EFE24C396F26F671628F4 /* StoreKitStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoreKitStrings.swift; path = Sources/Logging/Strings/StoreKitStrings.swift; sourceTree = ""; }; + 94C16D040BEC10E29CB27DE018724380 /* PeriodType+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "PeriodType+Extensions.swift"; path = "Sources/CodableExtensions/PeriodType+Extensions.swift"; sourceTree = ""; }; + 953A994B046DCFCD2FEE652AD8AD2103 /* FeatureEventStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FeatureEventStore.swift; path = Sources/Events/FeatureEvents/FeatureEventStore.swift; sourceTree = ""; }; + 9551D15B300B9E49AB65E0AD100281DB /* DiagnosticsSynchronizer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DiagnosticsSynchronizer.swift; path = Sources/Diagnostics/Networking/DiagnosticsSynchronizer.swift; sourceTree = ""; }; + 95531205282ED304C416F800F98EA53F /* RedeemWebPurchaseAPI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RedeemWebPurchaseAPI.swift; path = Sources/Networking/RedeemWebPurchaseAPI.swift; sourceTree = ""; }; + 965338A4A31115163DF10EB0DAB5A35F /* Offerings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Offerings.swift; path = Sources/Purchasing/Offerings.swift; sourceTree = ""; }; + 9667579EAAB94933AEA8860ECB03D7FD /* CommonPurchaseParams.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CommonPurchaseParams.swift; path = ios/PurchasesHybridCommon/PurchasesHybridCommon/CommonPurchaseParams.swift; sourceTree = ""; }; + 9689278B1C8252427CB02806EDD0966C /* SDKHealthCheckStatus+Icon.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "SDKHealthCheckStatus+Icon.swift"; path = "Sources/Support/DebugUI/SDKHealthCheckStatus+Icon.swift"; sourceTree = ""; }; + 982DD257A0E6F11A61B10C78C75219D1 /* Dimension.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Dimension.swift; path = Sources/Paywalls/Components/Common/Dimension.swift; sourceTree = ""; }; + 984D2C3735188D894207578D1804DA92 /* PurchasesHybridCommon.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = PurchasesHybridCommon.modulemap; sourceTree = ""; }; + 9885CBEA1078E82811ED1AB2167DAFCA /* StoreKit1Wrapper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoreKit1Wrapper.swift; path = Sources/Purchasing/StoreKit1/StoreKit1Wrapper.swift; sourceTree = ""; }; + 98E870720B12A477833EC662153EAA94 /* WebBillingProductsResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WebBillingProductsResponse.swift; path = Sources/Networking/Responses/WebBillingProductsResponse.swift; sourceTree = ""; }; + 9C07C024B48CD014121D93FFE67D317D /* ProductsManagerType.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProductsManagerType.swift; path = Sources/Purchasing/ProductsManagerType.swift; sourceTree = ""; }; + 9C74858ACCB6718D09F9A76A94E45922 /* AdHTTPRequestPath.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AdHTTPRequestPath.swift; path = Sources/Ads/Events/Networking/AdHTTPRequestPath.swift; sourceTree = ""; }; + 9D6433F205FDA9C6CA5BF3413B1A6544 /* Purchases+nonasync.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Purchases+nonasync.swift"; path = "Sources/Misc/Concurrency/Purchases+nonasync.swift"; sourceTree = ""; }; + 9D940727FF8FB9C785EB98E56350EF41 /* Podfile */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; includeInIndex = 1; indentWidth = 2; lastKnownFileType = text; name = Podfile; path = ../Podfile; sourceTree = SOURCE_ROOT; tabWidth = 2; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; + 9EA2D22D2E6CA3B7F5956EC6500603A5 /* Either.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Either.swift; path = Sources/Misc/Either.swift; sourceTree = ""; }; + 9EEF5C7982E9B875772855739D85E3A1 /* SubscriberAttributesManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SubscriberAttributesManager.swift; path = Sources/SubscriberAttributes/SubscriberAttributesManager.swift; sourceTree = ""; }; + 9F37C08824D3E2BC8EFAD684F8DD2D30 /* SigningStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SigningStrings.swift; path = Sources/Logging/Strings/SigningStrings.swift; sourceTree = ""; }; + 9FA708953532C337EB9E943F1E130927 /* UIConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = UIConfig.swift; path = Sources/Networking/Responses/RevenueCatUI/UIConfig.swift; sourceTree = ""; }; + 9FFBAFA262DFF87E003DA4F5E7D85D01 /* CacheStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CacheStrings.swift; path = Sources/Logging/Strings/CacheStrings.swift; sourceTree = ""; }; + A1C458C2BE7CC01A36C8235C83BC8EBC /* StoreProduct.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoreProduct.swift; path = Sources/Purchasing/StoreKitAbstractions/StoreProduct.swift; sourceTree = ""; }; + A22F90A4358F5D13F7E0BF83F7DABD18 /* TimingUtil.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TimingUtil.swift; path = Sources/Misc/DateAndTime/TimingUtil.swift; sourceTree = ""; }; + A23286ED5558C93B7CD6515FDFF13D6B /* PurchaseStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PurchaseStrings.swift; path = Sources/Logging/Strings/PurchaseStrings.swift; sourceTree = ""; }; + A2E8626622CAC40EF643A18775639D0D /* StoreMessagesHelper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoreMessagesHelper.swift; path = Sources/Support/StoreMessagesHelper.swift; sourceTree = ""; }; + A47F72C2370452B72036EC0A6F194747 /* VirtualCurrency.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = VirtualCurrency.swift; path = "Sources/Virtual Currencies/VirtualCurrency.swift"; sourceTree = ""; }; + A617AD237C24722AE93154558C4BD548 /* PurchasesHybridCommon-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "PurchasesHybridCommon-umbrella.h"; sourceTree = ""; }; + A65AECEC43CBADA572E58C2084B63AF0 /* Data+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Data+Extensions.swift"; path = "Sources/FoundationExtensions/Data+Extensions.swift"; sourceTree = ""; }; + A66AD16E450533FF112985315A1A6117 /* NetworkOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NetworkOperation.swift; path = Sources/Networking/Operations/NetworkOperation.swift; sourceTree = ""; }; + A6727B22C2DD81436B87F5B07744FFED /* DebugViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DebugViewController.swift; path = Sources/Support/DebugUI/DebugViewController.swift; sourceTree = ""; }; + A6997D4CD82F1DBB88676A21436D1B00 /* Signing.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Signing.swift; path = Sources/Security/Signing.swift; sourceTree = ""; }; + A738020BF03183667C17B8A12819E7E6 /* VirtualCurrencies+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "VirtualCurrencies+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/VirtualCurrencies+HybridAdditions.swift"; sourceTree = ""; }; + A88512127CC7038A2B20295A1BE47EDA /* LargeItemCacheType.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LargeItemCacheType.swift; path = Sources/Caching/LargeItemCacheType.swift; sourceTree = ""; }; + A8B8D1442C20FE45C844F1A8CF2FC978 /* ProductPaidPrice.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProductPaidPrice.swift; path = Sources/Identity/ProductPaidPrice.swift; sourceTree = ""; }; + A916D6FBB0F53B12D9BC92722540B52D /* HTTPRequestBody+Signing.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "HTTPRequestBody+Signing.swift"; path = "Sources/Security/HTTPRequestBody+Signing.swift"; sourceTree = ""; }; + A92823154D5BF0558321E066DD375B81 /* ExitOffer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExitOffer.swift; path = Sources/Paywalls/ExitOffer.swift; sourceTree = ""; }; + A9934FEA0DF74CCE48B0639282EF2C87 /* Offerings+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Offerings+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/Offerings+HybridAdditions.swift"; sourceTree = ""; }; + A9ECBD5A10BECFD6D6B78D9B18815437 /* TestStoreTransaction.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TestStoreTransaction.swift; path = Sources/Purchasing/StoreKitAbstractions/TestStoreTransaction.swift; sourceTree = ""; }; + AAD2628DBF8716B69F39514E51E899E8 /* DeviceCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DeviceCache.swift; path = Sources/Caching/DeviceCache.swift; sourceTree = ""; }; + AB1AE29494FF679ED68FC6191B1007F9 /* ProductsRequestFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProductsRequestFactory.swift; path = Sources/Purchasing/ProductsRequestFactory.swift; sourceTree = ""; }; + AB52161BBB21E3DB944AAD6127619230 /* ProcessInfo+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "ProcessInfo+Extensions.swift"; path = "Sources/LocalReceiptParsing/Helpers/ProcessInfo+Extensions.swift"; sourceTree = ""; }; + AB7802D37966EE1823520BCEF311C699 /* SK2StoreProduct.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SK2StoreProduct.swift; path = Sources/Purchasing/StoreKitAbstractions/SK2StoreProduct.swift; sourceTree = ""; }; + ABA3121E4FE255F17E267E7B7E5D9DE0 /* ASN1ContainerBuilder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ASN1ContainerBuilder.swift; path = Sources/LocalReceiptParsing/Builders/ASN1ContainerBuilder.swift; sourceTree = ""; }; + AC398DE3CBF2CF951D9F1956821D9D92 /* PaywallColor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallColor.swift; path = Sources/Paywalls/PaywallColor.swift; sourceTree = ""; }; + ACEEBBF233CE279392D2E23CD2EB2B5E /* TestStoreProductDiscount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TestStoreProductDiscount.swift; path = "Sources/Purchasing/StoreKitAbstractions/Test Data/TestStoreProductDiscount.swift"; sourceTree = ""; }; + ACF413EA2CEFBC52AFDBF08039BC2B1A /* VirtualCurrencyManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = VirtualCurrencyManager.swift; path = "Sources/Virtual Currencies/VirtualCurrencyManager.swift"; sourceTree = ""; }; + AD09B3518B3902E39C9B44C28578B656 /* VirtualCurrenciesAPI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = VirtualCurrenciesAPI.swift; path = Sources/Networking/VirtualCurrenciesAPI.swift; sourceTree = ""; }; + ADF15DBECBD34E96E716BA9E933D2A96 /* FileHandler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FileHandler.swift; path = Sources/Diagnostics/FileHandler.swift; sourceTree = ""; }; + AE1F9C9F63501650001C13DA131E2616 /* ManageSubscriptionsHelper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ManageSubscriptionsHelper.swift; path = Sources/Support/ManageSubscriptionsHelper.swift; sourceTree = ""; }; + AE96A3D55B67224E00ED86E5867DD3C3 /* Operators+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Operators+Extensions.swift"; path = "Sources/FoundationExtensions/Operators+Extensions.swift"; sourceTree = ""; }; + AEBB9B1BF0E9593CC606BF5001A6925D /* PurchasesHybridCommon-prefix.pch */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "PurchasesHybridCommon-prefix.pch"; sourceTree = ""; }; + B109616F0DBB8B553145755A69337FE8 /* ProductRequestData.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProductRequestData.swift; path = Sources/Purchasing/ProductRequestData.swift; sourceTree = ""; }; + B2403A13AAAE0FE4A1CEA7184A02C573 /* ProductRequestData+Initialization.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "ProductRequestData+Initialization.swift"; path = "Sources/Purchasing/ProductRequestData+Initialization.swift"; sourceTree = ""; }; + B313A8CDB736DB52EDDD3634F8131A94 /* PurchaseOwnershipType.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PurchaseOwnershipType.swift; path = Sources/Purchasing/PurchaseOwnershipType.swift; sourceTree = ""; }; + B37235A9E7F563EA5613B6127EEF9071 /* ETagManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ETagManager.swift; path = Sources/Networking/HTTPClient/ETagManager.swift; sourceTree = ""; }; + B3C639366CDC49C8E10C63953916B94A /* SimulatedStoreProductsManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SimulatedStoreProductsManager.swift; path = Sources/Purchasing/SimulatedStore/SimulatedStoreProductsManager.swift; sourceTree = ""; }; + B46B23738D8EAE9DD716F061861A47B2 /* Set+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Set+Extensions.swift"; path = "Sources/FoundationExtensions/Set+Extensions.swift"; sourceTree = ""; }; + B4E38DB9CE7A8E23D6C8B7FEE1C4DA92 /* SubscriptionInfo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SubscriptionInfo.swift; path = Sources/Identity/SubscriptionInfo.swift; sourceTree = ""; }; + B55C188AC2C1A94092CD8E91790E6C08 /* StoreKitVersion.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoreKitVersion.swift; path = Sources/Misc/StoreKitVersion.swift; sourceTree = ""; }; + B585AC5FD94D011C8EAB750F531DD9D4 /* Signing+ResponseVerification.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Signing+ResponseVerification.swift"; path = "Sources/Security/Signing+ResponseVerification.swift"; sourceTree = ""; }; + B76D7FFEF4333335EAFA0B2827258173 /* Pods-App-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "Pods-App-Info.plist"; sourceTree = ""; }; + B782EEACC35423EE8396C87C2A44FDBD /* HTTPRequestTimeoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HTTPRequestTimeoutManager.swift; path = Sources/Networking/HTTPClient/HTTPRequestTimeoutManager.swift; sourceTree = ""; }; + B7D58E95383516C9168038CFA612FAB5 /* PaywallComponentPropertyTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallComponentPropertyTypes.swift; path = Sources/Paywalls/Components/Common/PaywallComponentPropertyTypes.swift; sourceTree = ""; }; + B862D15197CF3107C7B56884695C03BF /* SwiftVersionCheck.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SwiftVersionCheck.swift; path = Sources/Support/SwiftVersionCheck.swift; sourceTree = ""; }; + B86DA289E7F22C32D00FC998DA306C51 /* ProductEntitlementMappingResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProductEntitlementMappingResponse.swift; path = Sources/Networking/Responses/ProductEntitlementMappingResponse.swift; sourceTree = ""; }; + B8B5E9919901D1A85E2C8CE70E94B48B /* Integer+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Integer+Extensions.swift"; path = "Sources/FoundationExtensions/Integer+Extensions.swift"; sourceTree = ""; }; + B8E77E31E19C324E81B04C28117B1A99 /* PaywallIconComponent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallIconComponent.swift; path = Sources/Paywalls/Components/PaywallIconComponent.swift; sourceTree = ""; }; + BA56B412BDF2C937EFB1687014EC5990 /* OperationQueue+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "OperationQueue+Extensions.swift"; path = "Sources/FoundationExtensions/OperationQueue+Extensions.swift"; sourceTree = ""; }; + BA9B90A92058E60129DF12B69DC9B22C /* UIApplication+RCExtensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "UIApplication+RCExtensions.swift"; path = "Sources/FoundationExtensions/UIApplication+RCExtensions.swift"; sourceTree = ""; }; + BAD2798C0929EA1B458ADB701AE1FFD5 /* PurchaseParamsBuilder+HybridExtensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "PurchaseParamsBuilder+HybridExtensions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/PurchaseParamsBuilder+HybridExtensions.swift"; sourceTree = ""; }; + BB05F7D7A24B3D579504E3878AEA3208 /* HTTPRequestPath.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HTTPRequestPath.swift; path = Sources/Networking/HTTPClient/HTTPRequestPath.swift; sourceTree = ""; }; + BB8B8C9C0633CE6C25F0B33F42EC298D /* Purchases+async.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Purchases+async.swift"; path = "Sources/Misc/Concurrency/Purchases+async.swift"; sourceTree = ""; }; + BBB1B62E71CD509474F1D600D45F213A /* PaywallComponentLocalization.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallComponentLocalization.swift; path = Sources/Paywalls/Components/Common/PaywallComponentLocalization.swift; sourceTree = ""; }; + BBF8ECB2D76A2E8A1CA77B8CE8169924 /* DescribableError.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DescribableError.swift; path = "Sources/Error Handling/DescribableError.swift"; sourceTree = ""; }; + BCEA109D76F5730F49E697444344B367 /* StoreKit2PurchaseIntentListener.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoreKit2PurchaseIntentListener.swift; path = Sources/Purchasing/StoreKit2/StoreKit2PurchaseIntentListener.swift; sourceTree = ""; }; + BDE5AC53E0E96375CC1225DD7E66C583 /* PurchasesAreCompletedBy+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "PurchasesAreCompletedBy+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/PurchasesAreCompletedBy+HybridAdditions.swift"; sourceTree = ""; }; + BE17C82E7C2192C4C5E7AD92ABF19E6A /* PaywallImageComponent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallImageComponent.swift; path = Sources/Paywalls/Components/PaywallImageComponent.swift; sourceTree = ""; }; + C0022D458214DD88C567CC6ADA1AA796 /* OfflineEntitlementsAPI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OfflineEntitlementsAPI.swift; path = Sources/Networking/OfflineEntitlementsAPI.swift; sourceTree = ""; }; + C09FB99B96B83ECDA4C9A114BF61B515 /* Codable+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Codable+Extensions.swift"; path = "Sources/LocalReceiptParsing/DataConverters/Codable+Extensions.swift"; sourceTree = ""; }; + C189A64874CE704A5EB5F9E4814B82AF /* SubscriptionInfo+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "SubscriptionInfo+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/SubscriptionInfo+HybridAdditions.swift"; sourceTree = ""; }; + C21042E9373E06C1FF2749EEB2023EC3 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; }; + C21C6634D66B5478C5D81E580547D16F /* EntitlementInfos.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EntitlementInfos.swift; path = Sources/Purchasing/EntitlementInfos.swift; sourceTree = ""; }; + C223355B1D7BA7EF7055BA32F0132078 /* TimeInterval+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "TimeInterval+Extensions.swift"; path = "Sources/FoundationExtensions/TimeInterval+Extensions.swift"; sourceTree = ""; }; + C2603375AE11B4D193BD0781F8C9E31F /* PaywallFontManagerType.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallFontManagerType.swift; path = Sources/Paywalls/PaywallFontManagerType.swift; sourceTree = ""; }; + C3D077092FF4842866C6ADEAEA1F7656 /* ProductType.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProductType.swift; path = Sources/Purchasing/StoreKitAbstractions/ProductType.swift; sourceTree = ""; }; + C453716CD6B42DA4DACF0707ACD5DD1B /* SK2BeginRefundRequestHelper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SK2BeginRefundRequestHelper.swift; path = Sources/Purchasing/StoreKit2/SK2BeginRefundRequestHelper.swift; sourceTree = ""; }; + C48FDD55070B9A0A8C52F0337FEAB59F /* ProductStatus+Icon.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "ProductStatus+Icon.swift"; path = "Sources/Support/DebugUI/ProductStatus+Icon.swift"; sourceTree = ""; }; + C546CE2528372F2E3CE7F7FF44AA8B0C /* PurchasedSK2Product.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PurchasedSK2Product.swift; path = Sources/OfflineEntitlements/PurchasedSK2Product.swift; sourceTree = ""; }; + C5DE0B7AD6EE4A13EEDBA37EF4239ED3 /* DebugViewSheetPresentation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DebugViewSheetPresentation.swift; path = Sources/Support/DebugUI/DebugViewSheetPresentation.swift; sourceTree = ""; }; + C62B0FEC9381399A0CF313D658B62FB9 /* URL+WebPurchaseRedemption.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "URL+WebPurchaseRedemption.swift"; path = "Sources/WebPurchaseRedemption/URL+WebPurchaseRedemption.swift"; sourceTree = ""; }; + C71FFD0EB89D5CDF617AF3EB7C1AC6DD /* OfferingsCallback.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OfferingsCallback.swift; path = Sources/Networking/Caching/OfferingsCallback.swift; sourceTree = ""; }; + C72682CB26A8A8798A45276B14DCD6FE /* EncodedAppleReceipt.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EncodedAppleReceipt.swift; path = Sources/Purchasing/StoreKitAbstractions/EncodedAppleReceipt.swift; sourceTree = ""; }; + C729B739E6AE47DBF843097F81DEB596 /* CustomerCenterConfigData.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CustomerCenterConfigData.swift; path = Sources/CustomerCenter/CustomerCenterConfigData.swift; sourceTree = ""; }; + C748BCC3EB0BE2C32357DD969EEC2957 /* CallbackCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CallbackCache.swift; path = Sources/Networking/Caching/CallbackCache.swift; sourceTree = ""; }; + C9878330F2E5A509C95464B5A9BDF996 /* ProductEntitlementMappingFetcher.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProductEntitlementMappingFetcher.swift; path = Sources/OfflineEntitlements/ProductEntitlementMappingFetcher.swift; sourceTree = ""; }; + CA50CB4C6E0ABB414E82FBFBC3B5B259 /* WebBillingProductsCallback.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WebBillingProductsCallback.swift; path = Sources/Networking/Caching/WebBillingProductsCallback.swift; sourceTree = ""; }; + CAF174732015CC1C1E5DDCBD5C1014D3 /* MapAppStoreDetector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MapAppStoreDetector.swift; path = Sources/Misc/MapAppStoreDetector.swift; sourceTree = ""; }; + CB1A30CEB1B8DE6971D0078A39D1608D /* Attribution.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Attribution.swift; path = Sources/Purchasing/Purchases/Attribution.swift; sourceTree = ""; }; + CBF1CC7C6CDEDA70A5B1B4A698B43255 /* StorefrontProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StorefrontProvider.swift; path = Sources/Purchasing/StoreKitAbstractions/StorefrontProvider.swift; sourceTree = ""; }; + CC22E86D5F7D7434FC244609913CDA0F /* PaywallCarouselComponent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallCarouselComponent.swift; path = Sources/Paywalls/Components/PaywallCarouselComponent.swift; sourceTree = ""; }; + CC5DED6775202924EF0C04B2E2A8F425 /* SDKHealthManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SDKHealthManager.swift; path = Sources/Support/SDKHealthManager.swift; sourceTree = ""; }; + CC6258085318619BB7C3D5AE8E2F820D /* PaywallCountdownComponent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallCountdownComponent.swift; path = Sources/Paywalls/Components/PaywallCountdownComponent.swift; sourceTree = ""; }; + CCC42359A1CA61861ADE99307393F1E0 /* LogIntent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LogIntent.swift; path = Sources/Logging/LogIntent.swift; sourceTree = ""; }; + CE040262DF475BDC6D9F04D91E9F6071 /* PurchasesOrchestrator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PurchasesOrchestrator.swift; path = Sources/Purchasing/Purchases/PurchasesOrchestrator.swift; sourceTree = ""; }; + CE0903967F6943A1CFA693D640698292 /* LocalTransactionMetadataStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LocalTransactionMetadataStore.swift; path = Sources/Purchasing/Purchases/LocalTransactionMetadataStore.swift; sourceTree = ""; }; + CE0D5E64BCD5A731B05D799C3899BD45 /* VirtualCurrenciesCallback.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = VirtualCurrenciesCallback.swift; path = Sources/Networking/Caching/VirtualCurrenciesCallback.swift; sourceTree = ""; }; + CE57945CF7FB4E615C08C02571AEA631 /* EntitlementInfo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EntitlementInfo.swift; path = Sources/Purchasing/EntitlementInfo.swift; sourceTree = ""; }; + CE9F5CD71E870407DC34DB7812BF0C48 /* StoreKit2PromotionalOfferPurchaseOptions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoreKit2PromotionalOfferPurchaseOptions.swift; path = Sources/Purchasing/StoreKit2/StoreKit2PromotionalOfferPurchaseOptions.swift; sourceTree = ""; }; + CF07BD63D9C5EA55B4B7E9E538E996E1 /* Pods-App-acknowledgements.markdown */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; path = "Pods-App-acknowledgements.markdown"; sourceTree = ""; }; + CFD39CF476F2A473004A7DDCD750F056 /* GetProductEntitlementMappingOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = GetProductEntitlementMappingOperation.swift; path = Sources/Networking/Operations/GetProductEntitlementMappingOperation.swift; sourceTree = ""; }; + D0163E5B4BF6BDC1A473065C2478040B /* SynchronizedLargeItemCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SynchronizedLargeItemCache.swift; path = Sources/Misc/Concurrency/SynchronizedLargeItemCache.swift; sourceTree = ""; }; + D0DFF719C20AC527A6BC2DC98C963E13 /* SK2AppTransaction.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SK2AppTransaction.swift; path = Sources/Purchasing/StoreKit2/SK2AppTransaction.swift; sourceTree = ""; }; + D16F31D095A5E8B7668451E9766B3C8B /* CustomerInfoStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CustomerInfoStrings.swift; path = Sources/Logging/Strings/CustomerInfoStrings.swift; sourceTree = ""; }; + D22D26B7714838694A449B7595D4FF67 /* SK2Storefront.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SK2Storefront.swift; path = Sources/Purchasing/StoreKitAbstractions/SK2Storefront.swift; sourceTree = ""; }; + D31C9077CE472B5356BE6196B56FFE3F /* NSDate+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "NSDate+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/NSDate+HybridAdditions.swift"; sourceTree = ""; }; + D41ADD45E9FC908CBB9064A6CB0FE5C1 /* SimpleNetworkServiceType.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SimpleNetworkServiceType.swift; path = Sources/Networking/HTTPClient/SimpleNetworkServiceType.swift; sourceTree = ""; }; + D44A2781799F278C4957B2D9704410C4 /* PurchasesHybridCommon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = PurchasesHybridCommon.release.xcconfig; sourceTree = ""; }; + D45B91C86E29354B695E56BD1795F6E0 /* OperationDispatcher.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OperationDispatcher.swift; path = Sources/Misc/Concurrency/OperationDispatcher.swift; sourceTree = ""; }; + D6C95818A043218391EF6DDF699C76F1 /* UInt8+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "UInt8+Extensions.swift"; path = "Sources/LocalReceiptParsing/DataConverters/UInt8+Extensions.swift"; sourceTree = ""; }; + D6DAEC63578C194A8F591F33C0A58342 /* DateProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DateProvider.swift; path = Sources/Misc/DateAndTime/DateProvider.swift; sourceTree = ""; }; + D6E844CEC04570752215BC22D972BE5D /* SimulatedStoreProduct.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SimulatedStoreProduct.swift; path = Sources/Purchasing/SimulatedStore/SimulatedStoreProduct.swift; sourceTree = ""; }; + D72D231BD7B9A3D4F83AF06454B15416 /* ReservedSubscriberAttributes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ReservedSubscriberAttributes.swift; path = Sources/SubscriberAttributes/ReservedSubscriberAttributes.swift; sourceTree = ""; }; + D83AE5D93C653BDBFDDF59AC3D1E32BB /* RevenueCat-RevenueCat */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; name = "RevenueCat-RevenueCat"; path = RevenueCat.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; + D9C72F237269BCD25DF95B5E2164B13E /* Decoder+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Decoder+Extensions.swift"; path = "Sources/FoundationExtensions/Decoder+Extensions.swift"; sourceTree = ""; }; + DA26904737F608373C9C7B92857448C4 /* StoredFeatureEventSerializer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoredFeatureEventSerializer.swift; path = Sources/Events/FeatureEvents/StoredFeatureEventSerializer.swift; sourceTree = ""; }; + DB9577237390742DC703110594A2987D /* HTTPRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HTTPRequest.swift; path = Sources/Networking/HTTPClient/HTTPRequest.swift; sourceTree = ""; }; + DBDABBB513EBF5A01231357F6511E84A /* CallbackCacheStatus.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CallbackCacheStatus.swift; path = Sources/Networking/Caching/CallbackCacheStatus.swift; sourceTree = ""; }; + DC6F53A0BB095AE1E03AF662A04BDA52 /* ReceiptParsingError.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ReceiptParsingError.swift; path = Sources/LocalReceiptParsing/ReceiptParsingError.swift; sourceTree = ""; }; + DCDB0AFAEE59344FC83922BAFAA8F84F /* DispatchTimeInterval+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "DispatchTimeInterval+Extensions.swift"; path = "Sources/FoundationExtensions/DispatchTimeInterval+Extensions.swift"; sourceTree = ""; }; + DF9D0CF26D635488D863AE5E82D2E617 /* NonSubscriptionTransaction+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "NonSubscriptionTransaction+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/NonSubscriptionTransaction+HybridAdditions.swift"; sourceTree = ""; }; + DFC2C7BF166DA2E780161AEDA0440880 /* SDKHealthError+CustomNSError.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "SDKHealthError+CustomNSError.swift"; path = "Sources/Support/SDKHealthError+CustomNSError.swift"; sourceTree = ""; }; + DFE092025639D2E60A58D4B9686537B2 /* Storefront.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Storefront.swift; path = Sources/Purchasing/StoreKitAbstractions/Storefront.swift; sourceTree = ""; }; + E068FB1DD1D8B6F0726EB4AF89FDAB3E /* AttributionDataMigrator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttributionDataMigrator.swift; path = Sources/SubscriberAttributes/AttributionDataMigrator.swift; sourceTree = ""; }; + E06D51866D5E4D037B1DF0A7248BB569 /* WebOfferingProductsResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WebOfferingProductsResponse.swift; path = Sources/Networking/Responses/WebOfferingProductsResponse.swift; sourceTree = ""; }; + E0988BF1A4E391F5D1E424FD6ECFF537 /* PaywallStackComponent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallStackComponent.swift; path = Sources/Paywalls/Components/PaywallStackComponent.swift; sourceTree = ""; }; + E0A3E95AB022B7D010C4ECA6DCE48491 /* AnalyticsStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AnalyticsStrings.swift; path = Sources/Logging/Strings/AnalyticsStrings.swift; sourceTree = ""; }; + E1AD6B3724C693795D04A81FBDDCA37D /* OfferingStrings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OfferingStrings.swift; path = Sources/Logging/Strings/OfferingStrings.swift; sourceTree = ""; }; + E25AAC9EDB04491D60DB27AEF0131F56 /* ReceiptParserLogger.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ReceiptParserLogger.swift; path = Sources/LocalReceiptParsing/Helpers/ReceiptParserLogger.swift; sourceTree = ""; }; + E2B3CC18E70A7CEC9AA5D543C14B079B /* CustomerCenterConfigResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CustomerCenterConfigResponse.swift; path = Sources/Networking/Responses/CustomerCenterConfigResponse.swift; sourceTree = ""; }; + E405D92F1496D54E211947AE2BF80437 /* WebBillingAPI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WebBillingAPI.swift; path = Sources/Networking/WebBillingAPI.swift; sourceTree = ""; }; + E42E9F22C0F3B953366046EB72B1452A /* CustomerCenterPresentationMode.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CustomerCenterPresentationMode.swift; path = Sources/CustomerCenter/CustomerCenterPresentationMode.swift; sourceTree = ""; }; + E43177DE9BFE35EAF4DDF828B198ED0F /* Assertions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Assertions.swift; path = "Sources/Error Handling/Assertions.swift"; sourceTree = ""; }; + E43D3F8ED7D4AE425544CEF8B68DADFD /* PaywallEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallEvent.swift; path = Sources/Paywalls/Events/PaywallEvent.swift; sourceTree = ""; }; + E4400C64812BBD064D273842F65A53DD /* Error+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Error+Extensions.swift"; path = "Sources/FoundationExtensions/Error+Extensions.swift"; sourceTree = ""; }; + E4A49717BD402DBC31C202C38543A009 /* PackageType.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PackageType.swift; path = Sources/Purchasing/PackageType.swift; sourceTree = ""; }; + E58C73BFF7155DD4AB1509DC65F6F6AA /* AnyDecodable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AnyDecodable.swift; path = Sources/Misc/Codable/AnyDecodable.swift; sourceTree = ""; }; + E60AE353D45C0E32FD77A16F2525C134 /* BackendErrorCode.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BackendErrorCode.swift; path = "Sources/Error Handling/BackendErrorCode.swift"; sourceTree = ""; }; + E64A488F942CAB289E50838D99391CA8 /* CustomerInfoCallback.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CustomerInfoCallback.swift; path = Sources/Networking/Caching/CustomerInfoCallback.swift; sourceTree = ""; }; + E64E1C26D494FBF0137B5E18D5B02B5D /* IdentityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = IdentityManager.swift; path = Sources/Identity/IdentityManager.swift; sourceTree = ""; }; + E69D0ED836DC2584E63FEA604B3AA390 /* PaywallTimelineComponent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallTimelineComponent.swift; path = Sources/Paywalls/Components/PaywallTimelineComponent.swift; sourceTree = ""; }; + E6A743E94DDA53FDEB5D7426089CB278 /* GetCustomerCenterConfigOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = GetCustomerCenterConfigOperation.swift; path = Sources/Networking/Operations/GetCustomerCenterConfigOperation.swift; sourceTree = ""; }; + E6C69D0347CD9870EDC9B9E2A36E2E16 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = ios/PurchasesHybridCommon/PurchasesHybridCommon/PrivacyInfo.xcprivacy; sourceTree = ""; }; + E7743565FD090970EC752509A9331B24 /* String+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "String+Extensions.swift"; path = "Sources/FoundationExtensions/String+Extensions.swift"; sourceTree = ""; }; + E7A22950F419E154E9F7E62484B3CC02 /* GetIntroEligibilityOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = GetIntroEligibilityOperation.swift; path = Sources/Networking/Operations/GetIntroEligibilityOperation.swift; sourceTree = ""; }; + E8951ED7C8061999BC1457F7F8F15F9E /* WebBillingHTTPRequestPath.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WebBillingHTTPRequestPath.swift; path = Sources/Networking/WebBillingHTTPRequestPath.swift; sourceTree = ""; }; + EA682E9FE7118CE9B9C8FF66FBCA9D3A /* ASN1Container.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ASN1Container.swift; path = Sources/LocalReceiptParsing/BasicTypes/ASN1Container.swift; sourceTree = ""; }; + EB41E7346EFDE57E402723AE013355EF /* FileReader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FileReader.swift; path = Sources/LocalReceiptParsing/Helpers/FileReader.swift; sourceTree = ""; }; + EC9BC9A8E600BC4B32CFBD0FBA5B222F /* Atomic.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Atomic.swift; path = Sources/Misc/Concurrency/Atomic.swift; sourceTree = ""; }; + ECABB8B06C508A1B0CFD0A60244244B5 /* IOSAPIAvailabilityChecker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = IOSAPIAvailabilityChecker.swift; path = ios/PurchasesHybridCommon/PurchasesHybridCommon/IOSAPIAvailabilityChecker.swift; sourceTree = ""; }; + ECB122B30DFFF8EA3C1D320B8CFA0735 /* ErrorCode.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ErrorCode.swift; path = "Sources/Error Handling/ErrorCode.swift"; sourceTree = ""; }; + EE1A893E27001C650745F283602FF9C5 /* Enums+HybridAdditions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Enums+HybridAdditions.swift"; path = "ios/PurchasesHybridCommon/PurchasesHybridCommon/Enums+HybridAdditions.swift"; sourceTree = ""; }; + EEB9F17B978BC7E226389FA816CC7123 /* Date+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Date+Extensions.swift"; path = "Sources/FoundationExtensions/Date+Extensions.swift"; sourceTree = ""; }; + EF2593C6653218A284C93AB9622A4E53 /* FeatureEventHTTPRequestPath.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FeatureEventHTTPRequestPath.swift; path = Sources/Events/FeatureEvents/Networking/FeatureEventHTTPRequestPath.swift; sourceTree = ""; }; + EF6A2D87D8C4049AC7CDFE088303F699 /* PaymentQueueWrapper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaymentQueueWrapper.swift; path = Sources/Purchasing/StoreKit1/PaymentQueueWrapper.swift; sourceTree = ""; }; + EF9F718CE997930CE32118B4B91C4B82 /* StoredFeatureEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StoredFeatureEvent.swift; path = Sources/Events/FeatureEvents/StoredFeatureEvent.swift; sourceTree = ""; }; + EFA6135F25B7790E77FF9616C066DB77 /* OfflineEntitlementsManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OfflineEntitlementsManager.swift; path = Sources/OfflineEntitlements/OfflineEntitlementsManager.swift; sourceTree = ""; }; + EFC6733DC521170E9EC0E84187ABAC82 /* NonSubscriptionTransaction.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NonSubscriptionTransaction.swift; path = Sources/Purchasing/NonSubscriptionTransaction.swift; sourceTree = ""; }; + F084B7D53DFB16EF49BBFE8B31822C0A /* PurchasesHybridCommon.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = PurchasesHybridCommon.debug.xcconfig; sourceTree = ""; }; + F17AE553BB4DF6C497F6AD3AE734D2F5 /* IgnoreHashable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = IgnoreHashable.swift; path = Sources/Misc/Codable/IgnoreHashable.swift; sourceTree = ""; }; + F2942D1070605FA57789397BE3734694 /* HealthReportOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HealthReportOperation.swift; path = Sources/Networking/Operations/HealthReportOperation.swift; sourceTree = ""; }; + F2A1A9A4FD00BFC410C13FDDD69ED238 /* DebugView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DebugView.swift; path = Sources/Support/DebugUI/DebugView.swift; sourceTree = ""; }; + F3769014434C82BEC6EC59E4F1156A10 /* VirtualCurrenciesResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = VirtualCurrenciesResponse.swift; path = Sources/Networking/Responses/VirtualCurrenciesResponse.swift; sourceTree = ""; }; + F39331A8229EED7942827BCE62A01F4D /* PaywallData.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallData.swift; path = Sources/Paywalls/PaywallData.swift; sourceTree = ""; }; + F3A1ECCA9B0AFF30A5B3239C7337FEA0 /* SKError+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "SKError+Extensions.swift"; path = "Sources/Error Handling/SKError+Extensions.swift"; sourceTree = ""; }; + F4064ABA2F049CCB130535FF67203494 /* PlatformInfo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PlatformInfo.swift; path = Sources/Misc/PlatformInfo.swift; sourceTree = ""; }; + F49C2C260F2FB279A9C18BB6EB020510 /* PostReceiptDataOperation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PostReceiptDataOperation.swift; path = Sources/Networking/Operations/PostReceiptDataOperation.swift; sourceTree = ""; }; + F52AACBDDB4E40E83FF56FA1DA1B314F /* WinBackOfferEligibilityCalculator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WinBackOfferEligibilityCalculator.swift; path = "Sources/Purchasing/StoreKit2/Win-Back Offers/WinBackOfferEligibilityCalculator.swift"; sourceTree = ""; }; + F52B96394F66BDADDF3D23F148FA8C4D /* Store+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Store+Extensions.swift"; path = "Sources/CodableExtensions/Store+Extensions.swift"; sourceTree = ""; }; + F6618B807930840144456BE060ECF4A6 /* AttributionKey.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttributionKey.swift; path = Sources/SubscriberAttributes/AttributionKey.swift; sourceTree = ""; }; + F72B95D42A565758D0A54C319A7A37A5 /* HealthReport+Validate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "HealthReport+Validate.swift"; path = "Sources/Support/HealthReport+Validate.swift"; sourceTree = ""; }; + FA9C293797882DD49FABBACE0A9A4C7A /* PaywallVideoComponent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallVideoComponent.swift; path = Sources/Paywalls/Components/PaywallVideoComponent.swift; sourceTree = ""; }; + FB9C59CAF6D0718768842AFA6937ACB7 /* CustomerInfo+NonSubscriptions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "CustomerInfo+NonSubscriptions.swift"; path = "Sources/Identity/CustomerInfo+NonSubscriptions.swift"; sourceTree = ""; }; + FD0E802DBD924DC3546667A598B442B0 /* PurchasesHybridCommon.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = PurchasesHybridCommon.h; path = ios/PurchasesHybridCommon/PurchasesHybridCommon/PurchasesHybridCommon.h; sourceTree = ""; }; + FD2A25D4575F6BE627919815E3E1F7DC /* PaywallTextComponent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaywallTextComponent.swift; path = Sources/Paywalls/Components/PaywallTextComponent.swift; sourceTree = ""; }; + FD6C6D3C09F444D3FD7D1A51DB59C6DD /* PurchaseParams.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PurchaseParams.swift; path = Sources/Purchasing/Purchases/PurchaseParams.swift; sourceTree = ""; }; + FE2736B26E9D8B3CF17719478794A9DB /* LocalReceiptFetcher.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LocalReceiptFetcher.swift; path = Sources/LocalReceiptParsing/LocalReceiptFetcher.swift; sourceTree = ""; }; + FEDCED199AD06DAFF4A82B6206F88905 /* PurchasesAreCompletedBy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PurchasesAreCompletedBy.swift; path = Sources/Purchasing/Purchases/PurchasesAreCompletedBy.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 4E62D121A8AA8226701D9C59225D874E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A093A62DF992EB27B7113BC2016C6982 /* Foundation.framework in Frameworks */, + 33F8A0FF2F9F88E58EADB444432FA656 /* StoreKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9B3B4135B51285497BA5AB94FA22F783 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B398AF8C382F09FFE36913BB595EDB0B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D9996A6684013F86D7940F8E2D3FEAE8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9EC4720EF15F220A22D6624FC065D7D9 /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DF6BFFB3519A2124C47D2BDE10A01524 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 65633024139A171F23654CD4E335976E /* Foundation.framework in Frameworks */, + D2A2D1F3F6F9284AAF2CE0F0CC9652DF /* StoreKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0A88DEF4A2CC7FAD5848D1F69B4ABFAB /* PurchasesHybridCommon */ = { + isa = PBXGroup; + children = ( + 093422977BA2834C3C9EE9B19661FF77 /* CommonFunctionality.swift */, + 9667579EAAB94933AEA8860ECB03D7FD /* CommonPurchaseParams.swift */, + 25E25D4FBC2376DD3A5F3B4723A6669E /* Constants.swift */, + 7106C3714AC8B3947AAF7682205D64DC /* CustomerInfo+HybridAdditions.swift */, + 8CB6178E26439BA8FBAAB00CFD0DD1CE /* EntitlementInfo+HybridAdditions.swift */, + 5377D542CC6A8DEE15A5FF76640C8D0D /* EntitlementInfos+HybridAdditions.swift */, + 27B3A3294778ED9BEE5035B2AE722969 /* EntitlementVerificationMode+HybridAdditions.swift */, + EE1A893E27001C650745F283602FF9C5 /* Enums+HybridAdditions.swift */, + 48E7FA372B2126E960DD9345ACD310EC /* ErrorContainer.swift */, + 573585F29EDFC095922D625F0D3B18D8 /* FatalErrorUtil.swift */, + 72F24EE29B2E7AA79A16E34349BD4FE7 /* IntroEligibility+HybridExtensions.swift */, + ECABB8B06C508A1B0CFD0A60244244B5 /* IOSAPIAvailabilityChecker.swift */, + DF9D0CF26D635488D863AE5E82D2E617 /* NonSubscriptionTransaction+HybridAdditions.swift */, + D31C9077CE472B5356BE6196B56FFE3F /* NSDate+HybridAdditions.swift */, + 153B8FA4E5198A2A285226070C62493D /* Offering+HybridAdditions.swift */, + A9934FEA0DF74CCE48B0639282EF2C87 /* Offerings+HybridAdditions.swift */, + 669737EF3E7F2EBAFEF056A15A64F9BC /* Package+HybridAdditions.swift */, + 8A6CDA6B79CEF8E8C41348883E4B7D6B /* PromotionalOffer+HybridAdditions.swift */, + BAD2798C0929EA1B458ADB701AE1FFD5 /* PurchaseParamsBuilder+HybridExtensions.swift */, + 12DE160C6746F68C2CD64AC5F7170E14 /* Purchases+HybridAdditions.swift */, + BDE5AC53E0E96375CC1225DD7E66C583 /* PurchasesAreCompletedBy+HybridAdditions.swift */, + FD0E802DBD924DC3546667A598B442B0 /* PurchasesHybridCommon.h */, + 761291AD85C6761A0D19DEDABFC56053 /* RefundRequestStatus+HybridAdditions.swift */, + 7C61AF345273EF54AC3D469D70F0E0A8 /* StoreKitVersion+HybridAdditions.swift */, + 2CFCD5D3EF7A3B23BE90664F4C137688 /* StoreProduct+HybridAdditions.swift */, + 4FAB0193EAC1911F1FE8D3DE4E149552 /* StoreProductDiscount+HybridAdditions.swift */, + 3A10F8EA83C029E9B6802BB90F1F9816 /* StoreTransaction+HybridAdditions.swift */, + C189A64874CE704A5EB5F9E4814B82AF /* SubscriptionInfo+HybridAdditions.swift */, + A738020BF03183667C17B8A12819E7E6 /* VirtualCurrencies+HybridAdditions.swift */, + 7F5DABF3AB2AFACD4C62AF610BCACD83 /* VirtualCurrency+HybridAdditions.swift */, + 73A88F340A4914A1B38585A668F787ED /* WinBackOffer+HybridAdditions.swift */, + 13C6BD77259F1D0CDA541D70BF240027 /* Resources */, + EC2D01F90AC6DB92440ECD2C7C4C80C0 /* Support Files */, + ); + name = PurchasesHybridCommon; + path = PurchasesHybridCommon; + sourceTree = ""; + }; + 13C6BD77259F1D0CDA541D70BF240027 /* Resources */ = { + isa = PBXGroup; + children = ( + E6C69D0347CD9870EDC9B9E2A36E2E16 /* PrivacyInfo.xcprivacy */, + ); + name = Resources; + sourceTree = ""; + }; + 1628BF05B4CAFDCC3549A101F5A10A17 /* Frameworks */ = { + isa = PBXGroup; + children = ( + D7C4191F07A506EB0B12C402F365E5DF /* iOS */, + ); + name = Frameworks; + sourceTree = ""; + }; + 2B9C811D4BAAD4BA19BB09C9AEC4B044 /* Resources */ = { + isa = PBXGroup; + children = ( + 50A6A3B1299C884DECA9398CA5434A53 /* PrivacyInfo.xcprivacy */, + ); + name = Resources; + sourceTree = ""; + }; + 452257B70B4B857250C9E64625DDB9E1 /* RevenueCat */ = { + isa = PBXGroup; + children = ( + 4D9DD7E59B0917282E2931D14844DE58 /* AdEvent.swift */, + 4C0C17CB45538B27EDBE538A2174480E /* AdEventsRequest.swift */, + 244DB81E8FD0371C546E639D39A9F21A /* AdEventStore.swift */, + 9C74858ACCB6718D09F9A76A94E45922 /* AdHTTPRequestPath.swift */, + 49962DEFE7B07C16FBD2C37C37CBBE1F /* AdTracker.swift */, + E0A3E95AB022B7D010C4ECA6DCE48491 /* AnalyticsStrings.swift */, + E58C73BFF7155DD4AB1509DC65F6F6AA /* AnyDecodable.swift */, + 5496A5BB6EFD4B1E2F477B637CB4E48D /* AnyEncodable.swift */, + 89B36287D3878611A40796176083DBE4 /* AppleReceipt.swift */, + 2E9D9683F758C52576E83A191DF57B0A /* AppleReceiptBuilder.swift */, + 042E2E05C8AAA27935046E9B1CC28EEE /* Array+Extensions.swift */, + 6A5A36BE17EDC4996FA118995EFF95A3 /* ArraySlice_UInt8+Extensions.swift */, + 73E16ABFCE212CEDA5D3816F74A918ED /* ASIdManagerProxy.swift */, + EA682E9FE7118CE9B9C8FF66FBCA9D3A /* ASN1Container.swift */, + ABA3121E4FE255F17E267E7B7E5D9DE0 /* ASN1ContainerBuilder.swift */, + 4C826229AC0D01A9A1DCAFD3FA49D664 /* ASN1ObjectIdentifier.swift */, + 64CD77CEDC8C3185F1FE264816ADF9D8 /* ASN1ObjectIdentifierBuilder.swift */, + E43177DE9BFE35EAF4DDF828B198ED0F /* Assertions.swift */, + 411B45CA57486D588D69BE38012EBC60 /* AsyncExtensions.swift */, + EC9BC9A8E600BC4B32CFBD0FBA5B222F /* Atomic.swift */, + CB1A30CEB1B8DE6971D0078A39D1608D /* Attribution.swift */, + 08DEBD207C132DF1952C479926EF40A2 /* AttributionData.swift */, + E068FB1DD1D8B6F0726EB4AF89FDAB3E /* AttributionDataMigrator.swift */, + 66616998505D0C66379137483045A4DF /* AttributionFetcher.swift */, + F6618B807930840144456BE060ECF4A6 /* AttributionKey.swift */, + 8BF7E8C10125597CD5C46CA7F0A69A95 /* AttributionNetwork.swift */, + 07C76ACA0029FE791E430477CF5488BF /* AttributionPoster.swift */, + 6C107B155D13EA52F7FFE15268FC1567 /* AttributionStrings.swift */, + 36C45E16C55EA2AE8F0D1E7CFF16B685 /* AttributionTypeFactory.swift */, + 12090462D9731132865898CF0C5FB3FD /* Backend.swift */, + 8A60B144B9CE8E6512A831EAC32E6089 /* BackendConfiguration.swift */, + 240B1A0BC46B3F7DCC0B4F0A790AC972 /* BackendError.swift */, + E60AE353D45C0E32FD77A16F2525C134 /* BackendErrorCode.swift */, + 38001264FD37589AF13374D1B55C3580 /* BackendErrorStrings.swift */, + 20084C04DD4D0456F699564398A40991 /* Background.swift */, + 4A355796FDA7429F6C3F8AEECF26D13B /* BeginRefundRequestHelper.swift */, + 254D224EE53AC495F856B7B6FAFE64FA /* Border.swift */, + 89575BF68A8CEE956D14067908790200 /* Box.swift */, + 3DF1E3F994BD3D68530C7CA1F5172F71 /* CacheFetchPolicy.swift */, + 3D4E5CAC728FFED4E79837CA237069E2 /* CacheStatus.swift */, + 9FFBAFA262DFF87E003DA4F5E7D85D01 /* CacheStrings.swift */, + 6B02F3C6E2F57EE78C8EF72930F25E8A /* CachingProductsManager.swift */, + 22F6DD5AE3228D84A03651163F045D95 /* CachingTrialOrIntroPriceEligibilityChecker.swift */, + C748BCC3EB0BE2C32357DD969EEC2957 /* CallbackCache.swift */, + DBDABBB513EBF5A01231357F6511E84A /* CallbackCacheStatus.swift */, + 4461612DEFB559B4AF58297EE6A4E5BF /* Checksum.swift */, + 339746A8D84F4A4528121114106A6473 /* Clock.swift */, + C09FB99B96B83ECDA4C9A114BF61B515 /* Codable+Extensions.swift */, + 585A42B7A612959DAFDF99089F7076E2 /* CodableStrings.swift */, + 369C96C96A059C108C26F63EF4C096B2 /* ComponentOverrides.swift */, + 421EAA5204753DA3639CE19B2FB2752D /* Configuration.swift */, + 11DFE5F44BA4F88D8736526122B43005 /* ConfigureStrings.swift */, + 5B60220F8AF0AD933C4600331FFC21F1 /* ConnectionErrorReason.swift */, + 52A64F0DFB604EFF49C3D9E0B8E4237B /* CustomerAPI.swift */, + 42C2D7B43E931C1C9B757627A675B90B /* CustomerCenterConfigAPI.swift */, + 7A75172A4413D9B31F18ACDB27F8365F /* CustomerCenterConfigCallback.swift */, + C729B739E6AE47DBF843097F81DEB596 /* CustomerCenterConfigData.swift */, + E2B3CC18E70A7CEC9AA5D543C14B079B /* CustomerCenterConfigResponse.swift */, + 58E54FD6EBD1D6AF85E66D46923BEFDC /* CustomerCenterEvent.swift */, + E42E9F22C0F3B953366046EB72B1452A /* CustomerCenterPresentationMode.swift */, + 614C8BDF4EF615DC21C0252EC9C68B1A /* CustomerInfo.swift */, + 920E9A6F1FBF9A3E24676E196676231C /* CustomerInfo+ActiveDates.swift */, + FB9C59CAF6D0718768842AFA6937ACB7 /* CustomerInfo+NonSubscriptions.swift */, + 204CC1573F6C6826DCCCCADDFE4D2458 /* CustomerInfo+OfflineEntitlements.swift */, + E64A488F942CAB289E50838D99391CA8 /* CustomerInfoCallback.swift */, + 16DDEB9434DD6BAA4198327BE0524964 /* CustomerInfoManager.swift */, + 92CFD404DC47FA588A5FCDBD87FE890A /* CustomerInfoResponse.swift */, + 54C6E8D902D8921B25981F3137A22646 /* CustomerInfoResponseHandler.swift */, + D16F31D095A5E8B7668451E9766B3C8B /* CustomerInfoStrings.swift */, + 2DE6D62CB5B0507D61F0943DC5B7803F /* DangerousSettings.swift */, + A65AECEC43CBADA572E58C2084B63AF0 /* Data+Extensions.swift */, + EEB9F17B978BC7E226389FA816CC7123 /* Date+Extensions.swift */, + 3413C64E09BB73FF739C2F58A9CCB69B /* DateExtensions.swift */, + 2B3CED652BC3338C72625BF4EC37592C /* DateFormatter+Extensions.swift */, + D6DAEC63578C194A8F591F33C0A58342 /* DateProvider.swift */, + 640C6F5ADC38815FB221C4355EAC255C /* DebugContentViews.swift */, + F2A1A9A4FD00BFC410C13FDDD69ED238 /* DebugView.swift */, + A6727B22C2DD81436B87F5B07744FFED /* DebugViewController.swift */, + 1309DABE4238A9917EB560A21A7DA63A /* DebugViewModel.swift */, + C5DE0B7AD6EE4A13EEDBA37EF4239ED3 /* DebugViewSheetPresentation.swift */, + D9C72F237269BCD25DF95B5E2164B13E /* Decoder+Extensions.swift */, + 2260E01F523D44DA02E6F1C279B3324B /* DeepLinkParser.swift */, + 0C51AE102965B03FA08BD6F7BEC4950A /* DefaultDecodable.swift */, + 01E1E4328DE00E9A112E7B280A00D700 /* Deprecations.swift */, + BBF8ECB2D76A2E8A1CA77B8CE8169924 /* DescribableError.swift */, + AAD2628DBF8716B69F39514E51E899E8 /* DeviceCache.swift */, + 1D57A0A13A7C4FE366352708CEC295C5 /* DiagnosticsEvent.swift */, + 09349528089AE3BF1C1ADA84D9101E55 /* DiagnosticsEventsRequest.swift */, + 4675D69D85060AD0CCF95F2F433C4CB6 /* DiagnosticsFileHandler.swift */, + 8F16A164D387C89633751E0685FF7E70 /* DiagnosticsHTTPRequestPath.swift */, + 51058E8152DEB2BF828A53F4AD450B01 /* DiagnosticsPostOperation.swift */, + 4AA483A3A60C364D45C2DC333D452445 /* DiagnosticsStrings.swift */, + 9551D15B300B9E49AB65E0AD100281DB /* DiagnosticsSynchronizer.swift */, + 14073EAA11882BFEA66F9D5446A20C01 /* DiagnosticsTracker.swift */, + 0CCBBBCD0B4137EC4520A77CE9DB8616 /* Dictionary+Extensions.swift */, + 982DD257A0E6F11A61B10C78C75219D1 /* Dimension.swift */, + 41B231852638AC98276D9882F5B8942F /* DirectoryHelper.swift */, + DCDB0AFAEE59344FC83922BAFAA8F84F /* DispatchTimeInterval+Extensions.swift */, + 13771028F9A83D1381BAB98A92991E48 /* DNSChecker.swift */, + 9EA2D22D2E6CA3B7F5956EC6500603A5 /* Either.swift */, + 609D91E64EC4D4A6D042D33246E4A6AD /* EligibilityStrings.swift */, + 4146B5A85087D99E08A1A3BD37BD8606 /* EmptyFile.swift */, + C72682CB26A8A8798A45276B14DCD6FE /* EncodedAppleReceipt.swift */, + 402596F229D0A94560681BDDA848E0B4 /* EnsureNonEmptyCollectionDecodable.swift */, + CE57945CF7FB4E615C08C02571AEA631 /* EntitlementInfo.swift */, + C21C6634D66B5478C5D81E580547D16F /* EntitlementInfos.swift */, + E4400C64812BBD064D273842F65A53DD /* Error+Extensions.swift */, + ECB122B30DFFF8EA3C1D320B8CFA0735 /* ErrorCode.swift */, + 1EE0CE490CF7D279BCAC2D9008011A53 /* ErrorDetails.swift */, + 7884B518EF2556A1388CDEBB756A896B /* ErrorResponse.swift */, + 2F5CC5B8C02683C98E9A43443D3C0005 /* ErrorUtils.swift */, + B37235A9E7F563EA5613B6127EEF9071 /* ETagManager.swift */, + 79F69394BA6293A6E77539088AB6A35B /* ETagStrings.swift */, + 1B8724774D8EAA3D595E2AD2D6F0A2DA /* EventsHTTPRequestPath.swift */, + 04640A438AEB80D9CDC05F4498C45246 /* EventsManager.swift */, + 050860AE16CF6518B71B90904CA8F627 /* EventsRequest+CustomerCenter.swift */, + 167781201460DD988699AD9F132B8239 /* EventsRequest+Paywall.swift */, + A92823154D5BF0558321E066DD375B81 /* ExitOffer.swift */, + 64EFE56FF515BDDBC227CB67D1730EFF /* FakeSigning.swift */, + 8DF9870E61DE1C5D9647AF5CCB7BD04B /* FeatureEvent.swift */, + EF2593C6653218A284C93AB9622A4E53 /* FeatureEventHTTPRequestPath.swift */, + 2D7638F462DE697A04D926423C8BFBE1 /* FeatureEventsRequest.swift */, + 953A994B046DCFCD2FEE652AD8AD2103 /* FeatureEventStore.swift */, + ADF15DBECBD34E96E716BA9E933D2A96 /* FileHandler.swift */, + EB41E7346EFDE57E402723AE013355EF /* FileReader.swift */, + 1CFF995830964954DCE98FA86C0769AC /* FileRepository.swift */, + 38EDA47D097A5C9EB5899D446B2AB23B /* FileRepositoryStrings.swift */, + 3A79718881EF41876F0ACC9A8C44AB73 /* FrameworkDisambiguation.swift */, + E6A743E94DDA53FDEB5D7426089CB278 /* GetCustomerCenterConfigOperation.swift */, + 219AB6D5487157E08F7CE7DC21CBC1FF /* GetCustomerInfoOperation.swift */, + E7A22950F419E154E9F7E62484B3CC02 /* GetIntroEligibilityOperation.swift */, + 10B1AA5292666C89BB23EAA215885DDF /* GetIntroEligibilityResponse.swift */, + 1DCDC2ECAE3A42D908B510F6E822DB23 /* GetOfferingsOperation.swift */, + CFD39CF476F2A473004A7DDCD750F056 /* GetProductEntitlementMappingOperation.swift */, + 3091166AE010D315AFE23BA999429369 /* GetVirtualCurrenciesOperation.swift */, + 39085DB222F7F991FA160FDD2EA404DD /* GetWebBillingProductsOperation.swift */, + 0BA54EC4A66CAF29728AD641B5EC7462 /* GetWebOfferingProductsOperation.swift */, + 0ACD1A5A68737844A45D1D0FCB388D9A /* HealthOperation.swift */, + F72B95D42A565758D0A54C319A7A37A5 /* HealthReport+Validate.swift */, + 3F929B4C2C5B3A0269A40D220994CFB9 /* HealthReportAvailabilityOperation.swift */, + 93CA4450613241C9258EF62F5E9908B6 /* HealthReportAvailabilityResponse.swift */, + F2942D1070605FA57789397BE3734694 /* HealthReportOperation.swift */, + 7746B40EE5186834473EF0C65F15A607 /* HealthReportResponse.swift */, + 22CD3659713C78546E489A2A66815B86 /* HTTPClient.swift */, + DB9577237390742DC703110594A2987D /* HTTPRequest.swift */, + 0F4600CE4F86683888FA412DD3200ABE /* HTTPRequest+Signing.swift */, + 4DA7A78EFA238D1D51ACA5C7A723974E /* HTTPRequestBody.swift */, + A916D6FBB0F53B12D9BC92722540B52D /* HTTPRequestBody+Signing.swift */, + BB05F7D7A24B3D579504E3878AEA3208 /* HTTPRequestPath.swift */, + B782EEACC35423EE8396C87C2A44FDBD /* HTTPRequestTimeoutManager.swift */, + 09B5AA42CE08338CF0FC191DB815A38F /* HTTPResponse.swift */, + 3FF3D640A2B8E39DDEB652E3C42552FE /* HTTPResponseBody.swift */, + 2BA2B0446F8676CF6AFB955BC90BCF40 /* HTTPStatusCode.swift */, + 794983798EEBEA8147747C6AA24D41A4 /* IdentityAPI.swift */, + E64E1C26D494FBF0137B5E18D5B02B5D /* IdentityManager.swift */, + 678FD19DD03283F7C4DE61D0D2AC2379 /* IdentityStrings.swift */, + F17AE553BB4DF6C497F6AD3AE734D2F5 /* IgnoreHashable.swift */, + 807C132E2A49B7544A75717CD942AE59 /* InAppPurchase.swift */, + 29F9D1208D1C4BB939FC2F38F8B5E866 /* InAppPurchaseBuilder.swift */, + 42A3AB7080B77B6F0334F03A9C8B2D84 /* InMemoryCachedObject.swift */, + B8B5E9919901D1A85E2C8CE70E94B48B /* Integer+Extensions.swift */, + 6321C6CAE67AD0B90C77BDE9108FE3B2 /* InternalAPI.swift */, + 76DCD34563B705E96DD53FE3CC8659EB /* IntroEligibility.swift */, + 5C19E63EBCB8B1A3E5DC6B1417EC36C8 /* IntroEligibilityCalculator.swift */, + 66395F21F3D3732A876F3989EFDAACAB /* ISODurationFormatter.swift */, + 8F49C84CFA1863E7868E02361FBB88D1 /* ISOPeriodFormatter.swift */, + 125FD7AF9118CF5729AF7B557AF935A8 /* IsPurchaseAllowedByRestoreBehaviorCallback.swift */, + 370718947692D51A24D4CE246D0158C9 /* IsPurchaseAllowedByRestoreBehaviorResponse.swift */, + 291662A72F8724CA0C2022AA2744585E /* KeyedDeferredValueStore.swift */, + A88512127CC7038A2B20295A1BE47EDA /* LargeItemCacheType.swift */, + 0BE26AC956D611F452BF266415E186C7 /* Locale+Comparison.swift */, + 67DB6BB03C169A5A099A774B7A97BC20 /* Locale+Extensions.swift */, + FE2736B26E9D8B3CF17719478794A9DB /* LocalReceiptFetcher.swift */, + 75F61E994F0997F8CE394AAC208B4DC8 /* LocalTransactionMetadata.swift */, + CE0903967F6943A1CFA693D640698292 /* LocalTransactionMetadataStore.swift */, + 46428AF439B212CAE8C44D1B2187C9E9 /* Lock.swift */, + 190C1838CF4C354E2B7F9EC2FD60CCA0 /* Logger.swift */, + 59F0EFFF8F045F27D410C4438F766BD3 /* LoggerType.swift */, + 2CDB60101318931DC0BC33B504865A0B /* LogInCallback.swift */, + 12CCC30F9D4787599E16E9D03D5AA781 /* LogInOperation.swift */, + CCC42359A1CA61861ADE99307393F1E0 /* LogIntent.swift */, + 299974220C820FA3089AE7228473EFB7 /* MacDevice.swift */, + AE1F9C9F63501650001C13DA131E2616 /* ManageSubscriptionsHelper.swift */, + 7C6DCE5AA49AC6AD95CAD94D34DC7107 /* ManageSubscriptionsStrings.swift */, + CAF174732015CC1C1E5DDCBD5C1014D3 /* MapAppStoreDetector.swift */, + 0ADC5CF5FB80DF59ABBB2AA88301A337 /* NetworkError.swift */, + A66AD16E450533FF112985315A1A6117 /* NetworkOperation.swift */, + 163559D4E96350598D8E7A1511432D47 /* NetworkStrings.swift */, + 5FDBADD2C97E2A55FDF2261BA910C2D2 /* NonEmptyStringDecodable.swift */, + EFC6733DC521170E9EC0E84187ABAC82 /* NonSubscriptionTransaction.swift */, + 55FD547023F2A20FF4544B62F0E586CF /* Obsoletions.swift */, + 6722B0341B3AF578C8A665E4C784C884 /* Offering.swift */, + 965338A4A31115163DF10EB0DAB5A35F /* Offerings.swift */, + 574D90A224BA283F29C96807AD089D4D /* OfferingsAPI.swift */, + C71FFD0EB89D5CDF617AF3EB7C1AC6DD /* OfferingsCallback.swift */, + 1B60F293050E52C352755F0B7DDBCDB2 /* OfferingsFactory.swift */, + 65CAC8117AD855E94F9AD43DE1CEE746 /* OfferingsManager.swift */, + 7DBA6A441AA1F122717DFA33959AACE3 /* OfferingsResponse.swift */, + E1AD6B3724C693795D04A81FBDDCA37D /* OfferingStrings.swift */, + 620A422511B4CA19BF6E2009C98AE691 /* OfflineCustomerInfoCreator.swift */, + C0022D458214DD88C567CC6ADA1AA796 /* OfflineEntitlementsAPI.swift */, + EFA6135F25B7790E77FF9616C066DB77 /* OfflineEntitlementsManager.swift */, + 45B358AABC35CB2D00DF8167FA1693D9 /* OfflineEntitlementsStrings.swift */, + D45B91C86E29354B695E56BD1795F6E0 /* OperationDispatcher.swift */, + BA56B412BDF2C937EFB1687014EC5990 /* OperationQueue+Extensions.swift */, + AE96A3D55B67224E00ED86E5867DD3C3 /* Operators+Extensions.swift */, + 43D73AAC4299967BF45F9FF01CB8787F /* Optional+Extensions.swift */, + 13FB5753CF3B322E6D6ED418E012E061 /* Package.swift */, + E4A49717BD402DBC31C202C38543A009 /* PackageType.swift */, + 4A53E1B1FF57A749F9042B85E80B999E /* PaymentAuthorizationProvider.swift */, + EF6A2D87D8C4049AC7CDFE088303F699 /* PaymentQueueWrapper.swift */, + 28302208340DDFACF06CF1E952BCD375 /* PaywallAnimation.swift */, + 6559E0AA4A21488E5AF0280AAA41E469 /* PaywallButtonComponent.swift */, + 8DA853BC3FDB08FEDFB505FA5E0C6173 /* PaywallCacheWarming.swift */, + CC22E86D5F7D7434FC244609913CDA0F /* PaywallCarouselComponent.swift */, + AC398DE3CBF2CF951D9F1956821D9D92 /* PaywallColor.swift */, + 1CFEAB3D036DEBADB4C70917B3971425 /* PaywallComponentBase.swift */, + BBB1B62E71CD509474F1D600D45F213A /* PaywallComponentLocalization.swift */, + B7D58E95383516C9168038CFA612FAB5 /* PaywallComponentPropertyTypes.swift */, + 8DADB2F738C652A10125B3D891E321E4 /* PaywallComponentsData.swift */, + CC6258085318619BB7C3D5AE8E2F820D /* PaywallCountdownComponent.swift */, + F39331A8229EED7942827BCE62A01F4D /* PaywallData.swift */, + 0399EA39C6ECD0B8E9FF2EAC45A595A1 /* PaywallData+Localization.swift */, + E43D3F8ED7D4AE425544CEF8B68DADFD /* PaywallEvent.swift */, + 171EF18DDC6787FAF20A5F9BF6D4BD8F /* PaywallExtensions.swift */, + C2603375AE11B4D193BD0781F8C9E31F /* PaywallFontManagerType.swift */, + B8E77E31E19C324E81B04C28117B1A99 /* PaywallIconComponent.swift */, + BE17C82E7C2192C4C5E7AD92ABF19E6A /* PaywallImageComponent.swift */, + 515D62CFA3C38D397A27B63D5F56FB63 /* PaywallPackageComponent.swift */, + 2A724FFF66F3706AF6B6F9F03DDCBD30 /* PaywallPurchaseButtonComponent.swift */, + 493EE7C69C52E3CE4E013AAD381662DB /* PaywallsStrings.swift */, + E0988BF1A4E391F5D1E424FD6ECFF537 /* PaywallStackComponent.swift */, + 267F75A0D2086BDC6DCC403A0CF82358 /* PaywallStickyFooterComponent.swift */, + 8AD5E61E36E46150F5EB885D9379D202 /* PaywallTabsComponent.swift */, + FD2A25D4575F6BE627919815E3E1F7DC /* PaywallTextComponent.swift */, + E69D0ED836DC2584E63FEA604B3AA390 /* PaywallTimelineComponent.swift */, + 614A2D3670D47E6D1CA8428911162808 /* PaywallTransition.swift */, + 34416EDC539D29D5CF431CD65CEE0A3C /* PaywallV2CacheWarming.swift */, + FA9C293797882DD49FABBACE0A9A4C7A /* PaywallVideoComponent.swift */, + 37F15050F581CBB4C04E20BB331FDA3A /* PaywallViewMode.swift */, + 94C16D040BEC10E29CB27DE018724380 /* PeriodType+Extensions.swift */, + F4064ABA2F049CCB130535FF67203494 /* PlatformInfo.swift */, + 2CA4FE0D817A656D07A8BECEA1FA4048 /* PostAdEventsOperation.swift */, + 56A90A644DA1F65D4BABA53C91FB3839 /* PostAdServicesTokenOperation.swift */, + 7C6ED52688C7A48D11AF18260782774E /* PostAttributionDataOperation.swift */, + 92791C0DE028C10279B0A9C37325E3BB /* PostFeatureEventsOperation.swift */, + 02E068784BF9CDD02905819DA9274C3E /* PostIsPurchaseAllowedByRestoreBehaviorOperation.swift */, + 66FEA9280C5974DC10249711D910CB10 /* PostOfferForSigningOperation.swift */, + 1ECE680BBFCE5A638EAF2E3100B14519 /* PostOfferResponse.swift */, + F49C2C260F2FB279A9C18BB6EB020510 /* PostReceiptDataOperation.swift */, + 92CFDC0C567E64E8CD81EC9BE7A59DC7 /* PostRedeemWebPurchaseOperation.swift */, + 2B4C221F9E26239D362F074E8B3AB200 /* PostSubscriberAttributesOperation.swift */, + 382B9CE21DD4DC115C8E591FCD6FD124 /* PreferredLocalesProvider.swift */, + 6FDDFFADA25052A655640C88A443EC44 /* PriceFormatterProvider.swift */, + AB52161BBB21E3DB944AAD6127619230 /* ProcessInfo+Extensions.swift */, + 57CF47D0307513EBACAECEEFE998F34D /* ProductEntitlementMapping.swift */, + 5AF8A07013B121F68476D9577FD026EC /* ProductEntitlementMappingCallback.swift */, + C9878330F2E5A509C95464B5A9BDF996 /* ProductEntitlementMappingFetcher.swift */, + B86DA289E7F22C32D00FC998DA306C51 /* ProductEntitlementMappingResponse.swift */, + A8B8D1442C20FE45C844F1A8CF2FC978 /* ProductPaidPrice.swift */, + B109616F0DBB8B553145755A69337FE8 /* ProductRequestData.swift */, + B2403A13AAAE0FE4A1CEA7184A02C573 /* ProductRequestData+Initialization.swift */, + 3BDA3EC3179F2D6F9366E6B08F62428E /* ProductsFetcherSK1.swift */, + 4F62083A4B0658AE77BFA307A544DBD2 /* ProductsFetcherSK2.swift */, + 7F6ECC6C4DD98E625F6E37A6F6C399C8 /* ProductsManager.swift */, + 43982CC961296BCB9016F25DF5636068 /* ProductsManagerFactory.swift */, + 9C07C024B48CD014121D93FFE67D317D /* ProductsManagerType.swift */, + AB1AE29494FF679ED68FC6191B1007F9 /* ProductsRequestFactory.swift */, + C48FDD55070B9A0A8C52F0337FEAB59F /* ProductStatus+Icon.swift */, + C3D077092FF4842866C6ADEAEA1F7656 /* ProductType.swift */, + 77E382909CD71BFADC9E5050D833E866 /* PromotionalOffer.swift */, + 1898596839397C9AD5DA180A888003F5 /* PurchasedProductsFetcher.swift */, + C546CE2528372F2E3CE7F7FF44AA8B0C /* PurchasedSK2Product.swift */, + B313A8CDB736DB52EDDD3634F8131A94 /* PurchaseOwnershipType.swift */, + 1B27429EA2264D28F334AF7BA26B273E /* PurchaseOwnershipType+Extensions.swift */, + FD6C6D3C09F444D3FD7D1A51DB59C6DD /* PurchaseParams.swift */, + 7FA5026DD91A2DED9B052E3E1198B3A6 /* Purchases.swift */, + BB8B8C9C0633CE6C25F0B33F42EC298D /* Purchases+async.swift */, + 9D6433F205FDA9C6CA5BF3413B1A6544 /* Purchases+nonasync.swift */, + FEDCED199AD06DAFF4A82B6206F88905 /* PurchasesAreCompletedBy.swift */, + 5259A4BCC0961A44A79FD7A37E3201F1 /* PurchasesDelegate.swift */, + 6E572374C713EAE0F6925B0C4177016B /* PurchasesDiagnostics.swift */, + 36B3F59C973860A8269FBAC688EF48C8 /* PurchasesError.swift */, + CE040262DF475BDC6D9F04D91E9F6071 /* PurchasesOrchestrator.swift */, + 5E473F9E55BE0F0931A5F499C838EB5F /* PurchasesReceiptParser.swift */, + A23286ED5558C93B7CD6515FDFF13D6B /* PurchaseStrings.swift */, + 05373132568C97719C8133092E0A62CC /* PurchasesType.swift */, + 8E0093159D58F9F03FE9A34BDCDCE831 /* RateLimiter.swift */, + 492DC2973A8438B3FD8BC91EE2D1C3AF /* RawDataContainer.swift */, + 9291FD1CC3EDC9A696B0E9D715A228CF /* ReceiptFetcher.swift */, + E25AAC9EDB04491D60DB27AEF0131F56 /* ReceiptParserLogger.swift */, + DC6F53A0BB095AE1E03AF662A04BDA52 /* ReceiptParsingError.swift */, + 181E1EE6E30B8FC9E1493E8579F6148D /* ReceiptRefreshPolicy.swift */, + 0AC56A666004484EE48980FB66564EA4 /* ReceiptStrings.swift */, + 95531205282ED304C416F800F98EA53F /* RedeemWebPurchaseAPI.swift */, + 1D758AE9F572BFC5F198EA01E9DF975E /* RedirectLoggerTaskDelegate.swift */, + D72D231BD7B9A3D4F83AF06454B15416 /* ReservedSubscriberAttributes.swift */, + 1334C5F2A9F962CA1F7F2F47425A447F /* Result+Extensions.swift */, + 0406D4D0D251FD0240084DDDD47A2AAC /* SandboxEnvironmentDetector.swift */, + 9689278B1C8252427CB02806EDD0966C /* SDKHealthCheckStatus+Icon.swift */, + DFC2C7BF166DA2E780161AEDA0440880 /* SDKHealthError+CustomNSError.swift */, + CC5DED6775202924EF0C04B2E2A8F425 /* SDKHealthManager.swift */, + 7807B1C6EB2D20BFF2529D4C0FF0040E /* SDKHealthStatus+Icon.swift */, + B46B23738D8EAE9DD716F061861A47B2 /* Set+Extensions.swift */, + A6997D4CD82F1DBB88676A21436D1B00 /* Signing.swift */, + B585AC5FD94D011C8EAB750F531DD9D4 /* Signing+ResponseVerification.swift */, + 9F37C08824D3E2BC8EFAD684F8DD2D30 /* SigningStrings.swift */, + D41ADD45E9FC908CBB9064A6CB0FE5C1 /* SimpleNetworkServiceType.swift */, + D6E844CEC04570752215BC22D972BE5D /* SimulatedStoreProduct.swift */, + B3C639366CDC49C8E10C63953916B94A /* SimulatedStoreProductsManager.swift */, + 88E4E5667EA6813DACB860344998405E /* SimulatedStorePurchaseHandler.swift */, + 1BF2BB4EAD6110F50B678BEEA212AFFA /* SimulatedStorePurchaseUI.swift */, + 0A1C3EFF43297F918D2F35BA617C14C8 /* SimulatedStoreTransaction.swift */, + 600CC8EB2F5188ABB89DEF7BB7838C86 /* SK1Storefront.swift */, + 0D46CB95801AB522A4036DEC508DC5B6 /* SK1StoreProduct.swift */, + 276E2BC6FC3AF1829144103AC86A4FEC /* SK1StoreProductDiscount.swift */, + 705AC44DA1E8E98E0B441DB0948BFF0B /* SK1StoreTransaction.swift */, + D0DFF719C20AC527A6BC2DC98C963E13 /* SK2AppTransaction.swift */, + C453716CD6B42DA4DACF0707ACD5DD1B /* SK2BeginRefundRequestHelper.swift */, + D22D26B7714838694A449B7595D4FF67 /* SK2Storefront.swift */, + AB7802D37966EE1823520BCEF311C699 /* SK2StoreProduct.swift */, + 86F3ED86A3C76F0AA6BED070C70CF4BE /* SK2StoreProductDiscount.swift */, + 0C18D1196A56E0AFB6F74E4B0D3575CB /* SK2StoreTransaction.swift */, + F3A1ECCA9B0AFF30A5B3239C7337FEA0 /* SKError+Extensions.swift */, + F52B96394F66BDADDF3D23F148FA8C4D /* Store+Extensions.swift */, + 609E66457F5C58D8FDEDD2F9C08CE662 /* StoredAdEvent.swift */, + 4429BAC03814B14CCA05FA621E7945B4 /* StoredAdEventSerializer.swift */, + EF9F718CE997930CE32118B4B91C4B82 /* StoredFeatureEvent.swift */, + DA26904737F608373C9C7B92857448C4 /* StoredFeatureEventSerializer.swift */, + 910374F9FAEC162807F13EA3A8C85814 /* StoreEnvironment.swift */, + DFE092025639D2E60A58D4B9686537B2 /* Storefront.swift */, + CBF1CC7C6CDEDA70A5B1B4A698B43255 /* StorefrontProvider.swift */, + 9885CBEA1078E82811ED1AB2167DAFCA /* StoreKit1Wrapper.swift */, + 7117D2E3139747D4C0AF1A2007E49862 /* StoreKit2ObserverModePurchaseDetector.swift */, + CE9F5CD71E870407DC34DB7812BF0C48 /* StoreKit2PromotionalOfferPurchaseOptions.swift */, + BCEA109D76F5730F49E697444344B367 /* StoreKit2PurchaseIntentListener.swift */, + 235323C5CE7B865F32673CFFCD7AABAF /* StoreKit2Receipt.swift */, + 35158BFC494344C16B5ABBC1099786BF /* StoreKit2StorefrontListener.swift */, + 82F811A0E6D79CE9AE93569A1B189427 /* StoreKit2TransactionFetcher.swift */, + 8230177979D569F3B9611599288B690E /* StoreKit2TransactionListener.swift */, + 57A8AE57A74B292C1510B351C293C0CA /* StoreKitError+Extensions.swift */, + 464B54FC3CD735207FE9083CB9904FED /* StoreKitErrorHelper.swift */, + 741F1FE82F5563A51C18047143318D51 /* StoreKitRequestFetcher.swift */, + 94BCD2231C5EFE24C396F26F671628F4 /* StoreKitStrings.swift */, + B55C188AC2C1A94092CD8E91790E6C08 /* StoreKitVersion.swift */, + 419AFCB98F8B32E3B67CBE20C5AD78EE /* StoreKitWorkarounds.swift */, + A2E8626622CAC40EF643A18775639D0D /* StoreMessagesHelper.swift */, + 3CA34C54BB65ECDFACD48E340F35CFAD /* StoreMessageType.swift */, + A1C458C2BE7CC01A36C8235C83BC8EBC /* StoreProduct.swift */, + 5EDEA4635193B221F9EE2E133BC48482 /* StoreProductDiscount.swift */, + 3F3829F4F6709CEF1D465E1B090726BF /* StoreTransaction.swift */, + E7743565FD090970EC752509A9331B24 /* String+Extensions.swift */, + 8992CDB73A6B912E0847E88CDC2A50E4 /* Strings.swift */, + 7CC23020549490D059E953FE9D3A1EF9 /* SubscriberAttribute.swift */, + 9EEF5C7982E9B875772855739D85E3A1 /* SubscriberAttributesManager.swift */, + 481504BF7369B000939A050011E64363 /* SubscriptionHistoryTracker.swift */, + B4E38DB9CE7A8E23D6C8B7FEE1C4DA92 /* SubscriptionInfo.swift */, + 067849C6531AF8172F2B92E8C70E55AC /* SubscriptionPeriod.swift */, + B862D15197CF3107C7B56884695C03BF /* SwiftVersionCheck.swift */, + D0163E5B4BF6BDC1A473065C2478040B /* SynchronizedLargeItemCache.swift */, + 7E1B2587339CB18DD341DDAD7C5BAD1E /* SynchronizedUserDefaults.swift */, + 1CF759005470A151F5C92C1ABCF08A1C /* SystemInfo.swift */, + 48ED6D60D075E6F7AEF9F00648E6578B /* TestStoreProduct.swift */, + ACEEBBF233CE279392D2E23CD2EB2B5E /* TestStoreProductDiscount.swift */, + A9ECBD5A10BECFD6D6B78D9B18815437 /* TestStoreTransaction.swift */, + C223355B1D7BA7EF7055BA32F0132078 /* TimeInterval+Extensions.swift */, + A22F90A4358F5D13F7E0BF83F7DABD18 /* TimingUtil.swift */, + 28909F20999A48234075586D77F3D448 /* TrackingManagerProxy.swift */, + 861F711AED94FA2C6115C3A7757FD1A6 /* TransactionMetadataStrings.swift */, + 8AE6C8B273904DBD8CF1DCAD8E07F42C /* TransactionMetadataSyncHelper.swift */, + 7B99AC8998CBD5E39EAF55D3A285403C /* TransactionNotifications.swift */, + 581324E62C9B9C66F986C485D64EC0C6 /* TransactionPoster.swift */, + 18BCDD139ADDE82AB46C6785541DFA6D /* TransactionReason.swift */, + 881041EB30032A832A0F458D97E93162 /* TransactionsFactory.swift */, + 85CBFB8711101557F06EE0748753997E /* TransactionsManager.swift */, + 31E998A2F775507890CED89B91DA99CC /* TrialOrIntroPriceEligibilityChecker.swift */, + BA9B90A92058E60129DF12B69DC9B22C /* UIApplication+RCExtensions.swift */, + 9FA708953532C337EB9E943F1E130927 /* UIConfig.swift */, + D6C95818A043218391EF6DDF699C76F1 /* UInt8+Extensions.swift */, + C62B0FEC9381399A0CF313D658B62FB9 /* URL+WebPurchaseRedemption.swift */, + 7C3AA691053AC1526EA7AD7112D87EB9 /* URLWithValidation.swift */, + 741E28294C6E366139D9B1A98FF4D201 /* UserDefaults+Extensions.swift */, + 43CC8A563BB74D631FDCBB5C81A713BD /* VerificationResult.swift */, + 6E767D12636C059F2945360E3798A816 /* VirtualCurrencies.swift */, + AD09B3518B3902E39C9B44C28578B656 /* VirtualCurrenciesAPI.swift */, + CE0D5E64BCD5A731B05D799C3899BD45 /* VirtualCurrenciesCallback.swift */, + F3769014434C82BEC6EC59E4F1156A10 /* VirtualCurrenciesResponse.swift */, + A47F72C2370452B72036EC0A6F194747 /* VirtualCurrency.swift */, + ACF413EA2CEFBC52AFDBF08039BC2B1A /* VirtualCurrencyManager.swift */, + 41A835CD3320BF4219C95068A6A91A85 /* VirtualCurrencyStrings.swift */, + E405D92F1496D54E211947AE2BF80437 /* WebBillingAPI.swift */, + E8951ED7C8061999BC1457F7F8F15F9E /* WebBillingHTTPRequestPath.swift */, + 416DB5A58251BC0BA7DA6C8F7BE5E68C /* WebBillingProduct+SimulatedStoreProduct.swift */, + CA50CB4C6E0ABB414E82FBFBC3B5B259 /* WebBillingProductsCallback.swift */, + 98E870720B12A477833EC662153EAA94 /* WebBillingProductsResponse.swift */, + 6D8C79631CF5388121CCCFAAD1C90129 /* WebOfferingProductsCallback.swift */, + E06D51866D5E4D037B1DF0A7248BB569 /* WebOfferingProductsResponse.swift */, + 472FC5C89285A9712BEABCDDD91FAAF5 /* WebPurchaseRedemption.swift */, + 154AAA9A3B0432A54E8802E6C7AD0CAB /* WebPurchaseRedemptionHelper.swift */, + 724E4C9353C10489676F511523A082CA /* WebPurchaseRedemptionResult.swift */, + 1A19C133F59B1808E6D3932D56DC8CEB /* WebRedemptionStrings.swift */, + 69AEABD735DE880752B3A57DA9663F93 /* WinBackOffer.swift */, + F52AACBDDB4E40E83FF56FA1DA1B314F /* WinBackOfferEligibilityCalculator.swift */, + 0C80098D91C52427263BECAB1F82D20F /* WinBackOfferEligibilityCalculatorType.swift */, + 2B9C811D4BAAD4BA19BB09C9AEC4B044 /* Resources */, + 7798ACE40596D7265BAB1779231EC426 /* Support Files */, + ); + name = RevenueCat; + path = RevenueCat; + sourceTree = ""; + }; + 56A71E93C9E1A7D5AE9E3E55B9EC5A69 /* Pods */ = { + isa = PBXGroup; + children = ( + 0A88DEF4A2CC7FAD5848D1F69B4ABFAB /* PurchasesHybridCommon */, + 452257B70B4B857250C9E64625DDB9E1 /* RevenueCat */, + ); + name = Pods; + sourceTree = ""; + }; + 5832643698C9991C79E97F6C009112D1 /* Pods-App */ = { + isa = PBXGroup; + children = ( + 8773B2E5D7F8A99B50F36D80B9AF21E1 /* Pods-App.modulemap */, + CF07BD63D9C5EA55B4B7E9E538E996E1 /* Pods-App-acknowledgements.markdown */, + 79D7A380E2CBB08C55B18415CD1215CB /* Pods-App-acknowledgements.plist */, + 6D748E1EB3CD22A59197E91481E3FBE9 /* Pods-App-dummy.m */, + 61B12281162B2669A9CD4FDB2800B3FF /* Pods-App-frameworks.sh */, + B76D7FFEF4333335EAFA0B2827258173 /* Pods-App-Info.plist */, + 8E75B29AE1472628C750F928572B5885 /* Pods-App-umbrella.h */, + 09209143938B2386BB3906033655559D /* Pods-App.debug.xcconfig */, + 76836226476D35BC62A098CF501DF10B /* Pods-App.release.xcconfig */, + ); + name = "Pods-App"; + path = "Target Support Files/Pods-App"; + sourceTree = ""; + }; + 7798ACE40596D7265BAB1779231EC426 /* Support Files */ = { + isa = PBXGroup; + children = ( + 7D7B6D8D2D0FB1D62E64B8C51A82A79B /* ResourceBundle-RevenueCat-RevenueCat-Info.plist */, + 2582F971E65634F0CF024A3785204906 /* RevenueCat.modulemap */, + 5CEC8A93A161ABEAA5C71216AAF12613 /* RevenueCat-dummy.m */, + 2F957AC5C000E03E731AFBC2C95A1B32 /* RevenueCat-Info.plist */, + 83B31470F770EB32564A13857A8BE25A /* RevenueCat-prefix.pch */, + 05CE0A318507A1B03E71FC3AE3B8A616 /* RevenueCat-umbrella.h */, + 92254E4924A2E640CA1B355E0D19EFCC /* RevenueCat.debug.xcconfig */, + 0DFBA4B190EFE203B3F2586C1BD3D910 /* RevenueCat.release.xcconfig */, + ); + name = "Support Files"; + path = "../Target Support Files/RevenueCat"; + sourceTree = ""; + }; + 7C6D044F0CD972C5CAB37A7A0E4EE782 /* Targets Support Files */ = { + isa = PBXGroup; + children = ( + 5832643698C9991C79E97F6C009112D1 /* Pods-App */, + ); + name = "Targets Support Files"; + sourceTree = ""; + }; + CF1408CF629C7361332E53B88F7BD30C = { + isa = PBXGroup; + children = ( + 9D940727FF8FB9C785EB98E56350EF41 /* Podfile */, + 1628BF05B4CAFDCC3549A101F5A10A17 /* Frameworks */, + 56A71E93C9E1A7D5AE9E3E55B9EC5A69 /* Pods */, + E7C40B9F6DADE6EFDCB59CC992F894AE /* Products */, + 7C6D044F0CD972C5CAB37A7A0E4EE782 /* Targets Support Files */, + ); + sourceTree = ""; + }; + D7C4191F07A506EB0B12C402F365E5DF /* iOS */ = { + isa = PBXGroup; + children = ( + 87A71B489ED684C9F56752C38BF66DF8 /* Foundation.framework */, + C21042E9373E06C1FF2749EEB2023EC3 /* StoreKit.framework */, + ); + name = iOS; + sourceTree = ""; + }; + E7C40B9F6DADE6EFDCB59CC992F894AE /* Products */ = { + isa = PBXGroup; + children = ( + 5BEF4602752E47C46E8C10FB8B4B57F2 /* Pods-App */, + 80B4EAC55FDCA2A0D3FC58E15C98A22E /* PurchasesHybridCommon */, + 3AA9124C018EA7BF6460091D86216692 /* PurchasesHybridCommon-PurchasesHybridCommon */, + 74A3C88613D4EB0E63038367698A81E9 /* RevenueCat */, + D83AE5D93C653BDBFDDF59AC3D1E32BB /* RevenueCat-RevenueCat */, + ); + name = Products; + sourceTree = ""; + }; + EC2D01F90AC6DB92440ECD2C7C4C80C0 /* Support Files */ = { + isa = PBXGroup; + children = ( + 984D2C3735188D894207578D1804DA92 /* PurchasesHybridCommon.modulemap */, + 5E636DE5DDB3B5BDF5FFC68F773CC609 /* PurchasesHybridCommon-dummy.m */, + 611DF8B4F21CB669239775C22230859C /* PurchasesHybridCommon-Info.plist */, + AEBB9B1BF0E9593CC606BF5001A6925D /* PurchasesHybridCommon-prefix.pch */, + A617AD237C24722AE93154558C4BD548 /* PurchasesHybridCommon-umbrella.h */, + F084B7D53DFB16EF49BBFE8B31822C0A /* PurchasesHybridCommon.debug.xcconfig */, + D44A2781799F278C4957B2D9704410C4 /* PurchasesHybridCommon.release.xcconfig */, + 6324CE2644473AD9256FF61329E8E281 /* ResourceBundle-PurchasesHybridCommon-PurchasesHybridCommon-Info.plist */, + ); + name = "Support Files"; + path = "../Target Support Files/PurchasesHybridCommon"; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 44FA5CCADB1A8C72BB83152FB6506BB5 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 1CEFEF736D8E92EC0A86ED83DC62F947 /* RevenueCat-umbrella.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 949A06E9BA0EBB182E55B2EC6A82A67D /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + F7D16DA69B13B1FE0142A981CC6E2677 /* Pods-App-umbrella.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0009AE45466F70E8A795D6879CFB259 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 56D9EC3F3BDE13F44396F49859BE37AA /* PurchasesHybridCommon.h in Headers */, + 03183A3B2358AA4112BC58439084EDA1 /* PurchasesHybridCommon-umbrella.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 58084B0686015596789324D0C42368C5 /* RevenueCat-RevenueCat */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8DCA7706206C92FD38A12DA664DF4E11 /* Build configuration list for PBXNativeTarget "RevenueCat-RevenueCat" */; + buildPhases = ( + AD727519F12FE177D695CFB4BBA09E2C /* Sources */, + 9B3B4135B51285497BA5AB94FA22F783 /* Frameworks */, + 1ADADD08A0C195401D76CC8DD1C0C985 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "RevenueCat-RevenueCat"; + productName = RevenueCat; + productReference = D83AE5D93C653BDBFDDF59AC3D1E32BB /* RevenueCat-RevenueCat */; + productType = "com.apple.product-type.bundle"; + }; + 5C415F33D02A6F89DB713EFB9D75A3FB /* Pods-App */ = { + isa = PBXNativeTarget; + buildConfigurationList = F424268303883603D482C62497C58F61 /* Build configuration list for PBXNativeTarget "Pods-App" */; + buildPhases = ( + 949A06E9BA0EBB182E55B2EC6A82A67D /* Headers */, + 7AC660A511ADFC78620560BD5B5E490C /* Sources */, + D9996A6684013F86D7940F8E2D3FEAE8 /* Frameworks */, + F0E5F7A3487834B8A0196F2D7B2B1B17 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 8D01F0854409A89515C2E35FAB47FFC7 /* PBXTargetDependency */, + 592FBBC2E14E8021683E91216F00F1DA /* PBXTargetDependency */, + ); + name = "Pods-App"; + productName = Pods_App; + productReference = 5BEF4602752E47C46E8C10FB8B4B57F2 /* Pods-App */; + productType = "com.apple.product-type.framework"; + }; + A9FD6F34305C03A1CC3A10B207522C48 /* RevenueCat */ = { + isa = PBXNativeTarget; + buildConfigurationList = 26207A69A37A7B227D3FC2A4421B3633 /* Build configuration list for PBXNativeTarget "RevenueCat" */; + buildPhases = ( + 44FA5CCADB1A8C72BB83152FB6506BB5 /* Headers */, + 3490941676954505F3EE25A34ED1FEC2 /* Sources */, + DF6BFFB3519A2124C47D2BDE10A01524 /* Frameworks */, + E6B5A43A8D6AD82D56039EBE1B3BBDF9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E3A57EAE23ABEE5B4C3DCB75FF9F226A /* PBXTargetDependency */, + ); + name = RevenueCat; + productName = RevenueCat; + productReference = 74A3C88613D4EB0E63038367698A81E9 /* RevenueCat */; + productType = "com.apple.product-type.framework"; + }; + D47CB7C8CD3E8F81E812E1BF4156FE15 /* PurchasesHybridCommon */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3B3DAE36F9DBD950172426C45F43D936 /* Build configuration list for PBXNativeTarget "PurchasesHybridCommon" */; + buildPhases = ( + D0009AE45466F70E8A795D6879CFB259 /* Headers */, + 1E9A190B7CED1668F6FEDDAB2E491FB5 /* Sources */, + 4E62D121A8AA8226701D9C59225D874E /* Frameworks */, + F2538BC4AFE47F0300EE66E15B4B3D26 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 78C98B8BA641F885BC7C41A4A155B0DC /* PBXTargetDependency */, + 0B89EA2AC5B7047DB5110D4D64CED7D4 /* PBXTargetDependency */, + ); + name = PurchasesHybridCommon; + productName = PurchasesHybridCommon; + productReference = 80B4EAC55FDCA2A0D3FC58E15C98A22E /* PurchasesHybridCommon */; + productType = "com.apple.product-type.framework"; + }; + E3F5D7A4C3AB3CFEB8B1C429405FED63 /* PurchasesHybridCommon-PurchasesHybridCommon */ = { + isa = PBXNativeTarget; + buildConfigurationList = C9ACE89E70D9BCEC1DE54219EB94372E /* Build configuration list for PBXNativeTarget "PurchasesHybridCommon-PurchasesHybridCommon" */; + buildPhases = ( + 0D3BFE234287270DB925F99D5A1C52D2 /* Sources */, + B398AF8C382F09FFE36913BB595EDB0B /* Frameworks */, + 1E7A67D9F998EA60F4A69F36010BC3CB /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "PurchasesHybridCommon-PurchasesHybridCommon"; + productName = PurchasesHybridCommon; + productReference = 3AA9124C018EA7BF6460091D86216692 /* PurchasesHybridCommon-PurchasesHybridCommon */; + productType = "com.apple.product-type.bundle"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + BFDFE7DC352907FC980B868725387E98 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + }; + buildConfigurationList = 4821239608C13582E20E6DA73FD5F1F9 /* Build configuration list for PBXProject "Pods" */; + compatibilityVersion = "Xcode 15.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = CF1408CF629C7361332E53B88F7BD30C; + minimizedProjectReferenceProxies = 0; + preferredProjectObjectVersion = 77; + productRefGroup = E7C40B9F6DADE6EFDCB59CC992F894AE /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 5C415F33D02A6F89DB713EFB9D75A3FB /* Pods-App */, + D47CB7C8CD3E8F81E812E1BF4156FE15 /* PurchasesHybridCommon */, + E3F5D7A4C3AB3CFEB8B1C429405FED63 /* PurchasesHybridCommon-PurchasesHybridCommon */, + A9FD6F34305C03A1CC3A10B207522C48 /* RevenueCat */, + 58084B0686015596789324D0C42368C5 /* RevenueCat-RevenueCat */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 1ADADD08A0C195401D76CC8DD1C0C985 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AF9854824B9384417638A854FFDE4790 /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 1E7A67D9F998EA60F4A69F36010BC3CB /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 01BFB44EEA988D5C06CF190F898AD5CA /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E6B5A43A8D6AD82D56039EBE1B3BBDF9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 65F402692D84E0334C9BEF775C8A8799 /* RevenueCat-RevenueCat in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F0E5F7A3487834B8A0196F2D7B2B1B17 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F2538BC4AFE47F0300EE66E15B4B3D26 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A05D19095E9514CD12EEA8B7DE67FAA1 /* PurchasesHybridCommon-PurchasesHybridCommon in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 0D3BFE234287270DB925F99D5A1C52D2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 1E9A190B7CED1668F6FEDDAB2E491FB5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 03B6CCFD551AA712027E915231F2D5E3 /* CommonFunctionality.swift in Sources */, + 21F61D7BDA1AEBDFD9FEC5DE13A74307 /* CommonPurchaseParams.swift in Sources */, + E9447A299FD6DB996759DCE4B4B609F9 /* Constants.swift in Sources */, + 5CA1FC8A23AE6F619CE711089F093C39 /* CustomerInfo+HybridAdditions.swift in Sources */, + A0C13F33ACA277E525CB93548C576AB5 /* EntitlementInfo+HybridAdditions.swift in Sources */, + 6883F9EBCA20083096B2D52AD3530F05 /* EntitlementInfos+HybridAdditions.swift in Sources */, + 208340C06D4DA3F40D229407246FD4A1 /* EntitlementVerificationMode+HybridAdditions.swift in Sources */, + 91D926DFE01EE8CDE6598B81CFE8ABBD /* Enums+HybridAdditions.swift in Sources */, + 5EC08752843008B7FA2C77868785B2E1 /* ErrorContainer.swift in Sources */, + CA33BC7DD153E27579F689CDB273E6D6 /* FatalErrorUtil.swift in Sources */, + 2BEEDE76AB0D00EFBD78E793B071D7B3 /* IntroEligibility+HybridExtensions.swift in Sources */, + B5911DA4A44191BF54F3884779787DB4 /* IOSAPIAvailabilityChecker.swift in Sources */, + 6A883EC0F7E1C3C42185398701DB0187 /* NonSubscriptionTransaction+HybridAdditions.swift in Sources */, + 180D839229B3B662F65D8B8838D5B690 /* NSDate+HybridAdditions.swift in Sources */, + 9722BD750D05A2A5760EDBC4FCAF9611 /* Offering+HybridAdditions.swift in Sources */, + F973398F934D30C07E6092C82785F74F /* Offerings+HybridAdditions.swift in Sources */, + 21691E8A6A56203ABEBB5644BD232747 /* Package+HybridAdditions.swift in Sources */, + C524453D31C15223446D9DF4E856A117 /* PromotionalOffer+HybridAdditions.swift in Sources */, + 17AF2784A076FECBD67D0C12206718CA /* PurchaseParamsBuilder+HybridExtensions.swift in Sources */, + 6C590FD333D3C1FA9D221016E847A422 /* Purchases+HybridAdditions.swift in Sources */, + 14E4C48C41BBA672B94EAAE59F45AABD /* PurchasesAreCompletedBy+HybridAdditions.swift in Sources */, + 8F5121FB350068C2AAD309B7717E5E14 /* PurchasesHybridCommon-dummy.m in Sources */, + EB992F6DEC2F138549EF43B672E0C455 /* RefundRequestStatus+HybridAdditions.swift in Sources */, + 9A6A9D2FEDA333096CC65CE0A824CC0F /* StoreKitVersion+HybridAdditions.swift in Sources */, + 9348952875E729527572646A4E96F13F /* StoreProduct+HybridAdditions.swift in Sources */, + E6DC692C7D87A5FCA6C447C51F583129 /* StoreProductDiscount+HybridAdditions.swift in Sources */, + EFAD008F7E4995E7CE79291FC3FE4652 /* StoreTransaction+HybridAdditions.swift in Sources */, + 1A81A29142D26A1B8EAF60C5D5BB2F28 /* SubscriptionInfo+HybridAdditions.swift in Sources */, + 77C3355901AE40A27B546502B75E0129 /* VirtualCurrencies+HybridAdditions.swift in Sources */, + DD0FCF9D527DD230105686159BC701E6 /* VirtualCurrency+HybridAdditions.swift in Sources */, + 2E43C8EE5DE7DE000CA8132A2BF4195D /* WinBackOffer+HybridAdditions.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3490941676954505F3EE25A34ED1FEC2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DA5265CB90A53B414716CF394DD60332 /* AdEvent.swift in Sources */, + 6455060844D0E34AEF3B3EE3DEFBC38F /* AdEventsRequest.swift in Sources */, + 0C9437A2E830FCFD7769A6299724B3E6 /* AdEventStore.swift in Sources */, + B63B5E6E34ED0F380A4AFB04069E2B18 /* AdHTTPRequestPath.swift in Sources */, + 70425D2CF9B0EB920FA5274A3694E587 /* AdTracker.swift in Sources */, + 2471BB2C5F6475EA1D566E721F7AD571 /* AnalyticsStrings.swift in Sources */, + A39B25F4B8AC63AF7A0DD44B03199E33 /* AnyDecodable.swift in Sources */, + 213F980DD8DA00BC54F6590CC4DA68D3 /* AnyEncodable.swift in Sources */, + AA77866411E19FB9D6D9B78F214667B8 /* AppleReceipt.swift in Sources */, + F17C543C5A5BF7613D6EE05E5F7C2D39 /* AppleReceiptBuilder.swift in Sources */, + A1B28F49322BEED2B0403265DE934BBD /* Array+Extensions.swift in Sources */, + B12F5B7D39A17ADFEA70C32D5EC30CE5 /* ArraySlice_UInt8+Extensions.swift in Sources */, + 13ECC7EC33ECA8C85DC2811C69CC2E19 /* ASIdManagerProxy.swift in Sources */, + F589F55B8508CD5B519CE739E4A3C6C0 /* ASN1Container.swift in Sources */, + A915CFF4FC0215FE996E2CF88D55B3FE /* ASN1ContainerBuilder.swift in Sources */, + CFAD65732AA47E2E7B6078E390F9B3BA /* ASN1ObjectIdentifier.swift in Sources */, + F1497A9480B7D8A32541AF9A8D1944D5 /* ASN1ObjectIdentifierBuilder.swift in Sources */, + 772206C12DD0B5CE7BD2E2A5FE46197E /* Assertions.swift in Sources */, + 6057874615E5DFDF77819375000B318C /* AsyncExtensions.swift in Sources */, + 43870D37B89CBD1F87144783926160D7 /* Atomic.swift in Sources */, + 76AE94A1A86A32AD29413EAA5CA7A1BD /* Attribution.swift in Sources */, + 3847043C02DE3BA5AED254C37AE3FCF3 /* AttributionData.swift in Sources */, + 8B93842F2A0FA79826FFB176092CA68A /* AttributionDataMigrator.swift in Sources */, + 4AFCE3BDDAA0B1B4BE39C95DF95F35D6 /* AttributionFetcher.swift in Sources */, + 8AEEC4076B85C1ED87BA1D0E028A3001 /* AttributionKey.swift in Sources */, + 8665003E6691051A04F90065D92C03A5 /* AttributionNetwork.swift in Sources */, + DD1CC65A882933401221C4B69BEEB1E7 /* AttributionPoster.swift in Sources */, + 04CDB63D6B40DCFBB65C16B951F53BFF /* AttributionStrings.swift in Sources */, + 10F4FD55E13BF23D01BBD6C3D188CCA4 /* AttributionTypeFactory.swift in Sources */, + CB7BCFB33043BAFEDD6C9CE15A47ED42 /* Backend.swift in Sources */, + E999DEC784AE613847A2B4E3960A613D /* BackendConfiguration.swift in Sources */, + E09CBD1852D8718B8A97CE089CC13162 /* BackendError.swift in Sources */, + A1EF5406367450EA2FA41E0E79E9A0D1 /* BackendErrorCode.swift in Sources */, + 8009EFBD68459BD7F154554CDAF0C476 /* BackendErrorStrings.swift in Sources */, + D4DC5EF6A39ABF5419B24DA4719BA0D6 /* Background.swift in Sources */, + D5CDAAE0B97EB7C65DD5B77F004AC24F /* BeginRefundRequestHelper.swift in Sources */, + 954D0E7EC83418A5BA496C10200DC885 /* Border.swift in Sources */, + 4D34A508E2D2714BAFE6E4D2119A74DA /* Box.swift in Sources */, + 917EE0107F13098DFA3C601C16F4439D /* CacheFetchPolicy.swift in Sources */, + 4A480AA741A5F40A2D5078A3B5B079FB /* CacheStatus.swift in Sources */, + B8B7151332606D3D4807004DFDEDA602 /* CacheStrings.swift in Sources */, + AC007C5CC26D3C43D3979B33C60C3CD4 /* CachingProductsManager.swift in Sources */, + 001602D3680D1D798A55CDA7C8806989 /* CachingTrialOrIntroPriceEligibilityChecker.swift in Sources */, + 3AFC91C477936253792D4D67F9929D0C /* CallbackCache.swift in Sources */, + EB43921987558ADBB6654583453FC747 /* CallbackCacheStatus.swift in Sources */, + 62691C2B39C26024F110B743D49D35D5 /* Checksum.swift in Sources */, + AEC3A5F2ECBB090DCE4B0A5ABEB3DA9F /* Clock.swift in Sources */, + DEFC0D6136E06F4C84440534FE74CB99 /* Codable+Extensions.swift in Sources */, + 7BE399E51C92F93CFFE1EE6D5F913365 /* CodableStrings.swift in Sources */, + EF9BDB5B7D0AC3BFF6E78446CB8CDCBA /* ComponentOverrides.swift in Sources */, + 030C4AB920D4945E144BDDFB3796CBFD /* Configuration.swift in Sources */, + C5B982498BFBD84F5D0AD4EC5E7FBEA1 /* ConfigureStrings.swift in Sources */, + 9541631B9653458879686FCBAB3C62EF /* ConnectionErrorReason.swift in Sources */, + CD027B2DD4808DA282F2F36C61D210A2 /* CustomerAPI.swift in Sources */, + 10A3B1D0006E841479F7899966C53BFE /* CustomerCenterConfigAPI.swift in Sources */, + FBF04E2554B6F5DD1830A1C8F8B41CF5 /* CustomerCenterConfigCallback.swift in Sources */, + E106FBD688BB86B42FF7DA63820A2DB0 /* CustomerCenterConfigData.swift in Sources */, + 5987821275DA7D2D54545DE990E43E3F /* CustomerCenterConfigResponse.swift in Sources */, + 5925A21D702BA8CB8C9A1DD7EFF6CA06 /* CustomerCenterEvent.swift in Sources */, + 8F1F6D04F6AF938E4148E9DD473D433A /* CustomerCenterPresentationMode.swift in Sources */, + A71B299E41E020C2BC475091C4320F38 /* CustomerInfo.swift in Sources */, + 2EDE750731D055CE89039F9DE66E2176 /* CustomerInfo+ActiveDates.swift in Sources */, + 747618F3DD2FF5CFF3923AA87F4184DD /* CustomerInfo+NonSubscriptions.swift in Sources */, + C0EBC01260388290B43203BA88D0D011 /* CustomerInfo+OfflineEntitlements.swift in Sources */, + F8217905363A5DC8876064053E3042C1 /* CustomerInfoCallback.swift in Sources */, + 7E92EA37828F4E9F88E37497092D246F /* CustomerInfoManager.swift in Sources */, + C51BF6C3E3CE4B055CAD1DFD243F4501 /* CustomerInfoResponse.swift in Sources */, + 4EF17FEDC16DDA63618ADAE6F884E77C /* CustomerInfoResponseHandler.swift in Sources */, + 4D6E07073A8D14E4AFA27A6475007323 /* CustomerInfoStrings.swift in Sources */, + 59D4C3938D6B2424BD231E14777D0427 /* DangerousSettings.swift in Sources */, + A7FD09C1F62919A6631C9447C9FFA244 /* Data+Extensions.swift in Sources */, + E8C2B760491C92809D413D143B2A03D1 /* Date+Extensions.swift in Sources */, + 86C07CAD53C2BD9945ADB4522C193583 /* DateExtensions.swift in Sources */, + C37877E7BE08E313E0239948775E8604 /* DateFormatter+Extensions.swift in Sources */, + 9EE03FCDD1577723000E62E384E389F8 /* DateProvider.swift in Sources */, + 2191A6742168C17FB9632D624A206768 /* DebugContentViews.swift in Sources */, + 65F39BF1ED8ADF7900D8E1A27AD22A0B /* DebugView.swift in Sources */, + 784BF0E8882593223EACA3FF833E85CB /* DebugViewController.swift in Sources */, + 3246AD757F5D3E065B6E22396BEB57BC /* DebugViewModel.swift in Sources */, + 4537CCA3F7D854F8423527D1AD861FC6 /* DebugViewSheetPresentation.swift in Sources */, + D7A4AAE763DE5C21E9320332071185CF /* Decoder+Extensions.swift in Sources */, + 80C5E97875B2415C462721211CFCB32C /* DeepLinkParser.swift in Sources */, + E265123CB9C97B72F2CC272BC5D98737 /* DefaultDecodable.swift in Sources */, + 2218ED4EBA9CE52918838D34B8EA4F3B /* Deprecations.swift in Sources */, + 4A30A63B65AF75F662364BA66ECA3DE3 /* DescribableError.swift in Sources */, + C4DFEBD184F7D3E5DBC21DA205AB5FF3 /* DeviceCache.swift in Sources */, + D9751900FB7AED06B4C1C181793CB8A4 /* DiagnosticsEvent.swift in Sources */, + 7F793D6609B0771DAB205DC80413F8A0 /* DiagnosticsEventsRequest.swift in Sources */, + 853F20C7F5DCA7D4EC281364F5E277A5 /* DiagnosticsFileHandler.swift in Sources */, + 737C86EBA24A886E53EE6DCCC2C986BF /* DiagnosticsHTTPRequestPath.swift in Sources */, + 9F60DC3031BEE3F5F12C70726312349B /* DiagnosticsPostOperation.swift in Sources */, + C61D4C05EC2F02FF6157FBB228784A19 /* DiagnosticsStrings.swift in Sources */, + D21CA64CD17B1D2F4F9DD1C6D1E5A1E4 /* DiagnosticsSynchronizer.swift in Sources */, + 7742BBC1F5D4443E3E1EBDCDE250A77E /* DiagnosticsTracker.swift in Sources */, + 2579B7B799C41C0CECACDCA597517BF1 /* Dictionary+Extensions.swift in Sources */, + 21041155EA9AAC704A2C275E02133F4E /* Dimension.swift in Sources */, + F9FFFA8CD4376820B19F4E6AC416955C /* DirectoryHelper.swift in Sources */, + 3A0AB7D40C480569B52BB0C568F9A09B /* DispatchTimeInterval+Extensions.swift in Sources */, + AF979601CEF7E72C1239B2C5C0C0073E /* DNSChecker.swift in Sources */, + 9F3C9F43E51710EAB7D3D4C91EE3C7C6 /* Either.swift in Sources */, + C40942B092E7382C3D80DC5C389D736C /* EligibilityStrings.swift in Sources */, + 07F85EE88D25BE60F0E332D642D204F4 /* EmptyFile.swift in Sources */, + 7256E12C68AAC32714327F166B7F22B4 /* EncodedAppleReceipt.swift in Sources */, + 3DCD237C98945FF5237F779681167768 /* EnsureNonEmptyCollectionDecodable.swift in Sources */, + 206FF8E3E673450A193CD90B95D0EA72 /* EntitlementInfo.swift in Sources */, + 517CCB855DCDD46EC3D8ED0DA41C2B96 /* EntitlementInfos.swift in Sources */, + F552CB4E0A203E85A72D0C1C9D42B38A /* Error+Extensions.swift in Sources */, + 9B3C4DE8E8018B995E6837023D39ECAF /* ErrorCode.swift in Sources */, + CCB5D0C444055BFDC81D53B54A30ACC6 /* ErrorDetails.swift in Sources */, + 9F45845EC907C7D3E8EC5CBF358C9F65 /* ErrorResponse.swift in Sources */, + 44970CF5CBACB735A2E96D73CFC8D4F1 /* ErrorUtils.swift in Sources */, + 929B2D1E9201C129D1F99911AD4E4BC6 /* ETagManager.swift in Sources */, + 4C9CC1416B0122CB6519DF3A15A41029 /* ETagStrings.swift in Sources */, + ADDC57AE1CCD0B526EAADE6ADB77AC48 /* EventsHTTPRequestPath.swift in Sources */, + 2A0D491FC575852938DCC01611B838D4 /* EventsManager.swift in Sources */, + 36AB5D7CB2009E336A8B5AC837B261A1 /* EventsRequest+CustomerCenter.swift in Sources */, + 626F76E0D8E45C0C562BAD8E06E43A5A /* EventsRequest+Paywall.swift in Sources */, + 60C9149A5F902CEB43020283532E7B15 /* ExitOffer.swift in Sources */, + 2D356B2490FDBBE760935EE7F2ECC501 /* FakeSigning.swift in Sources */, + 1409E78A536830E13709AD12EAFD1D20 /* FeatureEvent.swift in Sources */, + 30E7269EB51FF197416794A96A97481A /* FeatureEventHTTPRequestPath.swift in Sources */, + FA74D8A2EAB60D6B069EA30E2DCD8872 /* FeatureEventsRequest.swift in Sources */, + 276F8B6366A20E819F8B35BCBC0B3A83 /* FeatureEventStore.swift in Sources */, + CD608C8E934B7B25F1EFA643D1DBC531 /* FileHandler.swift in Sources */, + F0FA12C9742296F7418CF1A04C6FE48A /* FileReader.swift in Sources */, + 6DA0F9994A547B5FFA7A205F4192AFD2 /* FileRepository.swift in Sources */, + C1FF83EF8C0E2E49AD87DEA5EBD0154D /* FileRepositoryStrings.swift in Sources */, + 7EB2E4F9725D1CFB15D63E6D62257B7E /* FrameworkDisambiguation.swift in Sources */, + 53CE3CF2A4F3A055984E482FA53D27B4 /* GetCustomerCenterConfigOperation.swift in Sources */, + 34BD2C8AE79882E9CD1E4B20F3B83AD7 /* GetCustomerInfoOperation.swift in Sources */, + D47A2A68E3C44BE5D93D241403171340 /* GetIntroEligibilityOperation.swift in Sources */, + 9A1F93903A42A477256B361727DE8DB4 /* GetIntroEligibilityResponse.swift in Sources */, + D2EA911BF05E202B311D755DB5C43D81 /* GetOfferingsOperation.swift in Sources */, + B8CB1C1FC3F33EECBBD296220920E42A /* GetProductEntitlementMappingOperation.swift in Sources */, + 83DD040E6E7F68E92C206BE88A7B45A9 /* GetVirtualCurrenciesOperation.swift in Sources */, + 7819B2F212D80297276A833E670E9C8E /* GetWebBillingProductsOperation.swift in Sources */, + BC06E4B60F818A12740499225E78A417 /* GetWebOfferingProductsOperation.swift in Sources */, + 2B5236D888BF52D775DF0CCFF7BE3DB8 /* HealthOperation.swift in Sources */, + 83D077583C157AF74986453F33B08073 /* HealthReport+Validate.swift in Sources */, + B43EC17C209109CEAA80252ACAB47DF8 /* HealthReportAvailabilityOperation.swift in Sources */, + 07A59F29E59B77C88F03B6ECE606B3D6 /* HealthReportAvailabilityResponse.swift in Sources */, + B36A384567B248BE57D25650874C60EE /* HealthReportOperation.swift in Sources */, + 88200664E20C83D20178541A941DDAEC /* HealthReportResponse.swift in Sources */, + 8A7D431240AFF8546554FC76593ACA98 /* HTTPClient.swift in Sources */, + 68C2CA5FD79C893D82CF145A749D52E2 /* HTTPRequest.swift in Sources */, + 61D0AB83A1BF334C4F44E532C30AE0E5 /* HTTPRequest+Signing.swift in Sources */, + E91DE9EA53A5C841C09CB76AA923F1CA /* HTTPRequestBody.swift in Sources */, + 897C85D1CFB8BE60EF2DA468FEC0A233 /* HTTPRequestBody+Signing.swift in Sources */, + 6D86FE4F481EF154900291B74D4A8499 /* HTTPRequestPath.swift in Sources */, + 61E359193242F00C15B62EDA65305196 /* HTTPRequestTimeoutManager.swift in Sources */, + E1210474FADC31DCB701CCF595BCB678 /* HTTPResponse.swift in Sources */, + 4351539D4BCCD9C914D9F53BFBC07584 /* HTTPResponseBody.swift in Sources */, + C0787A171FA2D751F4946AC3EBE99EF0 /* HTTPStatusCode.swift in Sources */, + 763E2F69BD19993134A8B6AEB1D89994 /* IdentityAPI.swift in Sources */, + 3D072DD0150F8C6220A34AC6FBA4897A /* IdentityManager.swift in Sources */, + 501533E437DC8A1D3358AE04129C3322 /* IdentityStrings.swift in Sources */, + 156B39BB4CAAA1CC6CA823BEE8DD84B8 /* IgnoreHashable.swift in Sources */, + 7E26F9F95EDE7747AB14F7BD5FF55340 /* InAppPurchase.swift in Sources */, + 7F923D4B265D853318B659E8C94EAFB0 /* InAppPurchaseBuilder.swift in Sources */, + 8B1D04C144AC73F5B3B44393CA784D59 /* InMemoryCachedObject.swift in Sources */, + 3D43F3A991DDCBF702DAFB921D0E045C /* Integer+Extensions.swift in Sources */, + 6BC16D6C4EF5D73855198617F3C570FE /* InternalAPI.swift in Sources */, + 6F29229B7DAF07FE2BD52D41A48EBCA6 /* IntroEligibility.swift in Sources */, + A13D3A2C9C9DF25E4DC35F306F82915C /* IntroEligibilityCalculator.swift in Sources */, + 2CDAE1C27C590A918B0FCD78AB1913B2 /* ISODurationFormatter.swift in Sources */, + D30B4DB5DB2894428DD65EE18C73E57D /* ISOPeriodFormatter.swift in Sources */, + B46898EA7DC1D219FB751D276307F4FA /* IsPurchaseAllowedByRestoreBehaviorCallback.swift in Sources */, + 7DBBFC773DA0A00D66A543FED131F14F /* IsPurchaseAllowedByRestoreBehaviorResponse.swift in Sources */, + E98EBDDB7D5A2D571E4A37E0AC218C68 /* KeyedDeferredValueStore.swift in Sources */, + 3F8316FB0AE284C8A7F3B0B1A1BD1567 /* LargeItemCacheType.swift in Sources */, + 15742E67D3780DD5E1ED310B56F9D192 /* Locale+Comparison.swift in Sources */, + 34D26CD371C4C4A0C9413A96A2D06D1C /* Locale+Extensions.swift in Sources */, + 45D4F61E2302D7AD6F8DDA325F912E45 /* LocalReceiptFetcher.swift in Sources */, + D78890D9555AC822C78851ADFD587D27 /* LocalTransactionMetadata.swift in Sources */, + C5E92926FE1B42120709EDA8524842B9 /* LocalTransactionMetadataStore.swift in Sources */, + 5522B6C4005A1080C598A41E60C1D678 /* Lock.swift in Sources */, + 61011136C7896358E6EE825B52E44EC1 /* Logger.swift in Sources */, + 1515DF0219C03EFA63EFCDE2A575096E /* LoggerType.swift in Sources */, + 29DE1B55467F0D04EC4A300C21A0D88A /* LogInCallback.swift in Sources */, + AC342B627EC87B9B653555D36B18EEA4 /* LogInOperation.swift in Sources */, + 5C05E3117F5DBD93C0C76B9A643EEBC8 /* LogIntent.swift in Sources */, + F1ED2FFF6A85A43643AB68A99CE6DCC5 /* MacDevice.swift in Sources */, + 7BF461163C11F166126C05049E220874 /* ManageSubscriptionsHelper.swift in Sources */, + 8E41613FD4C11C71F3B715E34966553A /* ManageSubscriptionsStrings.swift in Sources */, + F7E14028DA7AD0C0C71C5402B168185E /* MapAppStoreDetector.swift in Sources */, + 9A780CB6C011F7FB63EAC26167D9617E /* NetworkError.swift in Sources */, + 6911A2866BA68E26F43227BBB14332F7 /* NetworkOperation.swift in Sources */, + 32758E91A629958F6F9597D7F8B0BC14 /* NetworkStrings.swift in Sources */, + BDBD3F8531DA20BA63A631D71D209B5E /* NonEmptyStringDecodable.swift in Sources */, + 3AEBA4EAF76EE56781B8C11EFA181843 /* NonSubscriptionTransaction.swift in Sources */, + 966AE973DE8439C5CB230AB8309AA9B3 /* Obsoletions.swift in Sources */, + 2206DFEB66D51DB4A4B80502EDF906F6 /* Offering.swift in Sources */, + C497D9F2D7CF9A3139F931212AAA229E /* Offerings.swift in Sources */, + 586114F07AFA49A0BD3D2B25689EA539 /* OfferingsAPI.swift in Sources */, + 5C0F7B99BB5D06D5681CC84531B894AA /* OfferingsCallback.swift in Sources */, + 53B4B42F7FD86E3F31C226E61261C96D /* OfferingsFactory.swift in Sources */, + A630B3770983BFA4E1062A4A206AAB98 /* OfferingsManager.swift in Sources */, + 55D8028893DADE1FA8510654182DBB25 /* OfferingsResponse.swift in Sources */, + 02E1542278A8D2D5859BFFA8C2813164 /* OfferingStrings.swift in Sources */, + 36235CC944CE2FC473A71E5BF5948A0E /* OfflineCustomerInfoCreator.swift in Sources */, + C10AFAFC73FC999742DC38C505C4ED4C /* OfflineEntitlementsAPI.swift in Sources */, + 6D421A2A914E6A0303FAF92EEB0C5A80 /* OfflineEntitlementsManager.swift in Sources */, + 41B66027F6EB3ECD1889410068488926 /* OfflineEntitlementsStrings.swift in Sources */, + 90ECDA89BFA33C09C0EA7CACB705E38D /* OperationDispatcher.swift in Sources */, + 34D795D69C1896928A9844E252BB8552 /* OperationQueue+Extensions.swift in Sources */, + E245DB241936662149239F251BAF8F76 /* Operators+Extensions.swift in Sources */, + 2F49EC74AA36DBB0D436B9B790A92E78 /* Optional+Extensions.swift in Sources */, + F695AA75AF3DEECCD9345CAD9DF7151E /* Package.swift in Sources */, + 8D068435B740743AFC141359A635C6A6 /* PackageType.swift in Sources */, + 5E659DB78ACF16037276DEDF6AAD3A5D /* PaymentAuthorizationProvider.swift in Sources */, + 6F16EBFB28BD79915C768BD45DFD8F94 /* PaymentQueueWrapper.swift in Sources */, + 152F5160ED3F8B7530021E3BC9B0B885 /* PaywallAnimation.swift in Sources */, + E69CAD7E81C16443125E4DA3E4D96480 /* PaywallButtonComponent.swift in Sources */, + CC1DA1DCA4BEA977787BCC17C039CB7D /* PaywallCacheWarming.swift in Sources */, + C9911D0E33DB5ACA693F5161033F688B /* PaywallCarouselComponent.swift in Sources */, + 12B4AE6D86BD110458267EE6548EB932 /* PaywallColor.swift in Sources */, + 14E381357117FB5E25B3CB2D4A6070A7 /* PaywallComponentBase.swift in Sources */, + 44C7EF3324C7975E053E620CC0BEDF9C /* PaywallComponentLocalization.swift in Sources */, + C19C4F122086D1B2EC60182C91C3EF4A /* PaywallComponentPropertyTypes.swift in Sources */, + E2295E3C99C8E9ED05E9CE2F0BF2DFD1 /* PaywallComponentsData.swift in Sources */, + D1EBE83909E908C2FE17803BBD1D8749 /* PaywallCountdownComponent.swift in Sources */, + D97FD83BB13F2936D4D159C25D47B11D /* PaywallData.swift in Sources */, + 71AAB546724AAAEC9F32A0827B397009 /* PaywallData+Localization.swift in Sources */, + 52D7645AEFCC26A59FB1610F154FF794 /* PaywallEvent.swift in Sources */, + 7B81B7AAC93B9AD9A4A3BCD5577D9E30 /* PaywallExtensions.swift in Sources */, + 05A8D362305FBD5E683851F6838B0B56 /* PaywallFontManagerType.swift in Sources */, + 4AF96C30871F7CB233FCF1B47E5C654B /* PaywallIconComponent.swift in Sources */, + FDBB3A93EE41E43FF91E07FFD4DBD8C9 /* PaywallImageComponent.swift in Sources */, + DA43B7486BD9A21FBCD6B73FF4C10777 /* PaywallPackageComponent.swift in Sources */, + 61DE9725A7F168F8B27ECE5A6AEF263E /* PaywallPurchaseButtonComponent.swift in Sources */, + 16BDDF5C12987345DF60FEA70303FC34 /* PaywallsStrings.swift in Sources */, + AF9833D45A51C28186500A67ECBE5BEC /* PaywallStackComponent.swift in Sources */, + 4D6EC71482A650EAA558F7D1A228BAF4 /* PaywallStickyFooterComponent.swift in Sources */, + 09BA0309D2BACF6583F84B8E5DFB407D /* PaywallTabsComponent.swift in Sources */, + 822B45B6091245E60A73AAD509430F59 /* PaywallTextComponent.swift in Sources */, + A4E7E2ABFDDB45A490DB0BDE92DAAEDB /* PaywallTimelineComponent.swift in Sources */, + BD48C95A7EA78365D974A0124E1BE62E /* PaywallTransition.swift in Sources */, + A11EBD6CCE3111CE42181721BB84A904 /* PaywallV2CacheWarming.swift in Sources */, + 1FDA6BD059FE84BC7AB93AF0A3B8C7FD /* PaywallVideoComponent.swift in Sources */, + 25060BEFF76C4B7369DF4E8F4D7A36EC /* PaywallViewMode.swift in Sources */, + 8F957D5CBF21994528EDE0E92C695455 /* PeriodType+Extensions.swift in Sources */, + 967E930166FD79D29E7F5948FA8ED5A4 /* PlatformInfo.swift in Sources */, + D676EC928CF04E162D3B97B0623901D1 /* PostAdEventsOperation.swift in Sources */, + A494E13FA39145F50323095B61A9F57C /* PostAdServicesTokenOperation.swift in Sources */, + 45D06B4201AC9DD5A44F16D0025773C0 /* PostAttributionDataOperation.swift in Sources */, + C4486D13B4187D2F6631CEF197088440 /* PostFeatureEventsOperation.swift in Sources */, + 53202F238900EBC88086410C61471CD5 /* PostIsPurchaseAllowedByRestoreBehaviorOperation.swift in Sources */, + D8CE88E583458C9A6FFE816B81DC3FA6 /* PostOfferForSigningOperation.swift in Sources */, + 42693FAE530586342A09CF9EC588E5B2 /* PostOfferResponse.swift in Sources */, + E00E7FEC38BA0C560A4A1D7E8CD8E847 /* PostReceiptDataOperation.swift in Sources */, + 3B00006AFCB08F69DCFB35B6A74C62C1 /* PostRedeemWebPurchaseOperation.swift in Sources */, + 71A4E7B1ABBA2010596B4796BD205747 /* PostSubscriberAttributesOperation.swift in Sources */, + E38883BAA6278EAECB9299E1B38AC69C /* PreferredLocalesProvider.swift in Sources */, + 378F1FB4146DC53F3E38FE12B83A4A23 /* PriceFormatterProvider.swift in Sources */, + 20B615542DF3C519130C52650E9DE1CB /* ProcessInfo+Extensions.swift in Sources */, + 2AE918F82916DD9EB251134CE888B904 /* ProductEntitlementMapping.swift in Sources */, + 4DF1428C9BBD4E84EFB82EDDAE9709C2 /* ProductEntitlementMappingCallback.swift in Sources */, + 6A4AE283A715A3537A23D34CC178CE48 /* ProductEntitlementMappingFetcher.swift in Sources */, + 327B6DE2691BA35C6FCC8FBC380A403F /* ProductEntitlementMappingResponse.swift in Sources */, + 043AE6B23610A87FB2FD32380FD24DF9 /* ProductPaidPrice.swift in Sources */, + AB7713C6FC578FE6FB5E0ED725B6C823 /* ProductRequestData.swift in Sources */, + A3E05972292D1D8C3574B2A158737E7F /* ProductRequestData+Initialization.swift in Sources */, + 213005A27193AF89E6C69108C18E0454 /* ProductsFetcherSK1.swift in Sources */, + DB3C51563385F086DB26C27EBB3D5D7E /* ProductsFetcherSK2.swift in Sources */, + 67FA180A0D518DDCD2F3A499C9895F15 /* ProductsManager.swift in Sources */, + 24D59F614C7F7DF7BF6F5BFE7E7798F7 /* ProductsManagerFactory.swift in Sources */, + 3107ACB6D1A55D8A8C8DB0192DB69A03 /* ProductsManagerType.swift in Sources */, + BE842972F21FE22B5803128040E2A0CA /* ProductsRequestFactory.swift in Sources */, + AA2393F2E17333DAA72DD5003E79B56D /* ProductStatus+Icon.swift in Sources */, + CFC0971372AF616EFDB66BBD3ED1D554 /* ProductType.swift in Sources */, + 1BBABCCF8C05DBA0BF23313C4EED1ECD /* PromotionalOffer.swift in Sources */, + AA7A0B547083A7449975B1845E17D74A /* PurchasedProductsFetcher.swift in Sources */, + E5485009A0D5F2CDA6ED0177ECAFE746 /* PurchasedSK2Product.swift in Sources */, + BC337DB759014BA9A848C8A127A8E000 /* PurchaseOwnershipType.swift in Sources */, + 72B32094C7DD9E4EC0B6809D97C3ED38 /* PurchaseOwnershipType+Extensions.swift in Sources */, + 1BB0D75317F0646F5F673D0AF4473C6A /* PurchaseParams.swift in Sources */, + DC047090760CE5550E9E06946441EFDD /* Purchases.swift in Sources */, + 939417F0CD8A6316FD00164A9354FA7A /* Purchases+async.swift in Sources */, + C71A17355E7F729ACE56A36F76FFB396 /* Purchases+nonasync.swift in Sources */, + 1566C952453E9745B770FE20B8CA8DB3 /* PurchasesAreCompletedBy.swift in Sources */, + 5EED0714A88E74BFCA2EA2114331CD65 /* PurchasesDelegate.swift in Sources */, + 08186A0136D03D87561D6E38C3F4A663 /* PurchasesDiagnostics.swift in Sources */, + B3126D5E3BFB1E308624F3DF43C4D248 /* PurchasesError.swift in Sources */, + 4F1D869A61AC9895817A0D1E186516B1 /* PurchasesOrchestrator.swift in Sources */, + 829CF3270E4E5F05FE0CE9ADA8B12822 /* PurchasesReceiptParser.swift in Sources */, + BFE5D0420E2FBBDFE558DAF5C03498C4 /* PurchaseStrings.swift in Sources */, + 586EE3D92C7EF6FBBBE671BD0849BC0B /* PurchasesType.swift in Sources */, + 4EDB5F0189CA9D0ED02DDE39F806AF5B /* RateLimiter.swift in Sources */, + B1DB0CF5F53E8E4FF5197EBEF12C0308 /* RawDataContainer.swift in Sources */, + 74419E4695126A2956F0510CFA6BE611 /* ReceiptFetcher.swift in Sources */, + 862570941311AA81CEC0E70D64715D65 /* ReceiptParserLogger.swift in Sources */, + 3076382EF535E9F1060CFC140617348F /* ReceiptParsingError.swift in Sources */, + 9D229E91428C0F33D6FC67A756522D71 /* ReceiptRefreshPolicy.swift in Sources */, + C08BD9D29FF795235836F0DB1B140307 /* ReceiptStrings.swift in Sources */, + 5351D5668E2B378389BB09AB962DABAD /* RedeemWebPurchaseAPI.swift in Sources */, + 7A301B3E33A2B101D7872CC170310629 /* RedirectLoggerTaskDelegate.swift in Sources */, + B9B9D6096020353B530D6CC1D4852FB8 /* ReservedSubscriberAttributes.swift in Sources */, + 8F86481D857009B299B6076E890F2386 /* Result+Extensions.swift in Sources */, + 308296D6BB22879826E82F568F6A6420 /* RevenueCat-dummy.m in Sources */, + 90C75EA6052F479E1B4E3B755C52B5E4 /* SandboxEnvironmentDetector.swift in Sources */, + 7B5198CCF1CFA02ED61EEB40CAA26E34 /* SDKHealthCheckStatus+Icon.swift in Sources */, + D9D214FCD2F31E8B3A9286E2D6408142 /* SDKHealthError+CustomNSError.swift in Sources */, + 5ADC8C584D7D620CF86FCAC95A63761E /* SDKHealthManager.swift in Sources */, + D5724DED5E59DB0B69944DD19A7A76FD /* SDKHealthStatus+Icon.swift in Sources */, + E78F0CCC5F66A632EDC1CAE094657BDA /* Set+Extensions.swift in Sources */, + 3CA663B0D64A682B6E10835DCD3CF6ED /* Signing.swift in Sources */, + 8AF8CA1ECAA366030F181DBFAA3539B2 /* Signing+ResponseVerification.swift in Sources */, + 223D8C5B39CA7D94879522EEDEBF9EC0 /* SigningStrings.swift in Sources */, + 52810FA18198B6C56C3EF93B32F51080 /* SimpleNetworkServiceType.swift in Sources */, + 23640BE7C9C4E7874AED750478FC9A04 /* SimulatedStoreProduct.swift in Sources */, + D0BDD146E036E53D6C4377121470A0AE /* SimulatedStoreProductsManager.swift in Sources */, + 131859884B3C435DFA786DDFB550A8C6 /* SimulatedStorePurchaseHandler.swift in Sources */, + F815DAEFCBA4D5597D9DF77055028858 /* SimulatedStorePurchaseUI.swift in Sources */, + 92A7CAA793F08187EFD059636A0F0B8A /* SimulatedStoreTransaction.swift in Sources */, + E9247C0420E6655DD68EC62404FAE64C /* SK1Storefront.swift in Sources */, + 10EC64BFB17FECD8B875C8CB843CD363 /* SK1StoreProduct.swift in Sources */, + 4D0111C816C4202EBCABD84588611821 /* SK1StoreProductDiscount.swift in Sources */, + F545CEA115FD66C136DE436CFA9459BA /* SK1StoreTransaction.swift in Sources */, + 624E5CDEA0823014638AA663F7BDB89D /* SK2AppTransaction.swift in Sources */, + 51900CD62D52368F2A4624C539E755CD /* SK2BeginRefundRequestHelper.swift in Sources */, + FA015721ACEC320497D11EC381E6C83A /* SK2Storefront.swift in Sources */, + 278374A2154BB820AD45C86AE5C8C001 /* SK2StoreProduct.swift in Sources */, + ACC3BE0CDB49D4B1FF8C850B90E8368D /* SK2StoreProductDiscount.swift in Sources */, + D7A4E34586ECC6E5D9DC63D6E9F61649 /* SK2StoreTransaction.swift in Sources */, + 2A1344CCB1AB136C7ADCC7E1D3438B21 /* SKError+Extensions.swift in Sources */, + 3E88E03632FC38E5F995E32D8C901050 /* Store+Extensions.swift in Sources */, + 1264A93DE7517ACD4F98008BC4B3BEBF /* StoredAdEvent.swift in Sources */, + 4D5C54021F5BC6B250F65AC2FFB2431F /* StoredAdEventSerializer.swift in Sources */, + 106641AB276CBAFF92012750D0211C75 /* StoredFeatureEvent.swift in Sources */, + 96A612B7F976E0B4D8E641ED758987BE /* StoredFeatureEventSerializer.swift in Sources */, + CC76A950588A6C13FCCF7AFB6FEDB475 /* StoreEnvironment.swift in Sources */, + 36A15A464BF447DD40144709F5ADDFB6 /* Storefront.swift in Sources */, + C9C3DBB60EDD836FE5542EACF92603A3 /* StorefrontProvider.swift in Sources */, + 39A7159CCC5FE2E6402B841C11F21A57 /* StoreKit1Wrapper.swift in Sources */, + 20573DE31015C3DFEEF2C9073840BB09 /* StoreKit2ObserverModePurchaseDetector.swift in Sources */, + 7CAD224349EB3588B0A73F1464D5E580 /* StoreKit2PromotionalOfferPurchaseOptions.swift in Sources */, + 89CC55C8ED64E48C2D7A72EBBC47963C /* StoreKit2PurchaseIntentListener.swift in Sources */, + D3CE6AB8A9F13F5F6CFCA33483BD26F4 /* StoreKit2Receipt.swift in Sources */, + 4CCE38129034AAEB9608AA5800C7EAD9 /* StoreKit2StorefrontListener.swift in Sources */, + FB44DCEBCA144D51E8C0879F09B194F3 /* StoreKit2TransactionFetcher.swift in Sources */, + 4245D221CFEF6AAB8D9F4EA719DE8396 /* StoreKit2TransactionListener.swift in Sources */, + 477F337DBB860F7FA41141DA6E8D2CAF /* StoreKitError+Extensions.swift in Sources */, + ACD871A1CA99981795758CBC85256AE8 /* StoreKitErrorHelper.swift in Sources */, + 5CAF071FB8EDFF15366291521C86F4F6 /* StoreKitRequestFetcher.swift in Sources */, + DCF7D0917F451C871DE8F3BF00E7A719 /* StoreKitStrings.swift in Sources */, + 66D8491B3A36D4C420D5ABEB74EDC4C6 /* StoreKitVersion.swift in Sources */, + 4995A36CE81246962C7A1E55F3A1AC85 /* StoreKitWorkarounds.swift in Sources */, + 7DFB68F5928E7F081CFEDF21659A8C66 /* StoreMessagesHelper.swift in Sources */, + 2A4DDD439C25E03965FB12619124E959 /* StoreMessageType.swift in Sources */, + 56C2B5FBB55D0C700D9C0C164AE07D37 /* StoreProduct.swift in Sources */, + D8A65A63D27980274ABF72BC3C6A46FC /* StoreProductDiscount.swift in Sources */, + 515A661C6F29D2093AE703E342A4080C /* StoreTransaction.swift in Sources */, + 699F09290490B89C7ADA9A61DE216485 /* String+Extensions.swift in Sources */, + D5A739E830743C21564B04F620C23262 /* Strings.swift in Sources */, + 09DC7E85F60D7186ECA3B9729BE7EB33 /* SubscriberAttribute.swift in Sources */, + 7AF61D3301BF23C426CBF119C8EFA692 /* SubscriberAttributesManager.swift in Sources */, + FF0BF4F77972ED239E5A3262FBC85761 /* SubscriptionHistoryTracker.swift in Sources */, + 4C5D08E7EEE4B916161F07C0AAE6D5C4 /* SubscriptionInfo.swift in Sources */, + A99D9A97FBCF14F226E24723A7910C8A /* SubscriptionPeriod.swift in Sources */, + 405E859EE0416E12CBCE6FD40BB3993E /* SwiftVersionCheck.swift in Sources */, + 49A9A167B76A20D10074132B1C53BEE8 /* SynchronizedLargeItemCache.swift in Sources */, + 091D391F1C499F73AA36F42F345A00CF /* SynchronizedUserDefaults.swift in Sources */, + 2377BEA5A8937BA519CBCA176F399C83 /* SystemInfo.swift in Sources */, + D14222F2BE3D20ADD09C6B163B80877D /* TestStoreProduct.swift in Sources */, + 1F17AD87208F203FB6966A49AFE59E39 /* TestStoreProductDiscount.swift in Sources */, + 9EC557C2B69D43E3754F865D87FBBA25 /* TestStoreTransaction.swift in Sources */, + 1CCCDFE36E423640BB45B58EC5D3B6BE /* TimeInterval+Extensions.swift in Sources */, + C4C5A2513784F2D139190C7C2779238A /* TimingUtil.swift in Sources */, + BB9EB99AB72DACF1697085BD308515A9 /* TrackingManagerProxy.swift in Sources */, + 7CBECC8C698BCBB1049FA8278BBE6D37 /* TransactionMetadataStrings.swift in Sources */, + DA75E9E46C62030FF02C65FD64436F1F /* TransactionMetadataSyncHelper.swift in Sources */, + E2E63416874647E29FABFD5F4457703B /* TransactionNotifications.swift in Sources */, + 3F6EC5931284554C8723F2136C18B2D6 /* TransactionPoster.swift in Sources */, + 74FDA5758CB9026D3C1269DF1D925927 /* TransactionReason.swift in Sources */, + B7B57DEE52B2B092F409EB6F1E227D2D /* TransactionsFactory.swift in Sources */, + E192696EF2F37D23D3A366595667D7F5 /* TransactionsManager.swift in Sources */, + 148D0FDFA60D4FC5D3610A080C7E79BD /* TrialOrIntroPriceEligibilityChecker.swift in Sources */, + C6B34F3A3347FC317ABBFB2DBF7962CE /* UIApplication+RCExtensions.swift in Sources */, + 42BEDE9FED3C4B88BBB111407DEB9285 /* UIConfig.swift in Sources */, + 0ADB6FCD9734BB9B0F11B62ACC65F715 /* UInt8+Extensions.swift in Sources */, + 5F38C55DD408F6339712D08BB4A6279F /* URL+WebPurchaseRedemption.swift in Sources */, + 6783F4EB72088C6CDE3F2CD7E72A6DEA /* URLWithValidation.swift in Sources */, + A31A78934D6EDB7302F56AC436DAED00 /* UserDefaults+Extensions.swift in Sources */, + D32850FA660E05D72D8600D49FDC45A9 /* VerificationResult.swift in Sources */, + FC6E16AF548205ED2D10E2B6A0B034A6 /* VirtualCurrencies.swift in Sources */, + 3920B1552E7225DDF97B799DB049E613 /* VirtualCurrenciesAPI.swift in Sources */, + 70F9FFE974854A0332AFEFF01AFFD806 /* VirtualCurrenciesCallback.swift in Sources */, + 963E80788E5ADD9CDC5E340AE6A6C024 /* VirtualCurrenciesResponse.swift in Sources */, + 1DD7DC56178A4753AB7CD93B74860D01 /* VirtualCurrency.swift in Sources */, + 625BD2AFC9EFFCD1F39237B27254B9F9 /* VirtualCurrencyManager.swift in Sources */, + 492EFD12496C5BBBFC5749C88C4071B3 /* VirtualCurrencyStrings.swift in Sources */, + 954021FD040EE33AD0E79B993266CF6E /* WebBillingAPI.swift in Sources */, + 2F0B92E488BE14E847168B6E4ECE9E5F /* WebBillingHTTPRequestPath.swift in Sources */, + 033121D335DAB7DE8C1097A0A157A355 /* WebBillingProduct+SimulatedStoreProduct.swift in Sources */, + A46F46AA15270BCAD792746BB0D04CC0 /* WebBillingProductsCallback.swift in Sources */, + 4D50158AF043606EAA5ADED80F15ED23 /* WebBillingProductsResponse.swift in Sources */, + 05AA3E46F27FCB5D62907A5A687C70DC /* WebOfferingProductsCallback.swift in Sources */, + 1FCAA2B2EE9DEB70AD020C866AC0737B /* WebOfferingProductsResponse.swift in Sources */, + 8708176D793338D813D8DEE81E4C696E /* WebPurchaseRedemption.swift in Sources */, + 8AF928B7B5596A441C8F6488EE215714 /* WebPurchaseRedemptionHelper.swift in Sources */, + 76FF8B4502362A884D951F14109F5F10 /* WebPurchaseRedemptionResult.swift in Sources */, + EEB95AE9933FF2BC0D559D7FEF448755 /* WebRedemptionStrings.swift in Sources */, + 8F88765D915734873DB5C3AEFF7D236E /* WinBackOffer.swift in Sources */, + 780EE2DE01CD3710169FE4D9D47BEEDD /* WinBackOfferEligibilityCalculator.swift in Sources */, + 9630DBC2BEAF472CF6A6849A5ADD3FD0 /* WinBackOfferEligibilityCalculatorType.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7AC660A511ADFC78620560BD5B5E490C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 23BF085B8F093E4DCC370ECD4461CAE9 /* Pods-App-dummy.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AD727519F12FE177D695CFB4BBA09E2C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 0B89EA2AC5B7047DB5110D4D64CED7D4 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = RevenueCat; + target = A9FD6F34305C03A1CC3A10B207522C48 /* RevenueCat */; + targetProxy = 79B4B6C291361F77C5AE178A64078EE0 /* PBXContainerItemProxy */; + }; + 592FBBC2E14E8021683E91216F00F1DA /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = RevenueCat; + target = A9FD6F34305C03A1CC3A10B207522C48 /* RevenueCat */; + targetProxy = 8BE5ABBF2AA986C8A26F97BDB17C2873 /* PBXContainerItemProxy */; + }; + 78C98B8BA641F885BC7C41A4A155B0DC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = "PurchasesHybridCommon-PurchasesHybridCommon"; + target = E3F5D7A4C3AB3CFEB8B1C429405FED63 /* PurchasesHybridCommon-PurchasesHybridCommon */; + targetProxy = 1CE178B10C550E37F0749E7A5188F684 /* PBXContainerItemProxy */; + }; + 8D01F0854409A89515C2E35FAB47FFC7 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = PurchasesHybridCommon; + target = D47CB7C8CD3E8F81E812E1BF4156FE15 /* PurchasesHybridCommon */; + targetProxy = BC8C6F246651D6565B3363A0602268C4 /* PBXContainerItemProxy */; + }; + E3A57EAE23ABEE5B4C3DCB75FF9F226A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = "RevenueCat-RevenueCat"; + target = 58084B0686015596789324D0C42368C5 /* RevenueCat-RevenueCat */; + targetProxy = BC049305D5A9B9D50C0F3961ECB7D8FB /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 26D316F1193E22D8A7201BB3A77A4EBD /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D44A2781799F278C4957B2D9704410C4 /* PurchasesHybridCommon.release.xcconfig */; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = ""; + CURRENT_PROJECT_VERSION = 1; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_PREFIX_HEADER = "Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon-prefix.pch"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon-Info.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MODULEMAP_FILE = "Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon.modulemap"; + PRODUCT_MODULE_NAME = PurchasesHybridCommon; + PRODUCT_NAME = PurchasesHybridCommon; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) "; + SWIFT_INSTALL_OBJC_HEADER = YES; + SWIFT_VERSION = 5.7; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + 30E0B9EFD9A5C45D0D351231E81B30B3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_PREPROCESSOR_DEFINITIONS = ( + "POD_CONFIGURATION_RELEASE=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRIP_INSTALLED_PRODUCT = NO; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + SYMROOT = "${SRCROOT}/../build"; + }; + name = Release; + }; + 4CF4491D0ED18A7CF1D95C846480A1AC /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F084B7D53DFB16EF49BBFE8B31822C0A /* PurchasesHybridCommon.debug.xcconfig */; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = ""; + CURRENT_PROJECT_VERSION = 1; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_PREFIX_HEADER = "Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon-prefix.pch"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon-Info.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MODULEMAP_FILE = "Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon.modulemap"; + PRODUCT_MODULE_NAME = PurchasesHybridCommon; + PRODUCT_NAME = PurchasesHybridCommon; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) "; + SWIFT_INSTALL_OBJC_HEADER = YES; + SWIFT_VERSION = 5.7; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + 52BC386A5DC8376F780578E8FCA03494 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0DFBA4B190EFE203B3F2586C1BD3D910 /* RevenueCat.release.xcconfig */; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = ""; + CURRENT_PROJECT_VERSION = 1; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_PREFIX_HEADER = "Target Support Files/RevenueCat/RevenueCat-prefix.pch"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "Target Support Files/RevenueCat/RevenueCat-Info.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MODULEMAP_FILE = "Target Support Files/RevenueCat/RevenueCat.modulemap"; + PRODUCT_MODULE_NAME = RevenueCat; + PRODUCT_NAME = RevenueCat; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) "; + SWIFT_INSTALL_OBJC_HEADER = YES; + SWIFT_VERSION = 5.8; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + 553FEB3B603F5E7904D432770F34F700 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0DFBA4B190EFE203B3F2586C1BD3D910 /* RevenueCat.release.xcconfig */; + buildSettings = { + CODE_SIGNING_ALLOWED = NO; + CONFIGURATION_BUILD_DIR = "$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/RevenueCat"; + IBSC_MODULE = RevenueCat; + INFOPLIST_FILE = "Target Support Files/RevenueCat/ResourceBundle-RevenueCat-RevenueCat-Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + PRODUCT_NAME = RevenueCat; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + WRAPPER_EXTENSION = bundle; + }; + name = Release; + }; + 80F9407579E18572845C043B75B46080 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F084B7D53DFB16EF49BBFE8B31822C0A /* PurchasesHybridCommon.debug.xcconfig */; + buildSettings = { + CODE_SIGNING_ALLOWED = NO; + CONFIGURATION_BUILD_DIR = "$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/PurchasesHybridCommon"; + IBSC_MODULE = PurchasesHybridCommon; + INFOPLIST_FILE = "Target Support Files/PurchasesHybridCommon/ResourceBundle-PurchasesHybridCommon-PurchasesHybridCommon-Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + PRODUCT_NAME = PurchasesHybridCommon; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + WRAPPER_EXTENSION = bundle; + }; + name = Debug; + }; + 8152D558832C10B7282C2608C8D7A16C /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 09209143938B2386BB3906033655559D /* Pods-App.debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + CLANG_ENABLE_OBJC_WEAK = NO; + "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = ""; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + INFOPLIST_FILE = "Target Support Files/Pods-App/Pods-App-Info.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MACH_O_TYPE = staticlib; + MODULEMAP_FILE = "Target Support Files/Pods-App/Pods-App.modulemap"; + OTHER_LDFLAGS = ""; + OTHER_LIBTOOLFLAGS = ""; + PODS_ROOT = "$(SRCROOT)"; + PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.${PRODUCT_NAME:rfc1034identifier}"; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + A612B5A022781D29F12BF3934A3FB7B0 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 92254E4924A2E640CA1B355E0D19EFCC /* RevenueCat.debug.xcconfig */; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = ""; + CURRENT_PROJECT_VERSION = 1; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_PREFIX_HEADER = "Target Support Files/RevenueCat/RevenueCat-prefix.pch"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "Target Support Files/RevenueCat/RevenueCat-Info.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MODULEMAP_FILE = "Target Support Files/RevenueCat/RevenueCat.modulemap"; + PRODUCT_MODULE_NAME = RevenueCat; + PRODUCT_NAME = RevenueCat; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) "; + SWIFT_INSTALL_OBJC_HEADER = YES; + SWIFT_VERSION = 5.8; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + A8849E2E7A08B4AEFF9AC14EE6C6A2CE /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D44A2781799F278C4957B2D9704410C4 /* PurchasesHybridCommon.release.xcconfig */; + buildSettings = { + CODE_SIGNING_ALLOWED = NO; + CONFIGURATION_BUILD_DIR = "$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/PurchasesHybridCommon"; + IBSC_MODULE = PurchasesHybridCommon; + INFOPLIST_FILE = "Target Support Files/PurchasesHybridCommon/ResourceBundle-PurchasesHybridCommon-PurchasesHybridCommon-Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + PRODUCT_NAME = PurchasesHybridCommon; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + WRAPPER_EXTENSION = bundle; + }; + name = Release; + }; + A929533FCB00A15702D9C48C9A6AFFB6 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 92254E4924A2E640CA1B355E0D19EFCC /* RevenueCat.debug.xcconfig */; + buildSettings = { + CODE_SIGNING_ALLOWED = NO; + CONFIGURATION_BUILD_DIR = "$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/RevenueCat"; + IBSC_MODULE = RevenueCat; + INFOPLIST_FILE = "Target Support Files/RevenueCat/ResourceBundle-RevenueCat-RevenueCat-Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + PRODUCT_NAME = RevenueCat; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + WRAPPER_EXTENSION = bundle; + }; + name = Debug; + }; + B1A8FB32263D1A43592610E7D9012B3E /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 76836226476D35BC62A098CF501DF10B /* Pods-App.release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + CLANG_ENABLE_OBJC_WEAK = NO; + "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = ""; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + INFOPLIST_FILE = "Target Support Files/Pods-App/Pods-App-Info.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MACH_O_TYPE = staticlib; + MODULEMAP_FILE = "Target Support Files/Pods-App/Pods-App.modulemap"; + OTHER_LDFLAGS = ""; + OTHER_LIBTOOLFLAGS = ""; + PODS_ROOT = "$(SRCROOT)"; + PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.${PRODUCT_NAME:rfc1034identifier}"; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + F4FF6A0D1970CA9705974E3CB2134802 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "POD_CONFIGURATION_DEBUG=1", + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRIP_INSTALLED_PRODUCT = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + SYMROOT = "${SRCROOT}/../build"; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 26207A69A37A7B227D3FC2A4421B3633 /* Build configuration list for PBXNativeTarget "RevenueCat" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A612B5A022781D29F12BF3934A3FB7B0 /* Debug */, + 52BC386A5DC8376F780578E8FCA03494 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 3B3DAE36F9DBD950172426C45F43D936 /* Build configuration list for PBXNativeTarget "PurchasesHybridCommon" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4CF4491D0ED18A7CF1D95C846480A1AC /* Debug */, + 26D316F1193E22D8A7201BB3A77A4EBD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 4821239608C13582E20E6DA73FD5F1F9 /* Build configuration list for PBXProject "Pods" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F4FF6A0D1970CA9705974E3CB2134802 /* Debug */, + 30E0B9EFD9A5C45D0D351231E81B30B3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 8DCA7706206C92FD38A12DA664DF4E11 /* Build configuration list for PBXNativeTarget "RevenueCat-RevenueCat" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A929533FCB00A15702D9C48C9A6AFFB6 /* Debug */, + 553FEB3B603F5E7904D432770F34F700 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C9ACE89E70D9BCEC1DE54219EB94372E /* Build configuration list for PBXNativeTarget "PurchasesHybridCommon-PurchasesHybridCommon" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 80F9407579E18572845C043B75B46080 /* Debug */, + A8849E2E7A08B4AEFF9AC14EE6C6A2CE /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F424268303883603D482C62497C58F61 /* Build configuration list for PBXNativeTarget "Pods-App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8152D558832C10B7282C2608C8D7A16C /* Debug */, + B1A8FB32263D1A43592610E7D9012B3E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = BFDFE7DC352907FC980B868725387E98 /* Project object */; +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Pods.xcodeproj/xcuserdata/ajpallares.xcuserdatad/xcschemes/Pods-App.xcscheme b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Pods.xcodeproj/xcuserdata/ajpallares.xcuserdatad/xcschemes/Pods-App.xcscheme new file mode 100644 index 00000000..624974d8 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Pods.xcodeproj/xcuserdata/ajpallares.xcuserdatad/xcschemes/Pods-App.xcscheme @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Pods.xcodeproj/xcuserdata/ajpallares.xcuserdatad/xcschemes/PurchasesHybridCommon-PurchasesHybridCommon.xcscheme b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Pods.xcodeproj/xcuserdata/ajpallares.xcuserdatad/xcschemes/PurchasesHybridCommon-PurchasesHybridCommon.xcscheme new file mode 100644 index 00000000..ef7cb889 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Pods.xcodeproj/xcuserdata/ajpallares.xcuserdatad/xcschemes/PurchasesHybridCommon-PurchasesHybridCommon.xcscheme @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Pods.xcodeproj/xcuserdata/ajpallares.xcuserdatad/xcschemes/PurchasesHybridCommon.xcscheme b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Pods.xcodeproj/xcuserdata/ajpallares.xcuserdatad/xcschemes/PurchasesHybridCommon.xcscheme new file mode 100644 index 00000000..61256c2c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Pods.xcodeproj/xcuserdata/ajpallares.xcuserdatad/xcschemes/PurchasesHybridCommon.xcscheme @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Pods.xcodeproj/xcuserdata/ajpallares.xcuserdatad/xcschemes/RevenueCat-RevenueCat.xcscheme b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Pods.xcodeproj/xcuserdata/ajpallares.xcuserdatad/xcschemes/RevenueCat-RevenueCat.xcscheme new file mode 100644 index 00000000..83227519 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Pods.xcodeproj/xcuserdata/ajpallares.xcuserdatad/xcschemes/RevenueCat-RevenueCat.xcscheme @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Pods.xcodeproj/xcuserdata/ajpallares.xcuserdatad/xcschemes/RevenueCat.xcscheme b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Pods.xcodeproj/xcuserdata/ajpallares.xcuserdatad/xcschemes/RevenueCat.xcscheme new file mode 100644 index 00000000..0858c326 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Pods.xcodeproj/xcuserdata/ajpallares.xcuserdatad/xcschemes/RevenueCat.xcscheme @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Pods.xcodeproj/xcuserdata/ajpallares.xcuserdatad/xcschemes/xcschememanagement.plist b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Pods.xcodeproj/xcuserdata/ajpallares.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 00000000..903d8593 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Pods.xcodeproj/xcuserdata/ajpallares.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,36 @@ + + + + + SchemeUserState + + Pods-App.xcscheme + + isShown + + + PurchasesHybridCommon-PurchasesHybridCommon.xcscheme + + isShown + + + PurchasesHybridCommon.xcscheme + + isShown + + + RevenueCat-RevenueCat.xcscheme + + isShown + + + RevenueCat.xcscheme + + isShown + + + + SuppressBuildableAutocreation + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/LICENSE b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/LICENSE new file mode 100644 index 00000000..128883bd --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 RevenueCat, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/README.md b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/README.md new file mode 100644 index 00000000..07d3686c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/README.md @@ -0,0 +1,25 @@ +# purchases-hybrid-common + +Common files and libraries for RevenueCat's Hybrid SDKs. This repository contains 4 distinct libraries that provide shared functionality across different platforms and implementations. + +## Libraries + +### 1. Android (`android/`) +Contains mappings and utilities for RevenueCat hybrid SDKs to interface with the native Android library. This library provides the bridge between hybrid frameworks and the RevenueCat Android SDK, handling platform-specific implementations and data transformations. + +### 2. iOS (`ios/`) +Contains mappings and utilities for RevenueCat hybrid SDKs to interface with the native iOS library. This provides the necessary bridge to connect hybrid frameworks with the RevenueCat iOS SDK, managing iOS-specific functionality and data transformations. + +### 3. TypeScript (`typescript/`) +Shared TypeScript types and interfaces commonly used by both [react-native-purchases](https://github.com/RevenueCat/react-native-purchases) and [purchases-capacitor](https://github.com/RevenueCat/purchases-capacitor). This library ensures type consistency across different hybrid implementations by providing a single source of truth for common data structures, enums, and type definitions. + +- **Package**: `@revenuecat/purchases-typescript-internal` +- **Purpose**: Internal shared TypeScript code for hybrid SDKs +- **Note**: Not intended for external usage + +### 4. JavaScript Hybrid Mappings (`purchases-js-hybrid-mappings/`) +Contains mappings from purchases-js for use by hybrid frameworks that support web platforms. This library enables hybrid SDKs to leverage RevenueCat's web functionality through standardized mappings and interfaces. + +- **Package**: `@revenuecat/purchases-js-hybrid-mappings` +- **Dependencies**: Built on top of `@revenuecat/purchases-js` +- **Purpose**: Bridge between purchases-js and hybrid implementations diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/CommonFunctionality.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/CommonFunctionality.swift new file mode 100644 index 00000000..07cb5d34 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/CommonFunctionality.swift @@ -0,0 +1,1237 @@ +// +// CommonFunctionality.swift +// PurchasesHybridCommon +// +// Created by Andrés Boedo on 4/19/22. +// Copyright © 2022 RevenueCat. All rights reserved. +// + +import Foundation +import StoreKit +@_spi(Internal) @_spi(Experimental) import RevenueCat + + +@objc(RCCommonFunctionality) public class CommonFunctionality: NSObject { + + typealias InstanceType = PurchasesType & PurchasesSwiftType + + static var sharedInstance: InstanceType { + get { + guard let purchases = Self._sharedInstance else { + fatalError("Purchases has not been configured. Please configure the SDK before calling this method") + } + + return purchases + } + set { + Self._sharedInstance = newValue + } + } + + private static var _sharedInstance: InstanceType? + + // MARK: properties and configuration + + @objc public static var simulatesAskToBuyInSandbox: Bool = false + @objc public static var appUserID: String { Self.sharedInstance.appUserID } + @objc public static var isAnonymous: Bool { Self.sharedInstance.isAnonymous } + @objc public static var hybridCommonVersion: String { Constants.hybridCommonVersion } + + @objc public static var proxyURLString: String? { + get { Purchases.proxyURL?.absoluteString } + set { + if let value = newValue { + let url: URL? + // Starting with iOS 17, URL(string:) returns a non-nil value from invalid URLs. + // So we use a new method to get the old behavior. + // Since the new method isn't recognized by older Xcodes, we use Swift 5.9 as a proxy for Xcode 15+. + // https://developer.apple.com/documentation/xcode-release-notes/xcode-15_0-release-notes + #if swift(>=5.9) + if #available(iOS 17.0, macCatalyst 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) { + url = URL(string: value, encodingInvalidCharacters: false) + } else { + url = URL(string: value) + } + #else + url = URL(string: value) + #endif + guard let proxyURL = url else { + fatalError("could not set the proxyURL, provided value is not a valid URL: \(value)") + } + Purchases.proxyURL = proxyURL + } else { + Purchases.proxyURL = nil + } + } + } + + @objc public var simulatesAskToBuyInSandbox: Bool { + get { + // all other platforms already support this feature + if #available(macOS 10.14, *) { + return Purchases.simulatesAskToBuyInSandbox + } else { + return false + } + } + set { + // all other platforms already support this feature + if #available(macOS 10.14, *) { + Purchases.simulatesAskToBuyInSandbox = newValue + } else { + NSLog("called setSimulatesAskToBuyInSandbox, but it's not available on this platform / OS version") + } + } + } + + private static var promoOffersByTimestamp: [String: PromotionalOffer] = [:] + private static var winBackOffersByID: [String: WinBackOffer] = [:] + + @available(*, deprecated, message: "Use the set functions instead") + @objc public static func setAllowSharingStoreAccount(_ allowSharingStoreAccount: Bool) { + Self.sharedInstance.allowSharingAppStoreAccount = allowSharingStoreAccount + } + + @available(*, deprecated, message: "Use setLogLevel instead") + @objc public static func setDebugLogsEnabled(_ enabled: Bool) { + Purchases.logLevel = enabled ? .debug : .info + } + + @objc public static func setLogLevel(_ level: String) { + guard let level = LogLevel.levelsByDescription[level] else { + NSLog("Unrecognized log level '\(level)'") + return + } + + Purchases.logLevel = level + } + + /** + * Sets a log handler and forwards all logs to completion function. + * + * - Parameter onLogReceived: Gets a map with two keys: `logLevel` (``LogLevel`` name uppercased), and `message` (the log message) + */ + @objc public static func setLogHander(onLogReceived: @escaping ([String: String]) -> Void) { + Purchases.logHandler = { logLevel, message in + let logDetails = [ + "logLevel": logLevel.description, + "message": message + ] + onLogReceived(logDetails) + } + } + + @objc public static func setTrackedEventListener(onEventReceived: @escaping ([String: Any]) -> Void) { + Purchases.shared.eventsListener = TrackedEventListener(callback: onEventReceived) + } + + @available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + @objc public static func enableAdServicesAttributionTokenCollection() { + Self.sharedInstance.attribution.enableAdServicesAttributionTokenCollection() + } + + @objc public static func setPurchasesAreCompletedBy(_ purchasesAreCompletedBy: String) { + if let actualPurchasesAreCompletedBy = PurchasesAreCompletedBy(name: purchasesAreCompletedBy) { + Self.sharedInstance.purchasesAreCompletedBy = actualPurchasesAreCompletedBy + } + } + + @objc public static func invalidateCustomerInfoCache() { + Self.sharedInstance.invalidateCustomerInfoCache() + } + + @objc public static func getStorefront(completion: @escaping ([String: Any]?) -> Void) { + Self.sharedInstance.getStorefront { storefront in + var storefrontMap: [String: Any]? = nil + if let storefront = storefront { + storefrontMap = ["identifier": storefront.identifier, "countryCode": storefront.countryCode] + } + completion(storefrontMap) + } + } + +#if os(iOS) + @available(iOS 14.0, *) + @available(tvOS, unavailable) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(macCatalyst, unavailable) + @objc public static func presentCodeRedemptionSheet() { + Self.sharedInstance.presentCodeRedemptionSheet() + } +#endif + + @objc public static func canMakePaymentsWithFeatures(_ features: [Int]) -> Bool { + // Features are for Google Play only, so we ignore them for iOS. + // See https://sdk.revenuecat.com/android/5.1.1/purchases/com.revenuecat.purchases/-purchases/-companion/can-make-payments.html + return Purchases.canMakePayments() + } +} + +// MARK: Refund request +@objc public extension CommonFunctionality { + +#if os(iOS) + @available(iOS 15.0, *) + @available(tvOS, unavailable) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @objc(beginRefundRequestProductId:completionBlock:) + static func beginRefundRequest(productId: String, + completion: @escaping (ErrorContainer?) -> Void) { + Self.sharedInstance.beginRefundRequest(forProduct: productId) { result in + Self.processRefundRequestResultWithCompletion(refundRequestResult: result, completion: completion) + } + } + + @available(iOS 15.0, *) + @available(tvOS, unavailable) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @objc(beginRefundRequestEntitlementId:completionBlock:) + static func beginRefundRequest(entitlementId: String, + completion: @escaping (ErrorContainer?) -> Void) { + Self.sharedInstance.beginRefundRequest(forEntitlement: entitlementId) { result in + Self.processRefundRequestResultWithCompletion(refundRequestResult: result, completion: completion) + } + } + + @available(iOS 15.0, *) + @available(tvOS, unavailable) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @objc(beginRefundRequestForActiveEntitlementCompletion:) + static func beginRefundRequestForActiveEntitlement(completion: @escaping (ErrorContainer?) -> Void) { + Self.sharedInstance.beginRefundRequestForActiveEntitlement { result in + Self.processRefundRequestResultWithCompletion(refundRequestResult: result, completion: completion) + } + } +#endif + +} + +#if os(iOS) || os(macOS) || VISION_OS + +// MARK: Manage subscriptions +@objc public extension CommonFunctionality { + + @available(iOS 13.0, macOS 10.15, *) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @objc(showManageSubscriptions:) + static func showManageSubscriptions(completion: @escaping (ErrorContainer?) -> Void) { + _ = Task { + do { + try await Self.sharedInstance.showManageSubscriptions() + completion(nil) + } catch { + completion(Self.createErrorContainer(error: error)) + } + } + } +} + +#endif + +// MARK: In app messages +@objc public extension CommonFunctionality { + +#if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + @available(iOS 16.0, *) + @available(tvOS, unavailable) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @objc(showStoreMessagesCompletion:) + static func showStoreMessages(completion: @escaping () -> Void) { + _ = Task { + await Self.sharedInstance.showStoreMessages(for: Set(StoreMessageType.allCases)) + completion() + } + } + + @available(iOS 16.0, *) + @available(tvOS, unavailable) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @objc(showStoreMessagesForTypes:completion:) + static func showStoreMessages(forRawValues rawValues: Set, + completion: @escaping () -> Void) { + let storeMessageTypes = rawValues.compactMap { number in + StoreMessageType(rawValue: number.intValue) + } + _ = Task { + await Self.sharedInstance.showStoreMessages(for: Set(storeMessageTypes)) + completion() + } + } +#endif + +} + +// MARK: purchasing and restoring +@objc public extension CommonFunctionality { + + @objc(restorePurchasesWithCompletionBlock:) + static func restorePurchases(completion: @escaping ([String: Any]?, ErrorContainer?) -> Void) { + let customerInfoCompletion = customerInfoCompletionBlock(from: completion) + Self.sharedInstance.restorePurchases(completion: customerInfoCompletion) + } + + @objc(syncPurchasesWithCompletionBlock:) + static func syncPurchases(completion: (([String: Any]?, ErrorContainer?) -> Void)?) { + if let completion = completion { + let customerInfoCompletion = customerInfoCompletionBlock(from: completion) + Self.sharedInstance.syncPurchases(completion: customerInfoCompletion) + } else { + Self.sharedInstance.syncPurchases(completion: nil) + } + } + + @objc(purchase:completionBlock:) + static func purchase(options: [String: Any], + completion: @escaping ([String: Any]?, ErrorContainer?) -> Void) { + do { + let validatedParams = try Self.validatePurchaseParams(options) + switch validatedParams.purchasableItem { + case let .package(packageIdentifier): + if let presentedOfferingContext = validatedParams.presentedOfferingContext { + if let winBackOfferID = validatedParams.winBackOfferID { + Self.purchase(package: packageIdentifier, + presentedOfferingContext: presentedOfferingContext, + winBackOfferID: winBackOfferID, + completion: completion) + } else { + Self.purchase(package: packageIdentifier, + presentedOfferingContext: presentedOfferingContext, + signedDiscountTimestamp: validatedParams.signedDiscountTimestamp, + completion: completion) + } + } else { + completion(nil, + Self.purchaseInvalidError("When purchasing a package, a presentedOfferingContext must be provided.")) + } + case let .product(productIdentifier): + if let winBackOfferID = validatedParams.winBackOfferID { + Self.purchase(product: productIdentifier, + winBackOfferID: winBackOfferID, + completion: completion) + } else { + Self.purchase(product: productIdentifier, + signedDiscountTimestamp: validatedParams.signedDiscountTimestamp, + completion: completion) + } + } + } catch { + completion(nil, Self.createErrorContainer(error: error)) + } + } + + @objc(purchaseProduct:signedDiscountTimestamp:completionBlock:) + static func purchase(product productIdentifier: String, + signedDiscountTimestamp: String?, + completion: @escaping ([String: Any]?, ErrorContainer?) -> Void) { + let hybridCompletion = Self.createPurchaseCompletionBlock(completion: completion) + + self.product(with: productIdentifier) { storeProduct in + guard let storeProduct = storeProduct else { + completion(nil, productNotFoundError(description: "Couldn't find product.", userCancelled: false)) + return + } + + if let signedDiscountTimestamp = signedDiscountTimestamp { + if #available(iOS 12.2, macOS 10.14.4, tvOS 12.2, *) { + guard let promotionalOffer = self.promoOffersByTimestamp[signedDiscountTimestamp] else { + completion(nil, productNotFoundError(description: "Couldn't find discount.", userCancelled: false)) + return + } + Self.sharedInstance.purchase(product: storeProduct, + promotionalOffer: promotionalOffer, + completion: hybridCompletion) + return + } + + } + + Self.sharedInstance.purchase(product: storeProduct, completion: hybridCompletion) + } + } + + @objc(purchasePackage:presentedOfferingContext:signedDiscountTimestamp:completionBlock:) + static func purchase(package packageIdentifier: String, + presentedOfferingContext: [String: Any], + signedDiscountTimestamp: String?, + completion: @escaping ([String: Any]?, ErrorContainer?) -> Void) { + let hybridCompletion = Self.createPurchaseCompletionBlock(completion: completion) + + Self.package( + withIdentifier: packageIdentifier, + presentedOfferingContext: Self.toPresentedOfferingContext(presentedOfferingContext: presentedOfferingContext) + ) { package in + guard let package = package else { + let error = productNotFoundError(description: "Couldn't find package", userCancelled: false) + completion(nil, error) + return + } + + if let signedDiscountTimestamp = signedDiscountTimestamp { + if #available(iOS 12.2, macOS 10.14.4, tvOS 12.2, *) { + guard let promotionalOffer = self.promoOffersByTimestamp[signedDiscountTimestamp] else { + completion(nil, productNotFoundError(description: "Couldn't find discount.", userCancelled: false)) + return + } + Self.sharedInstance.purchase(package: package, + promotionalOffer: promotionalOffer, + completion: hybridCompletion) + return + } + } + + Self.sharedInstance.purchase(package: package, completion: hybridCompletion) + } + + } + + @objc(makeDeferredPurchase:completionBlock:) + static func makeDeferredPurchase(_ startPurchase: StartPurchaseBlock, + completion: @escaping ([String: Any]?, ErrorContainer?) -> Void) { + startPurchase(Self.createPurchaseCompletionBlock(completion: completion)) + } + + private static func createPurchaseCompletionBlock( + completion: @escaping ([String: Any]?, ErrorContainer?) -> Void + ) -> @Sendable (StoreTransaction?, + CustomerInfo?, + Error?, + Bool) -> Void { + return { transaction, customerInfo, error, userCancelled in + if let error = error { + completion(nil, Self.createErrorContainer(error: error, userCancelled: userCancelled)) + } else if let customerInfo = customerInfo, + let transaction = transaction { + completion([ + "customerInfo": customerInfo.dictionary, + "productIdentifier": transaction.productIdentifier, + "transaction": transaction.dictionary + ], nil) + } else { + completion( + nil, + ErrorContainer(error: ErrorCode.unknownError as NSError, extraPayload: [:]) + ) + } + } + } + +} + +// MARK: identity +@objc public extension CommonFunctionality { + + @objc(logInWithAppUserID:completionBlock:) + static func logIn(appUserID: String, completion: @escaping ([String: Any]?, ErrorContainer?) -> Void) { + Self.sharedInstance.logIn(appUserID) { customerInfo, created, error in + if let error = error { + completion(nil, ErrorContainer(error: error, extraPayload: [:])) + } else if let customerInfo = customerInfo { + completion([ + "customerInfo": customerInfo.dictionary, + "created": created + ], nil) + } else { + let error = ErrorCode.unknownError as NSError + completion(nil, ErrorContainer(error: error, extraPayload: [:])) + } + } + } + + @objc(logOutWithCompletionBlock:) + static func logOut(completion: @escaping ([String: Any]?, ErrorContainer?) -> Void) { + Self.sharedInstance.logOut(completion: customerInfoCompletionBlock(from: completion)) + } + + @objc(getCustomerInfoWithCompletionBlock:) + static func customerInfo(completion: @escaping ([String: Any]?, ErrorContainer?) -> Void) { + Self.customerInfo(fetchPolicy: .default, completion: completion) + } + + internal static func customerInfo( + fetchPolicy: CacheFetchPolicy, + completion: @escaping ([String: Any]?, ErrorContainer?) -> Void + ) { + Self.sharedInstance.getCustomerInfo(fetchPolicy: fetchPolicy, + completion: customerInfoCompletionBlock(from: completion)) + } + +} + +// MARK: offerings and eligibility +@objc public extension CommonFunctionality { + + @objc(getOfferingsWithCompletionBlock:) + static func getOfferings(completion: @escaping ([String: Any]?, ErrorContainer?) -> Void) { + Self.sharedInstance.getOfferings { offerings, error in + if let error = error { + let errorContainer = ErrorContainer(error: error, extraPayload: [:]) + completion(nil, errorContainer) + } else { + completion(offerings?.dictionary, nil) + } + } + } + + @objc(getCurrentOfferingForPlacement:completionBlock:) + static func getCurrentOffering( + forPlacement placementIdentifier: String, + completion: @escaping ([String: Any]?, ErrorContainer?) -> Void + ) { + Self.sharedInstance.getOfferings { offerings, error in + if let error = error { + let errorContainer = ErrorContainer(error: error, extraPayload: [:]) + completion(nil, errorContainer) + } else { + let offering = offerings?.currentOffering(forPlacement: placementIdentifier) + let dict = offering?.dictionary + completion(dict, nil) + } + } + } + + @objc(syncAttributesAndOfferingsIfNeededWithCompletionBlock:) + static func syncAttributesAndOfferingsIfNeeded(completion: @escaping ([String: Any]?, ErrorContainer?) -> Void) { + Self.sharedInstance.syncAttributesAndOfferingsIfNeeded { offerings, error in + if let error = error { + let errorContainer = ErrorContainer(error: error, extraPayload: [:]) + completion(nil, errorContainer) + } else { + completion(offerings?.dictionary, nil) + } + } + } + + @objc(checkTrialOrIntroductoryPriceEligibility:completionBlock:) + static func checkTrialOrIntroductoryPriceEligibility( + for products: [String], + completion: @escaping([String: Any]) -> Void) { + Self.sharedInstance.checkTrialOrIntroDiscountEligibility(productIdentifiers: products) { eligibilityByProductId in + completion(eligibilityByProductId.mapValues { [ + "status": $0.status.rawValue, + "description": $0.description + ] as [String: Any] + }) + } + } + + @objc static func getProductInfo(_ productIds: [String], completionBlock: @escaping([[String: Any]]) -> Void) { + Self.sharedInstance.getProducts(productIds) { products in + let productDictionaries = products + .map { $0.rc_dictionary } + completionBlock(productDictionaries) + } + } + + @objc(promotionalOfferForProductIdentifier:discount:completionBlock:) + static func promotionalOffer(for productIdentifier: String, + discountIdentifier: String?, + completion: @escaping ([String: Any]?, ErrorContainer?) -> Void) { + guard #available(iOS 12.2, macOS 10.14.4, tvOS 12.2, *) else { + completion( + nil, + Self.createErrorContainer(error: ErrorCode.unsupportedError) + ) + return + } + + product(with: productIdentifier) { storeProduct in + guard let storeProduct = storeProduct else { + completion(nil, productNotFoundError(description: "Couldn't find product", userCancelled: false)) + return + } + + guard let discountToUse = self.discount(with: discountIdentifier, for: storeProduct) else { + completion(nil, productNotFoundError(description: "Couldn't find discount", userCancelled: false)) + return + } + + let promotionalOfferCompletion: (PromotionalOffer?, Error?) -> Void = { promotionalOffer, error in + guard let promotionalOffer = promotionalOffer else { + completion( + nil, + ErrorContainer(error: error ?? ErrorCode.unknownError as NSError, + extraPayload: [:]) + ) + return + } + promoOffersByTimestamp["\(promotionalOffer.signedData.timestamp)"] = promotionalOffer + completion(promotionalOffer.rc_dictionary, nil) + } + + Self.sharedInstance.getPromotionalOffer(forProductDiscount: discountToUse, + product: storeProduct, + completion: promotionalOfferCompletion) + } + } + +} + +// MARK: StoreKit 2 Observer Mode +@objc public extension CommonFunctionality { + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + @objc(recordPurchaseForProductID:completion:) + static func recordPurchase(productID: String, completion: (([String: Any]?, ErrorContainer?) -> Void)?) { + _ = Task { + let result = await StoreKit.Transaction.latest(for: productID) + if let result = result { + do { + let transaction = try await Self.sharedInstance.recordPurchase(.success(result)) + completion?(transaction?.dictionary, nil) + } catch { + completion?(nil, ErrorContainer(error: error, extraPayload: [:])) + } + } else { + completion?(nil, transactionNotFoundError( + description: "Couldn't find transaction for product ID '\(productID)'.", + userCancelled: false + )) + } + } + } + +} + +// MARK: Subscriber attributes +@objc public extension CommonFunctionality { + + @objc static func setAttributes(_ attributes: [String: Any]) { + Self.sharedInstance.attribution.setAttributes(attributes.mapValues { $0 as? String ?? "" }) + } + + @objc static func setEmail(_ email: String?) { + Self.sharedInstance.attribution.setEmail(email) + } + + @objc static func setPhoneNumber(_ phoneNumber: String?) { + Self.sharedInstance.attribution.setPhoneNumber(phoneNumber) + } + + @objc static func setDisplayName(_ displayName: String?) { + Self.sharedInstance.attribution.setDisplayName(displayName) + } + + @objc static func setPushToken(_ pushToken: String?) { + Self.sharedInstance.attribution.setPushTokenString(pushToken) + } + +} + +// MARK: Attribution IDs +@objc public extension CommonFunctionality { + + @objc static func collectDeviceIdentifiers() { + Self.sharedInstance.attribution.collectDeviceIdentifiers() + } + @objc static func setAdjustID(_ adjustID: String?) { + Self.sharedInstance.attribution.setAdjustID(adjustID) + } + @objc static func setCleverTapID(_ cleverTapID: String?) { + Self.sharedInstance.attribution.setCleverTapID(cleverTapID) + } + @objc static func setAppsflyerID(_ appsflyerID: String?) { + Self.sharedInstance.attribution.setAppsflyerID(appsflyerID) + } + @objc static func setFBAnonymousID(_ fbAnonymousID: String?) { + Self.sharedInstance.attribution.setFBAnonymousID(fbAnonymousID) + } + @objc static func setMparticleID(_ mParticleID: String?) { + Self.sharedInstance.attribution.setMparticleID(mParticleID) + } + @objc static func setMixpanelDistinctID(_ mixpanelDistinctID: String?) { + Self.sharedInstance.attribution.setMixpanelDistinctID(mixpanelDistinctID) + } + @objc static func setFirebaseAppInstanceID(_ firebaseAppInstanceID: String?) { + Self.sharedInstance.attribution.setFirebaseAppInstanceID(firebaseAppInstanceID) + } + @objc static func setTenjinAnalyticsInstallationID(_ tenjinAnalyticsInstallationID: String?) { + Self.sharedInstance.attribution.setTenjinAnalyticsInstallationID(tenjinAnalyticsInstallationID) + } + @objc static func setKochavaDeviceID(_ kochavaDeviceID: String?) { + Self.sharedInstance.attribution.setKochavaDeviceID(kochavaDeviceID) + } + @objc static func setOnesignalID(_ onesignalID: String?) { + Self.sharedInstance.attribution.setOnesignalID(onesignalID) + } + @objc static func setOnesignalUserID(_ onesignalUserID: String?) { + Self.sharedInstance.attribution.setOnesignalUserID(onesignalUserID) + } + @objc static func setAirshipChannelID(_ airshipChannelID: String?) { + Self.sharedInstance.attribution.setAirshipChannelID(airshipChannelID) + } + @objc static func setPostHogUserID(_ postHogUserId: String?) { + Self.sharedInstance.attribution.setPostHogUserID(postHogUserId) + } + @objc static func setAirbridgeDeviceID(_ airbridgeDeviceID: String?) { + Self.sharedInstance.attribution.setAirbridgeDeviceID(airbridgeDeviceID) + } + +} + +// MARK: Campaign parameters +@objc public extension CommonFunctionality { + + @objc static func setMediaSource(_ mediaSource: String?) { + Self.sharedInstance.attribution.setMediaSource(mediaSource) + } + @objc static func setCampaign(_ campaign: String?) { + Self.sharedInstance.attribution.setCampaign(campaign) + } + @objc static func setAdGroup(_ adGroup: String?) { + Self.sharedInstance.attribution.setAdGroup(adGroup) + } + @objc static func setAd(_ ad: String?) { + Self.sharedInstance.attribution.setAd(ad) + } + @objc static func setKeyword(_ keyword: String?) { + Self.sharedInstance.attribution.setKeyword(keyword) + } + @objc static func setCreative(_ creative: String?) { + Self.sharedInstance.attribution.setCreative(creative) + } + +} + +// MARK: - Ad Tracking +@objc public extension CommonFunctionality { + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + @objc static func trackAdDisplayed(_ adData: [String: Any]) { + guard let mediatorNameString = adData["mediatorName"] as? String, + let adFormatString = adData["adFormat"] as? String, + let adUnitId = adData["adUnitId"] as? String, + let impressionId = adData["impressionId"] as? String else { + NSLog("[PurchasesHybridCommon] trackAdDisplayed: Missing required parameters - mediatorName, adFormat, adUnitId, or impressionId") + return + } + + let networkName = adData["networkName"] as? String + let placement = adData["placement"] as? String + let mediatorName = MediatorName(rawValue: mediatorNameString) + let adFormat = AdFormat(rawValue: adFormatString) + let adDisplayed = AdDisplayed( + networkName: networkName, + mediatorName: mediatorName, + adFormat: adFormat, + placement: placement, + adUnitId: adUnitId, + impressionId: impressionId + ) + + Purchases.shared.adTracker.trackAdDisplayed(adDisplayed) + } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + @objc static func trackAdOpened(_ adData: [String: Any]) { + guard let mediatorNameString = adData["mediatorName"] as? String, + let adFormatString = adData["adFormat"] as? String, + let adUnitId = adData["adUnitId"] as? String, + let impressionId = adData["impressionId"] as? String else { + NSLog("[PurchasesHybridCommon] trackAdOpened: Missing required parameters - mediatorName, adFormat, adUnitId, or impressionId") + return + } + + let networkName = adData["networkName"] as? String + let placement = adData["placement"] as? String + let mediatorName = MediatorName(rawValue: mediatorNameString) + let adFormat = AdFormat(rawValue: adFormatString) + let adOpened = AdOpened( + networkName: networkName, + mediatorName: mediatorName, + adFormat: adFormat, + placement: placement, + adUnitId: adUnitId, + impressionId: impressionId + ) + + Purchases.shared.adTracker.trackAdOpened(adOpened) + } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + @objc static func trackAdRevenue(_ adData: [String: Any]) { + guard let mediatorNameString = adData["mediatorName"] as? String, + let adFormatString = adData["adFormat"] as? String, + let adUnitId = adData["adUnitId"] as? String, + let impressionId = adData["impressionId"] as? String, + let revenueMicros = adData["revenueMicros"] as? Int, + let currency = adData["currency"] as? String, + let precisionString = adData["precision"] as? String else { + NSLog("[PurchasesHybridCommon] trackAdRevenue: Missing required parameters - mediatorName, adFormat, adUnitId, impressionId, revenueMicros, currency, or precision") + return + } + + let networkName = adData["networkName"] as? String + let placement = adData["placement"] as? String + let mediatorName = MediatorName(rawValue: mediatorNameString) + let adFormat = AdFormat(rawValue: adFormatString) + let precision = AdRevenue.Precision(rawValue: precisionString) + let adRevenue = AdRevenue( + networkName: networkName, + mediatorName: mediatorName, + adFormat: adFormat, + placement: placement, + adUnitId: adUnitId, + impressionId: impressionId, + revenueMicros: revenueMicros, + currency: currency, + precision: precision + ) + + Purchases.shared.adTracker.trackAdRevenue(adRevenue) + } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + @objc static func trackAdLoaded(_ adData: [String: Any]) { + guard let mediatorNameString = adData["mediatorName"] as? String, + let adFormatString = adData["adFormat"] as? String, + let adUnitId = adData["adUnitId"] as? String, + let impressionId = adData["impressionId"] as? String else { + NSLog("[PurchasesHybridCommon] trackAdLoaded: Missing required parameters - mediatorName, adFormat, adUnitId, or impressionId") + return + } + + let networkName = adData["networkName"] as? String + let placement = adData["placement"] as? String + let mediatorName = MediatorName(rawValue: mediatorNameString) + let adFormat = AdFormat(rawValue: adFormatString) + let adLoaded = AdLoaded( + networkName: networkName, + mediatorName: mediatorName, + adFormat: adFormat, + placement: placement, + adUnitId: adUnitId, + impressionId: impressionId + ) + + Purchases.shared.adTracker.trackAdLoaded(adLoaded) + } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + @objc static func trackAdFailedToLoad(_ adData: [String: Any]) { + guard let mediatorNameString = adData["mediatorName"] as? String, + let adFormatString = adData["adFormat"] as? String, + let adUnitId = adData["adUnitId"] as? String else { + NSLog("[PurchasesHybridCommon] trackAdFailedToLoad: Missing required parameters - mediatorName, adFormat, or adUnitId") + return + } + + let placement = adData["placement"] as? String + let mediatorErrorCode = adData["mediatorErrorCode"] as? NSNumber + let mediatorName = MediatorName(rawValue: mediatorNameString) + let adFormat = AdFormat(rawValue: adFormatString) + let adFailedToLoad = AdFailedToLoad( + mediatorName: mediatorName, + adFormat: adFormat, + placement: placement, + adUnitId: adUnitId, + mediatorErrorCode: mediatorErrorCode + ) + + Purchases.shared.adTracker.trackAdFailedToLoad(adFailedToLoad) + } + +} + +// MARK: - Win-Back Offers +@objc public extension CommonFunctionality { + + /// Fetches and caches the eligible win-back offers for a given product identifier. + /// + /// - Parameters: + /// - productIdentifier: The identifier of the product to fetch win-back offers for. + /// - completion: A closure that receives an array of win-back offer dictionaries or an error container if something went wrong. + @objc(eligibleWinBackOffersForProductIdentifier:completionBlock:) + static func eligibleWinBackOffers( + for productIdentifier: String, + completion: @escaping ([[String: Any]]?, ErrorContainer?) -> Void + ) { + guard #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) else { + completion( + nil, + Self.createErrorContainer(error: ErrorCode.unsupportedError) + ) + return + } + + // Fetch the product + product(with: productIdentifier) { storeProduct in + guard let storeProduct = storeProduct else { + completion(nil, productNotFoundError(description: "Couldn't find product", userCancelled: false)) + return + } + + // Fetch the eligible win-back offers for the product + Self.sharedInstance.eligibleWinBackOffers(forProduct: storeProduct) { eligibleWinBackOffers, error in + if let error = error { + completion( + nil, + Self.createErrorContainer(error: error) + ) + return + } + + guard let eligibleWinBackOffers = eligibleWinBackOffers else { + completion( + [], + nil + ) + return + } + + // Cache the win-back offers + for winBackOffer in eligibleWinBackOffers { + if let winBackOfferIdentifier = winBackOffer.discount.offerIdentifier { + self.winBackOffersByID[winBackOfferIdentifier] = winBackOffer + } + } + + // Return the win-back offers + let winBackDictionaries: [[String: Any]] = eligibleWinBackOffers.map { winBackOffer in + winBackOffer.rc_dictionary // Returns the discount dictionary + } + + completion(winBackDictionaries, nil) + } + } + } + + @objc(purchaseProduct:winBackOfferID:completionBlock:) + static func purchase(product productIdentifier: String, + winBackOfferID: String, + completion: @escaping ([String: Any]?, ErrorContainer?) -> Void) { + let hybridCompletion = Self.createPurchaseCompletionBlock(completion: completion) + + self.product(with: productIdentifier) { storeProduct in + guard let storeProduct = storeProduct else { + completion(nil, productNotFoundError(description: "Couldn't find product.", userCancelled: false)) + return + } + + if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) { + guard let winBackOffer: WinBackOffer = self.winBackOffersByID[winBackOfferID] else { + completion( + nil, + productNotFoundError(description: "Couldn't find win-back offer.", userCancelled: false) + ) + return + } + + let purchaseParams = PurchaseParams.Builder(product: storeProduct) + .with(winBackOffer: winBackOffer) + .build() + + Self.sharedInstance.purchase(purchaseParams, completion: hybridCompletion) + return + } + + Self.sharedInstance.purchase(product: storeProduct, completion: hybridCompletion) + } + } + + @objc(purchasePackage:presentedOfferingContext:winBackOfferID:completionBlock:) + static func purchase(package packageIdentifier: String, + presentedOfferingContext: [String: Any], + winBackOfferID: String, + completion: @escaping ([String: Any]?, ErrorContainer?) -> Void) { + let hybridCompletion = Self.createPurchaseCompletionBlock(completion: completion) + + Self.package( + withIdentifier: packageIdentifier, + presentedOfferingContext: Self.toPresentedOfferingContext(presentedOfferingContext: presentedOfferingContext) + ) { package in + guard let package = package else { + let error = productNotFoundError(description: "Couldn't find package", userCancelled: false) + completion(nil, error) + return + } + + if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) { + guard let winBackOffer: WinBackOffer = self.winBackOffersByID[winBackOfferID] else { + completion( + nil, + productNotFoundError(description: "Couldn't find win-back offer.", userCancelled: false) + ) + return + } + + let purchaseParams = PurchaseParams.Builder(package: package) + .with(winBackOffer: winBackOffer) + .build() + + Self.sharedInstance.purchase(purchaseParams, completion: hybridCompletion) + return + } + + Self.sharedInstance.purchase(package: package, completion: hybridCompletion) + } + } +} + +// MARK: - Redemption links +@objc public extension CommonFunctionality { + + @objc static func parseAsWebPurchaseRedemption(urlString: String) -> WebPurchaseRedemption? { + guard let url = URL(string: urlString) else { + return nil + } + return url.asWebPurchaseRedemption + } + + @objc(isWebPurchaseRedemptionURL:) + static func isWebPurchaseRedemptionURL(urlString: String) -> Bool { + guard let url = URL(string: urlString) else { return false } + + return url.asWebPurchaseRedemption != nil + } + + @objc static func redeemWebPurchase(urlString: String, + completion: @escaping ([String: Any]?, ErrorContainer?) -> Void) { + guard let url = URL(string: urlString), let webPurchaseRedemption = url.asWebPurchaseRedemption else { + completion(nil, Self.createErrorContainer(error: ErrorCode.unsupportedError)) + return + } + + _ = Task { + let result = await Self.sharedInstance.redeemWebPurchase(webPurchaseRedemption) + var resultMap: [String: Any] = ["result": result.resultName] + switch (result) { + case let .success(customerInfo): + resultMap["customerInfo"] = customerInfo.dictionary + case let .error(error): + resultMap["error"] = Self.createErrorContainer(error: error) + case let .expired(obfuscatedEmail): + resultMap["obfuscatedEmail"] = obfuscatedEmail + case .purchaseBelongsToOtherUser, .invalidToken: + // Do nothing + break + } + completion(resultMap, nil) + } + } + +} + +private extension WebPurchaseRedemptionResult { + + var resultName: String { + switch self { + case .success: return "SUCCESS" + case .error: return "ERROR" + case .purchaseBelongsToOtherUser: return "PURCHASE_BELONGS_TO_OTHER_USER" + case .invalidToken: return "INVALID_TOKEN" + case .expired: return "EXPIRED" + } + } +} + +// MARK: - Virtual Currencies +@objc public extension CommonFunctionality { + + @objc static func getVirtualCurrencies( + completion: @escaping ([String: Any]?, ErrorContainer?) -> Void + ) { + Self.sharedInstance.getVirtualCurrencies { virtualCurrencies, error in + if let error = error { + completion(nil, Self.createErrorContainer(error: error)) + } else if let virtualCurrencies = virtualCurrencies { + completion(virtualCurrencies.rc_dictionary, nil) + } else { + completion( + nil, + ErrorContainer(error: ErrorCode.unknownError as NSError, extraPayload: [:]) + ) + } + } + } + + @objc static func getCachedVirtualCurrencies() -> [String: Any]? { + return Self.sharedInstance.cachedVirtualCurrencies?.rc_dictionary + } + + @objc static func invalidateVirtualCurrenciesCache() { + Self.sharedInstance.invalidateVirtualCurrenciesCache() + } + + @objc static func overridePreferredLocale(_ locale: String?) { + Purchases.shared.overridePreferredUILocale(locale) + } +} + +private extension CommonFunctionality { + + static func customerInfoCompletionBlock(from block: @escaping ([String: Any]?, ErrorContainer?) -> Void) + -> ((CustomerInfo?, Error?) -> Void) { + return { customerInfo, error in + if let error = error { + let errorContainer = ErrorContainer(error: error, extraPayload: [:]) + block(nil, errorContainer) + } else if let customerInfo = customerInfo { + block(customerInfo.dictionary, nil) + } else { + block(nil, ErrorContainer(error: ErrorCode.unknownError as NSError, extraPayload: [:])) + } + } + + } + + static func product(with identifier: String, completion: @escaping (StoreProduct?) -> Void) { + Self.sharedInstance.getProducts([identifier]) { products in + completion(products.first { $0.productIdentifier == identifier }) + } + } + + static func productNotFoundError(description: String, userCancelled: Bool?) -> ErrorContainer { + let error = NSError(domain: ErrorCode.errorDomain, + code: ErrorCode.productNotAvailableForPurchaseError.rawValue, + userInfo: [NSLocalizedDescriptionKey: description]) + return Self.createErrorContainer(error: error, userCancelled: userCancelled) + } + + static func purchaseInvalidError(_ description: String) -> ErrorContainer { + let error = NSError(domain: ErrorCode.errorDomain, + code: ErrorCode.purchaseInvalidError.rawValue, + userInfo: [NSLocalizedDescriptionKey: description]) + return Self.createErrorContainer(error: error) + } + + static func toPresentedOfferingContext(presentedOfferingContext: [String: Any?]?) -> PresentedOfferingContext? { + guard let presentedOfferingContext, let offeringIdentifier = presentedOfferingContext["offeringIdentifier"] as? String else { + return nil + } + + let placementIdentifier = presentedOfferingContext["placementIdentifier"] as? String + + let targetingContext: PresentedOfferingContext.TargetingContext? + + if let dict = presentedOfferingContext["targetingContext"] as? [String: Any?], + let revision = dict["revision"] as? Int, + let ruleId = dict["ruleId"] as? String { + targetingContext = .init(revision: revision, ruleId: ruleId) + } else { + targetingContext = nil + } + + return PresentedOfferingContext( + offeringIdentifier: offeringIdentifier, + placementIdentifier: placementIdentifier, + targetingContext: targetingContext + ) + } + + static func package(withIdentifier packageIdentifier: String, + presentedOfferingContext: PresentedOfferingContext?, + completion: @escaping(Package?) -> Void) { + guard let presentedOfferingContext else { + return completion(nil) + } + + Self.sharedInstance.getOfferings { offerings, error in + let offering = offerings?.offering(identifier: presentedOfferingContext.offeringIdentifier) + let foundPackage = offering?.package(identifier: packageIdentifier) + + let package = foundPackage.flatMap { pkg in + Package( + identifier: pkg.identifier, + packageType: pkg.packageType, + storeProduct: pkg.storeProduct, + presentedOfferingContext: presentedOfferingContext, + webCheckoutUrl: pkg.webCheckoutUrl + ) + } + + completion(package) + } + } + + @available(iOS 12.2, macOS 10.14.4, tvOS 12.2, *) + static func discount(with identifier: String?, for product: StoreProduct) -> StoreProductDiscount? { + if identifier == nil { + return product.discounts.first + } else { + return product.discounts.first { $0.offerIdentifier == identifier } + } + } + + static func transactionNotFoundError(description: String, userCancelled: Bool?) -> ErrorContainer { + let error = NSError(domain: ErrorCode.errorDomain, + code: ErrorCode.unknownError.rawValue, + userInfo: [NSLocalizedDescriptionKey: description]) + return Self.createErrorContainer(error: error, userCancelled: userCancelled) + } + + static func processRefundRequestResultWithCompletion( + refundRequestResult: Result, + completion: @escaping (ErrorContainer?) -> Void + ) { + switch refundRequestResult { + case let .success(refundRequestStatus): + switch refundRequestStatus { + case .success: + completion(nil) + case .userCancelled: + completion(Self.refundRequestError(description: "User cancelled refund request.", userCancelled: true)) + case .error: + completion(Self.refundRequestError(description: "Error during refund request.")) + } + case let .failure(error): + completion(ErrorContainer(error: error, extraPayload: [:])) + } + } + + static func refundRequestError(description: String, userCancelled: Bool? = nil) -> ErrorContainer { + let error = NSError(domain: ErrorCode.errorDomain, + code: ErrorCode.beginRefundRequestError.rawValue, + userInfo: [NSLocalizedDescriptionKey: description]) + return Self.createErrorContainer(error: error, userCancelled: userCancelled) + } + + static func createErrorContainer(error: Error, userCancelled: Bool? = nil) -> ErrorContainer { + var extraPayload: [String: Any] = [:] + if let userCancelled = userCancelled { + extraPayload["userCancelled"] = userCancelled + } + + return ErrorContainer(error: error, extraPayload: extraPayload) + } + +} + +// MARK: - Encoding + +@objc public extension CommonFunctionality { + + // Note: see https://github.com/RevenueCat/purchases-hybrid-common/pull/485 + // `CustomerInfo.dictionary` can't be made an `@objc public` method while supporting iOS < 13.0 + @objc(encodeCustomerInfo:) + static func encode(customerInfo: CustomerInfo) -> [String: Any] { + return customerInfo.dictionary + } + +} + +// MARK: - TrackedEventListener + +private class TrackedEventListener: EventsListener { + + private let callback: ([String: Any]) -> Void + + init(callback: @escaping ([String: Any]) -> Void) { + self.callback = callback + } + + func onEventTracked(_ event: [String: Any]) { + DispatchQueue.main.async { [callback] in + callback(event) + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/CommonPurchaseParams.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/CommonPurchaseParams.swift new file mode 100644 index 00000000..07469755 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/CommonPurchaseParams.swift @@ -0,0 +1,52 @@ +// +// CommonPurchaseParams.swift +// PurchasesHybridCommon +// +// Created by Antonio Rico Diez on 17/9/25. +// Copyright © 2025 RevenueCat. All rights reserved. +// + +import RevenueCat + +enum PurchasableItem { + case product(productIdentifier: String) + case package(packageIdentifier: String) +} + +struct CommonPurchaseParams { + let purchasableItem: PurchasableItem + let signedDiscountTimestamp: String? + let winBackOfferID: String? + let presentedOfferingContext: [String: Any]? +} + +extension CommonFunctionality { + + static func validatePurchaseParams(_ options: [String: Any]) throws -> CommonPurchaseParams { + let packageIdentifier = options["packageIdentifier"] as? String + let productIdentifier = options["productIdentifier"] as? String + + let presentedOfferingContext = options["presentedOfferingContext"] as? [String: Any] + let signedDiscountTimestamp = options["signedDiscountTimestamp"] as? String + let winBackOfferID = options["winBackOfferID"] as? String + + let purchasableItem: PurchasableItem + + if let packageIdentifier = packageIdentifier { + purchasableItem = .package(packageIdentifier: packageIdentifier) + } else if let productIdentifier = productIdentifier { + purchasableItem = .product(productIdentifier: productIdentifier) + } else { + throw PublicError(domain: ErrorCode.errorDomain, + code: ErrorCode.purchaseInvalidError.rawValue) + } + + return CommonPurchaseParams( + purchasableItem: purchasableItem, + signedDiscountTimestamp: signedDiscountTimestamp, + winBackOfferID: winBackOfferID, + presentedOfferingContext: presentedOfferingContext + ) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/Constants.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/Constants.swift new file mode 100644 index 00000000..48913274 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/Constants.swift @@ -0,0 +1,13 @@ +// +// Constants.swift +// PurchasesHybridCommon +// +// Created by Jay Shortway on 19/06/2024. +// Copyright © 2024 RevenueCat. All rights reserved. +// + +import Foundation + +internal struct Constants { + static let hybridCommonVersion = "17.41.1" +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/CustomerInfo+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/CustomerInfo+HybridAdditions.swift new file mode 100644 index 00000000..b20d6dda --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/CustomerInfo+HybridAdditions.swift @@ -0,0 +1,57 @@ +// +// CustomerInfo+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by Andrés Boedo on 4/13/22. +// Copyright © 2022 RevenueCat. All rights reserved. +// + +import Foundation +import RevenueCat + +public extension CustomerInfo { + + var dictionary: [String: Any] { + let sortedProductIdentifiers = allPurchasedProductIdentifiers.sorted() + + var allExpirations: [String: Any] = [:] + var allExpirationsMillis: [String: Any] = [:] + + var allPurchases: [String: Any] = [:] + var allPurchasesMillis: [String: Any] = [:] + + for identifier in sortedProductIdentifiers { + let expirationDate = expirationDate(forProductIdentifier:identifier) + allExpirations[identifier] = expirationDate?.rc_formattedAsISO8601() ?? NSNull() + allExpirationsMillis[identifier] = expirationDate?.rc_millisecondsSince1970AsDouble() ?? NSNull() + + let purchaseDate = purchaseDate(forProductIdentifier: identifier) + allPurchases[identifier] = purchaseDate?.rc_formattedAsISO8601() ?? NSNull() + allPurchasesMillis[identifier] = purchaseDate?.rc_millisecondsSince1970AsDouble() ?? NSNull() + } + + return [ + "entitlements": entitlements.dictionary, + "activeSubscriptions": Array(activeSubscriptions), + "allPurchasedProductIdentifiers": Array(allPurchasedProductIdentifiers), + "latestExpirationDate": latestExpirationDate?.rc_formattedAsISO8601() ?? NSNull(), + "latestExpirationDateMillis": latestExpirationDate?.rc_millisecondsSince1970AsDouble() ?? NSNull(), + "firstSeen": firstSeen.rc_formattedAsISO8601(), + "firstSeenMillis": firstSeen.rc_millisecondsSince1970AsDouble(), + "originalAppUserId": originalAppUserId, + "requestDate": requestDate.rc_formattedAsISO8601(), + "requestDateMillis": requestDate.rc_millisecondsSince1970AsDouble(), + "allExpirationDates": allExpirations, + "allExpirationDatesMillis": allExpirationsMillis, + "allPurchaseDates": allPurchases, + "allPurchaseDatesMillis": allPurchasesMillis, + "originalApplicationVersion": originalApplicationVersion ?? NSNull(), + "originalPurchaseDate": originalPurchaseDate?.rc_formattedAsISO8601() ?? NSNull(), + "originalPurchaseDateMillis": originalPurchaseDate?.rc_millisecondsSince1970AsDouble() ?? NSNull(), + "managementURL": managementURL?.absoluteString ?? NSNull(), + "nonSubscriptionTransactions": nonSubscriptions.map { $0.dictionary }, + "subscriptionsByProductIdentifier": subscriptionsByProductIdentifier.mapValues { $0.dictionary } + ] + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/EntitlementInfo+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/EntitlementInfo+HybridAdditions.swift new file mode 100644 index 00000000..2601f445 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/EntitlementInfo+HybridAdditions.swift @@ -0,0 +1,39 @@ +// +// EntitlementInfo+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by Andrés Boedo on 4/13/22. +// Copyright © 2022 RevenueCat. All rights reserved. +// + +import Foundation +import RevenueCat + +internal extension EntitlementInfo { + + var dictionary: [String: Any] { + return [ + "identifier": identifier, + "isActive": isActive, + "willRenew": willRenew, + "periodType": periodType.periodTypeString, + "latestPurchaseDate": latestPurchaseDate?.rc_formattedAsISO8601() ?? NSNull(), + "latestPurchaseDateMillis": latestPurchaseDate?.rc_millisecondsSince1970AsDouble() ?? NSNull(), + "originalPurchaseDate": originalPurchaseDate?.rc_formattedAsISO8601() ?? NSNull(), + "originalPurchaseDateMillis": originalPurchaseDate?.rc_millisecondsSince1970AsDouble() ?? NSNull(), + "expirationDate": expirationDate?.rc_formattedAsISO8601() ?? NSNull(), + "expirationDateMillis": expirationDate?.rc_millisecondsSince1970AsDouble() ?? NSNull(), + "store": store.storeString, + "productIdentifier": productIdentifier, + "productPlanIdentifier": productPlanIdentifier ?? NSNull(), + "isSandbox": isSandbox, + "unsubscribeDetectedAt": unsubscribeDetectedAt?.rc_formattedAsISO8601() ?? NSNull(), + "unsubscribeDetectedAtMillis": unsubscribeDetectedAt?.rc_millisecondsSince1970AsDouble() ?? NSNull(), + "billingIssueDetectedAt": billingIssueDetectedAt?.rc_formattedAsISO8601() ?? NSNull(), + "billingIssueDetectedAtMillis": billingIssueDetectedAt?.rc_millisecondsSince1970AsDouble() ?? NSNull(), + "ownershipType": ownershipType.ownershipTypeString, + "verification": verification.name + ] + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/EntitlementInfos+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/EntitlementInfos+HybridAdditions.swift new file mode 100644 index 00000000..e9176116 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/EntitlementInfos+HybridAdditions.swift @@ -0,0 +1,35 @@ +// +// EntitlementInfos+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by Andrés Boedo on 4/13/22. +// Copyright © 2022 RevenueCat. All rights reserved. +// + +import Foundation +import RevenueCat + +internal extension EntitlementInfos { + + var dictionary: [String: Any] { + return [ + "all": all.mapValues { $0.dictionary }, + "active": active.mapValues { $0.dictionary }, + "verification": verification.name + ] + } + + +} + +internal extension VerificationResult { + + var name: String { + switch self { + case .notRequested: return "NOT_REQUESTED" + case .verified: return "VERIFIED" + case .verifiedOnDevice: return "VERIFIED_ON_DEVICE" + case .failed: return "FAILED" + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/EntitlementVerificationMode+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/EntitlementVerificationMode+HybridAdditions.swift new file mode 100644 index 00000000..1b124173 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/EntitlementVerificationMode+HybridAdditions.swift @@ -0,0 +1,35 @@ +// +// EntitlementVerificationMode+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by Nacho Soto on 6/27/23. +// + +import RevenueCat + +extension Configuration.EntitlementVerificationMode { + + var name: String { + switch self { + case .disabled: return "DISABLED" + case .informational: return "INFORMATIONAL" + case .enforced: return "ENFORCED" + } + } + + init?(name: String) { + if let mode = Self.modesByName[name] { + self = mode + } else { + return nil + } + } + + private static let modesByName: [String: Self] = Dictionary(uniqueKeysWithValues: [ + Self.disabled, + Self.informational, + // Disabled temporarily since enforced is not available yet. + // Self.enforced + ].map { ($0.name, $0) }) + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/Enums+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/Enums+HybridAdditions.swift new file mode 100644 index 00000000..ce6d9e51 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/Enums+HybridAdditions.swift @@ -0,0 +1,81 @@ +// +// Enum+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by Toni Rico on 3/3/25. +// Copyright © 2025 RevenueCat. All rights reserved. +// + +import Foundation +import RevenueCat + +internal extension PeriodType { + + var periodTypeString: String { + switch self { + case .intro: + return "INTRO" + case .normal: + return "NORMAL" + case .trial: + return "TRIAL" + case .prepaid: + return "PREPAID" + @unknown default: + return "UNKNOWN" + } + } + +} + +internal extension PurchaseOwnershipType { + + var ownershipTypeString: String { + switch self { + case .familyShared: + return "FAMILY_SHARED" + case .unknown: + return "UNKNOWN" + case .purchased: + return "PURCHASED" + @unknown default: + return "UNKNOWN" + } + } + +} + +internal extension Store { + + var storeString: String { + switch self { + case .appStore: + return "APP_STORE" + case .macAppStore: + return "MAC_APP_STORE" + case .playStore: + return "PLAY_STORE" + case .promotional: + return "PROMOTIONAL" + case .unknownStore: + return "UNKNOWN_STORE" + case .amazon: + return "AMAZON" + case .stripe: + return "STRIPE" + case .rcBilling: + return "RC_BILLING" + case .external: + return "EXTERNAL" + case .paddle: + return "PADDLE" + case .testStore: + return "TEST_STORE" + case .galaxy: + return "GALAXY" + @unknown default: + return "UNKNOWN_STORE" + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/ErrorContainer.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/ErrorContainer.swift new file mode 100644 index 00000000..15ac9c72 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/ErrorContainer.swift @@ -0,0 +1,78 @@ +// +// ErrorContainer.swift +// PurchasesHybridCommon +// +// Created by Andrés Boedo on 4/13/22. +// Copyright © 2022 RevenueCat. All rights reserved. +// + +import Foundation +import RevenueCat + +@objc(RCErrorContainer) public class ErrorContainer: NSObject { + + @objc public let code: Int + @objc public let message: String + @objc public let info: [String: Any] + @objc public let error: NSError + + @objc public init(error: Error, extraPayload: [String: Any]) { + var nsError = error as NSError + + var info = extraPayload + info["code"] = nsError.code + info["message"] = nsError.localizedDescription + + let underlyingErrorMessage = (nsError.userInfo[NSUnderlyingErrorKey] as? NSError)?.localizedDescription + + info["underlyingErrorMessage"] = underlyingErrorMessage ?? "" + + if let storeKitError = ErrorContainer.findStoreKitErrorCodeIfAny(nsError) { + info["storeError"] = [ + "code": storeKitError.code, + "domain": storeKitError.domain, + "message": storeKitError.localizedDescription + ] + } + + // todo: remove "readable_error_code" and instead send whole user info instead + // also: code name is already exposed as error.code + if let readableErrorCode = nsError.userInfo["readable_error_code"] { + info["readableErrorCode"] = readableErrorCode + info["readable_error_code"] = readableErrorCode + + // Reason behind this is because React Native doesn't let reject the promises passing more information + // besides passing the original error, but it passes the extra userInfo from that error to the JS layer. + // Since we want to pass both readable_error_code (deprecated) and readableErrorCode when building the + // error JS object, and the error coming from purchases-ios only has the snake case version, we need to + // add readableErrorCode to the userInfo of the error. In a future project, we will remove the + // deprecated version and also improve error handling so it's easier to detect which errors come + // from RevenueCat and which don't + + var fixedUserInfo = nsError.userInfo + fixedUserInfo["readableErrorCode"] = readableErrorCode + + nsError = NSError(domain: nsError.domain, code: nsError.code, userInfo: fixedUserInfo) + } + + self.code = nsError.code + self.message = nsError.localizedDescription + self.error = nsError + + self.info = info + } + + private static func findStoreKitErrorCodeIfAny(_ error: Error) -> NSError? { + var currentError: NSError? = error as NSError + var storeKitError: NSError? + while let underlyingNSError = currentError?.userInfo[NSUnderlyingErrorKey] as? NSError { + if !underlyingNSError.domain.starts(with: "RevenueCat") { + storeKitError = underlyingNSError + break + } else { + currentError = underlyingNSError + } + } + return storeKitError + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/FatalErrorUtil.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/FatalErrorUtil.swift new file mode 100644 index 00000000..a7d783cd --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/FatalErrorUtil.swift @@ -0,0 +1,28 @@ +// +// FatalErrorUtil.swift +// PurchasesHybridCommon +// +// Created by Andrés Boedo on 4/20/22. +// Copyright © 2022 RevenueCat. All rights reserved. +// + +import Foundation + +enum FatalErrorUtil { + + fileprivate static var fatalErrorClosure: (String, StaticString, UInt) -> Never = defaultFatalErrorClosure + + private static let defaultFatalErrorClosure = { Swift.fatalError($0, file: $1, line: $2) } + + static func replaceFatalError(closure: @escaping (String, StaticString, UInt) -> Never) { + fatalErrorClosure = closure + } + + static func restoreFatalError() { + fatalErrorClosure = defaultFatalErrorClosure + } +} + +func fatalError(_ message: @autoclosure () -> String = "", file: StaticString = #fileID, line: UInt = #line) -> Never { + FatalErrorUtil.fatalErrorClosure(message(), file, line) +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/IOSAPIAvailabilityChecker.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/IOSAPIAvailabilityChecker.swift new file mode 100644 index 00000000..bcb0828c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/IOSAPIAvailabilityChecker.swift @@ -0,0 +1,60 @@ +// +// IOSAPIAvailabilityChecker.swift +// PurchasesHybridCommon +// +// Created by Will Taylor on 1/9/25. +// Copyright © 2025 RevenueCat. All rights reserved. +// + +import Foundation + +/// A utility class that checks the availability of iOS-specific APIs based on the operating system version. +@objc +public final class IOSAPIAvailabilityChecker: NSObject { + + /// Determines if the Win-Back Offer APIs are available on the current device. + /// + /// Note: This only checks if the APIs are available in the current OS version, + /// not if the SDK is using StoreKit 2, which is required for the APIs. + /// + /// - Returns: `true` if the Win-Back Offer APIs are available, `false` otherwise. + @objc + public func isWinBackOfferAPIAvailable() -> Bool { + if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) { + return true + } else { + return false + } + } + + /// Determines if the enableAdServicesAttributionTokenCollection API is available on the current device. + /// + /// - Returns: `true` if the ``CommonFunctionality/enableAdServicesAttributionTokenCollection()`` API is available, + /// `false` otherwise. + @objc + public func isEnableAdServicesAttributionTokenCollectionAPIAvailable() -> Bool { + #if os(tvOS) || os(watchOS) + return false + #else + if #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) { + return true + } else { + return false + } + #endif + } + + /// Determines if the Ad Tracking APIs are available on the current device. + /// + /// - Returns: `true` if the Ad Tracking APIs (trackAdDisplayed, trackAdOpened, etc.) are available, + /// `false` otherwise. + @objc + public func isAdTrackingAPIAvailable() -> Bool { + if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) { + return true + } else { + return false + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/IntroEligibility+HybridExtensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/IntroEligibility+HybridExtensions.swift new file mode 100644 index 00000000..9202588c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/IntroEligibility+HybridExtensions.swift @@ -0,0 +1,21 @@ +// +// IntroEligibility+HybridExtensions.swift +// PurchasesHybridCommon +// +// Created by Jay Shortway on 04/02/2025. +// Copyright © 2025 RevenueCat. All rights reserved. +// + +import Foundation +import RevenueCat + +@objc +public extension IntroEligibility { + + // IntroEligibility isn't exposed as a part of the PHC's public API to KMP by default. + // We can expose it by adding this function to the IntroEligibility class. This function is intentionally a no-op + // and shouldn't be called anywhere but also shouldn't be removed without ensuring that the IntroEligibility + // class is exposed to KMP in some other way. + @objc + func noOP() {} +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/NSDate+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/NSDate+HybridAdditions.swift new file mode 100644 index 00000000..93ef9282 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/NSDate+HybridAdditions.swift @@ -0,0 +1,33 @@ +// +// NSDate+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by Andrés Boedo on 4/13/22. +// Copyright © 2022 RevenueCat. All rights reserved. +// + +import Foundation + +extension Date { + + func rc_formattedAsISO8601() -> String { + return Self.stringFromDate(self) + } + + func rc_millisecondsSince1970AsDouble() -> Double { + return self.timeIntervalSince1970 * 1000.0 + } + +} + +private extension Date { + + static func stringFromDate(_ date: Date) -> String { + return Self.formatter.string(from: date) + } + + private static let formatter: ISO8601DateFormatter = { + return ISO8601DateFormatter() + }() + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/NonSubscriptionTransaction+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/NonSubscriptionTransaction+HybridAdditions.swift new file mode 100644 index 00000000..56019c59 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/NonSubscriptionTransaction+HybridAdditions.swift @@ -0,0 +1,27 @@ +// +// NonSubscriptionTransaction+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by NachoSoto on 7/4/22. +// Copyright © 2022 RevenueCat. All rights reserved. +// + +import Foundation +import RevenueCat + +internal extension NonSubscriptionTransaction { + + var dictionary: [String: Any] { + return [ + "transactionIdentifier": self.transactionIdentifier, + // Deprecated: Use transactionIdentifier in this map instead + "revenueCatId": self.transactionIdentifier, + "productIdentifier": self.productIdentifier, + // Deprecated: Use productIdentifier in this map instead + "productId": self.productIdentifier, + "purchaseDateMillis": self.purchaseDate.rc_millisecondsSince1970AsDouble(), + "purchaseDate": self.purchaseDate.rc_formattedAsISO8601() + ] + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/Offering+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/Offering+HybridAdditions.swift new file mode 100644 index 00000000..446785bb --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/Offering+HybridAdditions.swift @@ -0,0 +1,48 @@ +// +// Offering+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by Andrés Boedo on 4/13/22. +// Copyright © 2022 RevenueCat. All rights reserved. +// + +import Foundation +import RevenueCat + +@objc public extension Offering { + + @objc var dictionary: [String: Any] { + var result: [String: Any] = [ + "identifier": identifier, + "serverDescription": serverDescription, + "metadata": metadata, + "availablePackages": availablePackages.map { $0.dictionary(identifier) }, + "webCheckoutUrl": webCheckoutUrl?.absoluteString ?? NSNull() + ] + + if let lifetime = lifetime { + result["lifetime"] = lifetime.dictionary(identifier) + } + if let annual = annual { + result["annual"] = annual.dictionary(identifier) + } + if let sixMonth = sixMonth { + result["sixMonth"] = sixMonth.dictionary(identifier) + } + if let threeMonth = threeMonth { + result["threeMonth"] = threeMonth.dictionary(identifier) + } + if let twoMonth = twoMonth { + result["twoMonth"] = twoMonth.dictionary(identifier) + } + if let monthly = monthly { + result["monthly"] = monthly.dictionary(identifier) + } + if let weekly = weekly { + result["weekly"] = weekly.dictionary(identifier) + } + + return result + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/Offerings+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/Offerings+HybridAdditions.swift new file mode 100644 index 00000000..d89fcdf8 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/Offerings+HybridAdditions.swift @@ -0,0 +1,32 @@ +// +// Offerings+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by Andrés Boedo on 4/13/22. +// Copyright © 2022 RevenueCat. All rights reserved. +// + +import Foundation +import RevenueCat + +@objc public extension Offerings { + + // Re-exports currentOfferingForPlacement function for use in hybrids. + + @objc func currentOfferingForPlacement(_ placementIdentifier: String) -> Offering? { + return self.currentOffering(forPlacement: placementIdentifier) + } + +} + +internal extension Offerings { + + var dictionary: [String: Any] { + var result: [String: Any] = ["all": all.mapValues { $0.dictionary }] + if let current = current { + result["current"] = current.dictionary + } + + return result + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/Package+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/Package+HybridAdditions.swift new file mode 100644 index 00000000..55385eec --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/Package+HybridAdditions.swift @@ -0,0 +1,81 @@ +// +// Package+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by Andrés Boedo on 4/13/22. +// Copyright © 2022 RevenueCat. All rights reserved. +// + +import Foundation +import RevenueCat + +public extension PresentedOfferingContext { + + var dictionary: [String: Any] { + return [ + "offeringIdentifier": self.offeringIdentifier, + "placementIdentifier": self.placementIdentifier ?? NSNull(), + "targetingContext": self.targetingContext?.dictionary ?? NSNull() + ] + } + +} + +public extension PresentedOfferingContext.TargetingContext { + + var dictionary: [String: Any] { + return [ + "revision": self.revision, + "ruleId": self.ruleId + ] + } + +} + +public extension Package { + + var dictionary: [String: Any] { + return dictionary(offeringIdentifier) + } + + func dictionary(_ offeringIdentifier: String) -> [String: Any] { + return [ + "identifier": identifier, + "packageType": packageType.name, + "product": storeProduct.rc_dictionary, + "offeringIdentifier": offeringIdentifier, + "presentedOfferingContext": presentedOfferingContext.dictionary, + "webCheckoutUrl": webCheckoutUrl?.absoluteString ?? NSNull() + ] + } + +} + +private extension PackageType { + + var name: String { + switch self { + case .unknown: + return "UNKNOWN" + case .custom: + return "CUSTOM" + case .lifetime: + return "LIFETIME" + case .annual: + return "ANNUAL" + case .sixMonth: + return "SIX_MONTH" + case .threeMonth: + return "THREE_MONTH" + case .twoMonth: + return "TWO_MONTH" + case .monthly: + return "MONTHLY" + case .weekly: + return "WEEKLY" + @unknown default: + return "UNKNOWN" + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/PrivacyInfo.xcprivacy b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..5d540cee --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/PrivacyInfo.xcprivacy @@ -0,0 +1,21 @@ + + + + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + + + NSPrivacyTracking + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/PromotionalOffer+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/PromotionalOffer+HybridAdditions.swift new file mode 100644 index 00000000..4eef256f --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/PromotionalOffer+HybridAdditions.swift @@ -0,0 +1,24 @@ +// +// PromotionalOffer+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by Andrés Boedo on 4/13/22. +// Copyright © 2022 RevenueCat. All rights reserved. +// + +import Foundation +import RevenueCat + +@objc public extension PromotionalOffer { + + var rc_dictionary: [String: Any] { + return [ + "identifier": self.signedData.identifier, + "keyIdentifier": self.signedData.keyIdentifier, + "nonce": self.signedData.nonce.uuidString, + "signature": self.signedData.signature, + "timestamp": self.signedData.timestamp + ] + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/PurchaseParamsBuilder+HybridExtensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/PurchaseParamsBuilder+HybridExtensions.swift new file mode 100644 index 00000000..1d0511a0 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/PurchaseParamsBuilder+HybridExtensions.swift @@ -0,0 +1,21 @@ +// +// PurchaseParamsBuilder+HybridExtensions.swift +// PurchasesHybridCommon +// +// Created by Will Taylor on 1/23/25. +// Copyright © 2025 RevenueCat. All rights reserved. +// + +import Foundation +import RevenueCat + +@objc +public extension PurchaseParams.Builder { + + // PurchaseParams.Builder isn't exposed as a part of the PHC's public API to KMP by default. + // We can expose it by adding this function to the PurchaseParams.Builder class. This function is intentionally a no-op + // and shouldn't be called anywhere but also shouldn't be removed without ensuring that the PurchaseParams.Builder + // class is exposed to KMP in some other way. + @objc + func noOP() {} +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/Purchases+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/Purchases+HybridAdditions.swift new file mode 100644 index 00000000..840d707a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/Purchases+HybridAdditions.swift @@ -0,0 +1,226 @@ +// +// Purchases+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by Andrés Boedo on 4/13/22. +// Copyright © 2022 RevenueCat. All rights reserved. +// + +import Foundation +import RevenueCat + +@objc public extension Purchases { + + @objc(configureWithAPIKey:appUserID:purchasesAreCompletedBy:userDefaultsSuiteName:platformFlavor: + platformFlavorVersion:storeKitVersion:dangerousSettings:shouldShowInAppMessagesAutomatically: + verificationMode:diagnosticsEnabled:automaticDeviceIdentifierCollectionEnabled:preferredLocale:) + static func configure(apiKey: String, + appUserID: String?, + purchasesAreCompletedBy: String?, + userDefaultsSuiteName: String?, + platformFlavor: String?, + platformFlavorVersion: String?, + storeKitVersion: String = "DEFAULT", + dangerousSettings: DangerousSettings?, + shouldShowInAppMessagesAutomatically: Bool = true, + verificationMode: String?, + diagnosticsEnabled: Bool = false, + automaticDeviceIdentifierCollectionEnabled: Bool = true, + preferredLocale: String? = nil) -> Purchases { + var userDefaults: UserDefaults? + if let userDefaultsSuiteName = userDefaultsSuiteName { + userDefaults = UserDefaults(suiteName: userDefaultsSuiteName) + guard userDefaults != nil else { + fatalError("Could not create an instance of UserDefaults with suite name \(userDefaultsSuiteName)") + } + } + + var configurationBuilder: Configuration.Builder = .init(withAPIKey: apiKey) + if let appUserID = appUserID { + configurationBuilder = configurationBuilder.with(appUserID: appUserID) + } + + let storeKitVersion = StoreKitVersion(name: storeKitVersion) ?? .default + configurationBuilder = configurationBuilder.with(storeKitVersion: storeKitVersion) + + if let purchasesAreCompletedBy = purchasesAreCompletedBy { + if let actualPurchasesAreCompletedBy = PurchasesAreCompletedBy(name: purchasesAreCompletedBy) { + configurationBuilder = configurationBuilder.with( + purchasesAreCompletedBy: actualPurchasesAreCompletedBy, + storeKitVersion: storeKitVersion + ) + } + } + + if let userDefaults = userDefaults { + configurationBuilder = configurationBuilder.with(userDefaults: userDefaults) + } + if let dangerousSettings = dangerousSettings { + configurationBuilder = configurationBuilder.with(dangerousSettings: dangerousSettings) + } + if let platformFlavor = platformFlavor, let platformFlavorVersion = platformFlavorVersion { + let platformInfo = Purchases.PlatformInfo(flavor: platformFlavor, version: platformFlavorVersion) + configurationBuilder = configurationBuilder.with(platformInfo: platformInfo) + } + configurationBuilder = configurationBuilder.with(showStoreMessagesAutomatically: + shouldShowInAppMessagesAutomatically) + + if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) { + configurationBuilder = configurationBuilder.with(diagnosticsEnabled: diagnosticsEnabled) + } + + if let verificationMode { + if let mode = Configuration.EntitlementVerificationMode(name: verificationMode) { + if #available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) { + configurationBuilder = configurationBuilder.with(entitlementVerificationMode: mode) + } + } else { + NSLog("Attempted to configure with unknown verification mode: '\(verificationMode)'") + } + } + + if let preferredLocale { + configurationBuilder = configurationBuilder.with(preferredUILocaleOverride: preferredLocale) + } + + let purchases = self.configure(with: configurationBuilder.build()) + CommonFunctionality.sharedInstance = purchases + + return purchases + } + + @available(*, deprecated, message: "Use the full configure method instead") + @objc(configureWithAPIKey:appUserID:purchasesAreCompletedBy:userDefaultsSuiteName:platformFlavor: + platformFlavorVersion:storeKitVersion:dangerousSettings:shouldShowInAppMessagesAutomatically: + verificationMode:diagnosticsEnabled:automaticDeviceIdentifierCollectionEnabled:) + static func configure(apiKey: String, + appUserID: String?, + purchasesAreCompletedBy: String?, + userDefaultsSuiteName: String?, + platformFlavor: String?, + platformFlavorVersion: String?, + storeKitVersion: String = "DEFAULT", + dangerousSettings: DangerousSettings?, + shouldShowInAppMessagesAutomatically: Bool = true, + verificationMode: String?, + diagnosticsEnabled: Bool = false, + automaticDeviceIdentifierCollectionEnabled: Bool = true) -> Purchases { + return configure(apiKey: apiKey, + appUserID: appUserID, + purchasesAreCompletedBy: purchasesAreCompletedBy, + userDefaultsSuiteName: userDefaultsSuiteName, + platformFlavor: platformFlavor, + platformFlavorVersion: platformFlavorVersion, + storeKitVersion: storeKitVersion, + dangerousSettings: dangerousSettings, + shouldShowInAppMessagesAutomatically: shouldShowInAppMessagesAutomatically, + verificationMode: verificationMode, + diagnosticsEnabled: diagnosticsEnabled, + automaticDeviceIdentifierCollectionEnabled: automaticDeviceIdentifierCollectionEnabled, + preferredLocale: nil) + } + + @available(*, deprecated, message: "Use the full configure method instead") + @objc(configureWithAPIKey:appUserID:purchasesAreCompletedBy:userDefaultsSuiteName:platformFlavor: + platformFlavorVersion:storeKitVersion:dangerousSettings:shouldShowInAppMessagesAutomatically: + verificationMode:diagnosticsEnabled:) + static func configure(apiKey: String, + appUserID: String?, + purchasesAreCompletedBy: String?, + userDefaultsSuiteName: String?, + platformFlavor: String?, + platformFlavorVersion: String?, + storeKitVersion: String = "DEFAULT", + dangerousSettings: DangerousSettings?, + shouldShowInAppMessagesAutomatically: Bool = true, + verificationMode: String?, + diagnosticsEnabled: Bool = false) -> Purchases { + return configure(apiKey: apiKey, + appUserID: appUserID, + purchasesAreCompletedBy: purchasesAreCompletedBy, + userDefaultsSuiteName: userDefaultsSuiteName, + platformFlavor: platformFlavor, + platformFlavorVersion: platformFlavorVersion, + dangerousSettings: dangerousSettings, + shouldShowInAppMessagesAutomatically: shouldShowInAppMessagesAutomatically, + verificationMode: verificationMode, + diagnosticsEnabled: diagnosticsEnabled, + automaticDeviceIdentifierCollectionEnabled: true) + } + + @available(*, deprecated, message: "Use the full configure method instead") + @objc(configureWithAPIKey:appUserID:purchasesAreCompletedBy:userDefaultsSuiteName:platformFlavor: + platformFlavorVersion:storeKitVersion:dangerousSettings:shouldShowInAppMessagesAutomatically: + verificationMode:) + static func configure(apiKey: String, + appUserID: String?, + purchasesAreCompletedBy: String?, + userDefaultsSuiteName: String?, + platformFlavor: String?, + platformFlavorVersion: String?, + storeKitVersion: String = "DEFAULT", + dangerousSettings: DangerousSettings?, + shouldShowInAppMessagesAutomatically: Bool = true, + verificationMode: String?) -> Purchases { + return configure(apiKey: apiKey, + appUserID: appUserID, + purchasesAreCompletedBy: purchasesAreCompletedBy, + userDefaultsSuiteName: userDefaultsSuiteName, + platformFlavor: platformFlavor, + platformFlavorVersion: platformFlavorVersion, + storeKitVersion: storeKitVersion, + dangerousSettings: dangerousSettings, + shouldShowInAppMessagesAutomatically: shouldShowInAppMessagesAutomatically, + verificationMode: verificationMode, + diagnosticsEnabled: false) + } + + @available(*, deprecated, message: "Use the full configure method instead") + @objc(configureWithAPIKey:appUserID:purchasesAreCompletedBy:userDefaultsSuiteName:platformFlavor: + platformFlavorVersion:storeKitVersion:dangerousSettings:shouldShowInAppMessagesAutomatically:) + static func configure(apiKey: String, + appUserID: String?, + purchasesAreCompletedBy: String?, + userDefaultsSuiteName: String?, + platformFlavor: String?, + platformFlavorVersion: String?, + storeKitVersion: String = "DEFAULT", + dangerousSettings: DangerousSettings?, + shouldShowInAppMessagesAutomatically: Bool = true) -> Purchases { + return configure(apiKey: apiKey, + appUserID: appUserID, + purchasesAreCompletedBy: purchasesAreCompletedBy, + userDefaultsSuiteName: userDefaultsSuiteName, + platformFlavor: platformFlavor, + platformFlavorVersion: platformFlavorVersion, + storeKitVersion: storeKitVersion, + dangerousSettings: dangerousSettings, + shouldShowInAppMessagesAutomatically: shouldShowInAppMessagesAutomatically, + verificationMode: nil) + } +} + +extension LogLevel { + + static let levels: Set = [ + .verbose, + .debug, + .info, + .warn, + .error, + ] + + static let levelsByDescription: [String: LogLevel] = .init( + uniqueKeysWithValues: LogLevel.levels.map { ($0.description, $0) } + ) +} + +// MARK: - Deprecations + +protocol ConfigurationBuilderDeprecatable { + + func with(usesStoreKit2IfAvailable: Bool) -> Configuration.Builder + +} + +extension Configuration.Builder: ConfigurationBuilderDeprecatable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/PurchasesAreCompletedBy+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/PurchasesAreCompletedBy+HybridAdditions.swift new file mode 100644 index 00000000..5c78807f --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/PurchasesAreCompletedBy+HybridAdditions.swift @@ -0,0 +1,35 @@ +// +// PurchasesAreCompletedBy+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by Will Taylor on 7/26/24. +// Copyright © 2024 RevenueCat. All rights reserved. +// + +import Foundation +import RevenueCat + +public extension PurchasesAreCompletedBy { + + init?(name: String) { + if let mode = Self.valuesByName[name] { + self = mode + } else { + NSLog("Error: Unrecognized purchasesAreCompletedBy \(name)") + return nil + } + } + + var name: String { + switch self { + case .myApp: return "MY_APP" + case .revenueCat: return "REVENUECAT" + } + } + + private static let valuesByName: [String: Self] = [ + Self.myApp.name: Self.myApp, + Self.revenueCat.name: Self.revenueCat, + ] + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/PurchasesHybridCommon.h b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/PurchasesHybridCommon.h new file mode 100644 index 00000000..699e34ee --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/PurchasesHybridCommon.h @@ -0,0 +1,17 @@ +// +// PurchasesHybridCommon.h +// PurchasesHybridCommon +// +// Created by Andrés Boedo on 4/3/20. +// Copyright © 2020 RevenueCat. All rights reserved. +// + +#import + +//! Project version number for PurchasesHybridCommon. +FOUNDATION_EXPORT double PurchasesHybridCommonVersionNumber; + +//! Project version string for PurchasesHybridCommon. +FOUNDATION_EXPORT const unsigned char PurchasesHybridCommonVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/RefundRequestStatus+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/RefundRequestStatus+HybridAdditions.swift new file mode 100644 index 00000000..9c1281b7 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/RefundRequestStatus+HybridAdditions.swift @@ -0,0 +1,24 @@ +// +// RefundRequestStatus+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by Cesar de la Vega on 10/3/25. +// Copyright © 2025 RevenueCat. All rights reserved. +// + +import RevenueCat + +public extension RefundRequestStatus { + + var name: String { + switch self { + case .userCancelled: + return "USER_CANCELLED" + case .success: + return "SUCCESS" + case .error: + return "ERROR" + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/StoreKitVersion+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/StoreKitVersion+HybridAdditions.swift new file mode 100644 index 00000000..00b4b851 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/StoreKitVersion+HybridAdditions.swift @@ -0,0 +1,34 @@ +// +// StoreKitVersion+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by mark on 19/2/24. +// Copyright © 2024 RevenueCat. All rights reserved. +// + +import RevenueCat + +public extension StoreKitVersion { + + var name: String { + switch self { + case .storeKit1: return "STOREKIT_1" + case .storeKit2: return "STOREKIT_2" + } + } + + init?(name: String) { + if let mode = Self.modesByName[name] { + self = mode + } else { + return nil + } + } + + private static let modesByName: [String: Self] = [ + "DEFAULT": Self.default, + Self.storeKit1.name: Self.storeKit1, + Self.storeKit2.name: Self.storeKit2, + ] + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/StoreProduct+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/StoreProduct+HybridAdditions.swift new file mode 100644 index 00000000..883e5541 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/StoreProduct+HybridAdditions.swift @@ -0,0 +1,154 @@ +// +// SKProduct+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by Andrés Boedo on 4/13/22. +// Copyright © 2022 RevenueCat. All rights reserved. +// + +import Foundation +import RevenueCat +import StoreKit + +@objc public extension StoreProduct { + + // Re-exports price properties with different names to avoid recursion. + + @objc var priceAmount: NSDecimalNumber { + return self.priceDecimalNumber + } + + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + @objc var pricePerWeekAmount: NSDecimalNumber? { + return self.pricePerWeek + } + + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + @objc var pricePerMonthAmount: NSDecimalNumber? { + return self.pricePerMonth + } + + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + @objc var pricePerYearAmount: NSDecimalNumber? { + return self.pricePerYear + } + + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + @objc var pricePerWeekString: String? { + return self.localizedPricePerWeek + } + + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + @objc var pricePerMonthString: String? { + return self.localizedPricePerMonth + } + + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + @objc var pricePerYearString: String? { + return self.localizedPricePerYear + } + +} + +internal extension StoreProduct { + + var rc_dictionary: [String: Any] { + var dictionary: [String: Any] = [ + "currencyCode": self.currencyCode ?? NSNull(), + "description": self.localizedDescription, + "discounts": NSNull(), + "identifier": self.productIdentifier, + "introPrice": NSNull(), + "price": self.price, + "priceString": self.localizedPriceString, + "pricePerWeek": NSNull(), + "pricePerMonth": NSNull(), + "pricePerYear": NSNull(), + "pricePerWeekString": NSNull(), + "pricePerMonthString": NSNull(), + "pricePerYearString": NSNull(), + "productCategory": self.productCategoryString, + "productType": self.productTypeString, + "title": self.localizedTitle, + "subscriptionPeriod": NSNull(), + ] + + dictionary["pricePerWeek"] = self.pricePerWeek + dictionary["pricePerMonth"] = self.pricePerMonth + dictionary["pricePerYear"] = self.pricePerYear + dictionary["pricePerWeekString"] = self.localizedPricePerWeek + dictionary["pricePerMonthString"] = self.localizedPricePerMonth + dictionary["pricePerYearString"] = self.localizedPricePerYear + + if let introductoryDiscount = self.introductoryDiscount { + dictionary["introPrice"] = introductoryDiscount.rc_dictionary + } + + dictionary["discounts"] = self.discounts.map { $0.rc_dictionary } + + if let subscriptionPeriod = self.subscriptionPeriod { + dictionary["subscriptionPeriod"] = StoreProduct.rc_normalized(subscriptionPeriod: subscriptionPeriod) + } + + return dictionary + } + + static func rc_normalized(subscriptionPeriod: RevenueCat.SubscriptionPeriod) -> String { + let unitString: String + switch subscriptionPeriod.unit { + case .day: + unitString = "D" + case .week: + unitString = "W" + case .month: + unitString = "M" + case .year: + unitString = "Y" + @unknown default: + unitString = "-" + } + return "P\(subscriptionPeriod.value)\(unitString)" + } + + static func rc_normalized(subscriptionPeriodUnit: RevenueCat.SubscriptionPeriod.Unit) -> String { + switch subscriptionPeriodUnit { + case .day: + return "DAY" + case .week: + return "WEEK" + case .month: + return "MONTH" + case .year: + return "YEAR" + @unknown default: + return "-" + } + } + +} + +private extension StoreProduct { + + var productCategoryString: String { + switch self.productCategory { + case .nonSubscription: + return "NON_SUBSCRIPTION" + case .subscription: + return "SUBSCRIPTION" + } + } + + var productTypeString: String { + switch self.productType { + case .consumable: + return "CONSUMABLE" + case .nonConsumable: + return "NON_CONSUMABLE" + case .nonRenewableSubscription: + return "NON_RENEWABLE_SUBSCRIPTION" + case .autoRenewableSubscription: + return "AUTO_RENEWABLE_SUBSCRIPTION" + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/StoreProductDiscount+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/StoreProductDiscount+HybridAdditions.swift new file mode 100644 index 00000000..106205d2 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/StoreProductDiscount+HybridAdditions.swift @@ -0,0 +1,46 @@ +// +// SKProductDiscount+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by Andrés Boedo on 4/13/22. +// Copyright © 2022 RevenueCat. All rights reserved. +// + +import Foundation +import StoreKit +import RevenueCat + +@objc public extension StoreProductDiscount { + + // Re-exports price property with a different name to avoid recursion. + + @objc var priceAmount: NSDecimalNumber { + return self.priceDecimalNumber + } + +} + +internal extension StoreProductDiscount { + + var rc_currencyCode: String? { + return currencyCode + } + + var rc_dictionary: [String: Any] { + + var dictionary: [String: Any] = [ + "price": price, + "priceString": localizedPriceString, + "period": StoreProduct.rc_normalized(subscriptionPeriod: subscriptionPeriod), + "periodUnit": StoreProduct.rc_normalized(subscriptionPeriodUnit: subscriptionPeriod.unit), + "periodNumberOfUnits": subscriptionPeriod.value, + "cycles": numberOfPeriods + ] + + if offerIdentifier != nil { + dictionary["identifier"] = offerIdentifier + } + return dictionary + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/StoreTransaction+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/StoreTransaction+HybridAdditions.swift new file mode 100644 index 00000000..b2e7de1e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/StoreTransaction+HybridAdditions.swift @@ -0,0 +1,27 @@ +// +// StoreTransaction+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by Andrés Boedo on 4/13/22. +// Copyright © 2022 RevenueCat. All rights reserved. +// + +import Foundation +import RevenueCat + +public extension StoreTransaction { + + var dictionary: [String: Any] { + return [ + "transactionIdentifier": self.transactionIdentifier, + // Deprecated: Use transactionIdentifier in this map instead + "revenueCatId": self.transactionIdentifier, + "productIdentifier": self.productIdentifier, + // Deprecated: Use productIdentifier in this map instead + "productId": self.productIdentifier, + "purchaseDateMillis": self.purchaseDate.rc_millisecondsSince1970AsDouble(), + "purchaseDate": self.purchaseDate.rc_formattedAsISO8601() + ] + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/SubscriptionInfo+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/SubscriptionInfo+HybridAdditions.swift new file mode 100644 index 00000000..d6936530 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/SubscriptionInfo+HybridAdditions.swift @@ -0,0 +1,39 @@ +// +// SubscriptionInfo+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by Toni Rico on 3/3/25. +// Copyright © 2025 RevenueCat. All rights reserved. +// + +import Foundation +import RevenueCat + +internal extension SubscriptionInfo { + + var dictionary: [String: Any] { + var priceObject: [String: Any]? = nil + if let priceCurrency = price?.currency, let priceAmount = price?.amount { + priceObject = ["currency": priceCurrency, "amount": priceAmount] + } + return [ + "productIdentifier": productIdentifier, + "purchaseDate": purchaseDate.rc_formattedAsISO8601(), + "originalPurchaseDate": originalPurchaseDate?.rc_formattedAsISO8601() ?? NSNull(), + "expiresDate": expiresDate?.rc_formattedAsISO8601() ?? NSNull(), + "store": store.storeString, + "isSandbox": isSandbox, + "unsubscribeDetectedAt": unsubscribeDetectedAt?.rc_formattedAsISO8601() ?? NSNull(), + "billingIssuesDetectedAt": billingIssuesDetectedAt?.rc_formattedAsISO8601() ?? NSNull(), + "gracePeriodExpiresDate": gracePeriodExpiresDate?.rc_formattedAsISO8601() ?? NSNull(), + "ownershipType": ownershipType.ownershipTypeString, + "periodType": periodType.periodTypeString, + "refundedAt": refundedAt?.rc_formattedAsISO8601() ?? NSNull(), + "storeTransactionId": storeTransactionId ?? NSNull(), + "price": priceObject ?? NSNull(), + "isActive": isActive, + "willRenew": willRenew, + ] + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/VirtualCurrencies+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/VirtualCurrencies+HybridAdditions.swift new file mode 100644 index 00000000..1ad86d9e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/VirtualCurrencies+HybridAdditions.swift @@ -0,0 +1,19 @@ +// +// VirtualCurrencies+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by Will Taylor on 6/23/25. +// Copyright © 2025 RevenueCat. All rights reserved. +// + +import Foundation +import RevenueCat + +internal extension VirtualCurrencies { + + var rc_dictionary: [String: Any] { + return [ + "all": all.mapValues { $0.rc_dictionary }, + ] + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/VirtualCurrency+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/VirtualCurrency+HybridAdditions.swift new file mode 100644 index 00000000..082a47b4 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/VirtualCurrency+HybridAdditions.swift @@ -0,0 +1,23 @@ +// +// VirtualCurrency+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by Will Taylor on 3/27/25. +// Copyright © 2025 RevenueCat. All rights reserved. +// + +import Foundation +import RevenueCat + +internal extension VirtualCurrency { + + var rc_dictionary: [String: Any] { + return [ + "balance": self.balance, + "name": self.name, + "code": self.code, + "serverDescription": self.serverDescription ?? NSNull(), + ] + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/WinBackOffer+HybridAdditions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/WinBackOffer+HybridAdditions.swift new file mode 100644 index 00000000..058778c4 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/PurchasesHybridCommon/ios/PurchasesHybridCommon/PurchasesHybridCommon/WinBackOffer+HybridAdditions.swift @@ -0,0 +1,18 @@ +// +// WinBackOffer+HybridAdditions.swift +// PurchasesHybridCommon +// +// Created by Will Taylor on 11/18/24. +// Copyright © 2024 RevenueCat. All rights reserved. +// + +import Foundation +import RevenueCat + +@objc public extension WinBackOffer { + + var rc_dictionary: [String: Any] { + return self.discount.rc_dictionary + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/LICENSE b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/LICENSE new file mode 100644 index 00000000..1cd8dbef --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 RevenueCat, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/README.md b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/README.md new file mode 100644 index 00000000..8edf0d1a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/README.md @@ -0,0 +1,73 @@ +

😻 In-App Subscriptions Made Easy 😻

+ +[![License](https://img.shields.io/cocoapods/l/RevenueCat.svg?style=flat)](http://cocoapods.org/pods/RevenueCat) +[![Version](https://img.shields.io/cocoapods/v/RevenueCat.svg?style=flat)](https://cocoapods.org/pods/RevenueCat) +[![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://docs.revenuecat.com/docs/ios#section-install-via-carthage) +[![SwiftPM compatible](https://img.shields.io/badge/SwiftPM-compatible-orange.svg)](https://docs.revenuecat.com/docs/ios#section-install-via-swift-package-manager) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FRevenueCat%2Fpurchases-ios%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/RevenueCat/purchases-ios) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FRevenueCat%2Fpurchases-ios%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/RevenueCat/purchases-ios) + +RevenueCat is a powerful, reliable, and free to use in-app purchase server with cross-platform support. Our open-source framework provides a backend and a wrapper around StoreKit and Google Play Billing to make implementing in-app purchases and subscriptions easy. + +Whether you are building a new app or already have millions of customers, you can use RevenueCat to: + + * Fetch products, make purchases, and check subscription status with our [native SDKs](https://docs.revenuecat.com/docs/installation). + * Host and [configure products](https://docs.revenuecat.com/docs/entitlements) remotely from our dashboard. + * Analyze the most important metrics for your app business [in one place](https://docs.revenuecat.com/docs/charts). + * See customer transaction histories, chart lifetime value, and [grant promotional subscriptions](https://www.revenuecat.com/docs/dashboard-and-metrics/customer-history/promotionals). + * Get notified of real-time events through [webhooks](https://docs.revenuecat.com/docs/webhooks). + * Send enriched purchase events to analytics and attribution tools with our easy integrations. + +Sign up to [get started for free](https://app.revenuecat.com/signup). + +## RevenueCat.framework + +*RevenueCat* is the client for the [RevenueCat](https://www.revenuecat.com/) subscription and purchase tracking system. It's 100% `Swift` and compatible with `Objective-C`. + +## Migrating from Purchases v4 to v5 +- See our [Migration guide](https://revenuecat.github.io/purchases-ios-docs/v5_api_migration_guide.html) + +## Migrating from Purchases v3 to v4 +- See our [Migration guide](https://revenuecat.github.io/purchases-ios-docs/v4_api_migration_guide.html) + +## RevenueCat SDK Features +| | RevenueCat | +| --- | --- | +✅ | Server-side receipt validation +➡️ | [Webhooks](https://docs.revenuecat.com/docs/webhooks) - enhanced server-to-server communication with events for purchases, renewals, cancellations, and more +🖥 | iOS, tvOS, macOS, watchOS, Mac Catalyst, and visionOS support +🎯 | Subscription status tracking - know whether a user is subscribed whether they're on iOS, Android or web +📊 | Analytics - automatic calculation of metrics like conversion, mrr, and churn +📝 | [Online documentation](https://docs.revenuecat.com/docs) and [SDK Reference](http://revenuecat.github.io/purchases-ios-docs/) up to date +🔀 | [Integrations](https://www.revenuecat.com/integrations) - over a dozen integrations to easily send purchase data where you need it +💯 | Well maintained - [frequent releases](https://github.com/RevenueCat/purchases-ios/releases) +📮 | Great support - [Contact us](https://revenuecat.com/support) + +## Getting Started +For more detailed information, you can view our complete documentation at [docs.revenuecat.com](https://docs.revenuecat.com/docs). + +Please follow the [Quickstart Guide](https://docs.revenuecat.com/docs/) for more information on how to install the SDK. + +> [!TIP] +> When integrating with SPM, it is recommended to add the SPM mirror repository for faster download/integration times: https://github.com/RevenueCat/purchases-ios-spm + +Or view our iOS sample apps: +- [MagicWeather](Examples/MagicWeather) +- [MagicWeather SwiftUI](Examples/MagicWeatherSwiftUI) + +## Requirements +- Xcode 15.0+ + +| Platform | Minimum target | +|----------|----------------| +| iOS | 13.0+ | +| tvOS | 13.0+ | +| macOS | 10.15+ | +| watchOS | 6.2+ | +| visionOS | 1.0+ | + +## SDK Reference +Our full SDK reference [can be found here](https://revenuecat.github.io/purchases-ios-docs). + +## Contributing +Contributions are always welcome! To learn how you can contribute, please see the [Contributing Guide](./Contributing/CONTRIBUTING.md). diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/AdTracker.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/AdTracker.swift new file mode 100644 index 00000000..f58f433b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/AdTracker.swift @@ -0,0 +1,198 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// AdTracker.swift +// +// Created by RevenueCat on 1/15/25. + +import Foundation + +/** + Tracks ad-related events to RevenueCat. + + Use this class to report ad impressions, clicks, and revenue to RevenueCat alongside your subscription data. + This enables comprehensive LTV tracking across subscriptions and ad monetization. + + ## Usage + + Access the ad tracker through the `Purchases` singleton: + + ```swift + let adTracker = Purchases.shared.adTracker + ``` + + ## Example + + ```swift + // Track an ad impression + await Purchases.shared.adTracker.trackAdDisplayed(.init( + networkName: "AdMob", + mediatorName: .appLovin, + placement: "home_screen", + adUnitId: "ca-app-pub-123", + impressionId: "impression-456" + )) + + // Track ad revenue + await Purchases.shared.adTracker.trackAdRevenue(.init( + networkName: "AdMob", + mediatorName: .appLovin, + placement: "home_screen", + adUnitId: "ca-app-pub-123", + impressionId: "impression-456", + revenueMicros: 1500000, // $1.50 + currency: "USD", + precision: .exact + )) + ``` + */ +@_spi(Experimental) @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +@objc(RCAdTracker) +public final class AdTracker: NSObject { + + private let eventsManager: EventsManagerType? + + internal init(eventsManager: EventsManagerType?) { + self.eventsManager = eventsManager + super.init() + } + + /** + Tracks when an ad fails to load. + + Call this method from your ad SDK's failure callback to report load failures to RevenueCat. + Include the optional `mediatorErrorCode` if provided by the mediation SDK to aid debugging. + + - Parameter data: The failed to load ad event data, including optional `mediatorErrorCode` + + ## Example: + ```swift + Purchases.shared.adTracker.trackAdFailedToLoad(.init( + mediatorName: .appLovin, + adFormat: .banner, + placement: "home_screen", + adUnitId: "ca-app-pub-123", + mediatorErrorCode: 3 + )) + ``` + */ + @_spi(Experimental) @objc public func trackAdFailedToLoad(_ data: AdFailedToLoad) { + Task { + let event = AdEvent.failedToLoad(.init(id: UUID(), date: Date()), data) + await self.eventsManager?.track(adEvent: event) + } + } + + /** + Tracks when an ad successfully loads. + + Call this method from your ad SDK's load callback to report successful ad loads to RevenueCat. + Tracking load events helps correlate mediation performance with revenue and impressions. + + - Parameter data: The loaded ad event data + + ## Example: + ```swift + Purchases.shared.adTracker.trackAdLoaded(.init( + networkName: "AdMob", + mediatorName: .appLovin, + placement: "home_screen", + adUnitId: "ca-app-pub-123", + impressionId: "impression-456" + )) + ``` + */ + @_spi(Experimental) @objc public func trackAdLoaded(_ data: AdLoaded) { + Task { + let event = AdEvent.loaded(.init(id: UUID(), date: Date()), data) + await self.eventsManager?.track(adEvent: event) + } + } + + /** + Tracks when an ad impression is displayed. + + Call this method from your ad SDK's impression callback to report ad displays to RevenueCat. + This enables RevenueCat to track ad impressions alongside your subscription revenue. + + - Parameter data: The displayed ad event data + + ## Example: + ```swift + Purchases.shared.adTracker.trackAdDisplayed(.init( + networkName: "AdMob", + mediatorName: .appLovin, + placement: "home_screen", + adUnitId: "ca-app-pub-123", + impressionId: "impression-456" + )) + ``` + */ + @_spi(Experimental) @objc public func trackAdDisplayed(_ data: AdDisplayed) { + Task { + let event = AdEvent.displayed(.init(id: UUID(), date: Date()), data) + await self.eventsManager?.track(adEvent: event) + } + } + + /** + Tracks when an ad is opened or clicked. + + Call this method from your ad SDK's click callback to report ad interactions to RevenueCat. + + - Parameter data: The opened/clicked ad event data + + ## Example: + ```swift + Purchases.shared.adTracker.trackAdOpened(.init( + networkName: "AdMob", + mediatorName: .appLovin, + placement: "home_screen", + adUnitId: "ca-app-pub-123", + impressionId: "impression-456" + )) + ``` + */ + @_spi(Experimental) @objc public func trackAdOpened(_ data: AdOpened) { + Task { + let event = AdEvent.opened(.init(id: UUID(), date: Date()), data) + await self.eventsManager?.track(adEvent: event) + } + } + + /** + Tracks ad revenue from an impression. + + Call this method from your ad SDK's revenue callback to report ad revenue to RevenueCat. + This enables comprehensive LTV tracking across subscriptions and ad monetization. + + - Parameter data: The ad revenue data including amount, currency, and precision + + ## Example: + ```swift + Purchases.shared.adTracker.trackAdRevenue(.init( + networkName: "AdMob", + mediatorName: .appLovin, + placement: "home_screen", + adUnitId: "ca-app-pub-123", + impressionId: "impression-456", + revenueMicros: 1500000, // $1.50 + currency: "USD", + precision: .exact + )) + ``` + */ + @_spi(Experimental) @objc public func trackAdRevenue(_ data: AdRevenue) { + Task { + let event = AdEvent.revenue(.init(id: UUID(), date: Date()), data) + await self.eventsManager?.track(adEvent: event) + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/Events/AdEvent.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/Events/AdEvent.swift new file mode 100644 index 00000000..ccb29a54 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/Events/AdEvent.swift @@ -0,0 +1,707 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// AdEvent.swift +// +// Created by RevenueCat on 1/8/25. + +// swiftlint:disable file_length + +import Foundation + +// MARK: - Public Types + +// MARK: - Internal Protocol + +/// Internal protocol for base ad event fields shared by all ad event types. +internal protocol AdEventData { + var mediatorName: MediatorName { get } + var adFormat: AdFormat { get } + var placement: String? { get } + var adUnitId: String { get } +} + +/// Internal protocol for ad impression events that have a network name and impression ID. +internal protocol AdImpressionEventData: AdEventData { + var networkName: String? { get } + var impressionId: String { get } +} + +/// Type representing an ad mediation network name. +/// +/// Use the predefined static properties for common mediators, or create custom values +/// for other mediation networks. +@_spi(Experimental) @objc(RCMediatorName) public final class MediatorName: NSObject, Codable { + + /// The raw string value of the mediator name + @objc public let rawValue: String + + /// Creates a mediator name with the specified raw value + @objc public init(rawValue: String) { + self.rawValue = rawValue + super.init() + } + + /// Google AdMob mediation network + @objc public static let adMob = MediatorName(rawValue: "AdMob") + + /// AppLovin MAX mediation network + @objc public static let appLovin = MediatorName(rawValue: "AppLovin") + + // MARK: - NSObject overrides for equality + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? MediatorName else { return false } + return self.rawValue == other.rawValue + } + + public override var hash: Int { + return self.rawValue.hash + } + +} + +/// Type representing an ad format type. +/// +/// Use the predefined static properties for common ad formats, or create custom values +/// for other ad format types. +@_spi(Experimental) @objc(RCAdFormat) public final class AdFormat: NSObject, Codable { + + /// The raw string value of the ad format + @objc public let rawValue: String + + /// Creates an ad format with the specified raw value + @objc public init(rawValue: String) { + self.rawValue = rawValue + super.init() + } + + /// Ad format type not in our predefined list + @objc public static let other = AdFormat(rawValue: "other") + + /// Standard banner ad format + @objc public static let banner = AdFormat(rawValue: "banner") + + /// Full-screen interstitial ad format + @objc public static let interstitial = AdFormat(rawValue: "interstitial") + + /// Rewarded video ad format + @objc public static let rewarded = AdFormat(rawValue: "rewarded") + + /// Rewarded interstitial ad format + @objc public static let rewardedInterstitial = AdFormat(rawValue: "rewarded_interstitial") + + /// Native ad format that matches app design + @objc public static let native = AdFormat(rawValue: "native") + + /// App open ad format displayed at app launch + @objc public static let appOpen = AdFormat(rawValue: "app_open") + + /// Medium rectangle ad format + @objc public static let mrec = AdFormat(rawValue: "mrec") + + // MARK: - NSObject overrides for equality + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? AdFormat else { return false } + return self.rawValue == other.rawValue + } + + public override var hash: Int { + return self.rawValue.hash + } + +} + +/// Data for ad failed to load events. +@_spi(Experimental) @objc(RCAdFailedToLoad) public final class AdFailedToLoad: NSObject, + AdEventData, + Codable, + @unchecked Sendable { + + // swiftlint:disable missing_docs + @objc public private(set) var mediatorName: MediatorName + @objc public private(set) var adFormat: AdFormat + @objc public private(set) var placement: String? + @objc public private(set) var adUnitId: String + private let mediatorErrorCodeRawValue: Int? + @objc public var mediatorErrorCode: NSNumber? { + return self.mediatorErrorCodeRawValue.map(NSNumber.init(value:)) + } + public var mediatorErrorCodeValue: Int? { + return self.mediatorErrorCodeRawValue + } + + @objc public init( + mediatorName: MediatorName, + adFormat: AdFormat, + placement: String?, + adUnitId: String, + mediatorErrorCode: NSNumber? + ) { + self.mediatorName = mediatorName + self.adFormat = adFormat + self.placement = placement + self.adUnitId = adUnitId + self.mediatorErrorCodeRawValue = mediatorErrorCode?.intValue + super.init() + } + + public convenience init( + mediatorName: MediatorName, + adFormat: AdFormat, + placement: String?, + adUnitId: String, + mediatorErrorCode: Int? + ) { + self.init( + mediatorName: mediatorName, + adFormat: adFormat, + placement: placement, + adUnitId: adUnitId, + mediatorErrorCode: mediatorErrorCode.map(NSNumber.init(value:)) + ) + } + + @objc public convenience init( + mediatorName: MediatorName, + adFormat: AdFormat, + adUnitId: String, + mediatorErrorCode: NSNumber? = nil + ) { + self.init( + mediatorName: mediatorName, + adFormat: adFormat, + placement: nil, + adUnitId: adUnitId, + mediatorErrorCode: mediatorErrorCode + ) + } + // swiftlint:enable missing_docs + + // MARK: - NSObject overrides for equality + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? AdFailedToLoad else { return false } + return self.mediatorName == other.mediatorName && + self.adFormat == other.adFormat && + self.placement == other.placement && + self.adUnitId == other.adUnitId && + self.mediatorErrorCode == other.mediatorErrorCode + } + + public override var hash: Int { + var hasher = Hasher() + hasher.combine(mediatorName) + hasher.combine(adFormat) + hasher.combine(placement) + hasher.combine(adUnitId) + hasher.combine(mediatorErrorCode) + return hasher.finalize() + } + + private enum CodingKeys: String, CodingKey { + case mediatorName + case adFormat + case placement + case adUnitId + case mediatorErrorCodeRawValue = "mediatorErrorCode" + } + +} + +/// Data for ad loaded events. +@_spi(Experimental) @objc(RCAdLoaded) public final class AdLoaded: NSObject, + AdImpressionEventData, + Codable, + @unchecked Sendable { + + // swiftlint:disable missing_docs + @objc public private(set) var networkName: String? + @objc public private(set) var mediatorName: MediatorName + @objc public private(set) var adFormat: AdFormat + @objc public private(set) var placement: String? + @objc public private(set) var adUnitId: String + @objc public private(set) var impressionId: String + + @objc public init( + networkName: String?, + mediatorName: MediatorName, + adFormat: AdFormat, + placement: String?, + adUnitId: String, + impressionId: String + ) { + self.networkName = networkName + self.mediatorName = mediatorName + self.adFormat = adFormat + self.placement = placement + self.adUnitId = adUnitId + self.impressionId = impressionId + super.init() + } + + @objc public convenience init( + networkName: String?, + mediatorName: MediatorName, + adFormat: AdFormat, + adUnitId: String, + impressionId: String + ) { + self.init( + networkName: networkName, + mediatorName: mediatorName, + adFormat: adFormat, + placement: nil, + adUnitId: adUnitId, + impressionId: impressionId + ) + } + // swiftlint:enable missing_docs + + // MARK: - NSObject overrides for equality + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? AdLoaded else { return false } + return self.networkName == other.networkName && + self.mediatorName == other.mediatorName && + self.adFormat == other.adFormat && + self.placement == other.placement && + self.adUnitId == other.adUnitId && + self.impressionId == other.impressionId + } + + public override var hash: Int { + var hasher = Hasher() + hasher.combine(networkName) + hasher.combine(mediatorName) + hasher.combine(adFormat) + hasher.combine(placement) + hasher.combine(adUnitId) + hasher.combine(impressionId) + return hasher.finalize() + } + +} + +/// Data for ad displayed events. +@_spi(Experimental) @objc(RCAdDisplayed) public final class AdDisplayed: NSObject, + AdImpressionEventData, + Codable, + @unchecked Sendable { + + // swiftlint:disable missing_docs + @objc public private(set) var networkName: String? + @objc public private(set) var mediatorName: MediatorName + @objc public private(set) var adFormat: AdFormat + @objc public private(set) var placement: String? + @objc public private(set) var adUnitId: String + @objc public private(set) var impressionId: String + + @objc public init( + networkName: String?, + mediatorName: MediatorName, + adFormat: AdFormat, + placement: String?, + adUnitId: String, + impressionId: String + ) { + self.networkName = networkName + self.mediatorName = mediatorName + self.adFormat = adFormat + self.placement = placement + self.adUnitId = adUnitId + self.impressionId = impressionId + super.init() + } + + @objc public convenience init( + networkName: String?, + mediatorName: MediatorName, + adFormat: AdFormat, + adUnitId: String, + impressionId: String + ) { + self.init( + networkName: networkName, + mediatorName: mediatorName, + adFormat: adFormat, + placement: nil, + adUnitId: adUnitId, + impressionId: impressionId + ) + } + // swiftlint:enable missing_docs + + // MARK: - NSObject overrides for equality + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? AdDisplayed else { return false } + return self.networkName == other.networkName && + self.mediatorName == other.mediatorName && + self.adFormat == other.adFormat && + self.placement == other.placement && + self.adUnitId == other.adUnitId && + self.impressionId == other.impressionId + } + + public override var hash: Int { + var hasher = Hasher() + hasher.combine(networkName) + hasher.combine(mediatorName) + hasher.combine(adFormat) + hasher.combine(placement) + hasher.combine(adUnitId) + hasher.combine(impressionId) + return hasher.finalize() + } + +} + +/// Data for ad opened/clicked events. +@_spi(Experimental) @objc(RCAdOpened) public final class AdOpened: NSObject, + AdImpressionEventData, + Codable, + @unchecked Sendable { + + // swiftlint:disable missing_docs + @objc public private(set) var networkName: String? + @objc public private(set) var mediatorName: MediatorName + @objc public private(set) var adFormat: AdFormat + @objc public private(set) var placement: String? + @objc public private(set) var adUnitId: String + @objc public private(set) var impressionId: String + + @objc public init( + networkName: String?, + mediatorName: MediatorName, + adFormat: AdFormat, + placement: String?, + adUnitId: String, + impressionId: String + ) { + self.networkName = networkName + self.mediatorName = mediatorName + self.adFormat = adFormat + self.placement = placement + self.adUnitId = adUnitId + self.impressionId = impressionId + super.init() + } + + @objc public convenience init( + networkName: String?, + mediatorName: MediatorName, + adFormat: AdFormat, + adUnitId: String, + impressionId: String + ) { + self.init( + networkName: networkName, + mediatorName: mediatorName, + adFormat: adFormat, + placement: nil, + adUnitId: adUnitId, + impressionId: impressionId + ) + } + // swiftlint:enable missing_docs + + // MARK: - NSObject overrides for equality + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? AdOpened else { return false } + return self.networkName == other.networkName && + self.mediatorName == other.mediatorName && + self.adFormat == other.adFormat && + self.placement == other.placement && + self.adUnitId == other.adUnitId && + self.impressionId == other.impressionId + } + + public override var hash: Int { + var hasher = Hasher() + hasher.combine(networkName) + hasher.combine(mediatorName) + hasher.combine(adFormat) + hasher.combine(placement) + hasher.combine(adUnitId) + hasher.combine(impressionId) + return hasher.finalize() + } + +} + +/// Data for ad revenue events. +@_spi(Experimental) @objc(RCAdRevenue) public final class AdRevenue: NSObject, + AdImpressionEventData, + Codable, + @unchecked Sendable { + + // swiftlint:disable missing_docs + @objc public private(set) var networkName: String? + @objc public private(set) var mediatorName: MediatorName + @objc public private(set) var adFormat: AdFormat + @objc public private(set) var placement: String? + @objc public private(set) var adUnitId: String + @objc public private(set) var impressionId: String + @objc public private(set) var revenueMicros: Int + @objc public private(set) var currency: String + @objc public private(set) var precision: Precision + + @objc public init( + networkName: String?, + mediatorName: MediatorName, + adFormat: AdFormat, + placement: String?, + adUnitId: String, + impressionId: String, + revenueMicros: Int, + currency: String, + precision: Precision + ) { + self.networkName = networkName + self.mediatorName = mediatorName + self.adFormat = adFormat + self.placement = placement + self.adUnitId = adUnitId + self.impressionId = impressionId + self.revenueMicros = revenueMicros + self.currency = currency + self.precision = precision + super.init() + } + + @objc public convenience init( + networkName: String?, + mediatorName: MediatorName, + adFormat: AdFormat, + adUnitId: String, + impressionId: String, + revenueMicros: Int, + currency: String, + precision: Precision + ) { + self.init( + networkName: networkName, + mediatorName: mediatorName, + adFormat: adFormat, + placement: nil, + adUnitId: adUnitId, + impressionId: impressionId, + revenueMicros: revenueMicros, + currency: currency, + precision: precision + ) + } + // swiftlint:enable missing_docs + + // MARK: - NSObject overrides for equality + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? AdRevenue else { return false } + return self.networkName == other.networkName && + self.mediatorName == other.mediatorName && + self.adFormat == other.adFormat && + self.placement == other.placement && + self.adUnitId == other.adUnitId && + self.impressionId == other.impressionId && + self.revenueMicros == other.revenueMicros && + self.currency == other.currency && + self.precision == other.precision + } + + public override var hash: Int { + var hasher = Hasher() + hasher.combine(networkName) + hasher.combine(mediatorName) + hasher.combine(adFormat) + hasher.combine(placement) + hasher.combine(adUnitId) + hasher.combine(impressionId) + hasher.combine(revenueMicros) + hasher.combine(currency) + hasher.combine(precision) + return hasher.finalize() + } + +} + +extension AdRevenue { + + /// Type representing the level of accuracy for reported revenue values. + @_spi(Experimental) @objc(RCAdRevenuePrecision) public final class Precision: NSObject, Codable { + + /// The raw string value of the precision type + @objc public let rawValue: String + + /// Creates a precision value with the specified raw value + @objc public init(rawValue: String) { + self.rawValue = rawValue + super.init() + } + + /// Revenue value is exact and confirmed + @objc public static let exact = Precision(rawValue: "exact") + + /// Revenue value is defined by the publisher + @objc public static let publisherDefined = Precision(rawValue: "publisher_defined") + + /// Revenue value is an estimate + @objc public static let estimated = Precision(rawValue: "estimated") + + /// Revenue value accuracy cannot be determined + @objc public static let unknown = Precision(rawValue: "unknown") + + // MARK: - NSObject overrides for equality + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? Precision else { return false } + return self.rawValue == other.rawValue + } + + public override var hash: Int { + return self.rawValue.hash + } + + } + +} + +// MARK: - Internal Event Enum + +/// Internal event enum for type-safe routing through the events system. +internal enum AdEvent: Equatable, Codable, Sendable { + + // swiftlint:disable type_name + + /// An identifier that represents an ad event. + internal typealias ID = UUID + + // swiftlint:enable type_name + + /// An ad failed to load. + case failedToLoad(CreationData, AdFailedToLoad) + + /// An ad successfully loaded. + case loaded(CreationData, AdLoaded) + + /// An ad impression was displayed. + case displayed(CreationData, AdDisplayed) + + /// An ad was opened/clicked. + case opened(CreationData, AdOpened) + + /// An ad impression generated revenue. + case revenue(CreationData, AdRevenue) + +} + +extension AdEvent { + + /// Internal creation metadata that is automatically generated by the SDK. + internal struct CreationData: Equatable, Codable, Sendable { + + internal var id: ID + internal var date: Date + + internal init( + id: ID = .init(), + date: Date = .init() + ) { + self.id = id + self.date = date + } + + } + +} + +extension AdEvent { + + /// - Returns: the underlying ``AdEvent/CreationData-swift.struct`` for this event. + internal var creationData: CreationData { + switch self { + case let .failedToLoad(creationData, _): return creationData + case let .loaded(creationData, _): return creationData + case let .displayed(creationData, _): return creationData + case let .opened(creationData, _): return creationData + case let .revenue(creationData, _): return creationData + } + } + + /// - Returns: the underlying ad event data for this event. + internal var eventData: AdEventData { + switch self { + case let .failedToLoad(_, failed): + return failed + case let .loaded(_, loaded): + return loaded + case let .displayed(_, displayed): + return displayed + case let .opened(_, opened): + return opened + case let .revenue(_, revenue): + return revenue + } + } + + /// - Returns: the underlying ``AdRevenue`` for revenue events. + internal var revenueData: AdRevenue? { + switch self { + case .failedToLoad, .loaded, .displayed, .opened: + return nil + case let .revenue(_, revenueData): + return revenueData + } + } + + /// - Returns: the network name for impression events, nil for failed to load events. + internal var networkName: String? { + switch self { + case .failedToLoad: + return nil + case let .loaded(_, data): + return data.networkName + case let .displayed(_, data): + return data.networkName + case let .opened(_, data): + return data.networkName + case let .revenue(_, data): + return data.networkName + } + } + + /// - Returns: the impression identifier for events that include it. + internal var impressionIdentifier: String? { + switch self { + case .failedToLoad: + return nil + case let .loaded(_, data): + return data.impressionId + case let .displayed(_, data): + return data.impressionId + case let .opened(_, data): + return data.impressionId + case let .revenue(_, data): + return data.impressionId + } + } + + /// - Returns: the mediator error code for failed to load events. + internal var mediatorErrorCode: Int? { + switch self { + case let .failedToLoad(_, data): + return data.mediatorErrorCode?.intValue + case .loaded, .displayed, .opened, .revenue: + return nil + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/Events/AdEventStore.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/Events/AdEventStore.swift new file mode 100644 index 00000000..08775846 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/Events/AdEventStore.swift @@ -0,0 +1,211 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// AdEventStore.swift +// +// Created by RevenueCat on 1/21/25. + +import Foundation + +protocol AdEventStoreType: Sendable { + + /// Stores `event` into the store. + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func store(_ storedEvent: StoredAdEvent) async + + /// - Returns: the first `count` events from the store. + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func fetch(_ count: Int) async -> [StoredAdEvent] + + /// Removes the first `count` events from the store. + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func clear(_ count: Int) async + +} + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +internal actor AdEventStore: AdEventStoreType { + + private let handler: FileHandlerType + + init(handler: FileHandlerType) { + self.handler = handler + } + + func store(_ storedEvent: StoredAdEvent) async { + do { + // Check if store is too big and clear old events if needed + if await self.isEventStoreTooBig() { + Logger.warn(AdEventStoreStrings.event_store_size_limit_reached) + await self.clear(Self.eventBatchSizeToClear) + } + + if let eventDescription = try? storedEvent.encodedEvent.prettyPrintedJSON { + Logger.verbose(AdEventStoreStrings.storing_event(eventDescription)) + } else { + Logger.verbose(AdEventStoreStrings.storing_event_without_json) + } + + let event = try StoredAdEventSerializer.encode(storedEvent) + try await self.handler.append(line: event) + } catch { + Logger.error(AdEventStoreStrings.error_storing_event(error)) + } + } + + func fetch(_ count: Int) async -> [StoredAdEvent] { + assert(count > 0, "Invalid count: \(count)") + + do { + return try await self.handler.readLines() + .prefix(count) + .compactMap { try? StoredAdEventSerializer.decode($0) } + .extractValues() + } catch { + Logger.error(AdEventStoreStrings.error_fetching_events(error)) + return [] + } + } + + // - Note: If removing these `count` events fails, it will attempt to + // remove the entire file. This ensures that the same events again aren't sent again, + // but might mean that some events are not sent at all. + func clear(_ count: Int) async { + assert(count > 0, "Invalid count: \(count)") + + do { + try await self.handler.removeFirstLines(count) + } catch { + Logger.error(AdEventStoreStrings.error_removing_first_lines(count: count, error)) + + do { + try await self.handler.emptyFile() + } catch { + Logger.error(AdEventStoreStrings.error_emptying_file(error)) + } + } + } + + private func isEventStoreTooBig() async -> Bool { + do { + return try await self.handler.fileSizeInKB() > Self.maxEventFileSizeInKB + } catch { + Logger.error(AdEventStoreStrings.error_checking_file_size(error)) + return false + } + } + + private static let maxEventFileSizeInKB: Double = 2048 + private static let eventBatchSizeToClear = 50 + +} + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +extension AdEventStore { + + static func createDefault( + applicationSupportDirectory: URL? + ) throws -> AdEventStore { + let url = Self.url(in: try applicationSupportDirectory ?? Self.applicationSupportDirectory) + Logger.verbose(AdEventStoreStrings.initializing(url)) + + return try .init(handler: FileHandler(url)) + } + + private static func revenueCatFolder(in container: URL) -> URL { + return container.appendingPathComponent("revenuecat") + } + + private static func url(in container: URL) -> URL { + return self.revenueCatFolder(in: container).appendingPathComponent("ad_event_store") + } + + // See https://nemecek.be/blog/57/making-files-from-your-app-available-in-the-ios-files-app + // We don't want to store events in the documents directory in case app makes their documents + // accessible via the Files app. + // swiftlint:disable avoid_using_directory_apis_directly + private static var applicationSupportDirectory: URL { + get throws { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + return URL.applicationSupportDirectory + } else { + return try Self.fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + } + } + } + // swiftlint:enable avoid_using_directory_apis_directly + + private static let fileManager: FileManager = .default + +} + +// MARK: - Messages + +// swiftlint:disable identifier_name +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private enum AdEventStoreStrings { + + case initializing(URL) + + case storing_event(String) + case storing_event_without_json + + case error_storing_event(Error) + case error_fetching_events(Error) + case error_removing_first_lines(count: Int, Error) + case error_emptying_file(Error) + case error_checking_file_size(Error) + + case event_store_size_limit_reached + +} +// swiftlint:enable identifier_name + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension AdEventStoreStrings: LogMessage { + + var description: String { + switch self { + case let .initializing(directory): + return "Initializing AdEventStore: \(directory.absoluteString)" + + case let .storing_event(eventDescription): + return "Storing ad event: \(eventDescription)" + + case .storing_event_without_json: + return "Storing an ad event. There was an error trying to print it" + + case let .error_storing_event(error): + return "Error storing ad event: \((error as NSError).description)" + + case let .error_fetching_events(error): + return "Error fetching ad events: \((error as NSError).description)" + + case let .error_removing_first_lines(count, error): + return "Error removing first \(count) ad events: \((error as NSError).description)" + + case let .error_emptying_file(error): + return "Error emptying ad event file: \((error as NSError).description)" + + case let .error_checking_file_size(error): + return "Error checking ad event file size: \((error as NSError).description)" + + case .event_store_size_limit_reached: + return "Ad event store size limit reached. Clearing oldest events to free up space." + } + } + + var category: String { return "ad_event_store" } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/Events/Networking/AdEventsRequest.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/Events/Networking/AdEventsRequest.swift new file mode 100644 index 00000000..087168dc --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/Events/Networking/AdEventsRequest.swift @@ -0,0 +1,165 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// AdEventsRequest.swift +// +// Created by RevenueCat on 1/21/25. + +import Foundation + +/// The content of a request to the ad events endpoint. +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct AdEventsRequest { + + var events: [AnyEncodable] + + init(events: [AnyEncodable]) { + self.events = events + } + + init(events: [StoredAdEvent]) { + self.init(events: events.compactMap { storedEvent in + guard let event = AdEventRequest(storedEvent: storedEvent) else { + return nil + } + return AnyEncodable(event) + }) + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension AdEventsRequest: HTTPRequestBody {} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension AdEventsRequest { + + struct AdEventRequest { + + let id: String? + let version: Int + var type: AdEventRequest.EventType + var appUserId: String + var appSessionId: String + var timestamp: UInt64 + var networkName: String? + var mediatorName: String + var adFormat: String + var placement: String? + var adUnitId: String + var impressionId: String? + // For revenue events only: + var revenueMicros: Int? + var currency: String? + var precision: String? + // For failed to load events only: + var mediatorErrorCode: Int? + + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension AdEventsRequest.AdEventRequest { + + enum EventType: String { + + case failedToLoad = "rc_ads_ad_failed_to_load" + case loaded = "rc_ads_ad_loaded" + case displayed = "rc_ads_ad_displayed" + case opened = "rc_ads_ad_opened" + case revenue = "rc_ads_ad_revenue" + + } + + init?(storedEvent: StoredAdEvent) { + guard let jsonData = storedEvent.encodedEvent.data(using: .utf8) else { + Logger.error(Strings.paywalls.event_cannot_get_encoded_event) + return nil + } + + do { + let adEvent = try JSONDecoder.default.decode(AdEvent.self, from: jsonData) + let creationData = adEvent.creationData + let eventData = adEvent.eventData + + self.init( + id: creationData.id.uuidString, + version: Self.version, + type: adEvent.eventType, + appUserId: storedEvent.userID, + appSessionId: storedEvent.appSessionID.uuidString, + timestamp: creationData.date.millisecondsSince1970, + networkName: adEvent.networkName, + mediatorName: eventData.mediatorName.rawValue, + adFormat: eventData.adFormat.rawValue, + placement: eventData.placement, + adUnitId: eventData.adUnitId, + impressionId: adEvent.impressionIdentifier, + revenueMicros: adEvent.revenueData?.revenueMicros, + currency: adEvent.revenueData?.currency, + precision: adEvent.revenueData?.precision.rawValue, + mediatorErrorCode: adEvent.mediatorErrorCode + ) + } catch { + Logger.error(Strings.paywalls.event_cannot_deserialize(error)) + return nil + } + } + + private static let version: Int = 1 + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private extension AdEvent { + + var eventType: AdEventsRequest.AdEventRequest.EventType { + switch self { + case .failedToLoad: return .failedToLoad + case .loaded: return .loaded + case .displayed: return .displayed + case .opened: return .opened + case .revenue: return .revenue + } + + } + +} + +// MARK: - Codable + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension AdEventsRequest.AdEventRequest.EventType: Encodable {} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension AdEventsRequest.AdEventRequest: Encodable { + + /// When sending this to the backend `JSONEncoder.KeyEncodingStrategy.convertToSnakeCase` is used + private enum CodingKeys: String, CodingKey { + + case id + case version + case type + case appUserId + case appSessionId + case timestamp = "timestampMs" + case networkName + case mediatorName + case adFormat + case placement + case adUnitId + case impressionId + case revenueMicros + case currency + case precision + case mediatorErrorCode + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/Events/Networking/AdHTTPRequestPath.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/Events/Networking/AdHTTPRequestPath.swift new file mode 100644 index 00000000..779df22a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/Events/Networking/AdHTTPRequestPath.swift @@ -0,0 +1,28 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// AdHTTPRequestPath.swift +// +// Created by RevenueCat on 1/8/25. + +import Foundation + +extension HTTPRequest.AdPath: EventsHTTPRequestPath { + + // swiftlint:disable:next force_unwrapping + static let serverHostURL = URL(string: "https://a.revenue.cat")! + + var name: String { + switch self { + case .postEvents: + return "post_ad_events" + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/Events/Networking/PostAdEventsOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/Events/Networking/PostAdEventsOperation.swift new file mode 100644 index 00000000..669c7fcb --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/Events/Networking/PostAdEventsOperation.swift @@ -0,0 +1,55 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PostAdEventsOperation.swift +// +// Created by RevenueCat on 1/21/25. + +import Foundation + +/// A `NetworkOperation` for posting ad events to the ad events endpoint. +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +final class PostAdEventsOperation: NetworkOperation { + + private let configuration: Configuration + private let request: AdEventsRequest + private let path: HTTPRequestPath + private let responseHandler: CustomerAPI.SimpleResponseHandler? + + init( + configuration: Configuration, + request: AdEventsRequest, + path: HTTPRequestPath, + responseHandler: CustomerAPI.SimpleResponseHandler? + ) { + self.request = request + self.configuration = configuration + self.path = path + self.responseHandler = responseHandler + + super.init(configuration: configuration) + } + + override func begin(completion: @escaping () -> Void) { + let httpRequest = HTTPRequest(method: .post(self.request), requestPath: self.path) + + self.httpClient.perform(httpRequest) { (response: VerifiedHTTPResponse.Result) in + defer { + completion() + } + + self.responseHandler?(response.error.map(BackendError.networkError)) + } + } + +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension PostAdEventsOperation: @unchecked Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/Events/StoredAdEvent.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/Events/StoredAdEvent.swift new file mode 100644 index 00000000..7ddb42e6 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/Events/StoredAdEvent.swift @@ -0,0 +1,60 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StoredAdEvent.swift +// +// Created by RevenueCat on 1/21/25. + +import Foundation + +/// Contains the necessary information for storing and sending ad events. +struct StoredAdEvent { + + private(set) var encodedEvent: String + private(set) var userID: String + private(set) var appSessionID: UUID + + init?(event: T, userID: String, appSessionID: UUID) { + guard let data = try? JSONEncoder.sortedKeys.encode(event), + let encodedJSON = String(data: data, encoding: .utf8) else { + return nil + } + + self.encodedEvent = encodedJSON + self.userID = userID + self.appSessionID = appSessionID + } + +} + +// MARK: - Extensions + +extension StoredAdEvent: Sendable {} + +extension StoredAdEvent: Codable { + + private enum CodingKeys: String, CodingKey { + + case encodedEvent = "event" + case userID = "userId" + case appSessionID = "appSessionId" + + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.encodedEvent = try container.decode(String.self, forKey: .encodedEvent) + self.userID = try container.decode(String.self, forKey: .userID) + self.appSessionID = try container.decode(UUID.self, forKey: .appSessionID) + } + +} + +extension StoredAdEvent: Equatable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/Events/StoredAdEventSerializer.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/Events/StoredAdEventSerializer.swift new file mode 100644 index 00000000..ea2b6242 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Ads/Events/StoredAdEventSerializer.swift @@ -0,0 +1,34 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StoredAdEventSerializer.swift +// +// Created by RevenueCat on 1/21/25. + +import Foundation + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +enum StoredAdEventSerializer { + + private struct FailedEncodingEventError: Error {} + + /// Encodes a ``StoredAdEvent`` in a format suitable to be stored by `AdEventStore`. + static func encode(_ event: StoredAdEvent) throws -> String { + let data = try JSONEncoder.default.encode(value: event) + + return try String(data: data, encoding: .utf8) + .orThrow(FailedEncodingEventError()) + } + + /// Decodes a ``StoredAdEvent``. + static func decode(_ event: String) throws -> StoredAdEvent { + return try JSONDecoder.default.decode(jsonData: event.asData) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Attribution/ASIdManagerProxy.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Attribution/ASIdManagerProxy.swift new file mode 100644 index 00000000..fe869d0c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Attribution/ASIdManagerProxy.swift @@ -0,0 +1,50 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ASIdManagerProxy.swift +// +// Created by Juanpe Catalán on 14/7/21. +// + +import Foundation + +// We need this class to avoid Kid apps being rejected for getting idfa. It seems like App +// Review uses some grep to find the class names, so we ended up creating a fake class that +// exposes the same methods we're looking for in ASIdentifierManager to call the same methods and mangling +// the class names. So that Apple can't find them during the review, but we can still access them on runtime. +class FakeASIdManager: NSObject { + + // We need this method to be available as an optional implicitly unwrapped method for `AnyClass`. + @objc static func sharedManager() -> FakeASIdManager { + FakeASIdManager() + } + +} + +class ASIdManagerProxy { + + static let mangledIdentifierClassName = "NFVqragvsvreZnantre" + static let mangledIdentifierPropertyName = "nqiregvfvatVqragvsvre" + + static var identifierClass: AnyClass? { + // We need to do this mangling to avoid Kid apps being rejected for getting idfa. + // It looks like during the app review process Apple does some string matching looking for + // functions in the AdSupport.framework. We apply rot13 on these functions and classes names + // so that Apple can't find them during the review, but we can still access them on runtime. + NSClassFromString(Self.mangledIdentifierClassName.rot13()) + } + + var adsIdentifier: UUID? { + guard let classType: AnyClass = Self.identifierClass else { + return nil + } + return classType.sharedManager().value(forKey: Self.mangledIdentifierPropertyName.rot13()) as? UUID + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Attribution/AttributionData.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Attribution/AttributionData.swift new file mode 100644 index 00000000..202e5a29 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Attribution/AttributionData.swift @@ -0,0 +1,23 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// AttributionData.swift +// +// Created by Madeline Beyl on 7/7/21. +// + +import Foundation + +struct AttributionData { + + let data: [String: Any] + let network: AttributionNetwork + let networkUserId: String? + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Attribution/AttributionFetcher.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Attribution/AttributionFetcher.swift new file mode 100644 index 00000000..9ab39fbd --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Attribution/AttributionFetcher.swift @@ -0,0 +1,237 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// AttributionFetcher.swift +// +// Created by Andrés Boedo on 4/8/21. +// + +import Foundation +#if os(iOS) || os(tvOS) +import UIKit +#elseif os(watchOS) +import WatchKit +#endif + +#if canImport(AdServices) +import AdServices +#endif + +class AttributionFetcher { + + private let attributionFactory: AttributionTypeFactory + private let systemInfo: SystemInfo + +#if os(watchOS) || os(macOS) || targetEnvironment(macCatalyst) + private let appTrackingTransparencyRequired = false +#else + private let appTrackingTransparencyRequired = true +#endif + + init(attributionFactory: AttributionTypeFactory, systemInfo: SystemInfo) { + self.attributionFactory = attributionFactory + self.systemInfo = systemInfo + } + + var identifierForVendor: String? { + return self.systemInfo.identifierForVendor + } + + var identifierForAdvertisers: String? { + // should match available platforms here: + // https://developer.apple.com/documentation/adsupport/asidentifiermanager/1614151-advertisingidentifier +#if os(iOS) || os(tvOS) || os(macOS) || VISION_OS + if #available(macOS 10.14, *) { + let identifierManagerProxy = attributionFactory.asIdProxy() + guard let identifierManagerProxy = identifierManagerProxy else { + Logger.warn(Strings.configure.adsupport_not_imported) + return nil + } + + guard let identifierValue = identifierManagerProxy.adsIdentifier else { + return nil + } + + return identifierValue.uuidString + } +#endif + return nil + } + + // should match OS availability in https://developer.apple.com/documentation/ad_services + @available(iOS 14.3, tvOS 14.3, macOS 11.1, watchOS 6.2, macCatalyst 14.3, *) + var adServicesToken: String? { + get async { + #if canImport(AdServices) + return await Task.detached { + #if targetEnvironment(simulator) + return Self.simulatorAdServicesToken + #else + return Self.realAdServicesToken + #endif + }.value + #else + Logger.warn(Strings.attribution.adservices_not_supported) + return nil + #endif + } + } + + #if canImport(AdServices) + @available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) + private static var realAdServicesToken: String? { + RCTestAssertNotMainThread() + + do { + return try AAAttribution.attributionToken() + } catch { + Logger.appleWarning(Strings.attribution.adservices_token_fetch_failed(error: error)) + return nil + } + } + + #if targetEnvironment(simulator) + private static var simulatorAdServicesToken: String? { + RCTestAssertNotMainThread() + + #if DEBUG + if let mockToken = ProcessInfo.mockAdServicesToken { + Logger.warn(Strings.attribution.adservices_mocking_token(mockToken)) + return mockToken + } + #endif + + // See https://github.com/RevenueCat/purchases-ios/issues/2121 + Logger.appleWarning(Strings.attribution.adservices_token_unavailable_in_simulator) + return nil + } + #endif + + #endif + + var isAuthorizedToPostSearchAds: Bool { + // Should match platforms that require permissions detailed in + // https://developer.apple.com/app-store/user-privacy-and-data-use/ + if !appTrackingTransparencyRequired { + return true + } + + if #available(iOS 14.0.0, tvOS 14.0.0, *) { + return isAuthorizedToPostSearchAdsInATTRequiredOS + } + + return true + } + + var authorizationStatus: FakeTrackingManagerAuthorizationStatus { + // should match OS availability here: https://rev.cat/app-tracking-transparency + guard #available(iOS 14.0.0, tvOS 14.0.0, macOS 11.0.0, *) else { + return .notDetermined + } + return self.fetchAuthorizationStatus + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension AttributionFetcher: @unchecked Sendable {} + +// MARK: - Private + +private extension AttributionFetcher { + + enum Error: Swift.Error { + + case identifierForAdvertiserUnavailableForPlatform + case identifierForAdvertiserFrameworksUnavailable + + } + +} + +private extension AttributionFetcher { + + @available(iOS 14.0.0, tvOS 14.0.0, *) + private var isAuthorizedToPostSearchAdsInATTRequiredOS: Bool { + let needsTrackingAuthorization = self.needsTrackingAuthorization + + guard let trackingManagerProxy = self.trackingProxy else { + return !needsTrackingAuthorization + } + + let authStatusSelector = NSSelectorFromString(trackingManagerProxy.authorizationStatusPropertyName) + guard trackingManagerProxy.responds(to: authStatusSelector) else { + Logger.warn(Strings.attribution.att_framework_present_but_couldnt_call_tracking_authorization_status) + return false + } + + let authStatus = callAuthStatusSelector(authStatusSelector, trackingManagerProxy: trackingManagerProxy) + + switch authStatus { + case .restricted, .denied: + return false + case .notDetermined: + return !needsTrackingAuthorization + case .authorized: + return true + } + } + + @available(iOS 14.0.0, tvOS 14.0.0, *) + private var fetchAuthorizationStatus: FakeTrackingManagerAuthorizationStatus { + let needsTrackingAuthorization = self.needsTrackingAuthorization + + guard let trackingManagerProxy = self.trackingProxy else { + if needsTrackingAuthorization { + return .denied + } else { + return .notDetermined + } + } + + let authStatusSelector = NSSelectorFromString(trackingManagerProxy.authorizationStatusPropertyName) + guard trackingManagerProxy.responds(to: authStatusSelector) else { + Logger.warn(Strings.attribution.att_framework_present_but_couldnt_call_tracking_authorization_status) + return .denied + } + + let authStatus = callAuthStatusSelector(authStatusSelector, trackingManagerProxy: trackingManagerProxy) + return authStatus + } + + private var trackingProxy: TrackingManagerProxy? { + let trackingManagerProxy = attributionFactory.atFollowingProxy() + if trackingManagerProxy == nil && needsTrackingAuthorization { + Logger.warn(Strings.attribution.search_ads_attribution_cancelled_missing_att_framework) + } + return trackingManagerProxy + } + + private var needsTrackingAuthorization: Bool { + let minimumOSVersionRequiringAuthorization = OperatingSystemVersion(majorVersion: 14, + minorVersion: 5, + patchVersion: 0) + return systemInfo.isOperatingSystemAtLeast(minimumOSVersionRequiringAuthorization) + } + + private func callAuthStatusSelector( + _ authStatusSelector: Selector, + trackingManagerProxy: TrackingManagerProxy + ) -> FakeTrackingManagerAuthorizationStatus { + // we use unsafeBitCast to prevent direct references to tracking frameworks, which cause issues for + // kids apps when going through app review, even if they don't actually use them at all. + typealias ClosureType = @convention(c) (AnyObject, Selector) -> FakeTrackingManagerAuthorizationStatus + let authStatusMethodImplementation = trackingManagerProxy.method(for: authStatusSelector) + let authStatusMethod: ClosureType = unsafeBitCast(authStatusMethodImplementation, to: ClosureType.self) + let authStatus = authStatusMethod(trackingManagerProxy, authStatusSelector) + return authStatus + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Attribution/AttributionNetwork.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Attribution/AttributionNetwork.swift new file mode 100644 index 00000000..52a9af04 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Attribution/AttributionNetwork.swift @@ -0,0 +1,83 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// AttributionNetwork.swift +// +// Created by Joshua Liebowitz on 7/1/21. +// + +import Foundation + +/** + Enum of supported attribution networks + */ +@objc(RCAttributionNetwork) public enum AttributionNetwork: Int { + + /** + Apple's search ads + */ + @available(*, deprecated, message: "use adServices") + case appleSearchAds = 0 + + /** + Adjust https://www.adjust.com/ + */ + case adjust = 1 + + /** + AppsFlyer https://www.appsflyer.com/ + */ + case appsFlyer = 2 + + /** + Branch https://www.branch.io/ + */ + case branch = 3 + + /** + Tenjin https://www.tenjin.io/ + */ + case tenjin = 4 + + /** + Facebook https://developers.facebook.com/ + */ + case facebook = 5 + + /** + mParticle https://www.mparticle.com/ + */ + case mParticle = 6 + + /** + AdServices token + */ + case adServices = 7 + +} + +extension AttributionNetwork: Encodable { + + // swiftlint:disable:next missing_docs + public func encode(to encoder: Encoder) throws { + try self.rawValue.encode(to: encoder) + } + +} + +extension AttributionNetwork { + + var isAppleSearchAdds: Bool { + switch self { + case .appleSearchAds: return true + default: return false + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Attribution/AttributionPoster.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Attribution/AttributionPoster.swift new file mode 100644 index 00000000..f629bd36 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Attribution/AttributionPoster.swift @@ -0,0 +1,247 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// AttributionPoster.swift +// +// Created by Joshua Liebowitz on 8/11/21. + +import Foundation + +final class AttributionPoster { + + private let deviceCache: DeviceCache + private let currentUserProvider: CurrentUserProvider + private let backend: Backend + private let attributionFetcher: AttributionFetcher + private let subscriberAttributesManager: SubscriberAttributesManager + private let systemInfo: SystemInfo + + private static var postponedAttributionData: [AttributionData]? + + init(deviceCache: DeviceCache, + currentUserProvider: CurrentUserProvider, + backend: Backend, + attributionFetcher: AttributionFetcher, + subscriberAttributesManager: SubscriberAttributesManager, + systemInfo: SystemInfo) { + self.deviceCache = deviceCache + self.currentUserProvider = currentUserProvider + self.backend = backend + self.attributionFetcher = attributionFetcher + self.subscriberAttributesManager = subscriberAttributesManager + self.systemInfo = systemInfo + } + + func post(attributionData data: [String: Any], + fromNetwork network: AttributionNetwork, + networkUserId: String?) { + guard !self.systemInfo.dangerousSettings.uiPreviewMode else { + return + } + + Logger.debug(Strings.attribution.instance_configured_posting_attribution) + if data[AttributionKey.AppsFlyer.id.rawValue] != nil { + Logger.warn(Strings.attribution.appsflyer_id_deprecated) + } + + if network == .appsFlyer && networkUserId == nil { + Logger.warn(Strings.attribution.networkuserid_required_for_appsflyer) + } + + let identifierForAdvertisers = attributionFetcher.identifierForAdvertisers + if identifierForAdvertisers == nil { + Logger.warn(Strings.attribution.missing_advertiser_identifiers) + } + + let currentAppUserID = self.currentUserProvider.currentAppUserID + guard let newDictToCache = self.getNewDictToCache(currentAppUserID: currentAppUserID, + idfa: identifierForAdvertisers, + network: network, + networkUserId: networkUserId) else { + return + } + + var newData = data + + if let identifierForAdvertisers = identifierForAdvertisers { + newData[AttributionKey.idfa.rawValue] = identifierForAdvertisers + } else { + newData.removeValue(forKey: AttributionKey.idfa.rawValue) + } + + if let identifierForVendor = attributionFetcher.identifierForVendor { + newData[AttributionKey.idfv.rawValue] = identifierForVendor + } else { + newData.removeValue(forKey: AttributionKey.idfv.rawValue) + } + + if let networkUserId = networkUserId { + newData[AttributionKey.networkID.rawValue] = networkUserId + } else { + newData.removeValue(forKey: AttributionKey.networkID.rawValue) + } + + if !newData.isEmpty { + if network.isAppleSearchAdds { + postSearchAds(newData: newData, + network: network, + appUserID: currentAppUserID, + newDictToCache: newDictToCache) + } else { + postSubscriberAttributes(newData: newData, + network: network, + appUserID: currentAppUserID, + newDictToCache: newDictToCache) + } + } + } + + // should match OS availability in https://developer.apple.com/documentation/ad_services + @available(iOS 14.3, tvOS 14.3, watchOS 6.2, macOS 11.1, macCatalyst 14.3, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + func postAdServicesTokenOncePerInstallIfNeeded(completion: ((Error?) -> Void)? = nil) { + Task.detached(priority: .background) { + guard let attributionToken = await self.adServicesTokenToPostIfNeeded else { + completion?(nil) + return + } + + self.post(adServicesToken: attributionToken, completion: completion) + } + } + + var adServicesTokenToPostIfNeeded: String? { + get async { + #if os(tvOS) || os(watchOS) + return nil + #else + guard #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) else { + return nil + } + + guard self.latestNetworkIdAndAdvertisingIdentifierSent(network: .adServices) == nil else { + return nil + } + + return await self.attributionFetcher.adServicesToken + #endif + } + } + + @discardableResult + func markAdServicesToken(_ token: String, asSyncedFor userID: String) -> [AttributionNetwork: String] { + Logger.info(Strings.attribution.adservices_marking_as_synced(appUserID: userID)) + + var newDictToCache = self.deviceCache.latestAdvertisingIdsByNetworkSent(appUserID: userID) + newDictToCache[AttributionNetwork.adServices] = token + + self.deviceCache.set(latestAdvertisingIdsByNetworkSent: newDictToCache, appUserID: userID) + + return newDictToCache + } + + func postPostponedAttributionDataIfNeeded() { + if let postponedAttributionData = Self.postponedAttributionData, + !systemInfo.dangerousSettings.uiPreviewMode { + + for attributionData in postponedAttributionData { + post(attributionData: attributionData.data, + fromNetwork: attributionData.network, + networkUserId: attributionData.networkUserId) + } + } + + Self.postponedAttributionData = nil + } + + static func store(postponedAttributionData data: [String: Any], + fromNetwork network: AttributionNetwork, + forNetworkUserId networkUserID: String?) { + Logger.debug(Strings.attribution.no_instance_configured_caching_attribution) + + var postponedData = postponedAttributionData ?? [] + postponedData.append(AttributionData(data: data, network: network, networkUserId: networkUserID)) + postponedAttributionData = postponedData + } + + private func post(adServicesToken: String, completion: ((Error?) -> Void)? = nil) { + let currentAppUserID = self.currentUserProvider.currentAppUserID + + // set the cache in advance to avoid multiple post calls + var newDictToCache = self.markAdServicesToken(adServicesToken, asSyncedFor: currentAppUserID) + + self.backend.post(adServicesToken: adServicesToken, appUserID: currentAppUserID) { error in + guard let error = error else { + Logger.debug(Strings.attribution.adservices_token_post_succeeded) + completion?(nil) + return + } + Logger.warn(Strings.attribution.adservices_token_post_failed(error: error)) + + // if there's an error, reset the cache + newDictToCache[AttributionNetwork.adServices] = nil + self.deviceCache.set(latestAdvertisingIdsByNetworkSent: newDictToCache, appUserID: currentAppUserID) + + completion?(error) + } + } + + private func latestNetworkIdAndAdvertisingIdentifierSent(network: AttributionNetwork) -> String? { + let cachedDict = deviceCache.latestAdvertisingIdsByNetworkSent( + appUserID: self.currentUserProvider.currentAppUserID + ) + return cachedDict[network] + } + + private func postSearchAds(newData: [String: Any], + network: AttributionNetwork, + appUserID: String, + newDictToCache: [AttributionNetwork: String]) { + backend.post(attributionData: newData, network: network, appUserID: appUserID) { error in + guard error == nil else { + return + } + + self.deviceCache.set(latestAdvertisingIdsByNetworkSent: newDictToCache, appUserID: appUserID) + } + } + + private func postSubscriberAttributes(newData: [String: Any], + network: AttributionNetwork, + appUserID: String, + newDictToCache: [AttributionNetwork: String]) { + subscriberAttributesManager.setAttributes(fromAttributionData: newData, + network: network, + appUserID: appUserID) + deviceCache.set(latestAdvertisingIdsByNetworkSent: newDictToCache, appUserID: appUserID) + } + + private func getNewDictToCache(currentAppUserID: String, + idfa: String?, + network: AttributionNetwork, + networkUserId: String?) -> [AttributionNetwork: String]? { + let latestAdvertisingIdsByNetworkSent = + deviceCache.latestAdvertisingIdsByNetworkSent(appUserID: currentAppUserID) + let latestSentToNetwork = latestAdvertisingIdsByNetworkSent[network] + + let newValueForNetwork = "\(idfa ?? "(null)")_\(networkUserId ?? "(null)")" + guard latestSentToNetwork != newValueForNetwork else { + Logger.debug(Strings.attribution.skip_same_attributes) + return nil + } + + var newDictToCache = latestAdvertisingIdsByNetworkSent + newDictToCache[network] = newValueForNetwork + return newDictToCache + } + +} + +extension AttributionPoster: Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Attribution/AttributionTypeFactory.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Attribution/AttributionTypeFactory.swift new file mode 100644 index 00000000..c5c63782 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Attribution/AttributionTypeFactory.swift @@ -0,0 +1,31 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// AttributionTypeFactory.swift +// +// Created by Juanpe Catalán on 9/7/21. +// + +import Foundation + +class AttributionTypeFactory { + + func atFollowingProxy() -> TrackingManagerProxy? { + return TrackingManagerProxy.trackingClass == nil ? nil : TrackingManagerProxy() + } + + func asIdProxy() -> ASIdManagerProxy? { + return ASIdManagerProxy.identifierClass == nil ? nil : ASIdManagerProxy() + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension AttributionTypeFactory: @unchecked Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Attribution/TrackingManagerProxy.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Attribution/TrackingManagerProxy.swift new file mode 100644 index 00000000..65dda6e9 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Attribution/TrackingManagerProxy.swift @@ -0,0 +1,76 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// TrackingManagerProxy.swift +// +// Created by Juanpe Catalán on 14/7/21. +// + +import Foundation + +@objc enum FakeTrackingManagerAuthorizationStatus: Int { + + case notDetermined = 0 + case restricted + case denied + case authorized + +} + +extension FakeTrackingManagerAuthorizationStatus: CustomStringConvertible { + + var description: String { + switch self { + case .notDetermined: return "notDetermined" + case .restricted: return "restricted" + case .denied: return "denied" + case .authorized: return "authorized" + } + } + +} + +// We need this class to avoid Kid apps being rejected for getting idfa. It seems like App +// Review uses some grep to find the class names, so we ended up creating a fake class that +// exposes the same methods we're looking for in ATTrackingManager to call the same methods and mangling +// the class names. So that Apple can't find them during the review, but we can still access them on runtime. +// To be clear, we will NOT try to improperly access idfa. +class FakeTrackingManager: NSObject { + + // We need this method to be available as an optional implicitly unwrapped method for `AnyClass`. + @objc static func trackingAuthorizationStatus() -> Int { + -1 + } + +} + +class TrackingManagerProxy: NSObject { + + static let mangledTrackingClassName = "NGGenpxvatZnantre" + static let mangledAuthStatusPropertyName = "genpxvatNhgubevmngvbaFgnghf" + + static var trackingClass: AnyClass? { + // We need to do this mangling to avoid Kid apps being rejected for getting idfa. + // It looks like during the app review process Apple does some string matching looking for + // functions in ATTrackingTransparency. We apply rot13 on these functions and classes names + // so that Apple can't find them during the review, but we can still access them on runtime. + // To be clear, we will NOT try to improperly access idfa. + NSClassFromString(mangledTrackingClassName.rot13()) + } + + @objc var authorizationStatusPropertyName: String { + Self.mangledAuthStatusPropertyName.rot13() + } + + @objc func trackingAuthorizationStatus() -> Int { + let classType: AnyClass = Self.trackingClass ?? FakeTrackingManager.self + return classType.trackingAuthorizationStatus() + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/CacheStatus.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/CacheStatus.swift new file mode 100644 index 00000000..162ba5d4 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/CacheStatus.swift @@ -0,0 +1,19 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CacheStatus.swift +// +// Created by Toni Rico Diez on 10/3/25. + +enum CacheStatus: String, Codable { + case stale = "STALE" + case notFound = "NOT_FOUND" + case valid = "VALID" + case notChecked = "NOT_CHECKED" +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/Checksum.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/Checksum.swift new file mode 100644 index 00000000..6af00fef --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/Checksum.swift @@ -0,0 +1,92 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Checksum.swift +// RevenueCat +// +// Created by Jacob Zivan Rakidzich on 10/3/25. +// + +import CryptoKit +import Foundation + +/// A checksum +public struct Checksum: Codable, Sendable, Hashable { + + /// The algorithm used to generate the checksum + public let algorithm: Algorithm + + /// the value of the checksum + public let value: String + + /// Creates a checksum + /// - Parameters: + /// - algorithm: The algorithm used + /// - value: The checksum hash + public init(algorithm: Algorithm, value: String) { + self.algorithm = algorithm + self.value = value + } + + enum CodingKeys: String, CodingKey { + case algorithm = "algo" + case value + } + + /// The algoritms supported for generating a checksum + public enum Algorithm: String, Codable, Sendable { + // swiftlint:disable:next missing_docs + case sha256, sha384, sha512, md5 + + func getHasher() -> any HashFunction { + switch self { + case .sha256: + return SHA256() + case .sha384: + return SHA384() + case .sha512: + return SHA512() + case .md5: + return Insecure.MD5() + } + } + } +} + +public extension Checksum { + + /// + /// - Parameters: + /// - data: The data that should be hashed + /// - algorithm: the hashing algorithm + /// - Returns: a ``Checksum`` + static func generate(from data: Data, with algorithm: Checksum.Algorithm) -> Checksum { + switch algorithm { + case .sha256: + return Checksum(algorithm: algorithm, value: data.sha256String) + case .sha384: + return Checksum(algorithm: algorithm, value: data.sha384String) + case .sha512: + return Checksum(algorithm: algorithm, value: data.sha512String) + case .md5: + return Checksum(algorithm: algorithm, value: data.md5String) + } + } + + /// Compare to another checksum + /// - Parameter checksome: Another Checksum + func compare(to checksome: Checksum) throws { + if self != checksome { + throw ChecksumValidationFailure() + } + } + + /// An error describing a checksum validation failure + struct ChecksumValidationFailure: Error { } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/DeviceCache.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/DeviceCache.swift new file mode 100644 index 00000000..6e61b39a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/DeviceCache.swift @@ -0,0 +1,971 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DeviceCache.swift +// +// Created by Joshua Liebowitz on 7/13/21. +// + +import Foundation + +// swiftlint:disable file_length type_body_length + +/// Caches data in `UserDefaults` and in-memory caches. +/// +/// ## Thread Safety +/// +/// `UserDefaults` is thread-safe for individual read and write operations according to Apple's documentation. +/// This means simple get/set operations on single keys are safe without additional synchronization. +/// +/// However, **read-modify-write (RMW) operations are NOT atomic** and can have race conditions when +/// accessed from multiple threads simultaneously. This class uses two different `UserDefaults` wrappers: +/// +/// ### Lock-free wrapper (`userDefaults`) +/// Used for simple read/write operations that don't require atomicity. This avoids deadlocks that can occur +/// when the main thread waits for a lock while a background thread holds it and writes to UserDefaults +/// (which posts `didChangeNotification` to main queue). +/// +/// ### Locking wrapper (`lockingUserDefaults`) +/// Used for **subscriber attribute write operations** which require atomic read-modify-write: +/// - `store(subscriberAttributesByKey:)`, `deleteAttributesIfSynced()`, `copySubscriberAttributes()`, +/// `cleanupSubscriberAttributes()`, `clearCaches()` +/// +/// Read-only subscriber attribute operations (`subscriberAttribute()`, `unsyncedAttributesByKey()`, +/// `unsyncedAttributesForAllUsers()`) use the lock-free wrapper since they don't modify data. +/// +/// The locking wrapper has a potential deadlock risk if called from the main thread while a background thread +/// holds the lock. This will be addressed in a future PR by refactoring subscriber attributes to use +/// individual keys, eliminating the need for RMW operations. +/// +/// ### SK2 Observer Mode Transaction IDs +/// `registerNewSyncedSK2ObserverModeTransactionIDs()` performs RMW using a dedicated lock +/// (`cachedSyncedSK2ObserverModeTransactionIDsLock`) that is separate from the subscriber attributes lock. +/// +/// - SeeAlso: https://github.com/RevenueCat/purchases-ios/issues/4137 +/// - SeeAlso: https://github.com/RevenueCat/purchases-ios/issues/5729 +/// +class DeviceCache { + private static let defaultBasePath = "device-cache" + private static let oldDefaultBasePath = "RevenueCat" + + var cachedAppUserID: String? { return self._cachedAppUserID.value } + var cachedLegacyAppUserID: String? { return self._cachedLegacyAppUserID.value } + var cachedOfferings: Offerings? { self.offeringsCachedObject.cachedInstance } + + private let systemInfo: SystemInfo + private let userDefaults: SynchronizedUserDefaults + private let lockingUserDefaults: LockingSynchronizedUserDefaults + private let largeItemCache: SynchronizedLargeItemCache + private let offeringsCachedObject: InMemoryCachedObject + private let fileManager: FileManager + + private let _cachedAppUserID: Atomic + private let _cachedLegacyAppUserID: Atomic + + private let offeringsCachePreferredLocales: Atomic<[String]> = .init([]) + + private let migrationLock = Lock(.nonRecursive) + + init(systemInfo: SystemInfo, + userDefaults: UserDefaults, + cache: LargeItemCacheType = FileManager.default, + fileManager: FileManager = FileManager.default, + offeringsCachedObject: InMemoryCachedObject = .init()) { + self.offeringsCachedObject = offeringsCachedObject + self.systemInfo = systemInfo + self.userDefaults = .init(userDefaults: userDefaults) + self.lockingUserDefaults = .init(userDefaults: userDefaults) + self._cachedAppUserID = .init(userDefaults.string(forKey: CacheKeys.appUserDefaults)) + self._cachedLegacyAppUserID = .init(userDefaults.string(forKey: CacheKeys.legacyGeneratedAppUserDefaults)) + self.largeItemCache = .init( + cache: cache, + basePath: Self.defaultBasePath + ) + self.fileManager = fileManager + + Logger.verbose(Strings.purchase.device_cache_init(self)) + } + + deinit { + Logger.verbose(Strings.purchase.device_cache_deinit(self)) + } + + // MARK: - generic methods + + private func value(for key: Key) -> Value? { + // Large data used to be stored in the user defaults and resulted in crashes, we need to ensure that + // we are cleaning out that data + userDefaults.write { defaults in + defaults.removeObject(forKey: key) + } + + // Try to get from new cache location first + if let value: Value = try? self.largeItemCache.value(forKey: key.rawValue) { + return value + } + + // Check old documents directory and migrate if found + return self.migrateAndReturnValueIfNeeded(for: key.rawValue) + } + + // MARK: - appUserID + + func cache(appUserID: String) { + self.userDefaults.write { + $0.set(appUserID, forKey: CacheKeys.appUserDefaults) + } + self._cachedAppUserID.value = appUserID + } + + func clearCaches(oldAppUserID: String, andSaveWithNewUserID newUserID: String) { + self.lockingUserDefaults.write { userDefaults in + userDefaults.removeObject(forKey: CacheKeys.legacyGeneratedAppUserDefaults) + userDefaults.removeObject( + forKey: CacheKey.customerInfo(oldAppUserID) + ) + + // Clear CustomerInfo cache timestamp for oldAppUserID. + userDefaults.removeObject(forKey: CacheKey.customerInfoLastUpdated(oldAppUserID)) + + // Clear offerings cache. + self.offeringsCachedObject.clearCache() + // Remove offerings from UserDefaults to clear any pre-existing data from + // before the migration to largeItemCache + userDefaults.removeObject(forKey: CacheKey.offerings(oldAppUserID)) + + // Clear virtual currencies cache + userDefaults.removeObject(forKey: CacheKey.virtualCurrencies(oldAppUserID)) + + // Delete attributes if synced for the old app user id. + if Self.unsyncedAttributesByKey(userDefaults, appUserID: oldAppUserID).isEmpty { + var attributes = Self.storedAttributesForAllUsers(userDefaults) + attributes.removeValue(forKey: oldAppUserID) + userDefaults.set(attributes, forKey: CacheKeys.subscriberAttributes) + } + + // Cache new appUserID. + userDefaults.set(newUserID, forKey: CacheKeys.appUserDefaults) + self._cachedAppUserID.value = newUserID + self._cachedLegacyAppUserID.value = nil + } + + // Clear offerings cache from large item cache + self.largeItemCache.removeObject(forKey: CacheKey.offerings(oldAppUserID).rawValue) + + // Delete old offerings file from documents directory if it exists + self.deleteOldFileIfNeeded(for: CacheKey.offerings(oldAppUserID).rawValue) + } + + // MARK: - CustomerInfo + + func cachedCustomerInfoData(appUserID: String) -> Data? { + return self.userDefaults.read { + $0.data(forKey: CacheKey.customerInfo(appUserID)) + } + } + + func cache(customerInfo: Data, appUserID: String) { + self.userDefaults.write { + $0.set(customerInfo, forKey: CacheKey.customerInfo(appUserID)) + Self.setCustomerInfoCacheTimestampToNow($0, appUserID: appUserID) + } + } + + func isCustomerInfoCacheStale(appUserID: String, isAppBackgrounded: Bool) -> Bool { + return self.userDefaults.read { + guard let cachesLastUpdated = Self.customerInfoLastUpdated($0, appUserID: appUserID) else { + return true + } + + let timeSinceLastCheck = cachesLastUpdated.timeIntervalSinceNow * -1 + let cacheDurationInSeconds = self.cacheDurationInSeconds( + isAppBackgrounded: isAppBackgrounded, + isSandbox: self.systemInfo.isSandbox + ) + + return timeSinceLastCheck >= cacheDurationInSeconds + } + } + + func clearCustomerInfoCacheTimestamp(appUserID: String) { + self.userDefaults.write { + Self.clearCustomerInfoCacheTimestamp($0, appUserID: appUserID) + } + } + + func setCustomerInfoCache(timestamp: Date, appUserID: String) { + self.userDefaults.write { + Self.setCustomerInfoCache($0, timestamp: timestamp, appUserID: appUserID) + } + } + + func clearCustomerInfoCache(appUserID: String) { + self.userDefaults.write { + Self.clearCustomerInfoCacheTimestamp($0, appUserID: appUserID) + $0.removeObject(forKey: CacheKey.customerInfo(appUserID)) + } + } + + // MARK: - Offerings + + func cachedOfferingsContents(appUserID: String) -> Offerings.Contents? { + return self.value(for: CacheKey.offerings(appUserID)) + } + + func cache(offerings: Offerings, preferredLocales: [String], appUserID: String) { + // We can't get the preferred locales from the `systemInfo` object because they may change + // during the get offerings request, before this cache method gets called. + // For the cache we need the preferred locales that were used in the request. + self.cacheInMemory(offerings: offerings) + self.offeringsCachePreferredLocales.value = preferredLocales + + let key = CacheKey.offerings(appUserID).rawValue + if self.largeItemCache.set(codable: offerings.contents, forKey: key) { + + // Delete old file from documents directory if it exists + self.deleteOldFileIfNeeded(for: key) + } + } + + func cacheInMemory(offerings: Offerings) { + self.offeringsCachedObject.cache(instance: offerings) + } + + func clearOfferingsCache(appUserID: String) { + self.offeringsCachedObject.clearCache() + self.offeringsCachePreferredLocales.value = [] + self.largeItemCache.removeObject(forKey: CacheKey.offerings(appUserID).rawValue) + + // Delete old offerings file from documents directory if it exists + self.deleteOldFileIfNeeded(for: CacheKey.offerings(appUserID).rawValue) + } + + func isOfferingsCacheStale(isAppBackgrounded: Bool) -> Bool { + // Time-based staleness, or + return self.offeringsCachedObject.isCacheStale( + durationInSeconds: self.cacheDurationInSeconds(isAppBackgrounded: isAppBackgrounded, + isSandbox: self.systemInfo.isSandbox) + ) || + // Locale-based staleness + self.offeringsCachePreferredLocales.value != self.systemInfo.preferredLocales + } + + func forceOfferingsCacheStale() { + self.offeringsCachedObject.clearCacheTimestamp() + self.offeringsCachePreferredLocales.value = [] + } + + func offeringsCacheStatus(isAppBackgrounded: Bool) -> CacheStatus { + if self.offeringsCachedObject.cachedInstance == nil { + return .notFound + } else if self.isOfferingsCacheStale(isAppBackgrounded: isAppBackgrounded) { + return .stale + } else { + return .valid + } + } + + // MARK: - subscriber attributes + // Write operations use `lockingUserDefaults` to ensure atomic read-modify-write. + // Read operations use the lock-free `userDefaults` since they don't modify data. + // This will be addressed in a future PR by refactoring subscriber attributes to use + // individual keys, eliminating the need for RMW operations. + + func store(subscriberAttribute: SubscriberAttribute, appUserID: String) { + store(subscriberAttributesByKey: [subscriberAttribute.key: subscriberAttribute], appUserID: appUserID) + } + + func store(subscriberAttributesByKey: [String: SubscriberAttribute], appUserID: String) { + guard !subscriberAttributesByKey.isEmpty else { + return + } + + self.lockingUserDefaults.write { + Self.store($0, subscriberAttributesByKey: subscriberAttributesByKey, appUserID: appUserID) + } + } + + func subscriberAttribute(attributeKey: String, appUserID: String) -> SubscriberAttribute? { + return self.userDefaults.read { + Self.storedSubscriberAttributes($0, appUserID: appUserID)[attributeKey] + } + } + + func unsyncedAttributesByKey(appUserID: String) -> [String: SubscriberAttribute] { + return self.userDefaults.read { + Self.unsyncedAttributesByKey($0, appUserID: appUserID) + } + } + + func numberOfUnsyncedAttributes(appUserID: String) -> Int { + return self.unsyncedAttributesByKey(appUserID: appUserID).count + } + + func cleanupSubscriberAttributes() { + self.lockingUserDefaults.write { + Self.migrateSubscriberAttributes($0) + Self.deleteSyncedSubscriberAttributesForOtherUsers($0) + } + } + + func unsyncedAttributesForAllUsers() -> [String: [String: SubscriberAttribute]] { + self.userDefaults.read { + let attributesDict = $0.dictionary(forKey: CacheKeys.subscriberAttributes) ?? [:] + var attributes: [String: [String: SubscriberAttribute]] = [:] + for (appUserID, attributesDictForUser) in attributesDict { + var attributesForUser: [String: SubscriberAttribute] = [:] + let attributesDictForUser = attributesDictForUser as? [String: [String: Any]] ?? [:] + for (attributeKey, attributeDict) in attributesDictForUser { + if let attribute = SubscriberAttribute(dictionary: attributeDict), !attribute.isSynced { + attributesForUser[attributeKey] = attribute + } + } + if attributesForUser.count > 0 { + attributes[appUserID] = attributesForUser + } + } + return attributes + } + } + + func deleteAttributesIfSynced(appUserID: String) { + self.lockingUserDefaults.write { + guard Self.unsyncedAttributesByKey($0, appUserID: appUserID).isEmpty else { + return + } + Self.deleteAllAttributes($0, appUserID: appUserID) + } + } + + func copySubscriberAttributes(oldAppUserID: String, newAppUserID: String) { + self.lockingUserDefaults.write { + let unsyncedAttributesToCopy = Self.unsyncedAttributesByKey($0, appUserID: oldAppUserID) + guard !unsyncedAttributesToCopy.isEmpty else { + return + } + + Logger.info(Strings.attribution.copying_attributes(oldAppUserID: oldAppUserID, newAppUserID: newAppUserID)) + Self.store($0, subscriberAttributesByKey: unsyncedAttributesToCopy, appUserID: newAppUserID) + Self.deleteAllAttributes($0, appUserID: oldAppUserID) + } + } + + // MARK: - attribution + + func latestAdvertisingIdsByNetworkSent(appUserID: String) -> [AttributionNetwork: String] { + return self.userDefaults.read { + let key = CacheKey.attributionDataDefaults(appUserID) + let latestAdvertisingIdsByRawNetworkSent = $0.object(forKey: key.rawValue) as? [String: String] ?? [:] + + let latestSent: [AttributionNetwork: String] = + latestAdvertisingIdsByRawNetworkSent.compactMapKeys { networkKey in + guard let networkRawValue = Int(networkKey), + let attributionNetwork = AttributionNetwork(rawValue: networkRawValue) else { + Logger.error( + Strings.attribution.latest_attribution_sent_user_defaults_invalid( + networkKey: networkKey + ) + ) + return nil + } + return attributionNetwork + } + + return latestSent + } + } + + func set(latestAdvertisingIdsByNetworkSent: [AttributionNetwork: String], appUserID: String) { + self.userDefaults.write { + let latestAdIdsByRawNetworkStringSent = latestAdvertisingIdsByNetworkSent.mapKeys { String($0.rawValue) } + $0.set(latestAdIdsByRawNetworkStringSent, + forKey: CacheKey.attributionDataDefaults(appUserID)) + } + } + + func clearLatestNetworkAndAdvertisingIdsSent(appUserID: String) { + self.userDefaults.write { + $0.removeObject(forKey: CacheKey.attributionDataDefaults(appUserID)) + } + } + + private func cacheDurationInSeconds(isAppBackgrounded: Bool, isSandbox: Bool) -> TimeInterval { + return CacheDuration.duration(status: .init(backgrounded: isAppBackgrounded), + environment: .init(sandbox: isSandbox)) + } + + // MARK: - Products Entitlements + + var isProductEntitlementMappingCacheStale: Bool { + return self.userDefaults.read { + guard let cacheLastUpdated = Self.productEntitlementMappingLastUpdated($0) else { + return true + } + + let cacheAge = Date().timeIntervalSince(cacheLastUpdated) + return cacheAge > DeviceCache.productEntitlementMappingCacheDuration.seconds + } + } + + func store(productEntitlementMapping: ProductEntitlementMapping) { + let productEntitlementMappingKey = CacheKeys.productEntitlementMapping.rawValue + if self.largeItemCache.set( + codable: productEntitlementMapping, + forKey: productEntitlementMappingKey + ) { + self.userDefaults.write { + $0.set(Date(), forKey: CacheKeys.productEntitlementMappingLastUpdated) + } + + // Delete old file if it still exists + self.deleteOldFileIfNeeded(for: productEntitlementMappingKey) + } + } + + var cachedProductEntitlementMapping: ProductEntitlementMapping? { + return self.value(for: CacheKeys.productEntitlementMapping) + } + + // MARK: - StoreKit 2 + private let cachedSyncedSK2ObserverModeTransactionIDsLock = Lock(.nonRecursive) + + func registerNewSyncedSK2ObserverModeTransactionIDs(_ ids: [UInt64]) { + cachedSyncedSK2ObserverModeTransactionIDsLock.perform { + var transactionIDs = self.userDefaults.read { userDefaults in + userDefaults.array( + forKey: CacheKey.syncedSK2ObserverModeTransactionIDs.rawValue) as? [UInt64] + } ?? [] + + transactionIDs.append(contentsOf: ids) + + self.userDefaults.write { + $0.set( + transactionIDs, + forKey: CacheKey.syncedSK2ObserverModeTransactionIDs + ) + } + } + } + + func cachedSyncedSK2ObserverModeTransactionIDs() -> [UInt64] { + cachedSyncedSK2ObserverModeTransactionIDsLock.perform { + return self.userDefaults.read { userDefaults in + userDefaults.array( + forKey: CacheKey.syncedSK2ObserverModeTransactionIDs.rawValue) as? [UInt64] ?? [] + } + } + } + + // MARK: - Virtual Currencies + + func cache( + virtualCurrencies: Data, + appUserID: String + ) { + self.userDefaults.write { + $0.set(virtualCurrencies, forKey: CacheKey.virtualCurrencies(appUserID)) + Self.setVirtualCurrenciesCacheLastUpdatedTimestampToNow($0, appUserID: appUserID) + } + } + + func cachedVirtualCurrenciesData(forAppUserID appUserID: String) -> Data? { + return self.userDefaults.read { + $0.data(forKey: CacheKey.virtualCurrencies(appUserID)) + } + } + + func isVirtualCurrenciesCacheStale(appUserID: String, isAppBackgrounded: Bool) -> Bool { + return self.userDefaults.read { + guard let cachesLastUpdated = Self.virtualCurrenciesLastUpdated($0, appUserID: appUserID) else { + return true + } + + let timeSinceLastCheck = cachesLastUpdated.timeIntervalSinceNow * -1 + let cacheDurationInSeconds = self.cacheDurationInSeconds( + isAppBackgrounded: isAppBackgrounded, + isSandbox: self.systemInfo.isSandbox + ) + + return timeSinceLastCheck >= cacheDurationInSeconds + } + } + + func clearVirtualCurrenciesCache(appUserID: String) { + self.userDefaults.write { + Self.clearVirtualCurrenciesCacheLastUpdatedTimestamp($0, appUserID: appUserID) + $0.removeObject(forKey: CacheKey.virtualCurrencies(appUserID)) + } + } + + func clearVirtualCurrenciesCacheLastUpdatedTimestamp(appUserID: String) { + self.userDefaults.write { + Self.clearVirtualCurrenciesCacheLastUpdatedTimestamp($0, appUserID: appUserID) + } + } + + func setVirtualCurrenciesCacheLastUpdatedTimestamp( + timestamp: Date, + appUserID: String + ) { + self.userDefaults.write { + Self.setVirtualCurrenciesCacheLastUpdatedTimestamp($0, timestamp: timestamp, appUserID: appUserID) + } + } + + // MARK: - Helper functions + internal enum CacheKeys: String, DeviceCacheKeyType { + + case legacyGeneratedAppUserDefaults = "com.revenuecat.userdefaults.appUserID" + case appUserDefaults = "com.revenuecat.userdefaults.appUserID.new" + case subscriberAttributes = "com.revenuecat.userdefaults.subscriberAttributes" + case productEntitlementMapping = "com.revenuecat.userdefaults.productEntitlementMapping" + case productEntitlementMappingLastUpdated = "com.revenuecat.userdefaults.productEntitlementMappingLastUpdated" + + } + + internal enum CacheKey: DeviceCacheKeyType { + + static let base = "com.revenuecat.userdefaults." + static let legacySubscriberAttributesBase = "\(Self.base)subscriberAttributes." + + case customerInfo(String) + case customerInfoLastUpdated(String) + case offerings(String) + case legacySubscriberAttributes(String) + case attributionDataDefaults(String) + case syncedSK2ObserverModeTransactionIDs + case virtualCurrencies(String) + case virtualCurrenciesLastUpdated(String) + + var rawValue: String { + switch self { + case let .customerInfo(userID): return "\(Self.base)purchaserInfo.\(userID)" + case let .customerInfoLastUpdated(userID): return "\(Self.base)purchaserInfoLastUpdated.\(userID)" + case let .offerings(userID): return "\(Self.base)offerings.\(userID)" + case let .legacySubscriberAttributes(userID): return "\(Self.legacySubscriberAttributesBase)\(userID)" + case let .attributionDataDefaults(userID): return "\(Self.base)attribution.\(userID)" + case .syncedSK2ObserverModeTransactionIDs: + return "\(Self.base)syncedSK2ObserverModeTransactionIDs" + case let .virtualCurrencies(userID): return "\(Self.base)virtualCurrencies.\(userID)" + case let .virtualCurrenciesLastUpdated(userID): return "\(Self.base)virtualCurrenciesLastUpdated.\(userID)" + } + } + + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension DeviceCache: @unchecked Sendable {} + +// MARK: - + +extension DeviceCache: ProductEntitlementMappingFetcher { + + var productEntitlementMapping: ProductEntitlementMapping? { + return self.cachedProductEntitlementMapping + } + +} + +// MARK: - Private + +// All methods that modify or read from the UserDefaults data source but require external mechanisms for ensuring +// mutual exclusion. +private extension DeviceCache { + + static func appUserIDsWithLegacyAttributes(_ userDefaults: UserDefaults) -> [String] { + var appUserIDsWithLegacyAttributes: [String] = [] + + let userDefaultsDict = userDefaults.dictionaryRepresentation() + for key in userDefaultsDict.keys where key.starts(with: CacheKey.base) { + let appUserID = key.replacingOccurrences(of: CacheKey.legacySubscriberAttributesBase, with: "") + appUserIDsWithLegacyAttributes.append(appUserID) + } + + return appUserIDsWithLegacyAttributes + } + + static func cachedAppUserID(_ userDefaults: UserDefaults) -> String? { + userDefaults.string(forKey: CacheKeys.appUserDefaults) + } + + static func storedAttributesForAllUsers(_ userDefaults: UserDefaults) -> [String: Any] { + let attributes = userDefaults.dictionary(forKey: CacheKeys.subscriberAttributes) ?? [:] + return attributes + } + + static func customerInfoLastUpdated( + _ userDefaults: UserDefaults, + appUserID: String + ) -> Date? { + return userDefaults.date(forKey: CacheKey.customerInfoLastUpdated(appUserID)) + } + + static func clearCustomerInfoCacheTimestamp( + _ userDefaults: UserDefaults, + appUserID: String + ) { + userDefaults.removeObject(forKey: CacheKey.customerInfoLastUpdated(appUserID)) + } + + static func unsyncedAttributesByKey( + _ userDefaults: UserDefaults, + appUserID: String + ) -> [String: SubscriberAttribute] { + let allSubscriberAttributesByKey = Self.storedSubscriberAttributes( + userDefaults, + appUserID: appUserID + ) + var unsyncedAttributesByKey: [String: SubscriberAttribute] = [:] + for attribute in allSubscriberAttributesByKey.values where !attribute.isSynced { + unsyncedAttributesByKey[attribute.key] = attribute + } + return unsyncedAttributesByKey + } + + static func store( + _ userDefaults: UserDefaults, + subscriberAttributesByKey: [String: SubscriberAttribute], + appUserID: String + ) { + var groupedSubscriberAttributes = Self.storedAttributesForAllUsers(userDefaults) + var subscriberAttributesForAppUserID = groupedSubscriberAttributes[appUserID] as? [String: Any] ?? [:] + for (key, attributes) in subscriberAttributesByKey { + subscriberAttributesForAppUserID[key] = attributes.asDictionary() + } + groupedSubscriberAttributes[appUserID] = subscriberAttributesForAppUserID + userDefaults.set(groupedSubscriberAttributes, forKey: CacheKeys.subscriberAttributes) + } + + static func deleteAllAttributes( + _ userDefaults: UserDefaults, + appUserID: String + ) { + var groupedAttributes = Self.storedAttributesForAllUsers(userDefaults) + let attributesForAppUserID = groupedAttributes.removeValue(forKey: appUserID) + guard attributesForAppUserID != nil else { + Logger.warn(Strings.identity.deleting_attributes_none_found) + return + } + userDefaults.set(groupedAttributes, forKey: CacheKeys.subscriberAttributes) + } + + static func setCustomerInfoCache( + _ userDefaults: UserDefaults, + timestamp: Date, + appUserID: String + ) { + userDefaults.set(timestamp, forKey: CacheKey.customerInfoLastUpdated(appUserID)) + } + + static func setCustomerInfoCacheTimestampToNow( + _ userDefaults: UserDefaults, + appUserID: String + ) { + Self.setCustomerInfoCache(userDefaults, timestamp: Date(), appUserID: appUserID) + } + + static func subscriberAttributes( + _ userDefaults: UserDefaults, + appUserID: String + ) -> [String: Any] { + return Self.storedAttributesForAllUsers(userDefaults)[appUserID] as? [String: Any] ?? [:] + } + + static func storedSubscriberAttributes( + _ userDefaults: UserDefaults, + appUserID: String + ) -> [String: SubscriberAttribute] { + let allAttributesObjectsByKey = Self.subscriberAttributes(userDefaults, appUserID: appUserID) + var allSubscriberAttributesByKey: [String: SubscriberAttribute] = [:] + for (key, attributeDict) in allAttributesObjectsByKey { + if let dictionary = attributeDict as? [String: Any], + let attribute = SubscriberAttribute(dictionary: dictionary) { + allSubscriberAttributesByKey[key] = attribute + } + } + + return allSubscriberAttributesByKey + } + + static func migrateSubscriberAttributes(_ userDefaults: UserDefaults) { + let appUserIDsWithLegacyAttributes = Self.appUserIDsWithLegacyAttributes(userDefaults) + var attributesInNewFormat = userDefaults.dictionary(forKey: CacheKeys.subscriberAttributes) ?? [:] + for appUserID in appUserIDsWithLegacyAttributes { + let legacyAttributes = userDefaults.dictionary( + forKey: CacheKey.legacySubscriberAttributes(appUserID)) ?? [:] + let existingAttributes = Self.subscriberAttributes(userDefaults, + appUserID: appUserID) + let allAttributesForUser = legacyAttributes.merging(existingAttributes) + attributesInNewFormat[appUserID] = allAttributesForUser + + userDefaults.removeObject(forKey: CacheKey.legacySubscriberAttributes(appUserID)) + + } + userDefaults.set(attributesInNewFormat, forKey: CacheKeys.subscriberAttributes) + } + + static func deleteSyncedSubscriberAttributesForOtherUsers( + _ userDefaults: UserDefaults + ) { + let allStoredAttributes: [String: [String: Any]] + = userDefaults.dictionary(forKey: CacheKeys.subscriberAttributes) + as? [String: [String: Any]] ?? [:] + + var filteredAttributes: [String: Any] = [:] + + // swiftlint:disable:next force_unwrapping + let currentAppUserID = Self.cachedAppUserID(userDefaults)! + + filteredAttributes[currentAppUserID] = allStoredAttributes[currentAppUserID] + + for appUserID in allStoredAttributes.keys where appUserID != currentAppUserID { + var unsyncedAttributesForUser: [String: [String: Any]] = [:] + let allStoredAttributesForAppUserID = allStoredAttributes[appUserID] as? [String: [String: Any]] ?? [:] + for (attributeKey, storedAttributesForUser) in allStoredAttributesForAppUserID { + if let attribute = SubscriberAttribute(dictionary: storedAttributesForUser), !attribute.isSynced { + unsyncedAttributesForUser[attributeKey] = storedAttributesForUser + } + } + + if !unsyncedAttributesForUser.isEmpty { + filteredAttributes[appUserID] = unsyncedAttributesForUser + } + } + + userDefaults.set(filteredAttributes, forKey: CacheKeys.subscriberAttributes) + } + + static func productEntitlementMappingLastUpdated(_ userDefaults: UserDefaults) -> Date? { + return userDefaults.date(forKey: CacheKeys.productEntitlementMappingLastUpdated) + } + + static func virtualCurrenciesLastUpdated( + _ userDefaults: UserDefaults, + appUserID: String + ) -> Date? { + return userDefaults.date(forKey: CacheKey.virtualCurrenciesLastUpdated(appUserID)) + } + + static func setVirtualCurrenciesCacheLastUpdatedTimestamp( + _ userDefaults: UserDefaults, + timestamp: Date, + appUserID: String + ) { + userDefaults.set(timestamp, forKey: CacheKey.virtualCurrenciesLastUpdated(appUserID)) + } + + static func setVirtualCurrenciesCacheLastUpdatedTimestampToNow( + _ userDefaults: UserDefaults, + appUserID: String + ) { + Self.setVirtualCurrenciesCacheLastUpdatedTimestamp(userDefaults, timestamp: Date(), appUserID: appUserID) + } + + static func clearVirtualCurrenciesCacheLastUpdatedTimestamp( + _ userDefaults: UserDefaults, + appUserID: String + ) { + userDefaults.removeObject(forKey: CacheKey.virtualCurrenciesLastUpdated(appUserID)) + } +} + +fileprivate extension UserDefaults { + + func set(_ value: Any?, forKey key: DeviceCacheKeyType) { + self.set(value, forKey: key.rawValue) + } + + func string(forKey defaultName: DeviceCacheKeyType) -> String? { + return self.string(forKey: defaultName.rawValue) + } + + func removeObject(forKey defaultName: DeviceCacheKeyType) { + self.removeObject(forKey: defaultName.rawValue) + } + + func dictionary(forKey defaultName: DeviceCacheKeyType) -> [String: Any]? { + return self.dictionary(forKey: defaultName.rawValue) + } + + func date(forKey defaultName: DeviceCacheKeyType) -> Date? { + return self.object(forKey: defaultName.rawValue) as? Date + } + + func data(forKey key: DeviceCacheKeyType) -> Data? { + return self.data(forKey: key.rawValue) + } + +} + +private extension DeviceCache { + + enum CacheDuration { + + // swiftlint:disable:next nesting + enum AppStatus { + + case foreground + case background + + init(backgrounded: Bool) { + self = backgrounded ? .background : .foreground + } + + } + + // swiftlint:disable:next nesting + enum Environment { + + case production + case sandbox + + init(sandbox: Bool) { + self = sandbox ? .sandbox : .production + } + + } + + static func duration(status: AppStatus, environment: Environment) -> TimeInterval { + switch (environment, status) { + case (.production, .foreground): return 60 * 5.0 + case (.production, .background): return 60 * 60 * 25.0 + + case (.sandbox, .foreground): return 60 * 5.0 + case (.sandbox, .background): return 60 * 5.0 + } + } + + } + + static let productEntitlementMappingCacheDuration: DispatchTimeInterval = .hours(25) + + /* + We were previously storing these cache files in the Documents directory + which may end up in the Files app or the user's Documents directory on macOS. + We'll migrate files to the caches directory and try to delete the old documents + directory if it's empty after migrating. + */ + + // MARK: - Migration Helpers + + // swiftlint:disable avoid_using_directory_apis_directly + private func oldDocumentsDirectoryURL() -> URL? { + let documentsDirectoryURL: URL? + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + documentsDirectoryURL = URL.documentsDirectory + } else { + documentsDirectoryURL = fileManager.urls( + for: .documentDirectory, + in: .userDomainMask + ).first + } + return documentsDirectoryURL?.appendingPathComponent(Self.oldDefaultBasePath) + } + + // swiftlint:enable avoid_using_directory_apis_directly + private func migrateAndReturnValueIfNeeded(for key: String) -> Value? { + return self.migrationLock.perform { + guard let oldDirectoryURL = self.oldDocumentsDirectoryURL() else { return nil } + + let oldFileURL = oldDirectoryURL.appendingPathComponent(key) + + // Check if file already exists in new location (it may have been migrated before the lock was released) + if let value: Value = try? self.largeItemCache.value(forKey: key) { + // File already migrated, clean up old file if it still exists + if fileManager.fileExists(atPath: oldFileURL.path) { + try? fileManager.removeItem(at: oldFileURL) + self.deleteOldDocumentsDirectoryIfEmpty() + } + return value + } + + // Make sure the old file (still) exists + guard fileManager.fileExists(atPath: oldFileURL.path) else { + return nil + } + + // Try to load from old location + // If decoding of the file from the old location fails, remove it since the file is corrupt + guard let data = try? Data(contentsOf: oldFileURL), + let value: Value = try? JSONDecoder.default.decode(jsonData: data, logErrors: true) else { + try? fileManager.removeItem(at: oldFileURL) + return nil + } + + guard let newCacheURL = fileManager.createCacheDirectoryIfNeeded(basePath: Self.defaultBasePath) else { + return nil + } + let newFileURL = newCacheURL.appendingPathComponent(key) + + // Make sure the new location exists + guard fileManager.fileExists(atPath: newCacheURL.path) else { + return value + } + + // Migrate file to new location + do { + try fileManager.moveItem(at: oldFileURL, to: newFileURL) + self.deleteOldDocumentsDirectoryIfEmpty() + } catch { + Logger.error(Strings.cache.failed_to_migrate_file(oldFileURL.path, error)) + } + + return value + } + } + + private func deleteOldFileIfNeeded(for key: String) { + guard let oldDirectoryURL = self.oldDocumentsDirectoryURL() else { + return + } + + let oldFileURL = oldDirectoryURL.appendingPathComponent(key) + + // Use fileManager directly for file operations since LargeItemCacheType doesn't provide fileExists + guard fileManager.fileExists(atPath: oldFileURL.path) else { + return + } + + // Delete old file if it exists + do { + try fileManager.removeItem(at: oldFileURL) + self.deleteOldDocumentsDirectoryIfEmpty() + } catch { + Logger.error(Strings.cache.failed_to_delete_old_cache_directory(error)) + } + } + + private func deleteOldDocumentsDirectoryIfEmpty() { + guard let oldDirectoryURL = self.oldDocumentsDirectoryURL() else { + return + } + + // Use fileManager directly for file operations since LargeItemCacheType doesn't provide these + guard fileManager.fileExists(atPath: oldDirectoryURL.path), + (try? fileManager.contentsOfDirectory(atPath: oldDirectoryURL.path).isEmpty) == true else { + return + } + + do { + try fileManager.removeItem(at: oldDirectoryURL) + } catch { + Logger.error(Strings.cache.failed_to_delete_old_cache_directory(error)) + } + } + +} + +protocol DeviceCacheKeyType { + + var rawValue: String { get } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/DirectoryHelper.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/DirectoryHelper.swift new file mode 100644 index 00000000..dd0466c4 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/DirectoryHelper.swift @@ -0,0 +1,68 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DirectoryHelper.swift +// +// Created by Rick van der Linden on 7/1/26. +// + +import Foundation + +enum DirectoryHelper { + + enum DirectoryType { + case cache + case applicationSupport(overrideURL: URL? = nil) + } + + static func baseUrl(for type: DirectoryType, inAppSpecificDirectory: Bool = true) -> URL? { + guard let baseDirectory = type.url, let bundleIdentifier = Bundle.main.bundleIdentifier else { + return nil + } + + guard inAppSpecificDirectory else { + return baseDirectory + } + + let appSpecificRevenueCatDirectory = "\(bundleIdentifier).revenuecat" + + return baseDirectory.appendingPathComponent(appSpecificRevenueCatDirectory) + } +} + +fileprivate extension DirectoryHelper.DirectoryType { + var url: URL? { + switch self { + case .cache: + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + return URL.cachesDirectory + } else { + return FileManager.default.urls( + for: .cachesDirectory, + in: .userDomainMask + ).first + } + case .applicationSupport(let overrideURL): + if let overrideURL { + return overrideURL + } + + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + return URL.applicationSupportDirectory + } else { + return try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + } + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/FileRepository.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/FileRepository.swift new file mode 100644 index 00000000..c8ba920d --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/FileRepository.swift @@ -0,0 +1,194 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// FileRepository.swift +// +// Created by Jacob Zivan Rakidzich on 8/13/25. + +import Foundation + +/// A file repository +@available(iOS 15.0, macOS 12.0, tvOS 15.0, visionOS 1.0, watchOS 8.0, *) +@_spi(Internal) public final class FileRepository: FileRepositoryType, @unchecked Sendable { + /// A shared file repository + @_spi(Internal) public static let shared = FileRepository() + + private static let defaultBasePath = "RevenueCat" + + let networkService: SimpleNetworkServiceType + + private let store = KeyedDeferredValueStore() + private let fileManager: LargeItemCacheType + private let cacheURL: URL? + + /// Create a file repository + /// - Parameters: + /// - networkService: A service capable of fetching data from a URL + /// - fileManager: A service capable of storing data and returning the URL where that stored data exists + init( + networkService: SimpleNetworkServiceType = URLSession.shared, + fileManager: LargeItemCacheType = FileManager.default, + basePath: String = FileRepository.defaultBasePath + ) { + self.networkService = networkService + self.fileManager = fileManager + + self.cacheURL = fileManager.createCacheDirectoryIfNeeded( + basePath: basePath, + /* + In order to use the app specific directory structure the existing + cached files will have to be moved first. + Until that happens we'll keep using the existing 'RevenueCat' directory + */ + inAppSpecificDirectory: false + ) + } + + /// Create a file repository + @_spi(Internal) public convenience init() { + self.init( + networkService: URLSession.shared, + fileManager: FileManager.default + ) + } + + /// Prefetch files at the given urls + /// - Parameter urls: An array of URL to fetch data from + @_spi(Internal) public func prefetch(urls: [InputURL]) { + for url in urls { + Task { [weak self] in + try await self?.generateOrGetCachedFileURL(for: url, withChecksum: nil) + } + } + } + + /// Create and/or get the cached file url + /// - Parameters: + /// - url: The url for the remote data to cache into a file + @_spi(Internal) public func generateOrGetCachedFileURL( + for url: InputURL, + withChecksum checksum: Checksum? + ) async throws -> OutputURL { + return try await store.getOrPut( + Task { [weak self] in + guard let self, + let cachedUrl = self.generateLocalFilesystemURL( + forRemoteURL: url, + withChecksum: checksum + ) + else { + Logger.error(Strings.fileRepository.failedToCreateCacheDirectory(url)) + throw Error.failedToCreateCacheDirectory(url.absoluteString) + } + + if self.fileManager.cachedContentExists(at: cachedUrl) { + return cachedUrl + } + + let bytes = try await self.getBytes(from: url) + + try await self.saveCachedFile(url: cachedUrl, fromBytes: bytes, withChecksum: checksum) + return cachedUrl + }, + forKey: JobKey(url, checksum) + ).value + } + + /// Get the cached file url (if it exists) + /// - Parameters: + /// - url: The url for the remote data to cache into a file + @_spi(Internal) public func getCachedFileURL(for url: InputURL, withChecksum checksum: Checksum?) -> OutputURL? { + let cachedUrl = self.generateLocalFilesystemURL(forRemoteURL: url, withChecksum: checksum) + + if let cachedUrl, self.fileManager.cachedContentExists(at: cachedUrl) { + return cachedUrl + } + + return nil + } + + private func getBytes(from url: URL) async throws -> AsyncThrowingStream { + do { + return try await networkService.bytes(from: url) + } catch { + let message = Strings.fileRepository.failedToFetchFileFromRemoteSource(url, error) + Logger.error(message) + throw Error.failedToFetchFileFromRemoteSource(message.description) + } + } + + private func saveCachedFile( + url: URL, + fromBytes bytes: AsyncThrowingStream, + withChecksum checksum: Checksum? + ) async throws { + do { + try await fileManager.saveData(bytes, to: url, checksum: checksum) + } catch { + let message = Strings.fileRepository.failedToSaveCachedFile(url, error) + Logger.error(message) + throw Error.failedToSaveCachedFile(message.description) + } + } + + func generateLocalFilesystemURL(forRemoteURL url: URL, withChecksum checksum: Checksum?) -> URL? { + let path = checksum?.value ?? url.absoluteString.asData.md5String + return cacheURL? + .appendingPathComponent(path + url.lastPathComponent) + } +} + +/// A file cache +@_spi(Internal) public protocol FileRepositoryType: Sendable { + + /// Prefetch files at the given urls + /// - Parameter urls: An array of URL to fetch data from + func prefetch(urls: [InputURL]) + + /// Create and/or get the cached file url + /// - Parameters: + /// - url: The url for the remote data to cache into a file + /// - checksum: A checksum of the remote file if there is any + func generateOrGetCachedFileURL( + for url: InputURL, + withChecksum checksum: Checksum? + ) async throws -> OutputURL +} + +/// The input URL is the URL that the repository will read remote data from +@_spi(Internal) public typealias InputURL = URL + +/// The output URL is the local file's URL where the data can be found after caching is complete +@_spi(Internal) public typealias OutputURL = URL + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, visionOS 1.0, watchOS 8.0, *) +extension FileRepository { + + /// File repository error cases + @_spi(Internal) public enum Error: Swift.Error { + /// Used when creating the folder on disk fails + case failedToCreateCacheDirectory(String) + + /// Used when saving the file on disk fails + case failedToSaveCachedFile(String) + + /// Used when fetching the data fails + case failedToFetchFileFromRemoteSource(String) + } +} + +private struct JobKey: Hashable { + let url: InputURL + let checksum: Checksum? + + init(_ url: InputURL, _ checksum: Checksum?) { + self.url = url + self.checksum = checksum + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/InMemoryCachedObject.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/InMemoryCachedObject.swift new file mode 100644 index 00000000..995ac7fd --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/InMemoryCachedObject.swift @@ -0,0 +1,69 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// InMemoryCachedObject.swift +// +// Created by Joshua Liebowitz on 7/13/21. +// + +import Foundation + +class InMemoryCachedObject { + + private typealias Data = (cachedObject: T?, lastUpdated: Date?) + + private let content: Atomic = .init((nil, nil)) + + var lastUpdatedAt: Date? { + return self.content.value.lastUpdated + } + + func isCacheStale(durationInSeconds: Double) -> Bool { + return self.content.withValue { + guard let lastUpdated = $0.lastUpdated else { + return true + } + + let timeSinceLastCheck = -1.0 * lastUpdated.timeIntervalSinceNow + return timeSinceLastCheck >= durationInSeconds + } + } + + func clearCacheTimestamp() { + self.content.modify { $0.lastUpdated = nil } + } + + func clearCache() { + self.content.modify { + $0.cachedObject = nil + $0.lastUpdated = nil + } + } + + func updateCacheTimestamp(date: Date) { + self.content.modify { + $0.lastUpdated = date + } + } + + func cache(instance: T) { + self.content.modify { + $0.lastUpdated = Date() + $0.cachedObject = instance + } + } + + var cachedInstance: T? { + return self.content.value.cachedObject + } +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension InMemoryCachedObject: @unchecked Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/KeyedDeferredValueStore.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/KeyedDeferredValueStore.swift new file mode 100644 index 00000000..40d3431b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/KeyedDeferredValueStore.swift @@ -0,0 +1,74 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// KeyedDeferredValueStore.swift +// +// Created by Jacob Zivan Rakidzich on 8/12/25. + +import Foundation + +/// Some Task in which the value is Sendable and it can result in an error +typealias AnyTask = Task + +/// Holds onto ``AnyTask`` objects by key, autoclearing on failure +actor KeyedDeferredValueStore { + var deferred: [H: AnyTask] = [:] + + /// Sets the task in the cache if one is not found + /// - Parameters: + /// - task: The function that should execute if one is not found + /// - key: The key to look up the result by + /// - Returns: The stored task + func getOrPut( + _ task: @escaping @Sendable @autoclosure () -> AnyTask, + forKey key: H + ) -> AnyTask { + guard let result = self.deferred[key] else { + let wrapped: AnyTask = self.forgettingFailure(task, forKey: key) + self.deferred[key] = wrapped + return wrapped + } + return result + } + + /// Replaces a task in the store + /// - Parameter task: The new function to store + /// - Parameter key: The key to look up the result by + /// - Returns: The stored task + func replaceValue( + _ task: @escaping @Sendable @autoclosure () -> AnyTask, + forKey key: H + ) -> AnyTask { + let result = self.forgettingFailure(task, forKey: key) + self.deferred[key] = result + return result + } + + /// Removes all cached tasks + func clear() { + self.deferred = [:] + } + + private func forgettingFailure( + _ task: @escaping @Sendable () -> AnyTask, + forKey key: H + ) -> AnyTask { + return Task { + do { + return try await task().value + } catch { + self.deferred.removeValue(forKey: key) + throw error + } + } + } + + /// Create a KeyedDeferredValueStore + init() {} +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/LargeItemCacheType.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/LargeItemCacheType.swift new file mode 100644 index 00000000..2945fa8d --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/LargeItemCacheType.swift @@ -0,0 +1,191 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// LargeItemCacheType.swift +// +// Created by Jacob Zivan Rakidzich on 8/13/25. + +import Foundation + +/// An inteface representing a simple cache +protocol LargeItemCacheType { + /// Store data to a url + func saveData(_ data: Data, to url: URL) throws + + /// Store data to a url + @available(iOS 15.0, macOS 12.0, tvOS 15.0, visionOS 1.0, watchOS 8.0, *) + func saveData(_ bytes: AsyncThrowingStream, to url: URL, checksum: Checksum?) async throws + + /// Check if there is content cached at the url + func cachedContentExists(at url: URL) -> Bool + + /// Load data from url + func loadFile(at url: URL) throws -> Data + + /// delete data at url + func remove(_ url: URL) throws + + /// Creates a directory from a base path in the specified directory type + /// The `inAppSpecificDirectory` should be set to false only for components + /// that haven't migrated to the new app specific directory structure yet + func createDirectoryIfNeeded( + basePath: String, + directoryType: DirectoryHelper.DirectoryType, + inAppSpecificDirectory: Bool + ) -> URL? + + /// List all file URLs in a directory + func contentsOfDirectory(at url: URL) throws -> [URL] +} + +extension LargeItemCacheType { + /// Creates a directory in the cache from a base path. Defaults `inAppSpecificDirectory` to true. + func createCacheDirectoryIfNeeded(basePath: String, inAppSpecificDirectory: Bool = true) -> URL? { + createDirectoryIfNeeded( + basePath: basePath, + directoryType: .cache, + inAppSpecificDirectory: inAppSpecificDirectory + ) + } + + /// Creates a directory in the persistence (applicationSupport) directory from a base path. + /// Defaults `inAppSpecificDirectory` to true. + func createPersistenceDirectoryIfNeeded(basePath: String, inAppSpecificDirectory: Bool = true) -> URL? { + createDirectoryIfNeeded( + basePath: basePath, + directoryType: .applicationSupport(), + inAppSpecificDirectory: inAppSpecificDirectory + ) + } +} + +extension FileManager: LargeItemCacheType { + + /// Store data to a url + func saveData(_ data: Data, to url: URL) throws { + let directoryURL = url.deletingLastPathComponent() + try createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) + try data.write(to: url) + } + + /// Store data to a url and validate that the file is correct before saving + @available(iOS 15.0, macOS 12.0, tvOS 15.0, visionOS 1.0, watchOS 8.0, *) + func saveData( + _ bytes: AsyncThrowingStream, + to url: URL, + checksum: Checksum? + ) async throws { + + // Set up file handling + + let tempFileURL = temporaryDirectory.appendingPathComponent((checksum?.value ?? "") + url.lastPathComponent) + + guard createFile(atPath: tempFileURL.path, contents: nil, attributes: nil) else { + let message = Strings.fileRepository.failedToCreateTemporaryFile(tempFileURL) + Logger.error(message) + throw CocoaError(.fileWriteUnknown) + } + + let fileHandle = try FileHandle(forWritingTo: tempFileURL) + defer { try? fileHandle.close() } + defer { try? removeItem(at: tempFileURL) } + + // Write data in chunks to the temporary file + + let bufferSize: Int = 262_144 // 256KB + var buffer = Data() + buffer.reserveCapacity(bufferSize) + var hasher = checksum?.algorithm.getHasher() + + for try await byte in bytes { + buffer.append(byte) + + if buffer.count >= bufferSize { + hasher?.update(data: buffer) + try fileHandle.write(contentsOf: buffer) + buffer.removeAll(keepingCapacity: true) + } + } + + // Write any remaining bytes missed during the while loop + if !buffer.isEmpty { + hasher?.update(data: buffer) + try fileHandle.write(contentsOf: buffer) + } + + // Validate the stored data matches what the server has + if let checksum = checksum, let hasher = hasher { + // If this fails… should we retry? + + let digest = hasher.finalize() + let value = digest.compactMap { String(format: "%02x", $0) }.joined() + try Checksum(algorithm: checksum.algorithm, value: value) + .compare(to: checksum) + } + + // If all succeeds, move the temporary file to the more permanant storage location + // effectively a "save" operation + let directoryURL = url.deletingLastPathComponent() + try createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) + try moveItem(at: tempFileURL, to: url) + } + + /// Check if there is content cached at the given path + func cachedContentExists(at url: URL) -> Bool { + do { + if let size = try self.attributesOfItem(atPath: url.path)[.size] as? UInt64 { + return size > 0 + } + return false + } catch { + return false + } + } + + /// Creates a directory from a base path in the specified directory type + /// The `inAppSpecificDirectory` should be set to false only for components + /// that haven't migrated to the new app specific directory structure yet + func createDirectoryIfNeeded( + basePath: String, + directoryType: DirectoryHelper.DirectoryType, + inAppSpecificDirectory: Bool + ) -> URL? { + guard let baseDirectoryURL = DirectoryHelper.baseUrl( + for: directoryType, + inAppSpecificDirectory: inAppSpecificDirectory + ) else { return nil } + + let directoryURL = baseDirectoryURL.appendingPathComponent(basePath) + do { + try createDirectory( + at: directoryURL, + withIntermediateDirectories: true, + attributes: nil + ) + } catch { + let message = Strings.fileRepository.failedToCreateCacheDirectory(directoryURL) + Logger.error(message) + } + + return directoryURL + } + + /// Load data from url + func loadFile(at url: URL) throws -> Data { + return try Data(contentsOf: url) + } + + func remove(_ url: URL) throws { + try self.removeItem(at: url) + } + + func contentsOfDirectory(at url: URL) throws -> [URL] { + return try self.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: []) + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/URLWithValidation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/URLWithValidation.swift new file mode 100644 index 00000000..656be0d6 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Caching/URLWithValidation.swift @@ -0,0 +1,19 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// URLWithValidation.swift +// +// Created by Jacob Zivan Rakidzich on 10/3/25. + +import Foundation + +struct URLWithValidation: Hashable { + let url: URL + let checksum: Checksum? +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/CodableExtensions/PeriodType+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/CodableExtensions/PeriodType+Extensions.swift new file mode 100644 index 00000000..ec557171 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/CodableExtensions/PeriodType+Extensions.swift @@ -0,0 +1,59 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PeriodType+Extensions.swift +// +// Created by Juanpe Catalán on 26/8/21. + +import Foundation + +extension PeriodType: Decodable { + + // swiftlint:disable:next missing_docs + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + guard let periodTypeString = try? container.decode(String.self) else { + throw decoder.valueNotFoundError(expectedType: PeriodType.self, + message: "Unable to extract a periodTypeString") + } + + guard let type = Self.mapping[periodTypeString] else { + throw CodableError.unexpectedValue(PeriodType.self, periodTypeString) + } + + self = type + } + + private static let mapping: [String: Self] = Self.allCases + .dictionaryWithKeys { $0.name } + +} + +extension PeriodType: Encodable { + + // swiftlint:disable:next missing_docs + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.name) + } + +} + +private extension PeriodType { + + var name: String { + switch self { + case .normal: return "normal" + case .intro: return "intro" + case .trial: return "trial" + case .prepaid: return "prepaid" + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/CodableExtensions/PurchaseOwnershipType+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/CodableExtensions/PurchaseOwnershipType+Extensions.swift new file mode 100644 index 00000000..157e9604 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/CodableExtensions/PurchaseOwnershipType+Extensions.swift @@ -0,0 +1,68 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PurchaseOwnershipType+Extensions.swift +// +// Created by Juanpe Catalán on 26/8/21. + +import Foundation + +extension PurchaseOwnershipType: Decodable { + + // swiftlint:disable:next missing_docs + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + guard !container.decodeNil() else { + self = .unknown + return + } + + guard let purchaseOwnershipTypeString = try? container.decode(String.self) else { + throw decoder.valueNotFoundError(expectedType: PurchaseOwnershipType.self, + message: "Unable to extract an purchaseOwnershipTypeString") + } + + if let type = Self.mapping[purchaseOwnershipTypeString] { + self = type + } else { + Logger.error(Strings.codable.unexpectedValueError(type: PurchaseOwnershipType.self, + value: purchaseOwnershipTypeString)) + self = .unknown + } + } + + private static let mapping: [String: Self] = Self.allCases + .reduce(into: [:]) { result, type in + if let name = type.name { result[name] = type } + } + +} + +extension PurchaseOwnershipType: Encodable { + + // swiftlint:disable:next missing_docs + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.name) + } + +} + +private extension PurchaseOwnershipType { + + var name: String? { + switch self { + case .purchased: return "PURCHASED" + case .familyShared: return "FAMILY_SHARED" + case .unknown: return nil + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/CodableExtensions/Store+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/CodableExtensions/Store+Extensions.swift new file mode 100644 index 00000000..0f290290 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/CodableExtensions/Store+Extensions.swift @@ -0,0 +1,74 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Store+Extensions.swift +// +// Created by Juanpe Catalán on 26/8/21. + +import Foundation + +extension Store: Decodable { + + // swiftlint:disable:next missing_docs + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + guard !container.decodeNil() else { + self = .unknownStore + return + } + + guard let storeString = try? container.decode(String.self) else { + throw decoder.valueNotFoundError(expectedType: Store.self, message: "Unable to extract a storeString") + } + + guard let type = Self.mapping[storeString] else { + throw CodableError.unexpectedValue(Store.self, storeString) + } + + self = type + } + + private static let mapping: [String: Self] = Self.allCases + .reduce(into: [:]) { result, store in + if let name = store.name { result[name] = store } + } + +} + +extension Store: Encodable { + + // swiftlint:disable:next missing_docs + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.name) + } + +} + +private extension Store { + + var name: String? { + switch self { + case .appStore: return "app_store" + case .macAppStore: return "mac_app_store" + case .playStore: return "play_store" + case .stripe: return "stripe" + case .promotional: return "promotional" + case .amazon: return "amazon" + case .unknownStore: return nil + case .rcBilling: return "rc_billing" + case .external: return "external" + case .paddle: return "paddle" + case .testStore: return "test_store" + case .galaxy: return "galaxy" + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/CustomerCenter/CustomerCenterConfigData.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/CustomerCenter/CustomerCenterConfigData.swift new file mode 100644 index 00000000..e71b0753 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/CustomerCenter/CustomerCenterConfigData.swift @@ -0,0 +1,1086 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CustomerCenterConfigData.swift +// +// +// Created by Cesar de la Vega on 28/5/24. +// + +import Foundation + +// swiftlint:disable missing_docs nesting file_length type_body_length +@_spi(Internal) public typealias RCColor = PaywallColor + +@_spi(Internal) public struct CustomerCenterConfigData: Equatable { + + @_spi(Internal) public let screens: [Screen.ScreenType: Screen] + @_spi(Internal) public let appearance: Appearance + @_spi(Internal) public let localization: Localization + @_spi(Internal) public let support: Support + @_spi(Internal) public let changePlans: [ChangePlan] + @_spi(Internal) public let lastPublishedAppVersion: String? + @_spi(Internal) public let productId: UInt? + + @_spi(Internal) public init( + screens: [Screen.ScreenType: Screen], + appearance: Appearance, + localization: Localization, + support: Support, + changePlans: [ChangePlan], + lastPublishedAppVersion: String?, + productId: UInt? + ) { + self.screens = screens + self.appearance = appearance + self.localization = localization + self.support = support + self.changePlans = changePlans + self.lastPublishedAppVersion = lastPublishedAppVersion + self.productId = productId + } + + @_spi(Internal) public struct Localization: Equatable { + + let locale: String + let localizedStrings: [String: String] + + @_spi(Internal) public init(locale: String, localizedStrings: [String: String]) { + self.locale = locale + self.localizedStrings = localizedStrings + } + + @_spi(Internal) public enum CommonLocalizedString: String, Equatable { + + case buySubscrition = "buy_subscription" + case copy = "copy" + case noThanks = "no_thanks" + case noSubscriptionsFound = "no_subscriptions_found" + case tryCheckRestore = "try_check_restore" + case restorePurchases = "restore_purchases" + case cancel = "cancel" + case billingCycle = "billing_cycle" + case currentPrice = "current_price" + case expired = "expired" + case expires = "expires" + case nextBillingDate = "next_billing_date" + case refundCanceled = "refund_canceled" + case refundErrorGeneric = "refund_error_generic" + case refundGranted = "refund_granted" + case refundStatus = "refund_status" + case subEarliestExpiration = "sub_earliest_expiration" + case subEarliestRenewal = "sub_earliest_renewal" + case subExpired = "sub_expired" + case contactSupport = "contact_support" + case defaultBody = "default_body" + case defaultSubject = "default_subject" + case dismiss = "dismiss" + case done = "done" + case unknown = "unknown" + case updateWarningTitle = "update_warning_title" + case updateWarningDescription = "update_warning_description" + case updateWarningUpdate = "update_warning_update" + case updateWarningIgnore = "update_warning_ignore" + case pleaseContactSupportToManage = "please_contact_support" + case appleSubscriptionManage = "apple_subscription_manage" + case googleSubscriptionManage = "google_subscription_manage" + case amazonSubscriptionManage = "amazon_subscription_manage" + case webSubscriptionManage = "web_subscription_manage" + case platformMismatch = "platform_mismatch" + case goingToCheckPurchases = "going_to_check_purchases" + case checkPastPurchases = "check_past_purchases" + case purchasesRecovered = "purchases_recovered" + case purchasesRestoring = "purchases_restoring" + case purchasesRecoveredExplanation = "purchases_recovered_explanation" + case purchasesNotFound = "purchases_not_found" + case purchasesNotRecoveredExplanation = "purchases_not_recovered" + case manageSubscription = "manage_subscription" + case youHavePromo = "you_have_promo" + case youHaveLifetime = "you_have_lifetime" + case free = "free" + case never = "never" + case seeAllPurchases = "screen_management_see_all_purchases" + case purchaseInfoPurchasedOnDate = "purchase_info_purchased_on_date" + case purchaseInfoExpiredOnDate = "purchase_info_expired_on_date" + case purchaseInfoRenewsOnDate = "purchase_info_renews_on_date" + case purchaseInfoExpiresOnDate = "purchase_info_expires_on_date" + case activeSubscriptions = "screen_purchase_history_active_subscriptions_title" + case expiredSubscriptions = "screen_purchase_history_expired_subscriptions_title" + case otherPurchases = "screen_purchase_history_others_title" + case accountDetails = "screen_purchase_history_account_details_title" + case dateWhenAppWasPurchased = "screen_purchase_history_original_purchase_date" + case userId = "screen_purchase_history_user_id" + case purchaseHistory = "screen_purchase_history_title" + case sharedThroughFamilyMember = "shared_through_family_member" + case active = "active" + case inactive = "inactive" + case introductoryPrice = "introductory_price" + case trialPeriod = "trial_period" + case productName = "product_name" + case paidPrice = "paid_price" + case originalDownloadDate = "original_download_date" + case historyLatestPurchaseDate = "history_latest_purchase_date" + case status = "status" + case nextRenewalDate = "next_renewal" + case unsubscribedAt = "unsubscribed_at" + case billingIssueDetectedAt = "billing_issue_detected_at" + case gracePeriodExpiresAt = "grace_period_expires_at" + case periodType = "period_type" + case refundedAt = "refunded_at" + case store = "store" + case productID = "product_id" + case sandbox = "sandbox" + case transactionID = "transaction_id" + case answerYes = "yes" + case answerNo = "no" + case storeAppStore = "app_store" + case storeMacAppStore = "mac_app_store" + case storePlayStore = "google_play_store" + case testStore = "test_store" + case galaxyStore = "galaxy_store" + case storeStripe = "stripe" + case storePromotional = "promotional" + case storeAmazon = "amazon_store" + case cardStorePromotional = "card_store_promotional" + case storeExternal = "external_store" + case storeUnknownStore = "unknown_store" + case storePaddle = "store_paddle" + case storeWeb = "store_web" + case typeSubscription = "type_subscription" + case typeOneTimePurchase = "type_one_time_purchase" + case debugHeaderTitle = "Debug" + case seeAllVirtualCurrencies = "see_all_virtual_currencies" + case virtualCurrencyBalancesScreenHeader = "virtual_currency_balances_screen_header" + case noVirtualCurrencyBalancesFound = "no_virtual_currency_balances_found" + case youMayHaveDuplicatedSubscriptionsTitle = "you_may_have_duplicated_subscriptions_title" + case youMayHaveDuplicatedSubscriptionsSubtitle = "you_may_have_duplicated_subscriptions_subtitle" + case pricePaid = "price_paid" + case expiresOnDateWithoutChanges = "expires_on_date_without_changes" + case renewsOnDateForPrice = "renews_on_date_for_price" + case renewsOnDate = "renews_on_date" + case priceAfterwards = "price_afterwards" + case freeTrialUntilDate = "free_trial_until_date" + case priceExpiresOnDateWithoutChanges = "price_expires_on_date_without_changes" + case badgeLifetime = "badge_lifetime" + case badgeCancelled = "badge_cancelled" + case badgeTrialCancelled = "badge_free_trial_cancelled" + case badgeFreeTrial = "badge_free_trial" + case refundSuccess = "refund_success" + case actionsSectionTitle = "actions_section_title" + case subscriptionsSectionTitle = "subscriptions_section_title" + case purchasesSectionTitle = "purchases_section_title" + case supportTicketCreate = "support_ticket_create" + case email = "email" + case enterEmail = "enter_email" + case description = "description" + case sent = "sent" + case supportTicketFailed = "support_ticket_failed" + case submitTicket = "submit_ticket" + case characterCount = "character_count" + case promoOfferButtonRegularPrice = "ios_promo_offer_button_regular_price" + case promoOfferButtonFreeTrial = "ios_promo_offer_button_free_trial" + case promoOfferButtonRecurringDiscount = "ios_promo_offer_button_recurring_discount" + case promoOfferButtonUpfrontPayment = "ios_promo_offer_button_upfront_payment" + + @_spi(Internal) public var defaultValue: String { + switch self { + case .buySubscrition: + return "Subscribe" + case .copy: + return "Copy" + case .noThanks: + return "No, thanks" + case .noSubscriptionsFound: + return "No Subscriptions found" + case .tryCheckRestore: + return "We can try checking your Apple account for any previous purchases" + case .restorePurchases: + return "Restore purchases" + case .goingToCheckPurchases: + return "Let’s take a look! We’re going to check your account for missing purchases." + case .checkPastPurchases: + return "Check past purchases" + case .purchasesRecovered: + return "Purchases restored" + case .purchasesRestoring: + return "Restoring..." + case .purchasesRecoveredExplanation: + return "We restored your past purchases and applied them to your account." + case .purchasesNotFound: + return "No past purchases" + case .purchasesNotRecoveredExplanation: + return "We could not find any purchases with your account. " + + "If you think this is an error, please contact support." + case .cancel: + return "Cancel" + case .billingCycle: + return "Billing cycle" + case .currentPrice: + return "Price" + case .expired: + return "Expired" + case .expires: + return "Expires" + case .nextBillingDate: + return "Next billing date" + case .refundCanceled: + return "Refund canceled" + case .refundErrorGeneric: + return "An error occurred while processing the refund request. Please try again." + case .refundGranted: + return "Refund requested" + case .refundStatus: + return "Refund status" + case .subEarliestExpiration: + return "This is your subscription with the earliest expiration date." + case .subEarliestRenewal: + return "This is your subscription with the earliest billing date." + case .subExpired: + return "This subscription has expired." + case .contactSupport: + return "Contact support" + case .defaultBody: + return "Please describe your issue or question." + case .defaultSubject: + return "Support Request" + case .dismiss: + return "Dismiss" + case .done: + return "Done" + case .unknown: + return "Unknown" + case .updateWarningTitle: + return "Update available" + case .updateWarningDescription: + return "Downloading the latest version of the app may help solve the problem." + case .updateWarningUpdate: + return "Update" + case .updateWarningIgnore: + return "Continue" + case .platformMismatch: + return "Platform mismatch" + case .pleaseContactSupportToManage: + return "Please contact support to manage your subscription." + case .appleSubscriptionManage: + return "You have an active subscription from the Apple App Store. " + + "You can manage your subscription by using the App Store app on an Apple device." + case .googleSubscriptionManage: + return "You have an active subscription from the Google Play Store" + case .amazonSubscriptionManage: + return "You have an active subscription from the Amazon Appstore. " + + "You can manage your subscription in the Amazon Appstore app." + case .webSubscriptionManage: + return "You have an active subscription that was purchased on the web." + + " You can manage your subscription using the button below." + case .manageSubscription: + return "Manage your subscription" + case .youHavePromo: + return "You’ve been granted a subscription that doesn’t renew" + case .youHaveLifetime: + return "Your active lifetime subscription" + case .free: + return "Free" + case .never: + return "Never" + case .seeAllPurchases: + return "See All Purchases" + case .purchaseInfoPurchasedOnDate: + return "Purchased on {{ date }}" + case .purchaseInfoExpiredOnDate: + return "Expired on {{ date }}" + case .purchaseInfoRenewsOnDate: + return "Renews on {{ date }}" + case .purchaseInfoExpiresOnDate: + return "Expires on {{ date }}" + case .activeSubscriptions: + return "Active Subscriptions" + case .expiredSubscriptions: + return "Expired Subscriptions" + case .otherPurchases: + return "Other" + case .accountDetails: + return "Account Details" + case .dateWhenAppWasPurchased: + return "Original Download Date" + case .userId: + return "User ID" + case .purchaseHistory: + return "Purchase History" + case .sharedThroughFamilyMember: + return "Shared through family member" + case .active: + return "Active" + case .inactive: + return "Inactive" + case .introductoryPrice: + return "Introductory Price" + case .trialPeriod: + return "Trial Period" + case .productName: + return "Product Name" + case .paidPrice: + return "Paid Price" + case .originalDownloadDate: + return "Original Download Date" + case .historyLatestPurchaseDate: + return "Latest Purchase Date" + case .status: + return "Status" + case .nextRenewalDate: + return "Next Renewal" + case .unsubscribedAt: + return "Unsubscribed At" + case .billingIssueDetectedAt: + return "Billing Issue Detected At" + case .gracePeriodExpiresAt: + return "Grace Period Expires At" + case .periodType: + return "Period Type" + case .refundedAt: + return "Refunded At" + case .store: + return "Store" + case .productID: + return "Product ID" + case .sandbox: + return "Sandbox" + case .transactionID: + return "Transaction ID" + case .answerYes: + return "Yes" + case .answerNo: + return "No" + case .storeAppStore: + return "Apple App Store" + case .storeMacAppStore: + return "Mac App Store" + case .storePlayStore: + return "Google Play Store" + case .storeStripe: + return "Stripe" + case .storePromotional: + return "Promotional" + case .storeAmazon: + return "Amazon Store" + case .cardStorePromotional: + return "Via Support" + case .storeExternal: + return "External Purchases" + case .storeUnknownStore: + return "Unknown Store" + case .storePaddle: + return "Paddle" + case .storeWeb: + return "Web" + case .typeSubscription: + return "Subscription" + case .typeOneTimePurchase: + return "One-time Purchase" + case .debugHeaderTitle: + return "Debug" + case .virtualCurrencyBalancesScreenHeader: + return "In-App Currencies" + case .seeAllVirtualCurrencies: + return "See all in-app currencies" + case .noVirtualCurrencyBalancesFound: + return "It doesn't look like you've purchased any in-app currencies." + case .youMayHaveDuplicatedSubscriptionsTitle: + return "You may have duplicated subscriptions" + case .youMayHaveDuplicatedSubscriptionsSubtitle: + return "You might be subscribed both on the web and through the App Store." + + "To avoid being charged twice, please cancel your iOS subscription in your device settings." + case .pricePaid: + return "Paid {{ price }}." + case .expiresOnDateWithoutChanges: + return "Expires on {{ date }} without further charges." + case .renewsOnDateForPrice: + return "Renews on {{ date }} for {{ price }}." + case .renewsOnDate: + return "Renews on {{ date }}." + case .priceAfterwards: + return "{{ price }} afterwards." + case .freeTrialUntilDate: + return "Free trial until {{ date }}." + case .priceExpiresOnDateWithoutChanges: + return "{{ price }}. Expires on {{ date }} without changes." + case .badgeLifetime: + return "Lifetime" + case .badgeCancelled: + return "Cancelled" + case .badgeFreeTrial: + return "Free trial" + case .badgeTrialCancelled: + return "Cancelled trial" + case .refundSuccess: + return "Apple has received the refund request" + case .actionsSectionTitle: + return "Actions" + case .subscriptionsSectionTitle: + return "Subscriptions" + case .purchasesSectionTitle: + return "Purchases" + case .testStore: + return "Test Store" + case .galaxyStore: + return "Galaxy Store" + case .supportTicketCreate: + return "Create a support ticket" + case .email: + return "Email" + case .enterEmail: + return "Enter your email" + case .description: + return "Description" + case .sent: + return "Message sent" + case .supportTicketFailed: + return "Failed to send, please try again." + case .submitTicket: + return "Submit ticket" + case .characterCount: + return "{{ count }} characters" + case .promoOfferButtonRegularPrice: + return "then {{ price }}" + case .promoOfferButtonFreeTrial: + return "{{ duration }} for free" + case .promoOfferButtonRecurringDiscount: + return "{{ price }} during {{ duration }}" + case .promoOfferButtonUpfrontPayment: + return "{{ duration }} for {{ price }}" + } + } + } + + @_spi(Internal) public subscript(_ key: CommonLocalizedString) -> String { + localizedStrings[key.rawValue] ?? key.defaultValue + } + } + + @_spi(Internal) public struct HelpPath: Equatable { + + @_spi(Internal) public let id: String + @_spi(Internal) public let title: String + @_spi(Internal) public let url: URL? + @_spi(Internal) public let openMethod: OpenMethod? + @_spi(Internal) public let type: PathType + @_spi(Internal) public let detail: PathDetail? + @_spi(Internal) public let refundWindowDuration: RefundWindowDuration? + @_spi(Internal) public let customActionIdentifier: String? + + @_spi(Internal) public init( + id: String, + title: String, + url: URL? = nil, + openMethod: OpenMethod? = nil, + type: PathType, + detail: PathDetail?, + refundWindowDuration: RefundWindowDuration? = nil, + customActionIdentifier: String? = nil + ) { + self.id = id + self.title = title + self.url = url + self.openMethod = openMethod + self.type = type + self.detail = detail + self.refundWindowDuration = refundWindowDuration + self.customActionIdentifier = customActionIdentifier + } + + @_spi(Internal) public enum PathDetail: Equatable { + + case promotionalOffer(PromotionalOffer) + case feedbackSurvey(FeedbackSurvey) + + } + + @_spi(Internal) public enum RefundWindowDuration: Equatable { + case forever + case duration(ISODuration) + } + + @_spi(Internal) public enum PathType: String, Equatable { + + case missingPurchase = "MISSING_PURCHASE" + case refundRequest = "REFUND_REQUEST" + case changePlans = "CHANGE_PLANS" + case cancel = "CANCEL" + case customUrl = "CUSTOM_URL" + case customAction = "CUSTOM_ACTION" + case unknown + + init(from rawValue: String) { + switch rawValue { + case "MISSING_PURCHASE": + self = .missingPurchase + case "REFUND_REQUEST": + self = .refundRequest + case "CHANGE_PLANS": + self = .changePlans + case "CANCEL": + self = .cancel + case "CUSTOM_URL": + self = .customUrl + case "CUSTOM_ACTION": + self = .customAction + default: + self = .unknown + } + } + + } + + @_spi(Internal) public enum OpenMethod: String, Equatable { + + case inApp = "IN_APP" + case external = "EXTERNAL" + + init?(from rawValue: String?) { + switch rawValue { + case "IN_APP": + self = .inApp + case "EXTERNAL": + self = .external + default: + return nil + } + } + + } + + @_spi(Internal) public struct PromotionalOffer: Equatable { + + @_spi(Internal) public let iosOfferId: String + @_spi(Internal) public let eligible: Bool + @_spi(Internal) public let title: String + @_spi(Internal) public let subtitle: String + @_spi(Internal) public let productMapping: [String: String] + @_spi(Internal) public let crossProductPromotions: [String: CrossProductPromotion] + + @_spi(Internal) public struct CrossProductPromotion: Equatable { + @_spi(Internal) public let storeOfferIdentifier: String + @_spi(Internal) public let targetProductId: String + + @_spi(Internal) public init( + storeofferingidentifier: String, + targetproductid: String + ) { + self.storeOfferIdentifier = storeofferingidentifier + self.targetProductId = targetproductid + } + } + + @_spi(Internal) public init( + iosOfferId: String, + eligible: Bool, + title: String, + subtitle: String, + productMapping: [String: String], + crossProductPromotions: [String: CrossProductPromotion] = [:] + ) { + self.iosOfferId = iosOfferId + self.eligible = eligible + self.title = title + self.subtitle = subtitle + self.productMapping = productMapping + self.crossProductPromotions = crossProductPromotions + } + } + + @_spi(Internal) public struct FeedbackSurvey: Equatable { + + @_spi(Internal) public let title: String + @_spi(Internal) public let options: [Option] + + @_spi(Internal) public init(title: String, options: [Option]) { + self.title = title + self.options = options + } + + @_spi(Internal) public struct Option: Equatable { + + public let id: String + public let title: String + public let promotionalOffer: PromotionalOffer? + + public init(id: String, title: String, promotionalOffer: PromotionalOffer?) { + self.id = id + self.title = title + self.promotionalOffer = promotionalOffer + } + + } + + } + + } + + @_spi(Internal) public struct Appearance: Equatable { + + @_spi(Internal) public let accentColor: ColorInformation + @_spi(Internal) public let textColor: ColorInformation + @_spi(Internal) public let backgroundColor: ColorInformation + @_spi(Internal) public let buttonTextColor: ColorInformation + @_spi(Internal) public let buttonBackgroundColor: ColorInformation + + @_spi(Internal) public init( + accentColor: ColorInformation, + textColor: ColorInformation, + backgroundColor: ColorInformation, + buttonTextColor: ColorInformation, + buttonBackgroundColor: ColorInformation + ) { + self.accentColor = accentColor + self.textColor = textColor + self.backgroundColor = backgroundColor + self.buttonTextColor = buttonTextColor + self.buttonBackgroundColor = buttonBackgroundColor + } + + @_spi(Internal) public struct ColorInformation: Equatable { + + @_spi(Internal) public var light: RCColor? + @_spi(Internal) public var dark: RCColor? + + @_spi(Internal) public init() { + self.light = nil + self.dark = nil + } + + @_spi(Internal) public init( + light: String?, + dark: String? + ) { + if let light = light { + do { + self.light = try RCColor(stringRepresentation: light) + } catch { + Logger.error("Failed to parse light color \(light)") + } + } + if let dark = dark { + do { + self.dark = try RCColor(stringRepresentation: dark) + } catch { + Logger.error("Failed to parse dark color \(dark)") + } + } + } + } + + } + + @_spi(Internal) public struct Screen: Equatable { + + @_spi(Internal) public let type: ScreenType + @_spi(Internal) public let title: String + @_spi(Internal) public let subtitle: String? + @_spi(Internal) public let paths: [HelpPath] + @_spi(Internal) public let offering: ScreenOffering? + + @_spi(Internal) public init( + type: ScreenType, + title: String, + subtitle: String?, + paths: [HelpPath], + offering: ScreenOffering? + ) { + self.type = type + self.title = title + self.subtitle = subtitle + self.paths = paths + self.offering = offering + } + + @_spi(Internal) public enum ScreenType: String, Equatable { + case management = "MANAGEMENT" + case noActive = "NO_ACTIVE" + case unknown + + init(from rawValue: String) { + switch rawValue { + case "MANAGEMENT": + self = .management + case "NO_ACTIVE": + self = .noActive + default: + self = .unknown + } + } + } + + } + + @_spi(Internal) public struct Support: Equatable { + + @_spi(Internal) public let email: String + @_spi(Internal) public let shouldWarnCustomerToUpdate: Bool + @_spi(Internal) public let displayPurchaseHistoryLink: Bool + @_spi(Internal) public let displayUserDetailsSection: Bool + @_spi(Internal) public let displayVirtualCurrencies: Bool + @_spi(Internal) public let shouldWarnCustomersAboutMultipleSubscriptions: Bool + @_spi(Internal) public let supportTickets: SupportTickets? + + @_spi(Internal) public init( + email: String, + shouldWarnCustomerToUpdate: Bool, + displayPurchaseHistoryLink: Bool, + displayUserDetailsSection: Bool, + displayVirtualCurrencies: Bool, + shouldWarnCustomersAboutMultipleSubscriptions: Bool, + supportTickets: SupportTickets? = nil + ) { + self.email = email + self.shouldWarnCustomerToUpdate = shouldWarnCustomerToUpdate + self.displayPurchaseHistoryLink = displayPurchaseHistoryLink + self.displayUserDetailsSection = displayUserDetailsSection + self.displayVirtualCurrencies = displayVirtualCurrencies + self.shouldWarnCustomersAboutMultipleSubscriptions = shouldWarnCustomersAboutMultipleSubscriptions + self.supportTickets = supportTickets + } + + @_spi(Internal) public struct SupportTickets: Equatable { + @_spi(Internal) public let allowCreation: Bool + @_spi(Internal) public let customerType: CustomerType + @_spi(Internal) public let customerDetails: CustomerDetails? + + @_spi(Internal) public init( + allowCreation: Bool, + customerType: CustomerType, + customerDetails: CustomerDetails? = nil + ) { + self.allowCreation = allowCreation + self.customerType = customerType + self.customerDetails = customerDetails + } + + @_spi(Internal) public enum CustomerType: String, Equatable { + case active + case notActive = "not_active" + case all + case none + } + + @_spi(Internal) public struct CustomerDetails: Equatable { + @_spi(Internal) public let activeEntitlements: Bool + @_spi(Internal) public let appUserId: Bool + @_spi(Internal) public let attConsent: Bool + @_spi(Internal) public let country: Bool + @_spi(Internal) public let deviceVersion: Bool + @_spi(Internal) public let email: Bool + @_spi(Internal) public let facebookAnonId: Bool + @_spi(Internal) public let idfa: Bool + @_spi(Internal) public let idfv: Bool + @_spi(Internal) public let ipAddress: Bool + @_spi(Internal) public let lastOpened: Bool + @_spi(Internal) public let lastSeenAppVersion: Bool + @_spi(Internal) public let totalSpent: Bool + @_spi(Internal) public let userSince: Bool + + @_spi(Internal) public init( + activeEntitlements: Bool = false, + appUserId: Bool = false, + attConsent: Bool = false, + country: Bool = false, + deviceVersion: Bool = false, + email: Bool = false, + facebookAnonId: Bool = false, + idfa: Bool = false, + idfv: Bool = false, + ipAddress: Bool = false, + lastOpened: Bool = false, + lastSeenAppVersion: Bool = false, + totalSpent: Bool = false, + userSince: Bool = false + ) { + self.activeEntitlements = activeEntitlements + self.appUserId = appUserId + self.attConsent = attConsent + self.country = country + self.deviceVersion = deviceVersion + self.email = email + self.facebookAnonId = facebookAnonId + self.idfa = idfa + self.idfv = idfv + self.ipAddress = ipAddress + self.lastOpened = lastOpened + self.lastSeenAppVersion = lastSeenAppVersion + self.totalSpent = totalSpent + self.userSince = userSince + } + } + } + + } + + @_spi(Internal) public struct ChangePlan: Equatable { + @_spi(Internal) public let groupId: String + @_spi(Internal) public let groupName: String + @_spi(Internal) public let products: [ChangePlanProduct] + + @_spi(Internal) public init( + groupId: String, + groupName: String, + products: [ChangePlanProduct] + ) { + self.groupId = groupId + self.groupName = groupName + self.products = products + } + } + + @_spi(Internal) public struct ChangePlanProduct: Equatable { + @_spi(Internal) public let productId: String + @_spi(Internal) public let selected: Bool + + @_spi(Internal) public init( + productId: String, + selected: Bool + ) { + self.productId = productId + self.selected = selected + } + } + + @_spi(Internal) public struct ScreenOffering: Equatable { + @_spi(Internal) public let type: OfferingType + @_spi(Internal) public let offeringId: String? + @_spi(Internal) public let buttonText: String? + + @_spi(Internal) public init( + type: OfferingType, + offeringId: String?, + buttonText: String? + ) { + self.type = type + self.offeringId = offeringId + self.buttonText = buttonText + } + + @_spi(Internal) public enum OfferingType: String, Equatable { + case current = "CURRENT" + case specific = "SPECIFIC" + } + } +} + +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +extension CustomerCenterConfigData { + + init(from response: CustomerCenterConfigResponse) { + let localization = Localization(from: response.customerCenter.localization) + self.localization = localization + self.appearance = Appearance(from: response.customerCenter.appearance) + self.screens = Dictionary(uniqueKeysWithValues: response.customerCenter.screens.map { + let type = CustomerCenterConfigData.Screen.ScreenType(from: $0.key) + return (type, Screen(from: $0.value, localization: localization)) + }) + self.support = Support(from: response.customerCenter.support) + self.lastPublishedAppVersion = response.lastPublishedAppVersion + self.productId = response.itunesTrackId + self.changePlans = response.customerCenter.changePlans.map { + .init(groupId: $0.groupId, groupName: $0.groupName, products: $0.products.map { + .init(productId: $0.productId, selected: $0.selected) + }) + } + } + +} + +extension CustomerCenterConfigData.Screen { + + init(from response: CustomerCenterConfigResponse.Screen, + localization: CustomerCenterConfigData.Localization) { + self.type = ScreenType(from: response.type.rawValue) + self.title = response.title + self.subtitle = response.subtitle + self.paths = response.paths.compactMap { CustomerCenterConfigData.HelpPath(from: $0) } + self.offering = response.offering.map { offering in + switch offering.type { + case CustomerCenterConfigData.ScreenOffering.OfferingType.specific.rawValue: + return CustomerCenterConfigData.ScreenOffering( + type: .specific, + offeringId: offering.offeringId, + buttonText: offering.buttonText + ) + + default: + return CustomerCenterConfigData.ScreenOffering( + type: .current, + offeringId: nil, + buttonText: offering.buttonText + ) + } + } + } + +} + +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +extension CustomerCenterConfigData.Appearance { + + init(from response: CustomerCenterConfigResponse.Appearance) { + self.accentColor = .init(light: response.light.accentColor, + dark: response.dark.accentColor) + self.textColor = .init(light: response.light.textColor, + dark: response.dark.textColor) + self.backgroundColor = .init(light: response.light.backgroundColor, + dark: response.dark.backgroundColor) + self.buttonTextColor = .init(light: response.light.buttonTextColor, + dark: response.dark.buttonTextColor) + self.buttonBackgroundColor = .init(light: response.light.buttonBackgroundColor, + dark: response.dark.buttonBackgroundColor) + } + +} + +extension CustomerCenterConfigData.Localization { + + init(from response: CustomerCenterConfigResponse.Localization) { + // swiftlint:disable:next todo + // TODO: convert to Locale + self.locale = response.locale + self.localizedStrings = response.localizedStrings + } + +} + +extension CustomerCenterConfigData.HelpPath { + + init?(from response: CustomerCenterConfigResponse.HelpPath) { + self.id = response.id + self.title = response.title + self.type = CustomerCenterConfigData.HelpPath.PathType(from: response.type.rawValue) + if self.type == .customUrl { + if let responseUrl = response.url, + let url = URL(string: responseUrl), + let openMethod = CustomerCenterConfigData.HelpPath.OpenMethod(from: response.openMethod?.rawValue) { + self.url = url + self.openMethod = openMethod + } else { + return nil + } + } else { + self.url = nil + self.openMethod = nil + } + if let promotionalOfferResponse = response.promotionalOffer { + self.detail = .promotionalOffer(PromotionalOffer(from: promotionalOfferResponse)) + } else if let feedbackSurveyResponse = response.feedbackSurvey { + self.detail = .feedbackSurvey(FeedbackSurvey(from: feedbackSurveyResponse)) + } else { + self.detail = nil + } + + if let window = response.refundWindow { + self.refundWindowDuration = window == "forever" + ? RefundWindowDuration.forever + : ISODurationFormatter.parse(from: window).map { .duration($0) } + } else { + self.refundWindowDuration = nil + } + + self.customActionIdentifier = response.actionIdentifier + } +} + +extension CustomerCenterConfigData.HelpPath.PromotionalOffer { + + init(from response: CustomerCenterConfigResponse.HelpPath.PromotionalOffer) { + self.iosOfferId = response.iosOfferId + self.eligible = response.eligible + self.title = response.title + self.subtitle = response.subtitle + self.productMapping = response.productMapping + self.crossProductPromotions = response.crossProductPromotions?.mapValues { CrossProductPromotion(from: $0) } + ?? [:] + } + +} + +extension CustomerCenterConfigData.HelpPath.PromotionalOffer.CrossProductPromotion { + + init(from response: CustomerCenterConfigResponse.HelpPath.PromotionalOffer.CrossProductPromotion) { + self.storeOfferIdentifier = response.storeOfferIdentifier + self.targetProductId = response.targetProductId + } + +} + +extension CustomerCenterConfigData.HelpPath.FeedbackSurvey { + + init(from response: CustomerCenterConfigResponse.HelpPath.FeedbackSurvey) { + self.title = response.title + self.options = response.options.map { Option(from: $0) } + } + +} + +extension CustomerCenterConfigData.HelpPath.FeedbackSurvey.Option { + + init(from response: CustomerCenterConfigResponse.HelpPath.FeedbackSurvey.Option) { + self.id = response.id + self.title = response.title + if let promotionalOffer = response.promotionalOffer { + self.promotionalOffer = CustomerCenterConfigData.HelpPath.PromotionalOffer(from: promotionalOffer) + } else { + self.promotionalOffer = nil + } + + } + +} + +extension CustomerCenterConfigData.Support { + + init(from response: CustomerCenterConfigResponse.Support) { + self.email = response.email + self.shouldWarnCustomerToUpdate = response.shouldWarnCustomerToUpdate ?? true + self.displayPurchaseHistoryLink = response.displayPurchaseHistoryLink ?? false + self.displayUserDetailsSection = response.displayUserDetailsSection ?? true + self.displayVirtualCurrencies = response.displayVirtualCurrencies ?? false + self.shouldWarnCustomersAboutMultipleSubscriptions = response.shouldWarnCustomersAboutMultipleSubscriptions + ?? false + self.supportTickets = response.supportTickets.map { SupportTickets(from: $0) } + } + +} + +extension CustomerCenterConfigData.Support.SupportTickets { + + init(from response: CustomerCenterConfigResponse.Support.SupportTickets) { + self.allowCreation = response.allowCreation + self.customerType = CustomerType(rawValue: response.customerType) ?? .none + self.customerDetails = response.customerDetails.map { CustomerDetails(from: $0) } + } + +} + +extension CustomerCenterConfigData.Support.SupportTickets.CustomerDetails { + + init(from response: CustomerCenterConfigResponse.Support.SupportTickets.CustomerDetails) { + self.activeEntitlements = response.activeEntitlements ?? false + self.appUserId = response.appUserId ?? false + self.attConsent = response.attConsent ?? false + self.country = response.country ?? false + self.deviceVersion = response.deviceVersion ?? false + self.email = response.email ?? false + self.facebookAnonId = response.facebookAnonId ?? false + self.idfa = response.idfa ?? false + self.idfv = response.idfv ?? false + self.ipAddress = response.ipAddress ?? false + self.lastOpened = response.lastOpened ?? false + self.lastSeenAppVersion = response.lastSeenAppVersion ?? false + self.totalSpent = response.totalSpent ?? false + self.userSince = response.userSince ?? false + } + +} + +extension CustomerCenterConfigData.HelpPath.PathType: Sendable, Codable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/CustomerCenter/CustomerCenterPresentationMode.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/CustomerCenter/CustomerCenterPresentationMode.swift new file mode 100644 index 00000000..5c4fa107 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/CustomerCenter/CustomerCenterPresentationMode.swift @@ -0,0 +1,80 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CustomerCenterPresentationMode.swift +// +// Created by Cesar de la Vega on 27/11/24. + +import Foundation + +/// Presentation options to use with the [presentCustomerCenter](x-source-tag://presentCustomerCenter) View modifiers. +public enum CustomerCenterPresentationMode { + + /// Customer Center presented using SwiftUI's `.sheet`. + case sheet + + /// Customer Center presented using SwiftUI's `.fullScreenCover`. + case fullScreen + +} + +extension CustomerCenterPresentationMode { + + // swiftlint:disable:next missing_docs + public static let `default`: Self = .sheet + +} + +extension CustomerCenterPresentationMode { + + var identifier: String { + switch self { + case .fullScreen: return "full_screen" + case .sheet: return "sheet" + } + } + +} + +// MARK: - Extensions + +extension CustomerCenterPresentationMode: CaseIterable { + + // swiftlint:disable:next missing_docs + public static var allCases: [CustomerCenterPresentationMode] { + return [ + .fullScreen, + .sheet + ] + } + +} + +extension CustomerCenterPresentationMode: Equatable, Sendable {} + +extension CustomerCenterPresentationMode: Codable { + + // swiftlint:disable:next missing_docs + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.identifier) + } + + // swiftlint:disable:next missing_docs + public init(from decoder: Decoder) throws { + let identifier = try decoder.singleValueContainer().decode(String.self) + + self = try Self.modesByIdentifier[identifier] + .orThrow(CodableError.unexpectedValue(Self.self, identifier)) + } + + private static let modesByIdentifier: [String: Self] = Set(Self.allCases) + .dictionaryWithKeys(\.identifier) + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/CustomerCenter/Events/CustomerCenterEvent.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/CustomerCenter/Events/CustomerCenterEvent.swift new file mode 100644 index 00000000..f38d4aa7 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/CustomerCenter/Events/CustomerCenterEvent.swift @@ -0,0 +1,231 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CustomerCenterEvent.swift +// +// Created by Cesar de la Vega on 21/10/24. + +import Foundation + +/// A protocol that represents a customer center event. +@_spi(Internal) public protocol CustomerCenterEventType {} + +extension CustomerCenterEventType { + + var feature: Feature { .customerCenter } + +} + +enum CustomerCenterEventDiscriminator: String { + + case lifecycle = "lifecycle" + case answerSubmitted = "answer_submitted" + +} + +/// Data that represents a customer center event creation. +@_spi(Internal) public struct CustomerCenterEventCreationData { + + let id: UUID + let date: Date + + // swiftlint:disable:next missing_docs + @_spi(Internal) public init( + id: UUID = .init(), + date: Date = .init() + ) { + self.id = id + self.date = date + } + +} + +/// An event to be sent by the `RevenueCatUI` SDK. +@_spi(Internal) public enum CustomerCenterEvent: FeatureEvent, CustomerCenterEventType { + + var eventDiscriminator: String? { CustomerCenterEventDiscriminator.lifecycle.rawValue } + + /// The Customer Center was displayed. + case impression(CustomerCenterEventCreationData, Data) + +} + +/// An event to be sent by the `RevenueCatUI` SDK. +@_spi(Internal) public enum CustomerCenterAnswerSubmittedEvent: FeatureEvent, CustomerCenterEventType { + + var eventDiscriminator: String? { CustomerCenterEventDiscriminator.answerSubmitted.rawValue } + + /// A feedback survey was completed with a particular option. + case answerSubmitted(CustomerCenterEventCreationData, Data) + +} + +extension CustomerCenterEvent { + + /// The content of a ``CustomerCenterEvent``. + public struct Data { + + // swiftlint:disable missing_docs + public var localeIdentifier: String { base.localeIdentifier } + public var darkMode: Bool { base.darkMode } + public var isSandbox: Bool { base.isSandbox } + public var displayMode: CustomerCenterPresentationMode { base.displayMode } + + private let base: CustomerCenterBaseData + + public init( + locale: Locale, + darkMode: Bool, + isSandbox: Bool, + displayMode: CustomerCenterPresentationMode + ) { + self.base = CustomerCenterBaseData( + locale: locale, + darkMode: darkMode, + isSandbox: isSandbox, + displayMode: displayMode + ) + } + // swiftlint:enable missing_docs + + } + +} + +extension CustomerCenterAnswerSubmittedEvent { + + /// The content of a ``CustomerCenterAnswerSubmittedEvent``. + @_spi(Internal) public struct Data { + + // swiftlint:disable missing_docs + @_spi(Internal) public var localeIdentifier: String { base.localeIdentifier } + @_spi(Internal) public var darkMode: Bool { base.darkMode } + @_spi(Internal) public var isSandbox: Bool { base.isSandbox } + @_spi(Internal) public var displayMode: CustomerCenterPresentationMode { base.displayMode } + @_spi(Internal) public let path: CustomerCenterConfigData.HelpPath.PathType + @_spi(Internal) public let url: URL? + @_spi(Internal) public let surveyOptionID: String + @_spi(Internal) public let additionalContext: String? + @_spi(Internal) public let revisionID: Int + + private let base: CustomerCenterBaseData + + @_spi(Internal) public init( + locale: Locale, + darkMode: Bool, + isSandbox: Bool, + displayMode: CustomerCenterPresentationMode, + path: CustomerCenterConfigData.HelpPath.PathType, + url: URL?, + surveyOptionID: String, + additionalContext: String? = nil, + revisionID: Int + ) { + self.base = CustomerCenterBaseData( + locale: locale, + darkMode: darkMode, + isSandbox: isSandbox, + displayMode: displayMode + ) + self.path = path + self.url = url + self.surveyOptionID = surveyOptionID + self.additionalContext = additionalContext + self.revisionID = revisionID + } + // swiftlint:enable missing_docs + + } + +} + +extension CustomerCenterEvent { + + /// - Returns: the underlying ``CustomerCenterEventCreationData-swift.struct`` for this event. + public var creationData: CustomerCenterEventCreationData { + switch self { + case let .impression(creationData, _): return creationData + } + } + + /// - Returns: the underlying ``CustomerCenterEvent/Data-swift.struct`` for this event. + public var data: Data { + switch self { + case let .impression(_, data): return data + } + } + +} + +extension CustomerCenterAnswerSubmittedEvent { + + /// - Returns: the underlying ``CustomerCenterEventCreationData-swift.struct`` for this event. + public var creationData: CustomerCenterEventCreationData { + switch self { + case let .answerSubmitted(creationData, _): return creationData + } + } + + /// - Returns: the underlying ``CustomerCenterAnswerSubmittedEvent/Data-swift.struct`` for this event. + public var data: Data { + switch self { + case let .answerSubmitted(_, surveyData): return surveyData + } + } + +} + +private struct CustomerCenterBaseData { + + // swiftlint:disable missing_docs + public let localeIdentifier: String + public let darkMode: Bool + public let isSandbox: Bool + public let displayMode: CustomerCenterPresentationMode + + public init( + locale: Locale, + darkMode: Bool, + isSandbox: Bool, + displayMode: CustomerCenterPresentationMode + ) { + self.localeIdentifier = locale.identifier + self.darkMode = darkMode + self.isSandbox = isSandbox + self.displayMode = displayMode + } + // swiftlint:enable missing_docs + +} + +// MARK: - + +extension CustomerCenterEventCreationData: Equatable, Codable, Sendable {} +extension CustomerCenterEvent.Data: Equatable, Codable, Sendable {} +extension CustomerCenterEvent: Equatable, Codable, Sendable {} + +extension CustomerCenterBaseData: Equatable, Codable, Sendable {} + +extension CustomerCenterAnswerSubmittedEvent.Data: Equatable, Codable, Sendable { + + // These keys are used for `StoredFeatureEvent` only + private enum CodingKeys: String, CodingKey { + + case base + case path + case url + case surveyOptionID = "surveyOptionId" + case additionalContext = "additionalContext" + case revisionID = "revisionId" + + } + +} + +extension CustomerCenterAnswerSubmittedEvent: Equatable, Codable, Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/CustomerCenter/Events/Networking/EventsRequest+CustomerCenter.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/CustomerCenter/Events/Networking/EventsRequest+CustomerCenter.swift new file mode 100644 index 00000000..6d797ae7 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/CustomerCenter/Events/Networking/EventsRequest+CustomerCenter.swift @@ -0,0 +1,256 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Untitled.swift +// +// Created by Cesar de la Vega on 21/10/24. + +import Foundation + +extension FeatureEventsRequest { + + struct TypeContainer: Decodable { + + let type: String + + } + + enum CustomerCenterEventType: String { + + case impression = "customer_center_impression" + case answerSubmitted = "customer_center_survey_option_chosen" + + } + + class CustomerCenterEventBaseRequest { + + let id: String? + let version: Int + var type: CustomerCenterEventType + var appUserID: String + var appSessionID: String + var timestamp: UInt64 + var darkMode: Bool + var locale: String + var isSandbox: Bool + var displayMode: CustomerCenterPresentationMode + // We don't support revisions in the backend yet so hardcoding to 1 for now + let revisionId: Int = 1 + + init(id: String?, + version: Int, + type: CustomerCenterEventType, + appUserID: String, + appSessionID: String, + timestamp: UInt64, + darkMode: Bool, + locale: String, + isSandbox: Bool, + displayMode: CustomerCenterPresentationMode) { + self.id = id + self.version = version + self.type = type + self.appUserID = appUserID + self.appSessionID = appSessionID + self.timestamp = timestamp + self.darkMode = darkMode + self.locale = locale + self.isSandbox = isSandbox + self.displayMode = displayMode + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + static func createBase(from storedEvent: StoredFeatureEvent) -> CustomerCenterEventBaseRequest? { + guard let appSessionID = storedEvent.appSessionID else { + Logger.error(Strings.paywalls.event_missing_app_session_id) + return nil + } + + guard let jsonData = storedEvent.encodedEvent.data(using: .utf8) else { + Logger.error(Strings.paywalls.event_cannot_get_encoded_event) + return nil + } + guard let customerCenterEvent = try? JSONDecoder.default.decode(CustomerCenterEvent.self, + from: jsonData) else { + Logger.error(Strings.paywalls.event_cannot_get_encoded_event) + return nil + } + + let creationData = customerCenterEvent.creationData + let data = customerCenterEvent.data + + return CustomerCenterEventBaseRequest( + id: creationData.id.uuidString, + version: version, + type: customerCenterEvent.eventType, + appUserID: storedEvent.userID, + appSessionID: appSessionID.uuidString, + timestamp: creationData.date.millisecondsSince1970, + darkMode: data.darkMode, + locale: data.localeIdentifier, + isSandbox: data.isSandbox, + displayMode: data.displayMode + ) + } + + private static let version: Int = 1 + } + + // swiftlint:disable:next type_name + final class CustomerCenterAnswerSubmittedEventRequest { + + let id: String? + let version: Int + var type: CustomerCenterEventType + var appUserID: String + var appSessionID: String + var timestamp: UInt64 + var darkMode: Bool + var locale: String + var isSandbox: Bool + var displayMode: CustomerCenterPresentationMode + var path: String + var url: String? + var surveyOptionID: String + var additionalContext: String? + var revisionId: Int + + init(id: String?, + version: Int, + appUserID: String, + appSessionID: String, + timestamp: UInt64, + darkMode: Bool, + locale: String, + isSandbox: Bool, + displayMode: CustomerCenterPresentationMode, + path: CustomerCenterConfigData.HelpPath.PathType, + url: URL?, + surveyOptionID: String, + additionalContext: String?, + revisionId: Int) { + self.id = id + self.version = version + self.type = .answerSubmitted + self.appUserID = appUserID + self.appSessionID = appSessionID + self.timestamp = timestamp + self.darkMode = darkMode + self.locale = locale + self.isSandbox = isSandbox + self.displayMode = displayMode + self.path = path.rawValue + self.url = url?.absoluteString + self.surveyOptionID = surveyOptionID + self.additionalContext = additionalContext + self.revisionId = revisionId + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + static func create(from storedEvent: StoredFeatureEvent) -> CustomerCenterAnswerSubmittedEventRequest? { + guard let appSessionID = storedEvent.appSessionID else { + Logger.error(Strings.paywalls.event_missing_app_session_id) + return nil + } + + guard let jsonData = storedEvent.encodedEvent.data(using: .utf8) else { + Logger.error(Strings.paywalls.event_cannot_get_encoded_event) + return nil + } + guard let customerCenterEvent = try? JSONDecoder.default.decode(CustomerCenterAnswerSubmittedEvent.self, + from: jsonData) else { + Logger.error(Strings.paywalls.event_cannot_get_encoded_event) + return nil + } + + let creationData = customerCenterEvent.creationData + let data = customerCenterEvent.data + + return CustomerCenterAnswerSubmittedEventRequest( + id: creationData.id.uuidString, + version: version, + appUserID: storedEvent.userID, + appSessionID: appSessionID.uuidString, + timestamp: creationData.date.millisecondsSince1970, + darkMode: data.darkMode, + locale: data.localeIdentifier, + isSandbox: data.isSandbox, + displayMode: data.displayMode, + path: data.path, + url: data.url, + surveyOptionID: data.surveyOptionID, + additionalContext: data.additionalContext, + revisionId: data.revisionID + ) + } + + private static let version: Int = 1 + + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private extension CustomerCenterEvent { + + var eventType: FeatureEventsRequest.CustomerCenterEventType { + switch self { + case .impression: return .impression + } + + } + +} + +// MARK: - Codable + +extension FeatureEventsRequest.CustomerCenterEventType: Encodable {} +extension FeatureEventsRequest.CustomerCenterEventBaseRequest: Encodable { + + private enum CodingKeys: String, CodingKey { + + case id + case version + case type + case appUserID = "appUserId" + case appSessionID = "appSessionId" + case timestamp + case darkMode = "darkMode" + case locale + case isSandbox = "isSandbox" + case displayMode = "displayMode" + case revisionId = "revisionId" + + } + +} + +extension FeatureEventsRequest.CustomerCenterAnswerSubmittedEventRequest: Encodable { + + private enum CodingKeys: String, CodingKey { + + case id + case version + case type + case appUserID = "appUserId" + case appSessionID = "appSessionId" + case timestamp + case darkMode + case locale + case isSandbox = "isSandbox" + case displayMode = "displayMode" + case path + case url + case surveyOptionID = "surveyOptionId" + case additionalContext = "additionalContext" + case revisionId = "revisionId" + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/DeepLink/DeepLinkParser.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/DeepLink/DeepLinkParser.swift new file mode 100644 index 00000000..c7bb7c80 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/DeepLink/DeepLinkParser.swift @@ -0,0 +1,29 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DeepLinkParser.swift +// +// Created by Antonio Rico Diez on 2024-10-17. + +import Foundation + +enum DeepLinkParser { + + private static let redeemRCBPurchaseHost = "redeem_web_purchase" + + static func parseAsWebPurchaseRedemption(_ url: URL) -> WebPurchaseRedemption? { + if url.host == Self.redeemRCBPurchaseHost, + let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems, + let redemptionToken = queryItems.first(where: { queryItem in queryItem.name == "redemption_token" })?.value { + return WebPurchaseRedemption(redemptionToken: redemptionToken) + } + return nil + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/DiagnosticsEvent.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/DiagnosticsEvent.swift new file mode 100644 index 00000000..908b7722 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/DiagnosticsEvent.swift @@ -0,0 +1,228 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DiagnosticsEntry.swift +// +// Created by Cesar de la Vega on 1/4/24. + +import Foundation + +/// When sending this to the backend `JSONEncoder.KeyEncodingStrategy.convertToSnakeCase` is used. +struct DiagnosticsEvent: Codable, Equatable { + + let id: UUID + private(set) var version: Int = 1 + let name: EventName + let properties: Properties + let timestamp: Date + let appSessionId: UUID + + init(id: UUID = UUID(), + name: EventName, + properties: Properties, + timestamp: Date, + appSessionId: UUID) { + self.id = id + self.name = name + self.properties = properties + self.timestamp = timestamp + self.appSessionId = appSessionId + } + + enum EventName: String, Codable, Equatable { + case httpRequestPerformed = "http_request_performed" + case appleProductsRequest = "apple_products_request" + case customerInfoVerificationResult = "customer_info_verification_result" + case maxEventsStoredLimitReached = "max_events_stored_limit_reached" + case clearingDiagnosticsAfterFailedSync = "clearing_diagnostics_after_failed_sync" + case enteredOfflineEntitlementsMode = "entered_offline_entitlements_mode" + case errorEnteringOfflineEntitlementsMode = "error_entering_offline_entitlements_mode" + case applePurchaseAttempt = "apple_purchase_attempt" + case applePurchaseIntentReceived = "apple_purchase_intent_received" + case maxDiagnosticsSyncRetriesReached = "max_diagnostics_sync_retries_reached" + case getOfferingsStarted = "get_offerings_started" + case getOfferingsResult = "get_offerings_result" + case getProductsStarted = "get_products_started" + case getProductsResult = "get_products_result" + case getCustomerInfoStarted = "get_customer_info_started" + case getCustomerInfoResult = "get_customer_info_result" + case purchaseStarted = "purchase_started" + case purchaseResult = "purchase_result" + case syncPurchasesStarted = "sync_purchases_started" + case syncPurchasesResult = "sync_purchases_result" + case restorePurchasesStarted = "restore_purchases_started" + case restorePurchasesResult = "restore_purchases_result" + case applePresentCodeRedemptionSheetRequest = "apple_present_code_redemption_sheet_request" + case appleTrialOrIntroEligibilityRequest = "apple_trial_or_intro_eligibility_request" + case appleTransactionQueueReceived = "apple_transaction_queue_received" + case appleTransactionUpdateReceived = "apple_transaction_update_received" + case appleAppTransactionError = "apple_app_transaction_error" + } + + enum PurchaseResult: String, Codable, Equatable { + case verified = "VERIFIED" + case unverified = "UNVERIFIED" + case userCancelled = "USER_CANCELLED" + case pending = "PENDING" + } + + enum OfflineEntitlementsModeErrorReason: String, Codable, Equatable { + case oneTimePurchaseFound = "ONE_TIME_PURCHASE_FOUND" + case noEntitlementMappingAvailable = "NO_ENTITLEMENT_MAPPING_AVAILABLE" + case unknown = "UNKNOWN" + } + + struct Properties: Codable, Equatable { + let verificationResult: String? + let endpointName: String? + let host: String? + let responseTimeMillis: Int? + let storeKitVersion: String? + let successful: Bool? + let responseCode: Int? + let backendErrorCode: Int? + let offlineEntitlementErrorReason: OfflineEntitlementsModeErrorReason? + let errorMessage: String? + let errorCode: Int? + let skErrorDescription: String? + let etagHit: Bool? + let requestedProductIds: Set? + let notFoundProductIds: Set? + let productId: String? + let productType: String? + let promotionalOfferId: String? + let offerId: String? + let offerType: String? + let winBackOfferApplied: Bool? + let purchaseResult: PurchaseResult? + let cacheStatus: CacheStatus? + let fetchPolicy: String? + let hadUnsyncedPurchasesBefore: Bool? + let isRetry: Bool? + let eligibilityUnknownCount: Int? + let eligibilityIneligibleCount: Int? + let eligibilityEligibleCount: Int? + let eligibilityNoIntroOfferCount: Int? + let transactionId: UInt64? + let environment: String? + let storefront: String? + let purchaseDate: Int? + let expirationDate: Int? + let price: Float? + let currency: String? + let reason: String? + let connectionErrorReason: ConnectionErrorReason? + + init(verificationResult: String? = nil, + endpointName: String? = nil, + host: String? = nil, + responseTime: TimeInterval? = nil, + storeKitVersion: StoreKitVersion? = nil, + successful: Bool? = nil, + responseCode: Int? = nil, + backendErrorCode: Int? = nil, + offlineEntitlementErrorReason: OfflineEntitlementsModeErrorReason? = nil, + errorMessage: String? = nil, + errorCode: Int? = nil, + skErrorDescription: String? = nil, + etagHit: Bool? = nil, + requestedProductIds: Set? = nil, + notFoundProductIds: Set? = nil, + productId: String? = nil, + productType: StoreProduct.ProductType? = nil, + promotionalOfferId: String? = nil, + offerId: String? = nil, + offerType: String? = nil, + winBackOfferApplied: Bool? = nil, + purchaseResult: PurchaseResult? = nil, + cacheStatus: CacheStatus? = nil, + cacheFetchPolicy: CacheFetchPolicy? = nil, + hadUnsyncedPurchasesBefore: Bool? = nil, + isRetry: Bool? = nil, + eligibilityUnknownCount: Int? = nil, + eligibilityIneligibleCount: Int? = nil, + eligibilityEligibleCount: Int? = nil, + eligibilityNoIntroOfferCount: Int? = nil, + transactionId: UInt64? = nil, + environment: String? = nil, + storefront: String? = nil, + purchaseDate: Date? = nil, + expirationDate: Date? = nil, + price: Float? = nil, + currency: String? = nil, + reason: String? = nil, + connectionErrorReason: ConnectionErrorReason? = nil) { + self.verificationResult = verificationResult + self.endpointName = endpointName + self.host = host + self.responseTimeMillis = responseTime.map { Int($0 * 1000) } + self.storeKitVersion = storeKitVersion.map { "store_kit_\($0.debugDescription)" } + self.successful = successful + self.responseCode = responseCode + self.backendErrorCode = backendErrorCode + self.offlineEntitlementErrorReason = offlineEntitlementErrorReason + self.errorMessage = errorMessage + self.errorCode = errorCode + self.skErrorDescription = skErrorDescription + self.etagHit = etagHit + self.requestedProductIds = requestedProductIds + self.notFoundProductIds = notFoundProductIds + self.productId = productId + self.productType = productType?.diagnosticsName + self.promotionalOfferId = promotionalOfferId + self.offerId = offerId + self.offerType = offerType + self.winBackOfferApplied = winBackOfferApplied + self.purchaseResult = purchaseResult + self.cacheStatus = cacheStatus + self.fetchPolicy = cacheFetchPolicy.map { $0.diagnosticsName } + self.hadUnsyncedPurchasesBefore = hadUnsyncedPurchasesBefore + self.isRetry = isRetry + self.eligibilityUnknownCount = eligibilityUnknownCount + self.eligibilityIneligibleCount = eligibilityIneligibleCount + self.eligibilityEligibleCount = eligibilityEligibleCount + self.eligibilityNoIntroOfferCount = eligibilityNoIntroOfferCount + self.transactionId = transactionId + self.environment = environment + self.storefront = storefront + self.purchaseDate = purchaseDate.map { Int($0.timeIntervalSince1970 * 1000) } + self.expirationDate = expirationDate.map { Int($0.timeIntervalSince1970 * 1000) } + self.price = price + self.currency = currency + self.reason = reason + self.connectionErrorReason = connectionErrorReason + } + + static let empty = Properties() + } +} + +fileprivate extension CacheFetchPolicy { + + var diagnosticsName: String { + switch self { + case .fromCacheOnly: return "FROM_CACHE_ONLY" + case .fetchCurrent: return "FETCH_CURRENT" + case .notStaleCachedOrFetched: return "NOT_STALE_CACHED_OR_FETCHED" + case .cachedOrFetched: return "CACHED_OR_FETCHED" + } + } +} + +fileprivate extension StoreProduct.ProductType { + + var diagnosticsName: String { + switch self { + case .consumable: return "CONSUMABLE" + case .nonConsumable: return "NON_CONSUMABLE" + case .nonRenewableSubscription: return "NON_RENEWABLE_SUBSCRIPTION" + case .autoRenewableSubscription: return "AUTO_RENEWABLE_SUBSCRIPTION" + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/DiagnosticsFileHandler.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/DiagnosticsFileHandler.swift new file mode 100644 index 00000000..582af929 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/DiagnosticsFileHandler.swift @@ -0,0 +1,228 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DiagnosticsFileHandler.swift +// +// Created by Cesar de la Vega on 8/4/24. + +import Foundation + +protocol DiagnosticsFileHandlerType: Sendable { + + func updateDelegate(_ delegate: DiagnosticsFileHandlerDelegate?) async + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + func getEntries() async -> [DiagnosticsEvent?] + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + func appendEvent(diagnosticsEvent: DiagnosticsEvent) async + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + func cleanSentDiagnostics(diagnosticsSentCount: Int) async + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + func emptyDiagnosticsFile() async + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + func isDiagnosticsFileTooBig() async -> Bool + +} + +protocol DiagnosticsFileHandlerDelegate: AnyObject, Sendable { + func onFileSizeIncreasedBeyondAutomaticSyncLimit() async +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +actor DiagnosticsFileHandler: DiagnosticsFileHandlerType { + + private weak var delegate: DiagnosticsFileHandlerDelegate? + + private let fileHandler: FileHandlerType + private var diagnosticsFileExistedOnInit = false + private var didDeleteOldDiagnosticsFile = false + private let fileManager = FileManager.default + + init?() { + guard let diagnosticsFileURL = Self.diagnosticsFileURL else { + Logger.error(Strings.diagnostics.failed_to_create_diagnostics_file_url) + return nil + } + + diagnosticsFileExistedOnInit = fileManager.fileExists(atPath: diagnosticsFileURL.path) + + do { + self.fileHandler = try FileHandler(diagnosticsFileURL) + } catch { + Logger.error(Strings.diagnostics.failed_to_initialize_file_handler(error: error)) + return nil + } + } + + #if DEBUG + /// Only used in testing. In any other case the init above should be used + init(_ fileHandler: FileHandlerType) { + self.fileHandler = fileHandler + } + #endif + + func updateDelegate(_ delegate: DiagnosticsFileHandlerDelegate?) async { + self.delegate = delegate + } + + func appendEvent(diagnosticsEvent: DiagnosticsEvent) async { + var jsonString: String? + do { + jsonString = try diagnosticsEvent.encodedJSON + } catch { + Logger.error(Strings.diagnostics.failed_to_serialize_diagnostic_event(error: error)) + } + + guard let jsonString else { return } + + do { + deleteOldDiagnosticsFileIfNeeded() + + try await self.fileHandler.append(line: jsonString) + } catch { + Logger.error(Strings.diagnostics.failed_to_store_diagnostics_event(error: error)) + } + + if await self.isDiagnosticsFileBigEnoughToSync() { + await self.delegate?.onFileSizeIncreasedBeyondAutomaticSyncLimit() + } + } + + func getEntries() async -> [DiagnosticsEvent?] { + do { + return try await self.fileHandler.readLines() + .map { try? JSONDecoder.default.decode(jsonData: $0.asData) } + .extractValues() + } catch { + Logger.error(Strings.diagnostics.error_fetching_events(error: error)) + return [] + } + } + + func cleanSentDiagnostics(diagnosticsSentCount: Int) async { + guard diagnosticsSentCount > 0 else { + Logger.error(Strings.diagnostics.invalid_sent_diagnostics_count(count: diagnosticsSentCount)) + return + } + + do { + try await self.fileHandler.removeFirstLines(diagnosticsSentCount) + } catch { + Logger.error(Strings.diagnostics.failed_to_clean_sent_diagnostics(error: error)) + } + } + + func emptyDiagnosticsFile() async { + do { + try await self.fileHandler.emptyFile() + } catch { + Logger.error(Strings.diagnostics.failed_to_empty_diagnostics_file(error: error)) + } + } + + func isDiagnosticsFileTooBig() async -> Bool { + do { + return try await self.fileHandler.fileSizeInKB() > Self.maxFileSizeInKb + } catch { + Logger.error(Strings.diagnostics.failed_check_diagnostics_size(error: error)) + return true + } + } + + private static let maxFileSizeInKb: Double = 500 + private static let minFileSizeEnoughToSyncInKb: Double = 200 +} + +// MARK: - Private + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private extension DiagnosticsFileHandler { + + static var diagnosticsFileURL: URL? { + guard let baseURL = DirectoryHelper.baseUrl(for: .applicationSupport()) else { + return nil + } + return baseURL + .appendingPathComponent("diagnostics", isDirectory: true) + .appendingPathComponent("diagnostics") + .appendingPathExtension("jsonl") + } + + // swiftlint:disable avoid_using_directory_apis_directly + private static var oldDiagnosticsDirectoryURL: URL? { + let documentsDirectoryURL: URL? + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + documentsDirectoryURL = URL.documentsDirectory + } else { + documentsDirectoryURL = FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask + ).first + } + + return documentsDirectoryURL?.appendingPathComponent("com.revenuecat", isDirectory: true) + } + // swiftlint:enable avoid_using_directory_apis_directly + + private static var oldDiagnosticsFileURL: URL? { + oldDiagnosticsDirectoryURL? + .appendingPathComponent("diagnostics") + .appendingPathExtension("jsonl") + } + + /* + We were previously storing the diagnostics file in the Documents directory + which may end up in the Files app or the user's Documents directory on macOS. + We'll try to delete it if the new file did not exist yet. + */ + private func deleteOldDiagnosticsFileIfNeeded() { + guard !diagnosticsFileExistedOnInit && !didDeleteOldDiagnosticsFile else { return } + + guard let oldDiagnosticsDirectoryURL = Self.oldDiagnosticsDirectoryURL, + let oldDiagnosticsFileURL = Self.oldDiagnosticsFileURL, + FileManager.default.fileExists(atPath: oldDiagnosticsFileURL.path) + else { + return + } + + do { + try FileManager.default.removeItem(at: oldDiagnosticsFileURL) + + let contents = try? FileManager.default.contentsOfDirectory(atPath: oldDiagnosticsDirectoryURL.path) + if let contents = contents, contents.isEmpty { + try? FileManager.default.removeItem(at: oldDiagnosticsDirectoryURL) + } + didDeleteOldDiagnosticsFile = true + } catch { + Logger.error(Strings.diagnostics.failed_to_delete_old_diagnostics_file(error: error)) + } + } + + private func isDiagnosticsFileBigEnoughToSync() async -> Bool { + do { + return try await self.fileHandler.fileSizeInKB() > Self.minFileSizeEnoughToSyncInKb + } catch { + Logger.error(Strings.diagnostics.failed_check_diagnostics_size(error: error)) + return true + } + } + + private func decodeDiagnosticsEvent(from line: String) -> DiagnosticsEvent? { + do { + guard let data = line.data(using: .utf8) else { return nil } + return try JSONDecoder.default.decode(DiagnosticsEvent.self, from: data) + } catch { + return nil + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/DiagnosticsTracker.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/DiagnosticsTracker.swift new file mode 100644 index 00000000..3228a60f --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/DiagnosticsTracker.swift @@ -0,0 +1,552 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DiagnosticsTracker.swift +// +// Created by Cesar de la Vega on 4/4/24. + +import Foundation + +// swiftlint:disable function_parameter_count +// swiftlint:disable file_length +// swiftlint:disable type_body_length +protocol DiagnosticsTrackerType: Sendable { + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func track(_ event: DiagnosticsEvent) + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackCustomerInfoVerificationResultIfNeeded(_ customerInfo: CustomerInfo) + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackProductsRequest(wasSuccessful: Bool, + storeKitVersion: StoreKitVersion, + errorMessage: String?, + errorCode: Int?, + storeKitErrorDescription: String?, + storefront: String?, + requestedProductIds: Set, + notFoundProductIds: Set, + responseTime: TimeInterval) + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackHttpRequestPerformed(endpointName: String, + host: String?, + responseTime: TimeInterval, + wasSuccessful: Bool, + responseCode: Int, + backendErrorCode: Int?, + resultOrigin: HTTPResponseOrigin?, + verificationResult: VerificationResult, + isRetry: Bool, + connectionErrorReason: ConnectionErrorReason?) + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackPurchaseAttempt(wasSuccessful: Bool, + storeKitVersion: StoreKitVersion, + errorMessage: String?, + errorCode: Int?, + storeKitErrorDescription: String?, + storefront: String?, + productId: String, + promotionalOfferId: String?, + winBackOfferApplied: Bool, + purchaseResult: DiagnosticsEvent.PurchaseResult?, + responseTime: TimeInterval) + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackPurchaseIntentReceived(productId: String, + offerId: String?, + offerType: String?) + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackMaxDiagnosticsSyncRetriesReached() + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackClearingDiagnosticsAfterFailedSync() + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackEnteredOfflineEntitlementsMode() + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackErrorEnteringOfflineEntitlementsMode(reason: DiagnosticsEvent.OfflineEntitlementsModeErrorReason, + errorMessage: String) + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackOfferingsStarted() + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackOfferingsResult(requestedProductIds: Set?, + notFoundProductIds: Set?, + errorMessage: String?, + errorCode: Int?, + verificationResult: VerificationResult?, + cacheStatus: CacheStatus, + responseTime: TimeInterval) + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackProductsStarted(requestedProductIds: Set) + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackProductsResult(requestedProductIds: Set, + notFoundProductIds: Set?, + errorMessage: String?, + errorCode: Int?, + responseTime: TimeInterval) + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackGetCustomerInfoStarted() + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackGetCustomerInfoResult(cacheFetchPolicy: CacheFetchPolicy, + verificationResult: VerificationResult?, + hadUnsyncedPurchasesBefore: Bool?, + errorMessage: String?, + errorCode: Int?, + responseTime: TimeInterval) + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackPurchaseStarted(productId: String, + productType: StoreProduct.ProductType) + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackPurchaseResult(productId: String, + productType: StoreProduct.ProductType, + verificationResult: VerificationResult?, + errorMessage: String?, + errorCode: Int?, + responseTime: TimeInterval) + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackSyncPurchasesStarted() + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackSyncPurchasesResult(errorMessage: String?, + errorCode: Int?, + responseTime: TimeInterval) + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackRestorePurchasesStarted() + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackRestorePurchasesResult(errorMessage: String?, + errorCode: Int?, + responseTime: TimeInterval) + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackApplePresentCodeRedemptionSheetRequest() + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackAppleTrialOrIntroEligibilityRequest(storeKitVersion: StoreKitVersion, + requestedProductIds: Set, + eligibilityUnknownCount: Int?, + eligibilityIneligibleCount: Int?, + eligibilityEligibleCount: Int?, + eligibilityNoIntroOfferCount: Int?, + errorMessage: String?, + errorCode: Int?, + storefront: String?, + responseTime: TimeInterval) + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackAppleTransactionQueueReceived(productId: String?, + paymentDiscountId: String?, + transactionState: String, + storefront: String?, + errorMessage: String?) + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackAppleTransactionUpdateReceived(transactionId: UInt64, + environment: String?, + storefront: String?, + productId: String, + purchaseDate: Date, + expirationDate: Date?, + price: Float?, + currency: String?, + reason: String?) + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func trackAppleAppTransactionError(errorMessage: String, + errorCode: Int?, + storeKitErrorDescription: String?) +} + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +final class DiagnosticsTracker: DiagnosticsTrackerType, Sendable { + + private let diagnosticsFileHandler: DiagnosticsFileHandlerType + private let diagnosticsDispatcher: OperationDispatcher + private let dateProvider: DateProvider + private let appSessionID: UUID + + init(diagnosticsFileHandler: DiagnosticsFileHandlerType, + diagnosticsDispatcher: OperationDispatcher = .default, + dateProvider: DateProvider = DateProvider(), + appSessionID: UUID = SystemInfo.appSessionID) { + self.diagnosticsFileHandler = diagnosticsFileHandler + self.diagnosticsDispatcher = diagnosticsDispatcher + self.dateProvider = dateProvider + self.appSessionID = appSessionID + } + + func track(_ event: DiagnosticsEvent) { + self.diagnosticsDispatcher.dispatchOnWorkerThread { + await self.clearDiagnosticsFileIfTooBig() + await self.diagnosticsFileHandler.appendEvent(diagnosticsEvent: event) + } + } + + func trackCustomerInfoVerificationResultIfNeeded( + _ customerInfo: CustomerInfo + ) { + let verificationResult = customerInfo.entitlements.verification + if verificationResult == .notRequested { + return + } + + self.trackEvent(name: .customerInfoVerificationResult, + properties: DiagnosticsEvent.Properties(verificationResult: verificationResult.name)) + } + + func trackProductsRequest(wasSuccessful: Bool, + storeKitVersion: StoreKitVersion, + errorMessage: String?, + errorCode: Int?, + storeKitErrorDescription: String?, + storefront: String?, + requestedProductIds: Set, + notFoundProductIds: Set, + responseTime: TimeInterval) { + self.trackEvent(name: .appleProductsRequest, + properties: DiagnosticsEvent.Properties( + responseTime: responseTime, + storeKitVersion: storeKitVersion, + successful: wasSuccessful, + errorMessage: errorMessage, + errorCode: errorCode, + skErrorDescription: storeKitErrorDescription, + requestedProductIds: requestedProductIds, + notFoundProductIds: notFoundProductIds, + storefront: storefront + )) + } + + func trackHttpRequestPerformed(endpointName: String, + host: String?, + responseTime: TimeInterval, + wasSuccessful: Bool, + responseCode: Int, + backendErrorCode: Int?, + resultOrigin: HTTPResponseOrigin?, + verificationResult: VerificationResult, + isRetry: Bool, + connectionErrorReason: ConnectionErrorReason?) { + self.trackEvent(name: .httpRequestPerformed, + properties: DiagnosticsEvent.Properties( + verificationResult: verificationResult.name, + endpointName: endpointName, + host: host, + responseTime: responseTime, + successful: wasSuccessful, + responseCode: responseCode, + backendErrorCode: backendErrorCode, + etagHit: resultOrigin == .cache, + isRetry: isRetry, + connectionErrorReason: connectionErrorReason + )) + } + + func trackPurchaseAttempt(wasSuccessful: Bool, + storeKitVersion: StoreKitVersion, + errorMessage: String?, + errorCode: Int?, + storeKitErrorDescription: String?, + storefront: String?, + productId: String, + promotionalOfferId: String?, + winBackOfferApplied: Bool, + purchaseResult: DiagnosticsEvent.PurchaseResult?, + responseTime: TimeInterval) { + self.trackEvent(name: .applePurchaseAttempt, + properties: DiagnosticsEvent.Properties( + responseTime: responseTime, + storeKitVersion: storeKitVersion, + successful: wasSuccessful, + errorMessage: errorMessage, + errorCode: errorCode, + skErrorDescription: storeKitErrorDescription, + productId: productId, + promotionalOfferId: promotionalOfferId, + winBackOfferApplied: winBackOfferApplied, + purchaseResult: purchaseResult, + storefront: storefront + )) + } + + func trackPurchaseIntentReceived(productId: String, + offerId: String?, + offerType: String?) { + self.trackEvent(name: .applePurchaseIntentReceived, + properties: DiagnosticsEvent.Properties( + productId: productId, + offerId: offerId, + offerType: offerType + )) + } + + func trackMaxDiagnosticsSyncRetriesReached() { + self.trackEvent(name: .maxEventsStoredLimitReached, properties: .empty) + } + + func trackClearingDiagnosticsAfterFailedSync() { + self.trackEvent(name: .clearingDiagnosticsAfterFailedSync, properties: .empty) + } + + func trackEnteredOfflineEntitlementsMode() { + self.trackEvent(name: .enteredOfflineEntitlementsMode, properties: .empty) + } + + func trackErrorEnteringOfflineEntitlementsMode(reason: DiagnosticsEvent.OfflineEntitlementsModeErrorReason, + errorMessage: String) { + self.trackEvent(name: .errorEnteringOfflineEntitlementsMode, + properties: DiagnosticsEvent.Properties( + offlineEntitlementErrorReason: reason, + errorMessage: errorMessage + )) + } + + func trackOfferingsStarted() { + self.trackEvent(name: .getOfferingsStarted, properties: .empty) + } + + func trackOfferingsResult(requestedProductIds: Set?, + notFoundProductIds: Set?, + errorMessage: String?, + errorCode: Int?, + verificationResult: VerificationResult?, + cacheStatus: CacheStatus, + responseTime: TimeInterval) { + self.trackEvent(name: .getOfferingsResult, + properties: DiagnosticsEvent.Properties( + verificationResult: verificationResult?.name, + responseTime: responseTime, + errorMessage: errorMessage, + errorCode: errorCode, + requestedProductIds: requestedProductIds, + notFoundProductIds: notFoundProductIds, + cacheStatus: cacheStatus + )) + } + + func trackProductsStarted(requestedProductIds: Set) { + self.trackEvent(name: .getProductsResult, + properties: DiagnosticsEvent.Properties( + requestedProductIds: requestedProductIds + )) + } + + func trackProductsResult(requestedProductIds: Set, + notFoundProductIds: Set?, + errorMessage: String?, + errorCode: Int?, + responseTime: TimeInterval) { + self.trackEvent(name: .getProductsResult, + properties: DiagnosticsEvent.Properties( + responseTime: responseTime, + errorMessage: errorMessage, + errorCode: errorCode, + requestedProductIds: requestedProductIds, + notFoundProductIds: notFoundProductIds + )) + } + + func trackGetCustomerInfoStarted() { + self.trackEvent(name: .getCustomerInfoStarted, properties: .empty) + } + + func trackGetCustomerInfoResult(cacheFetchPolicy: CacheFetchPolicy, + verificationResult: VerificationResult?, + hadUnsyncedPurchasesBefore: Bool?, + errorMessage: String?, + errorCode: Int?, + responseTime: TimeInterval) { + self.trackEvent(name: .getCustomerInfoResult, + properties: DiagnosticsEvent.Properties( + verificationResult: verificationResult?.name, + responseTime: responseTime, + errorMessage: errorMessage, + errorCode: errorCode, + cacheFetchPolicy: cacheFetchPolicy, + hadUnsyncedPurchasesBefore: hadUnsyncedPurchasesBefore + )) + } + + func trackSyncPurchasesStarted() { + self.trackEvent(name: .syncPurchasesStarted, properties: .empty) + } + + func trackSyncPurchasesResult(errorMessage: String?, + errorCode: Int?, + responseTime: TimeInterval) { + self.trackEvent(name: .syncPurchasesResult, + properties: DiagnosticsEvent.Properties( + responseTime: responseTime, + errorMessage: errorMessage, + errorCode: errorCode + )) + } + + func trackRestorePurchasesStarted() { + self.trackEvent(name: .restorePurchasesStarted, properties: .empty) + } + + func trackRestorePurchasesResult(errorMessage: String?, + errorCode: Int?, + responseTime: TimeInterval) { + self.trackEvent(name: .restorePurchasesResult, + properties: DiagnosticsEvent.Properties( + responseTime: responseTime, + errorMessage: errorMessage, + errorCode: errorCode + )) + } + + func trackPurchaseStarted(productId: String, + productType: StoreProduct.ProductType) { + self.trackEvent(name: .purchaseStarted, + properties: DiagnosticsEvent.Properties( + productId: productId, + productType: productType + ) + ) + } + + func trackPurchaseResult(productId: String, + productType: StoreProduct.ProductType, + verificationResult: VerificationResult?, + errorMessage: String?, + errorCode: Int?, + responseTime: TimeInterval) { + self.trackEvent(name: .purchaseResult, + properties: DiagnosticsEvent.Properties( + verificationResult: verificationResult?.name, + responseTime: responseTime, + errorMessage: errorMessage, + errorCode: errorCode, + productId: productId, + productType: productType + ) + ) + } + + func trackApplePresentCodeRedemptionSheetRequest() { + self.trackEvent(name: .applePresentCodeRedemptionSheetRequest, properties: .empty) + } + + func trackAppleTransactionUpdateReceived(transactionId: UInt64, + environment: String?, + storefront: String?, + productId: String, + purchaseDate: Date, + expirationDate: Date?, + price: Float?, + currency: String?, + reason: String?) { + self.trackEvent(name: .appleTransactionUpdateReceived, + properties: DiagnosticsEvent.Properties( + productId: productId, + transactionId: transactionId, + environment: environment, + storefront: storefront, + purchaseDate: purchaseDate, + expirationDate: expirationDate, + price: price, + currency: currency, + reason: reason + )) + } + + func trackAppleTrialOrIntroEligibilityRequest(storeKitVersion: StoreKitVersion, + requestedProductIds: Set, + eligibilityUnknownCount: Int?, + eligibilityIneligibleCount: Int?, + eligibilityEligibleCount: Int?, + eligibilityNoIntroOfferCount: Int?, + errorMessage: String?, + errorCode: Int?, + storefront: String?, + responseTime: TimeInterval) { + self.trackEvent(name: .appleTrialOrIntroEligibilityRequest, + properties: DiagnosticsEvent.Properties( + responseTime: responseTime, + storeKitVersion: storeKitVersion, + errorMessage: errorMessage, + errorCode: errorCode, + requestedProductIds: requestedProductIds, + eligibilityUnknownCount: eligibilityUnknownCount, + eligibilityIneligibleCount: eligibilityIneligibleCount, + eligibilityEligibleCount: eligibilityEligibleCount, + eligibilityNoIntroOfferCount: eligibilityNoIntroOfferCount, + storefront: storefront + )) + } + + func trackAppleTransactionQueueReceived(productId: String?, + paymentDiscountId: String?, + transactionState: String, + storefront: String?, + errorMessage: String?) { + self.trackEvent(name: .appleTransactionQueueReceived, + properties: DiagnosticsEvent.Properties( + errorMessage: errorMessage, + skErrorDescription: transactionState, + productId: productId, + promotionalOfferId: paymentDiscountId, + storefront: storefront + )) + } + + func trackAppleAppTransactionError(errorMessage: String, + errorCode: Int?, + storeKitErrorDescription: String?) { + self.trackEvent(name: .appleAppTransactionError, + properties: DiagnosticsEvent.Properties( + errorMessage: errorMessage, + errorCode: errorCode, + skErrorDescription: storeKitErrorDescription + )) + } + +} + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +private extension DiagnosticsTracker { + + func trackEvent(name: DiagnosticsEvent.EventName, properties: DiagnosticsEvent.Properties) { + self.track( + DiagnosticsEvent(name: name, + properties: properties, + timestamp: self.dateProvider.now(), + appSessionId: self.appSessionID) + ) + } + + func clearDiagnosticsFileIfTooBig() async { + if await self.diagnosticsFileHandler.isDiagnosticsFileTooBig() { + await self.diagnosticsFileHandler.emptyDiagnosticsFile() + let maxEventsStoredEvent = DiagnosticsEvent(name: .maxEventsStoredLimitReached, + properties: .empty, + timestamp: self.dateProvider.now(), + appSessionId: self.appSessionID) + await self.diagnosticsFileHandler.appendEvent(diagnosticsEvent: maxEventsStoredEvent) + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/FileHandler.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/FileHandler.swift new file mode 100644 index 00000000..ad8ac63b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/FileHandler.swift @@ -0,0 +1,292 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// FileHandler.swift +// +// Created by Nacho Soto on 6/16/23. + +import Foundation + +/// A wrapper that allows basic operations on a file, synchronized as an `actor`. +protocol FileHandlerType: Sendable { + + /// Returns an async sequence for every line in the file + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func readLines() async throws -> AsyncLineSequence + + /// Adds a line at the end of the file + @available(iOS 13.4, tvOS 13.4, watchOS 6.2, macOS 10.15.4, *) + func append(line: String) async throws + + /// Removes the contents of the file + func emptyFile() async throws + + /// Deletes the first N lines from the file, without loading the entire file in memory. + func removeFirstLines(_ count: Int) async throws + + func fileSizeInKB() async throws -> Double + +} + +actor FileHandler: FileHandlerType { + + private var fileHandle: FileHandle + + let url: URL + + init(_ fileURL: URL) throws { + try Self.createFileIfNecessary(fileURL) + + self.url = fileURL + self.fileHandle = try FileHandle(fileURL) + } + + deinit { + let url = self.url + Logger.verbose(Message.closing_handle(url)) + + self.fileHandle.closeAndLogErrors() + } + + /// - Note: this loads the entire file in memory + /// For newer versions, consider using `readLines` instead. + func readFile() throws -> Data { + RCTestAssertNotMainThread() + + try self.moveToBeginningOfFile() + + return self.fileHandle.availableData + } + + /// Returns an async sequence for every line in the file + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func readLines() throws -> AsyncLineSequence { + RCTestAssertNotMainThread() + + try self.moveToBeginningOfFile() + + return self.fileHandle.bytes.lines + } + + /// Adds a line at the end of the file + @available(iOS 13.4, tvOS 13.4, watchOS 6.2, macOS 10.15.4, *) + func append(line: String) throws { + RCTestAssertNotMainThread() + + try self.fileHandle.seekToEnd() + try self.fileHandle.write(contentsOf: line.asData) + try self.fileHandle.write(contentsOf: Self.lineBreakData) + try self.fileHandle.synchronize() + } + + /// Removes the contents of the file + func emptyFile() async throws { + RCTestAssertNotMainThread() + + do { + try self.fileHandle.truncate(atOffset: 0) + try self.fileHandle.synchronize() + } catch { + throw Error.failedEmptyingFile(error) + } + } + + /// Deletes the first N lines from the file, without loading the entire file in memory. + func removeFirstLines(_ count: Int) async throws { + precondition(count > 0, "Invalid count: \(count)") + + try self.moveToBeginningOfFile() + + // Create a handle to write the new contents + let tempURL = try Self.createTemporaryFile() + let outputFile = try FileHandle(tempURL) + + var linesDetected = 0 + + repeat { + // Read N bytes at a time + let data = self.fileHandle.readData(ofLength: Self.bufferSize) + + guard !data.isEmpty, let string = String(data: data, encoding: .utf8) else { + break + } + + // After detecting `count` lines, start writing + linesDetected += string.countOccurences(of: Self.lineBreak) + if linesDetected >= count { + let linesToWrite = string + .components(separatedBy: String(Self.lineBreak)) + .suffix(linesDetected - count + 1) + .joined(separator: String(Self.lineBreak)) + + outputFile.write(linesToWrite.asData) + } + } while true + + // Replace with temporary file + try self.replaceHandler(with: tempURL) + } + + func fileSizeInKB() async throws -> Double { + let attributes = try FileManager.default.attributesOfItem(atPath: self.url.path) + guard let fileSizeInBytes = attributes[.size] as? NSNumber else { + throw Error.failedGettingFileSize(self.url) + } + return Double(fileSizeInBytes.intValue) / 1024 + } + + // MARK: - + + private static let fileManager: FileManager = .default + + private static let lineBreak: Character = "\n" + private static let lineBreakData = String(FileHandler.lineBreak).asData + private static let bufferSize = 4096 + +} + +// MARK: - Errors + +extension FileHandler { + + enum Error: Swift.Error { + + case failedCreatingFile(URL) + case failedCreatingDirectory(URL) + case failedCreatingHandle(Swift.Error) + case failedSeeking(Swift.Error) + case failedEmptyingFile(Swift.Error) + case failedMovingNewFile(from: URL, toURL: URL, Swift.Error) + case failedGettingFileSize(URL) + + } + +} + +// MARK: - Private + +private extension FileHandler { + + func moveToBeginningOfFile() throws { + do { + try self.fileHandle.seek(toOffset: 0) + } catch { + throw Error.failedSeeking(error) + } + } + + static func createFileIfNecessary(_ url: URL) throws { + guard !Self.fileManager.fileExists(atPath: url.path) else { return } + + let directoryURL = url.deletingLastPathComponent() + if !Self.fileManager.fileExists(atPath: directoryURL.path) { + do { + Logger.verbose(Message.creating_directory(directoryURL)) + + try Self.fileManager.createDirectory(at: directoryURL, + withIntermediateDirectories: true, + attributes: nil) + } catch { + throw Error.failedCreatingDirectory(directoryURL) + } + } + + Logger.verbose(Message.creating_file(url)) + + if !Self.fileManager.createFile(atPath: url.path, contents: nil, attributes: nil) { + throw Error.failedCreatingFile(url) + } + } + + static func createTemporaryFile() throws -> URL { + let result = Self.fileManager.temporaryDirectory + .appendingPathComponent("com.revenenuecat") + .appendingPathComponent(UUID().uuidString) + + try Self.createFileIfNecessary(result) + return result + } + + func replaceHandler(with otherURL: URL) throws { + do { + try Self.fileManager.removeItem(at: self.url) + try Self.fileManager.moveItem(at: otherURL, to: self.url) + } catch { + throw Error.failedMovingNewFile(from: otherURL, toURL: self.url, error) + } + + self.fileHandle.closeAndLogErrors() + self.fileHandle = try .init(self.url) + } + +} + +private extension FileHandle { + + convenience init(_ url: URL) throws { + do { + Logger.verbose(Message.creating_handler(url)) + try self.init(forUpdating: url) + } catch { + Logger.warn(Message.failed_creating_handle(error)) + throw FileHandler.Error.failedCreatingHandle(error) + } + } + + func closeAndLogErrors() { + do { + try self.close() + } catch { + Logger.warn(Message.failed_closing_handle(error)) + } + } + +} + +// MARK: - Messages + +// swiftlint:disable identifier_name + +private enum Message: LogMessage { + + case creating_handler(URL) + case closing_handle(URL) + case failed_creating_handle(Error) + case failed_closing_handle(Error) + + case creating_directory(URL) + case creating_file(URL) + + var description: String { + switch self { + case let .creating_handler(url): + return "Creating FileHandler for: \(url)" + + case let .closing_handle(url): + return "Closing FileHandler for: \(url)" + + case let .failed_creating_handle(error): + return "Error creating FileHandle: \(error.localizedDescription)" + + case let .failed_closing_handle(error): + return "Error closing FileHandle: \(error.localizedDescription)" + + case let .creating_directory(url): + return "Creating directory: \(url)" + + case let .creating_file(url): + return "Creating file: \(url)" + } + } + + var category: String { return "file_handler" } + +} + +// swiftlint:enable identifier_name diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/Networking/DiagnosticsEventsRequest.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/Networking/DiagnosticsEventsRequest.swift new file mode 100644 index 00000000..a095e976 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/Networking/DiagnosticsEventsRequest.swift @@ -0,0 +1,26 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DiagnosticsEventsRequest.swift +// +// Created by Cesar de la Vega on 11/4/24. + +import Foundation + +/// The content of a request to the events endpoints. +struct DiagnosticsEventsRequest { + + var entries: [DiagnosticsEvent] + + init(events: [DiagnosticsEvent]) { + self.entries = events + } +} + +extension DiagnosticsEventsRequest: HTTPRequestBody {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/Networking/DiagnosticsHTTPRequestPath.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/Networking/DiagnosticsHTTPRequestPath.swift new file mode 100644 index 00000000..e6322029 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/Networking/DiagnosticsHTTPRequestPath.swift @@ -0,0 +1,63 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DiagnosticsHTTPRequestPath.swift +// +// Created by Cesar de la Vega on 8/4/24. + +import Foundation + +extension HTTPRequest.DiagnosticsPath: HTTPRequestPath { + + // swiftlint:disable:next force_unwrapping + static let serverHostURL = URL(string: "https://api-diagnostics.revenuecat.com")! + + var authenticated: Bool { + switch self { + case .postDiagnostics: + return true + } + } + + var shouldSendEtag: Bool { + switch self { + case .postDiagnostics: + return false + } + } + + var supportsSignatureVerification: Bool { + switch self { + case .postDiagnostics: + return false + } + } + + var needsNonceForSigning: Bool { + switch self { + case .postDiagnostics: + return false + } + } + + var relativePath: String { + switch self { + case .postDiagnostics: + return "/v1/diagnostics" + } + } + + var name: String { + switch self { + case .postDiagnostics: + return "post_diagnostics" + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/Networking/DiagnosticsPostOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/Networking/DiagnosticsPostOperation.swift new file mode 100644 index 00000000..65e9b7ef --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/Networking/DiagnosticsPostOperation.swift @@ -0,0 +1,49 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DiagnosticsPostOperation.swift +// +// Created by Cesar de la Vega on 8/4/24. + +import Foundation + +final class DiagnosticsPostOperation: NetworkOperation { + + private let configuration: Configuration + private let request: DiagnosticsEventsRequest + private let responseHandler: CustomerAPI.SimpleResponseHandler? + + init( + configuration: Configuration, + request: DiagnosticsEventsRequest, + responseHandler: CustomerAPI.SimpleResponseHandler? + ) { + self.configuration = configuration + self.request = request + self.responseHandler = responseHandler + + super.init(configuration: configuration) + } + + override func begin(completion: @escaping () -> Void) { + let request = HTTPRequest(method: .post(self.request), path: .postDiagnostics) + + self.httpClient.perform(request) { (response: VerifiedHTTPResponse.Result) in + defer { + completion() + } + + self.responseHandler?(response.error.map(BackendError.networkError)) + } + } + +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension DiagnosticsPostOperation: @unchecked Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/Networking/DiagnosticsSynchronizer.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/Networking/DiagnosticsSynchronizer.swift new file mode 100644 index 00000000..8d22578c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Diagnostics/Networking/DiagnosticsSynchronizer.swift @@ -0,0 +1,159 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DiagnosticsSynchronizer.swift +// +// Created by Cesar de la Vega on 8/4/24. + +import Foundation + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +protocol DiagnosticsSynchronizerType { + + func syncDiagnosticsIfNeeded() async throws + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +actor DiagnosticsSynchronizer: DiagnosticsSynchronizerType { + + private let internalAPI: InternalAPI + private let handler: DiagnosticsFileHandlerType + private let tracker: DiagnosticsTrackerType? + private let userDefaults: SynchronizedUserDefaults + + private var syncInProgress = false + + init( + internalAPI: InternalAPI, + handler: DiagnosticsFileHandlerType, + tracker: DiagnosticsTrackerType?, + userDefaults: SynchronizedUserDefaults + ) { + self.internalAPI = internalAPI + self.handler = handler + self.tracker = tracker + self.userDefaults = userDefaults + } + + func syncDiagnosticsIfNeeded() async throws { + guard !self.syncInProgress else { + Logger.debug(Strings.diagnostics.event_sync_already_in_progress) + return + } + + self.syncInProgress = true + defer { self.syncInProgress = false } + + let optionalEvents = await self.handler.getEntries() + let count = optionalEvents.count + + guard !optionalEvents.isEmpty else { + Logger.verbose(Strings.diagnostics.event_sync_with_empty_store) + return + } + + Logger.verbose(Strings.diagnostics.event_sync_starting(count: count)) + + let events = optionalEvents.compactMap { $0 } + + do { + try await self.internalAPI.postDiagnosticsEvents(events: events) + + await self.handler.cleanSentDiagnostics(diagnosticsSentCount: count) + + self.clearSyncRetries() + } catch { + Logger.error(Strings.diagnostics.could_not_synchronize_diagnostics(error: error)) + + if let backendError = error as? BackendError, + backendError.shouldRetryDiagnosticsSync { + let currentSyncRetries = self.getCurrentSyncRetries() + + if currentSyncRetries >= Self.maxSyncRetries { + Logger.error(Strings.diagnostics.failed_diagnostics_sync_more_than_max_retries) + await self.handler.emptyDiagnosticsFile() + self.tracker?.trackMaxDiagnosticsSyncRetriesReached() + self.clearSyncRetries() + } else { + self.increaseSyncRetries(currentRetries: currentSyncRetries) + } + } else { + await self.handler.cleanSentDiagnostics(diagnosticsSentCount: count) + self.tracker?.trackClearingDiagnosticsAfterFailedSync() + self.clearSyncRetries() + } + + throw error + } + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension DiagnosticsSynchronizer: DiagnosticsFileHandlerDelegate { + + func onFileSizeIncreasedBeyondAutomaticSyncLimit() async { + Logger.verbose(Strings.diagnostics.syncing_events_due_to_enough_file_size_reached) + do { + try await self.syncDiagnosticsIfNeeded() + } catch { + Logger.error(Strings.diagnostics.could_not_synchronize_diagnostics(error: error)) + } + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private extension DiagnosticsSynchronizer { + + static let maxSyncRetries = 3 + + enum CacheKeys: String { + case numberOfRetries = "com.revenuecat.diagnostics.number_sync_retries" + } + + func increaseSyncRetries(currentRetries: Int) { + self.userDefaults.write { + $0.set(currentRetries + 1, forKey: CacheKeys.numberOfRetries.rawValue) + } + } + + func clearSyncRetries() { + self.userDefaults.write { + $0.removeObject(forKey: CacheKeys.numberOfRetries.rawValue) + } + } + + func getCurrentSyncRetries() -> Int { + return self.userDefaults.read { + $0.integer(forKey: CacheKeys.numberOfRetries.rawValue) + } + } + +} + +private extension BackendError { + + var shouldRetryDiagnosticsSync: Bool { + guard case .networkError(let networkError) = self else { + return false + } + + switch networkError { + case .networkError: + return true + case .errorResponse(_, let statusCode, _): + return statusCode.isServerError + default: + return false + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/DocCDocumentation/EmptyFile.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/DocCDocumentation/EmptyFile.swift new file mode 100644 index 00000000..d7b655a6 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/DocCDocumentation/EmptyFile.swift @@ -0,0 +1,10 @@ +// +// EmptyFile.swift +// +// +// Created by Andrés Boedo on 3/28/22. +// +// This is an empty source file used to make RevenueCat a valid +// documentation build target. +// +// see Apple's example at https://github.com/apple/swift-docc-plugin/ diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/Assertions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/Assertions.swift new file mode 100644 index 00000000..837c3496 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/Assertions.swift @@ -0,0 +1,46 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Assertions.swift +// +// Created by Nacho Soto on 5/16/23. + +import Foundation + +/// Equivalent to `assert`, but will only evaluate condition during RC tests. +/// - Note: this is a no-op in release builds. +@inline(__always) +func RCTestAssert( + _ condition: @autoclosure () -> Bool, + _ message: @autoclosure () -> String, + file: StaticString = #fileID, + line: UInt = #line +) { + #if DEBUG + guard ProcessInfo.isRunningRevenueCatTests else { return } + + precondition(condition(), message(), file: file, line: line) + #endif +} + +@inline(__always) +func RCTestAssertNotMainThread( + function: StaticString = #function, + file: StaticString = #fileID, + line: UInt = #line +) { + #if DEBUG + RCTestAssert( + !Thread.isMainThread, + "\(function) should not be called from the main thread", + file: file, + line: line + ) + #endif +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/BackendError.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/BackendError.swift new file mode 100644 index 00000000..71695f3b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/BackendError.swift @@ -0,0 +1,313 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// BackendError.swift +// +// Created by Nacho Soto on 4/7/22. + +// swiftlint:disable multiline_parameters + +import Foundation + +/// An `Error` produced by ``Backend``. +enum BackendError: Error, Equatable { + + case networkError(NetworkError) + case missingAppUserID(Source) + case emptySubscriberAttributes(Source) + case missingReceiptFile(URL?, Source) + case missingTransactionProductIdentifier(Source) + case missingCachedCustomerInfo(Source) + case invalidAppleSubscriptionKey(Source) + case unexpectedBackendResponse(UnexpectedBackendResponseError, extraContext: String?, Source) + case invalidWebRedemptionToken + case purchaseBelongsToOtherUser + case expiredWebRedemptionToken(obfuscatedEmail: String) + case unsupportedInUIPreviewMode(Source) + case missingTransactionJWS(Source) + +} + +extension BackendError { + + static func missingAppUserID( + file: String = #fileID, function: String = #function, line: UInt = #line + ) -> Self { + return .missingAppUserID(.init(file: file, function: function, line: line)) + } + + static func missingTransactionJWS( + file: String = #fileID, function: String = #function, line: UInt = #line + ) -> Self { + return .missingTransactionJWS(.init(file: file, function: function, line: line)) + } + + static func emptySubscriberAttributes( + file: String = #fileID, function: String = #function, line: UInt = #line + ) -> Self { + return .emptySubscriberAttributes(.init(file: file, function: function, line: line)) + } + + static func missingTransactionProductIdentifier( + file: String = #fileID, function: String = #function, line: UInt = #line + ) -> Self { + return .missingTransactionProductIdentifier(.init(file: file, function: function, line: line)) + } + + static func missingReceiptFile( + _ receiptURL: URL?, + file: String = #fileID, function: String = #function, line: UInt = #line + ) -> Self { + return .missingReceiptFile(receiptURL, .init(file: file, function: function, line: line)) + } + + static func missingCachedCustomerInfo( + file: String = #fileID, function: String = #function, line: UInt = #line + ) -> Self { + return .missingCachedCustomerInfo(.init(file: file, function: function, line: line)) + } + + static func unexpectedBackendResponse( + _ error: UnexpectedBackendResponseError, + extraContext: String? = nil, + file: String = #fileID, function: String = #function, line: UInt = #line + ) -> Self { + return .unexpectedBackendResponse(error, + extraContext: extraContext, + .init(file: file, function: function, line: line)) + } + + static func unsupportedInUIPreviewMode( + file: String = #fileID, function: String = #function, line: UInt = #line + ) -> Self { + return .unsupportedInUIPreviewMode(.init(file: file, function: function, line: line)) + } + +} + +extension BackendError: PurchasesErrorConvertible { + + var asPurchasesError: PurchasesError { + switch self { + case let .networkError(error): + return error.asPurchasesError + + case let .missingAppUserID(source): + return ErrorUtils.missingAppUserIDError(fileName: source.file, + functionName: source.function, + line: source.line) + + case let .missingTransactionJWS(source): + return ErrorUtils.storeProblemError(fileName: source.file, + functionName: source.function, + line: source.line) + + case let .emptySubscriberAttributes(source): + return ErrorUtils.emptySubscriberAttributesError(fileName: source.file, + functionName: source.function, + line: source.line) + + case let .missingReceiptFile(receiptURL, source): + return ErrorUtils.missingReceiptFileError(receiptURL, + fileName: source.file, + functionName: source.function, + line: source.line) + + case let .missingTransactionProductIdentifier(source): + return ErrorUtils.unknownError( + message: Strings.purchase.skpayment_missing_product_identifier.description, + fileName: source.file, + functionName: source.function, + line: source.line + ) + + case let .missingCachedCustomerInfo(source): + return ErrorUtils.customerInfoError(withMessage: Strings.purchase.missing_cached_customer_info.description, + fileName: source.file, + functionName: source.function, + line: source.line) + + case let .unexpectedBackendResponse(error, extraContext, source): + return ErrorUtils.unexpectedBackendResponse(withSubError: error, + extraContext: extraContext, + fileName: source.file, + functionName: source.function, + line: source.line) + case let .invalidAppleSubscriptionKey(source): + return ErrorUtils.configurationError( + message: ErrorCode.invalidAppleSubscriptionKeyError.description, + fileName: source.file, + functionName: source.function, + line: source.line + ) + case .invalidWebRedemptionToken: + let code = BackendErrorCode.invalidWebRedemptionToken + return ErrorUtils.backendError(withBackendCode: code, + originalBackendErrorCode: code.rawValue) + case .purchaseBelongsToOtherUser: + let code = BackendErrorCode.purchaseBelongsToOtherUser + return ErrorUtils.backendError(withBackendCode: code, + originalBackendErrorCode: code.rawValue) + case let .expiredWebRedemptionToken(obfuscatedEmail): + let code = BackendErrorCode.expiredWebRedemptionToken + return ErrorUtils.backendError(withBackendCode: code, + originalBackendErrorCode: code.rawValue, + extraUserInfo: [ + .obfuscatedEmail: obfuscatedEmail + ]) + case let .unsupportedInUIPreviewMode(source): + return ErrorUtils.unsupportedInUIPreviewModeError(fileName: source.file, + functionName: source.function, + line: source.line) + + } + } + +} + +extension BackendError: DescribableError { } + +extension BackendError { + + /// Whether the operation producing this error actually synced the data. + var successfullySynced: Bool { + return self.networkError?.successfullySynced ?? false + } + + /// Whether the operation producing this error can be completed. + /// If `false`, the underlying error was fatal. + var finishable: Bool { + return self.networkError?.finishable ?? false + } + + /// Whether the error represents a `NetworkError` from the server being down. + var isServerDown: Bool { + return self.networkError?.isServerDown == true + } + + private var networkError: NetworkError? { + switch self { + case let .networkError(networkError): + return networkError + + case .missingAppUserID, + .emptySubscriberAttributes, + .missingReceiptFile, + .invalidAppleSubscriptionKey, + .missingTransactionProductIdentifier, + .missingCachedCustomerInfo, + .unexpectedBackendResponse, + .invalidWebRedemptionToken, + .purchaseBelongsToOtherUser, + .expiredWebRedemptionToken, + .unsupportedInUIPreviewMode, + .missingTransactionJWS: + return nil + } + } + +} + +extension BackendError { + + var underlyingError: Error? { + switch self { + case let .networkError(error): + return error + + case .missingAppUserID, + .emptySubscriberAttributes, + .missingReceiptFile, + .invalidAppleSubscriptionKey, + .missingTransactionProductIdentifier, + .missingCachedCustomerInfo, + .invalidWebRedemptionToken, + .purchaseBelongsToOtherUser, + .expiredWebRedemptionToken, + .unsupportedInUIPreviewMode, + .missingTransactionJWS: + return nil + + case let .unexpectedBackendResponse(error, _, _): + return error + } + } + +} + +extension BackendError { + + enum UnexpectedBackendResponseError: Error, Equatable { + + /// Login call failed due to a problem with the response. + case loginResponseDecoding + + /// Received a bad response after posting an offer- "offers" couldn't be read from response. + case postOfferIdBadResponse + + /// Received a bad response after posting an offer- "offers" was totally missing. + case postOfferIdMissingOffersInResponse + + /// Received a bad response after posting an offer- there was an issue with the signature. + case postOfferIdSignature + + /// getOffer call failed with an invalid response. + case getOfferUnexpectedResponse + + /// A call that is supposed to retrieve a CustomerInfo failed because the CustomerInfo in the response was nil. + case customerInfoNil + + /// A call that is supposed to retrieve a CustomerInfo failed because the json object couldn't be parsed. + case customerInfoResponseParsing(error: NSError, json: String) + } + +} + +extension BackendError.UnexpectedBackendResponseError: DescribableError { + + var description: String { + switch self { + case .loginResponseDecoding: + return "Unable to decode response returned from login." + case .postOfferIdBadResponse: + return "Unable to decode response returned from posting offer for signing." + case .postOfferIdMissingOffersInResponse: + return "Missing offers from response returned from posting offer for signing." + case .postOfferIdSignature: + return "Signature error encountered in response returned from posting offer for signing." + case .getOfferUnexpectedResponse: + return "Unknown error encountered while getting offerings." + case .customerInfoNil: + return "Unable to instantiate a CustomerInfoResponse, CustomerInfo in response was nil." + case .customerInfoResponseParsing: + return "Unable to instantiate a CustomerInfoResponse due to malformed json." + } + } + +} + +extension BackendError { + + typealias Source = ErrorSource + +} + +extension BackendError { + + /// Whether to fall back to cached offerings in case of this error when fetching offerings. + var shouldFallBackToCachedOfferings: Bool { + switch self { + case .networkError(let networkError): + return networkError.shouldFallBackToCachedOfferings + default: + return true + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/BackendErrorCode.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/BackendErrorCode.swift new file mode 100644 index 00000000..8a66a1d4 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/BackendErrorCode.swift @@ -0,0 +1,145 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// BackendErrorCode.swift +// +// Created by Joshua Liebowitz on 7/12/21. +// + +import Foundation + +/** + Error codes sent by the RevenueCat backend. This only includes the errors that matter to the SDK + */ +enum BackendErrorCode: Int, Error { + + case unknownBackendError = -1 // Some backend problem we don't know the specifics of. + case unknownError = 0 // We don't know what happened. + case invalidPlatform = 7000 + case storeProblem = 7101 + case cannotTransferPurchase = 7102 + case invalidReceiptToken = 7103 + case invalidAppStoreSharedSecret = 7104 + case invalidPaymentModeOrIntroPriceNotProvided = 7105 + case productIdForGoogleReceiptNotProvided = 7106 + case invalidPlayStoreCredentials = 7107 + case internalServerError = 7110 + case emptyAppUserId = 7220 + case invalidAuthToken = 7224 + case invalidAPIKey = 7225 + case badRequest = 7226 + case playStoreQuotaExceeded = 7229 + case playStoreInvalidPackageName = 7230 + case playStoreGenericError = 7231 + case userIneligibleForPromoOffer = 7232 + case invalidAppleSubscriptionKey = 7234 + case couldNotCreateAlias = 7255 + case invalidAppUserId = 7256 + case subscriptionNotFoundForCustomer = 7259 + case invalidSubscriberAttributes = 7263 + case invalidSubscriberAttributesBody = 7264 + case requestAlreadyInProgress = 7638 + case subscriberAttributesAreBeingUpdated = 7629 + case purchasedProductMissingInAppleReceipt = 7712 + case invalidWebRedemptionToken = 7849 + case purchaseBelongsToOtherUser = 7852 + case expiredWebRedemptionToken = 7853 + + /** + * - Parameter code: Generally comes from the backend in json. This may be a String, or an Int, or nothing. + */ + init(code: Any?) { + let codeInt = BackendErrorCode.extractCodeNumber(from: code) + + guard let codeInt = codeInt else { + self = .unknownBackendError + return + } + + self = BackendErrorCode(rawValue: codeInt) ?? .unknownBackendError + } + + static func extractCodeNumber(from codeObject: Any?) -> Int? { + // The code can be a String or Int + if let codeString = codeObject as? String { + return Int(codeString) ?? nil + } + + return codeObject as? Int + } + +} + +extension BackendErrorCode: ExpressibleByIntegerLiteral { + + init(integerLiteral value: IntegerLiteralType) { + self = BackendErrorCode(rawValue: value) ?? .unknownBackendError + } + +} + +extension BackendErrorCode { + + // swiftlint:disable cyclomatic_complexity + /// Turns ``BackendErrorCode``(RCBackendErrorCode) codes into ``ErrorCode``(RCPurchasesErrorCode) error codes + func toPurchasesErrorCode() -> ErrorCode { + // swiftlint:enable cyclomatic_complexity + switch self { + case .invalidPlatform: + return .configurationError + case .storeProblem: + return .storeProblemError + case .cannotTransferPurchase: + return .receiptAlreadyInUseError + case .invalidReceiptToken, + .purchasedProductMissingInAppleReceipt: + return .invalidReceiptError + case .invalidAppStoreSharedSecret, + .invalidAuthToken, + .invalidAPIKey: + return .invalidCredentialsError + case .invalidPaymentModeOrIntroPriceNotProvided, + .productIdForGoogleReceiptNotProvided: + return .purchaseInvalidError + case .emptyAppUserId, + .invalidAppUserId: + return .invalidAppUserIdError + case .invalidAppleSubscriptionKey: + return .invalidAppleSubscriptionKeyError + case .userIneligibleForPromoOffer: + return .ineligibleError + case .invalidSubscriberAttributes, + .invalidSubscriberAttributesBody: + return .invalidSubscriberAttributesError + case .couldNotCreateAlias: + return .configurationError + case .requestAlreadyInProgress, + .subscriberAttributesAreBeingUpdated: + return .operationAlreadyInProgressForProductError + case .unknownBackendError, + .playStoreInvalidPackageName, + .playStoreQuotaExceeded, + .playStoreGenericError, + .invalidPlayStoreCredentials, + .subscriptionNotFoundForCustomer, + .badRequest, + .internalServerError: + return .unknownBackendError + case .invalidWebRedemptionToken: + return .invalidWebPurchaseToken + case .purchaseBelongsToOtherUser: + return .purchaseBelongsToOtherUser + case .expiredWebRedemptionToken: + return .expiredWebPurchaseToken + case .unknownError: + return .unknownError + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/DescribableError.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/DescribableError.swift new file mode 100644 index 00000000..94e3ff20 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/DescribableError.swift @@ -0,0 +1,16 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DescribableError.swift +// +// Created by Joshua Liebowitz on 10/28/21. + +import Foundation + +protocol DescribableError: Error, CustomStringConvertible { } diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/ErrorCode.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/ErrorCode.swift new file mode 100644 index 00000000..94999ab6 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/ErrorCode.swift @@ -0,0 +1,344 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ErrorCode.swift +// +// Created by Joshua Liebowitz on 7/8/21. +// + +import Foundation + +/** + Error codes used by the Purchases SDK + */ +@objc(RCPurchasesErrorCode) public enum ErrorCode: Int, Error { + + // swiftlint:disable missing_docs + + @objc(RCUnknownError) case unknownError = 0 + @objc(RCPurchaseCancelledError) case purchaseCancelledError = 1 + @objc(RCStoreProblemError) case storeProblemError = 2 + @objc(RCPurchaseNotAllowedError) case purchaseNotAllowedError = 3 + @objc(RCPurchaseInvalidError) case purchaseInvalidError = 4 + @objc(RCProductNotAvailableForPurchaseError) case productNotAvailableForPurchaseError = 5 + @objc(RCProductAlreadyPurchasedError) case productAlreadyPurchasedError = 6 + @objc(RCReceiptAlreadyInUseError) case receiptAlreadyInUseError = 7 + @objc(RCInvalidReceiptError) case invalidReceiptError = 8 + @objc(RCMissingReceiptFileError) case missingReceiptFileError = 9 + @objc(RCNetworkError) case networkError = 10 + @objc(RCInvalidCredentialsError) case invalidCredentialsError = 11 + @objc(RCUnexpectedBackendResponseError) case unexpectedBackendResponseError = 12 + @objc(RCReceiptInUseByOtherSubscriberError) case receiptInUseByOtherSubscriberError = 13 + @objc(RCInvalidAppUserIdError) case invalidAppUserIdError = 14 + @objc(RCOperationAlreadyInProgressForProductError) case operationAlreadyInProgressForProductError = 15 + @objc(RCUnknownBackendError) case unknownBackendError = 16 + @objc(RCInvalidAppleSubscriptionKeyError) case invalidAppleSubscriptionKeyError = 17 + @objc(RCIneligibleError) case ineligibleError = 18 + @objc(RCInsufficientPermissionsError) case insufficientPermissionsError = 19 + @objc(RCPaymentPendingError) case paymentPendingError = 20 + @objc(RCInvalidSubscriberAttributesError) case invalidSubscriberAttributesError = 21 + @objc(RCLogOutAnonymousUserError) case logOutAnonymousUserError = 22 + @objc(RCConfigurationError) case configurationError = 23 + @objc(RCUnsupportedError) case unsupportedError = 24 + @objc(RCEmptySubscriberAttributesError) case emptySubscriberAttributes = 25 + @objc(RCProductDiscountMissingIdentifierError) case productDiscountMissingIdentifierError = 26 + @objc(RCProductDiscountMissingSubscriptionGroupIdentifierError) + case productDiscountMissingSubscriptionGroupIdentifierError = 28 + @objc(RCCustomerInfoError) case customerInfoError = 29 + @objc(RCSystemInfoError) case systemInfoError = 30 + @objc(RCBeginRefundRequestError)case beginRefundRequestError = 31 + @objc(RCProductRequestTimedOut) case productRequestTimedOut = 32 + @objc(RCAPIEndpointBlocked) case apiEndpointBlockedError = 33 + @objc(RCInvalidPromotionalOfferError) case invalidPromotionalOfferError = 34 + @objc(RCOfflineConnectionError) case offlineConnectionError = 35 + @objc(RCFeatureNotAvailableInCustomEntitlementsComputationMode) + case featureNotAvailableInCustomEntitlementsComputationMode = 36 + @objc(RCSignatureVerificationFailed) case signatureVerificationFailed = 37 + @objc(RCFeatureNotSupportedWithStoreKit1) case featureNotSupportedWithStoreKit1 = 38 + @objc(RCInvalidWebPurchaseToken) case invalidWebPurchaseToken = 39 + @objc(RCPurchaseBelongsToOtherUser) case purchaseBelongsToOtherUser = 40 + @objc(RCExpiredWebPurchaseToken) case expiredWebPurchaseToken = 41 + @objc(RCTestStoreSimulatedPurchaseError) case testStoreSimulatedPurchaseError = 42 + + // swiftlint:enable missing_docs + +} + +extension ErrorCode { + + /** + * When an ErrorCode has been deprecated and then removed, add it to the reserved list so that we do not + * accidentally reuse it. For example: + * `@objc(RCMissingAppUserIDForAliasCreationError) case missingAppUserIDForAliasCreationError = 27` was removed, + * so we add its rawValue of `27` to the `reservedRawValues` array. That way our unit tests will catch if we + * accidentally add `27` back into the enumeration. + */ + static var reservedRawValues: Set { + return [27] + } + +} + +extension ErrorCode: CaseIterable { } + +extension ErrorCode: DescribableError { + + // swiftlint:disable:next missing_docs + public var description: String { + switch self { + case .networkError: + return "A network error has occurred." + case .unknownError: + return "Unknown error." + case .purchaseCancelledError: + return "Purchase was cancelled." + case .storeProblemError: + #if os(macOS) || targetEnvironment(macCatalyst) + // See https://github.com/RevenueCat/purchases-ios/issues/370 + return "There was a problem with the App Store. This could also indicate the purchase dialog was cancelled." + #else + return "There was a problem with the App Store." + #endif + case .purchaseNotAllowedError: + return "The device or user is not allowed to make the purchase." + case .purchaseInvalidError: + return "One or more of the arguments provided are invalid." + case .productNotAvailableForPurchaseError: + return "The product is not available for purchase." + case .productAlreadyPurchasedError: + return "This product is already active for the user." + case .receiptAlreadyInUseError: + return "There is already another active subscriber using the same receipt." + case .missingReceiptFileError: + return "The receipt is missing." + case .invalidCredentialsError: + return "There was a credentials issue. Check the underlying error for more details." + case .unexpectedBackendResponseError: + return "Received malformed response from the backend." + case .invalidReceiptError: + return "The receipt is not valid." + case .invalidAppUserIdError: + return "The app user id is not valid." + case .operationAlreadyInProgressForProductError: + return "The operation is already in progress for this product." + case .unknownBackendError: + return "There was an unknown backend error." + case .receiptInUseByOtherSubscriberError: + return "The receipt is in use by other subscriber." + case .invalidAppleSubscriptionKeyError: + return """ + Apple In-App Purchase Key is invalid or not present. You must configure an In-App Purchase Key. + Please see https://rev.cat/in-app-purchase-key-configuration for more info. + """ + case .ineligibleError: + return "The User is ineligible for that action." + case .insufficientPermissionsError: + return "App does not have sufficient permissions to make purchases" + case .paymentPendingError: + return "The payment is pending." + case .invalidSubscriberAttributesError: + return "One or more of the attributes sent could not be saved." + case .logOutAnonymousUserError: + return "LogOut was called but the current user is anonymous." + case .configurationError: + return """ + There is an issue with your configuration. Check the underlying error for more details. + More information: https://rev.cat/sdk-troubleshooting + """ + case .unsupportedError: + return """ + There was a problem with the operation. Looks like we doesn't support that yet. + Check the underlying error for more details. + """ + case .emptySubscriberAttributes: + return "A request for subscriber attributes returned none." + case .productDiscountMissingIdentifierError: + return """ + The SKProductDiscount or Product.SubscriptionOffer wrapped + by StoreProductDiscount is missing an identifier. + This is a required property and likely an AppStore quirk that it is missing. + """ + case .productDiscountMissingSubscriptionGroupIdentifierError: + return "Unable to create a discount offer, the product is missing a subscriptionGroupIdentifier." + case .customerInfoError: + return "There was a problem related to the customer info." + case .systemInfoError: + return "There was a problem related to the system info." + case .beginRefundRequestError: + return "Error when trying to begin refund request." + case .productRequestTimedOut: + return "SKProductsRequest took too long to complete." + case .apiEndpointBlockedError: + return "Requests to RevenueCat are being blocked. See: https://rev.cat/dnsBlocking for more info." + case .invalidPromotionalOfferError: + return """ + The information associated with this PromotionalOffer is not valid. + See https://rev.cat/ios-subscription-offers for more info. + """ + case .offlineConnectionError: + return "Error performing request because the internet connection appears to be offline." + + case .featureNotAvailableInCustomEntitlementsComputationMode: + return "This feature is not available when utilizing the customEntitlementsComputation dangerousSetting." + case .signatureVerificationFailed: + return "Request failed signature verification. See https://rev.cat/trusted-entitlements for more info." + case .featureNotSupportedWithStoreKit1: + return "This feature is not supported when using StoreKit 1." + + "Configure the SDK to use StoreKit 2 to use this feature." + + case .invalidWebPurchaseToken: + return "The link you provided does not contain a valid purchase token." + case .purchaseBelongsToOtherUser: + return "The web purchase already belongs to other user." + case .expiredWebPurchaseToken: + return "The link you provided has expired. A new one will be sent to the email used to make the purchase." + case .testStoreSimulatedPurchaseError: + return "Purchase failure simulated successfully in Test Store." + @unknown default: + return "Something went wrong." + } + } + +} + +extension ErrorCode: CustomNSError { + + // swiftlint:disable missing_docs + public var errorUserInfo: [String: Any] { + return [ + NSDebugDescriptionErrorKey: self.description, + "rc_code_name": self.codeName + ] + } + +} + +extension ErrorCode { + + /** + * The error short string, based on the error code. + */ + var codeName: String { + switch self { + case .networkError: + return "NETWORK_ERROR" + case .unknownError: + return "UNKNOWN" + case .purchaseCancelledError: + return "PURCHASE_CANCELLED" + case .storeProblemError: + return "STORE_PROBLEM" + case .purchaseNotAllowedError: + return "PURCHASE_NOT_ALLOWED" + case .purchaseInvalidError: + return "PURCHASE_INVALID" + case .productNotAvailableForPurchaseError: + return "PRODUCT_NOT_AVAILABLE_FOR_PURCHASE" + case .productAlreadyPurchasedError: + return "PRODUCT_ALREADY_PURCHASED" + case .receiptAlreadyInUseError: + return "RECEIPT_ALREADY_IN_USE" + case .missingReceiptFileError: + return "MISSING_RECEIPT_FILE" + case .invalidCredentialsError: + return "INVALID_CREDENTIALS" + case .unexpectedBackendResponseError: + return "UNEXPECTED_BACKEND_RESPONSE_ERROR" + case .invalidReceiptError: + return "INVALID_RECEIPT" + case .invalidAppUserIdError: + return "INVALID_APP_USER_ID" + case .operationAlreadyInProgressForProductError: + return "OPERATION_ALREADY_IN_PROGRESS_FOR_PRODUCT_ERROR" + case .unknownBackendError: + return "UNKNOWN_BACKEND_ERROR" + case .receiptInUseByOtherSubscriberError: + return "RECEIPT_IN_USE_BY_OTHER_SUBSCRIBER" + case .invalidAppleSubscriptionKeyError: + return "INVALID_APPLE_SUBSCRIPTION_KEY" + case .ineligibleError: + return "INELIGIBLE_ERROR" + case .insufficientPermissionsError: + return "INSUFFICIENT_PERMISSIONS_ERROR" + case .paymentPendingError: + return "PAYMENT_PENDING_ERROR" + case .invalidSubscriberAttributesError: + return "INVALID_SUBSCRIBER_ATTRIBUTES" + case .logOutAnonymousUserError: + return "LOGOUT_CALLED_WITH_ANONYMOUS_USER" + case .configurationError: + return "CONFIGURATION_ERROR" + case .unsupportedError: + return "UNSUPPORTED_ERROR" + case .emptySubscriberAttributes: + return "EMPTY_SUBSCRIBER_ATTRIBUTES" + case .productDiscountMissingIdentifierError: + return "PRODUCT_DISCOUNT_MISSING_IDENTIFIER_ERROR" + case .productDiscountMissingSubscriptionGroupIdentifierError: + return "PRODUCT_DISCOUNT_MISSING_SUBSCRIPTION_GROUP_IDENTIFIER_ERROR" + case .customerInfoError: + return "CUSTOMER_INFO_ERROR" + case .systemInfoError: + return "SYSTEM_INFO_ERROR" + case .beginRefundRequestError: + return "BEGIN_REFUND_REQUEST_ERROR" + case .productRequestTimedOut: + return "PRODUCT_REQUEST_TIMED_OUT_ERROR" + case .apiEndpointBlockedError: + return "API_ENDPOINT_BLOCKED_ERROR" + case .invalidPromotionalOfferError: + return "INVALID_PROMOTIONAL_OFFER_ERROR" + case .offlineConnectionError: + return "OFFLINE_CONNECTION_ERROR" + case .featureNotAvailableInCustomEntitlementsComputationMode: + return "FEATURE_NOT_AVAILABLE_IN_CUSTOM_ENTITLEMENTS_COMPUTATION_MODE_ERROR" + case .signatureVerificationFailed: + return "SIGNATURE_VERIFICATION_FAILED" + case .featureNotSupportedWithStoreKit1: + return "FEATURE_NOT_SUPPORTED_WITH_STOREKIT1" + case .invalidWebPurchaseToken: + return "INVALID_WEB_PURCHASE_TOKEN" + case .purchaseBelongsToOtherUser: + return "ALREADY_REDEEMED_WEB_PURCHASE_TOKEN" + case .expiredWebPurchaseToken: + return "EXPIRED_WEB_PURCHASE_TOKEN" + case .testStoreSimulatedPurchaseError: + return "TEST_STORE_SIMULATED_PURCHASE_ERROR" + @unknown default: + return "UNRECOGNIZED_ERROR" + } + } + +} + +// MARK: - PurchasesErrorConvertible + +/// An `Error` that can be converted into a `PurchasesError` +protocol PurchasesErrorConvertible: Swift.Error { + + /// Convert the receiver into a `PurchasesError` with all the necessary context. + /// + /// ### Related symbols: + /// - ``ErrorUtils`` + /// - ``ErrorCode`` + var asPurchasesError: PurchasesError { get } + +} + +extension PurchasesErrorConvertible { + + var asPublicError: PublicError { + return self.asPurchasesError.asPublicError + } + + var description: String { + return self.asPurchasesError.localizedDescription + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/ErrorDetails.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/ErrorDetails.swift new file mode 100644 index 00000000..9d42256a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/ErrorDetails.swift @@ -0,0 +1,46 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ErrorDetails.swift +// +// Created by Joshua Liebowitz on 7/12/21. +// + +import Foundation + +extension NSError.UserInfoKey { + + static let attributeErrors: NSError.UserInfoKey = "attribute_errors" + static let attributeErrorsResponse: NSError.UserInfoKey = "attributes_error_response" + static let statusCode: NSError.UserInfoKey = "rc_response_status_code" + static let obfuscatedEmail: NSError.UserInfoKey = "rc_obfuscated_email" + static let rootError: NSError.UserInfoKey = "rc_root_error" + + static let readableErrorCode: NSError.UserInfoKey = "readable_error_code" + static let backendErrorCode: NSError.UserInfoKey = "rc_backend_error_code" + static let extraContext: NSError.UserInfoKey = "extra_context" + static let file: NSError.UserInfoKey = "source_file" + static let function: NSError.UserInfoKey = "source_function" + +} + +enum ErrorDetails { + + static let attributeErrorsKey = NSError.UserInfoKey.attributeErrors as String + static let attributeErrorsResponseKey = NSError.UserInfoKey.attributeErrorsResponse as String + static let statusCodeKey = NSError.UserInfoKey.statusCode as String + static let obfuscatedEmailKey = NSError.UserInfoKey.obfuscatedEmail as String + static let rootErrorKey = NSError.UserInfoKey.rootError as String + + static let readableErrorCodeKey = NSError.UserInfoKey.readableErrorCode as String + static let extraContextKey = NSError.UserInfoKey.extraContext as String + static let fileKey = NSError.UserInfoKey.file as String + static let functionKey = NSError.UserInfoKey.function as String + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/ErrorUtils.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/ErrorUtils.swift new file mode 100644 index 00000000..61db2491 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/ErrorUtils.swift @@ -0,0 +1,799 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// RCPurchasesErrorUtils.swift +// +// Created by Cesar de la Vega on 7/21/21. +// + +import Foundation +import StoreKit + +// swiftlint:disable file_length multiline_parameters type_body_length + +enum ErrorUtils { + + /** + * Constructs an NSError with the ``ErrorCode/networkError`` code and a populated `NSUnderlyingErrorKey` in + * the `NSError.userInfo` dictionary. + * + * - Parameter underlyingError: The value of the `NSUnderlyingErrorKey` key. + * + * - Note: This error is used when there is an error performing network request returns an error or when there + * is an `NSJSONSerialization` error. + */ + static func networkError( + message: String? = nil, + withUnderlyingError underlyingError: Error? = nil, + extraUserInfo: [NSError.UserInfoKey: Any] = [:], + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + + let errorCode: ErrorCode + if case NetworkError.dnsError(_, _, _)? = underlyingError { + errorCode = .apiEndpointBlockedError + } else { + errorCode = .networkError + } + + return error(with: errorCode, + message: message ?? underlyingError?.localizedDescription, + underlyingError: underlyingError, + extraUserInfo: extraUserInfo, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an NSError with the ``ErrorCode/offlineConnection`` code. + */ + static func offlineConnectionError( + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return error(with: .offlineConnectionError, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an NSError with the ``ErrorCode/signatureVerificationFailed`` code. + */ + static func signatureVerificationFailedError( + path: String, + code: HTTPStatusCode, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return error( + with: .signatureVerificationFailed, + message: "Request to '\(path)' failed verification", + extraUserInfo: [ + "request_path": path, + "status_code": code.rawValue + ], + fileName: fileName, functionName: functionName, line: line + ) + } + + /** + * Maps a ``BackendErrorCode`` code to a ``ErrorCode``. code. Constructs an Error with the mapped code and adds a + * `NSUnderlyingErrorKey` in the `NSError.userInfo` dictionary. The backend error code will be mapped using + * ``BackendErrorCode/toPurchasesErrorCode()``. + * + * - Parameter backendCode: The code of the error. + * - Parameter originalBackendErrorCode: the original numerical value of this error. + * - Parameter backendMessage: The message of the errror contained under the `NSUnderlyingErrorKey` key. + * + * - Note: This error is used when an network request returns an error. The backend error returned is wrapped in + * this internal error code. + */ + static func backendError( + withBackendCode backendCode: BackendErrorCode, + originalBackendErrorCode: Int, + backendMessage: String?, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return backendError(withBackendCode: backendCode, + originalBackendErrorCode: originalBackendErrorCode, + backendMessage: backendMessage, + extraUserInfo: [:], + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/unexpectedBackendResponseError`` code. + * + * - Note: This error is used when a network request returns an unexpected response. + */ + static func unexpectedBackendResponseError( + extraUserInfo: [NSError.UserInfoKey: Any] = [:], + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return error(with: ErrorCode.unexpectedBackendResponseError, extraUserInfo: extraUserInfo, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/unexpectedBackendResponseError`` code which contains an underlying + * ``UnexpectedBackendResponseSubErrorCode`` + * + * - Note: This error is used when a network request returns an unexpected response and we can determine some + * of what went wrong with the response. + */ + static func unexpectedBackendResponse( + withSubError subError: Error?, + extraContext: String? = nil, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return backendResponseError(withSubError: subError, + extraContext: extraContext, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/missingReceiptFileError`` code. + * + * - Note: This error is used when the receipt is missing in the device. This can happen if the user is in + * sandbox or if there are no previous purchases. + */ + static func missingReceiptFileError( + _ receiptURL: URL?, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + let fileExists: Bool = { + if let receiptURL = receiptURL { + return FileManager.default.fileExists(atPath: receiptURL.path) + } else { + return false + } + }() + + return error(with: ErrorCode.missingReceiptFileError, + extraUserInfo: [ + "rc_receipt_url": receiptURL?.absoluteString ?? "", + "rc_receipt_file_exists": fileExists + ], + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/emptySubscriberAttributes`` code. + */ + static func emptySubscriberAttributesError( + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return error(with: .emptySubscriberAttributes, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/invalidAppUserIdError`` code. + * + * - Note: This error is used when the appUserID can't be found in user defaults. This can happen if user defaults + * are removed manually or if the OS deletes entries when running out of space. + */ + static func missingAppUserIDError( + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return error(with: ErrorCode.invalidAppUserIdError, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/productDiscountMissingIdentifierError`` code. + * + * - Note: This error code is used when attemping to post data about product discounts but the discount is + * missing an indentifier. + */ + static func productDiscountMissingIdentifierError( + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return error(with: ErrorCode.productDiscountMissingIdentifierError, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/productDiscountMissingSubscriptionGroupIdentifierError`` code. + * + * - Note: This error code is used when attemping to post data about product discounts but the discount is + * missing a subscriptionGroupIndentifier. + */ + static func productDiscountMissingSubscriptionGroupIdentifierError( + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return error(with: ErrorCode.productDiscountMissingSubscriptionGroupIdentifierError, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/logOutAnonymousUserError`` code. + * + * - Note: This error is used when logOut is called but the current user is anonymous, + * as noted by ``Purchases/isAnonymous`` property. + */ + static func logOutAnonymousUserError( + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return error(with: ErrorCode.logOutAnonymousUserError, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/paymentPendingError`` code. + * + * - Note: This error is used during an “ask to buy” flow for a payment. The completion block of the purchasing + * function will get this error to indicate the guardian has to complete the purchase. + */ + static func paymentDeferredError( + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return error(with: ErrorCode.paymentPendingError, message: "The payment is deferred.", + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/unknownError`` code and optional message. + */ + static func unknownError( + message: String? = nil, error: Error? = nil, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return ErrorUtils.error(with: ErrorCode.unknownError, message: message, underlyingError: error, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/operationAlreadyInProgressForProductError`` code. + * + * - Note: This error is used when a purchase is initiated for a product, but there's already a purchase for the + * same product in progress. + */ + static func operationAlreadyInProgressError( + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return error(with: ErrorCode.operationAlreadyInProgressForProductError, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/configurationError`` code. + * + * - Note: This error is used when the configuration in App Store Connect doesn't match the configuration + * in the RevenueCat dashboard. + */ + static func configurationError( + message: String? = nil, + underlyingError: Error? = nil, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return self.error(with: ErrorCode.configurationError, + message: message, underlyingError: underlyingError, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Maps an `SKError` to a Error with an ``ErrorCode``. Adds a underlying error in the `NSError.userInfo` dictionary. + * + * - Parameter skError: The originating `SKError`. + */ + static func purchasesError( + withSKError error: Error, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + switch error { + case let skError as SKError: + return skError.asPurchasesError + case let purchasesError as PurchasesError: + return purchasesError + case is URLError: + // Some StoreKit APIs can return `URLError`s. + // See https://github.com/RevenueCat/purchases-ios/issues/3343 + return NetworkError.networkError( + error as NSError, + .init(file: fileName, + function: functionName, + line: line) + ).asPurchasesError + default: + return ErrorUtils.unknownError( + error: error, + fileName: fileName, functionName: functionName, line: line + ) + } + } + + /** + * Maps a `StoreKitError` or `Product.PurchaseError` to an `Error` with an ``ErrorCode``. + * Adds a underlying error in the `NSError.userInfo` dictionary. + * + * - Parameter storeKitError: The originating `StoreKitError` or `Product.PurchaseError`. + */ + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + static func purchasesError( + withStoreKitError error: Error, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + switch error { + case let storeKitError as StoreKitError: + return storeKitError.asPurchasesError + case let purchaseError as Product.PurchaseError: + return purchaseError.asPurchasesError + case let purchasesError as PurchasesError: + return purchasesError + default: + return ErrorUtils.unknownError( + error: error, + fileName: fileName, functionName: functionName, line: line + ) + } + } + + /** + * Maps an untyped `Error` into a `PurchasesError`. + * If the error is already a `PurchasesError`, this simply returns the same value, + * otherwise it wraps it into a ``ErrorCode/unknownError``. + */ + static func purchasesError( + withUntypedError error: Error, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + func handlePublicError(_ error: PublicError) -> PurchasesError { + if let errorCode = ErrorCode(rawValue: error.code) { + return .init(error: errorCode, userInfo: error.userInfo) + } else { + return createUnknownError() + } + } + + func createUnknownError() -> PurchasesError { + ErrorUtils.unknownError( + error: error, + fileName: fileName, functionName: functionName, line: line + ) + } + + switch error { + case let purchasesError as PurchasesError: + return purchasesError + case let convertible as PurchasesErrorConvertible: + return convertible.asPurchasesError + case let error as PublicError where error.domain == ErrorCode.errorDomain: + return handlePublicError(error) + default: + return createUnknownError() + } + } + + /** + * Constructs an Error with the ``ErrorCode/purchaseCancelledError`` code. + * + * - Note: This error is used when a purchase is cancelled by the user. + */ + static func purchaseCancelledError( + error: Error? = nil, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + let errorCode = ErrorCode.purchaseCancelledError + return ErrorUtils.error(with: errorCode, + message: errorCode.description, + underlyingError: error, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/productNotAvailableForPurchaseError`` code. + * + * #### Related Articles + * - [`StoreKitError.notAvailableInStorefront`](https://rev.cat/storekit-error-not-available-in-storefront) + */ + static func productNotAvailableForPurchaseError( + withMessage message: String? = nil, + error: Error? = nil, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return ErrorUtils.error(with: .productNotAvailableForPurchaseError, + message: message, + underlyingError: error) + } + + /** + * Constructs an Error with the ``ErrorCode/productAlreadyPurchasedError`` code. + */ + static func productAlreadyPurchasedError( + error: Error? = nil, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return ErrorUtils.error(with: .productAlreadyPurchasedError, + underlyingError: error) + } + + /** + * Constructs an Error with the ``ErrorCode/purchaseNotAllowedError`` code. + */ + static func purchaseNotAllowedError( + error: Error? = nil, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return ErrorUtils.error(with: .purchaseNotAllowedError, + underlyingError: error) + } + + /** + * Constructs an Error with the ``ErrorCode/purchaseInvalidError`` code. + */ + static func purchaseInvalidError( + message: String? = nil, + error: Error? = nil, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return ErrorUtils.error(with: .purchaseInvalidError, + message: message, + underlyingError: error, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/ineligibleError`` code. + */ + static func ineligibleError( + error: Error? = nil, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return ErrorUtils.error(with: .ineligibleError, + underlyingError: error) + } + + /** + * Constructs an Error with the ``ErrorCode/ineligibleError`` code. + */ + static func invalidPromotionalOfferError( + error: Error? = nil, + message: String? = nil, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return ErrorUtils.error(with: .invalidPromotionalOfferError, + message: message, + underlyingError: error) + } + + /** + * Constructs an Error with the ``ErrorCode/storeProblemError`` code. + * + * - Note: This error is used when there is a problem with the App Store. + */ + static func storeProblemError( + withMessage message: String? = nil, error: Error? = nil, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + let errorCode = ErrorCode.storeProblemError + return ErrorUtils.error(with: errorCode, + message: message, + underlyingError: error, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/customerInfoError`` code. + * + * - Note: This error is used when there is a problem related to the customer info. + */ + static func customerInfoError( + withMessage message: String? = nil, error: Error? = nil, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + let errorCode = ErrorCode.customerInfoError + return ErrorUtils.error(with: errorCode, + message: message, + underlyingError: error, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/systemInfoError`` code. + * + * - Note: This error is used when there is a problem related to the system info. + */ + static func systemInfoError( + withMessage message: String, error: Error? = nil, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + let errorCode = ErrorCode.systemInfoError + return ErrorUtils.error(with: errorCode, + message: message, + underlyingError: error, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/beginRefundRequestError`` code. + * + * - Note: This error is used when there is a problem beginning a refund request. + */ + static func beginRefundRequestError( + withMessage message: String, error: Error? = nil, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + let errorCode = ErrorCode.beginRefundRequestError + return ErrorUtils.error(with: errorCode, + message: message, + underlyingError: error, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/productRequestTimedOut`` code. + * + * - Note: This error is used when fetching products times out. + */ + static func productRequestTimedOutError( + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return ErrorUtils.error(with: .productRequestTimedOut, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/featureNotAvailableInCustomEntitlementsComputationMode`` code. + * + * - Note: This error is used when trying to use a feature that isn't supported + * in customEntitlementsComputation mode + * and the ``DangerousSettings/customEntitlementsComputation`` flag is set to true. + */ + static func featureNotAvailableInCustomEntitlementsComputationModeError( + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return ErrorUtils.error(with: .featureNotAvailableInCustomEntitlementsComputationMode, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/featureNotSupportedWithStoreKit1`` code. + * + * - Note: This error is used when trying to use a feature that isn't supported + * by StoreKit 1 when the SDK is running in StoreKit 1 mode. + */ + static func featureNotSupportedWithStoreKit1Error( + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return ErrorUtils.error(with: .featureNotSupportedWithStoreKit1, + fileName: fileName, functionName: functionName, line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/unsupportedError`` code. + * + * - Note: This error is used when trying to use a feature that isn't supported + * by StoreKit 1 when the SDK is running in StoreKit 1 mode. + */ + static func unsupportedInUIPreviewModeError( + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return ErrorUtils.error(with: .unsupportedError, + message: "Operation not supported in UI preview mode", + fileName: fileName, + functionName: functionName, + line: line) + } + + /** + * Constructs an Error with the ``ErrorCode/testStoreSimulatedPurchaseError`` code. + * + * - Note: This error is only used when simulating the failure of a purchase in the Test Store. + */ + static func testStoreSimulatedPurchaseError( + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + return ErrorUtils.error(with: .testStoreSimulatedPurchaseError, + fileName: fileName, + functionName: functionName, + line: line) + } + +} + +extension ErrorUtils { + + static func backendError(withBackendCode backendCode: BackendErrorCode, + originalBackendErrorCode: Int, + message: String? = nil, + backendMessage: String? = nil, + extraUserInfo: [NSError.UserInfoKey: Any] = [:], + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + let errorCode = backendCode.toPurchasesErrorCode() + let underlyingError = self.backendUnderlyingError(backendCode: backendCode, + originalBackendErrorCode: originalBackendErrorCode, + backendMessage: backendMessage) + + return error(with: errorCode, + message: message ?? backendMessage, + underlyingError: underlyingError, + extraUserInfo: extraUserInfo + [ + .backendErrorCode: originalBackendErrorCode + ], + fileName: fileName, functionName: functionName, line: line) + } + +} + +private extension ErrorUtils { + + static func error(with code: ErrorCode, + message: String? = nil, + underlyingError: Error? = nil, + extraUserInfo: [NSError.UserInfoKey: Any] = [:], + fileName: String = #fileID, + functionName: String = #function, + line: UInt = #line) -> PurchasesError { + let localizedDescription: String + + if let message = message, message != code.description { + // Print both ErrorCode and message only if they're different + localizedDescription = "\(code.description) \(message)" + } else { + localizedDescription = code.description + } + + var userInfo = extraUserInfo + userInfo[NSLocalizedDescriptionKey as NSError.UserInfoKey] = localizedDescription + if let underlyingError = underlyingError { + userInfo[NSUnderlyingErrorKey as NSError.UserInfoKey] = underlyingError + } + userInfo[.readableErrorCode] = code.codeName + userInfo[.file] = "\(fileName):\(line)" + userInfo[.function] = functionName + + Self.logErrorIfNeeded( + code, + localizedDescription: localizedDescription, + fileName: fileName, functionName: functionName, line: line + ) + + return .init(error: code, userInfo: userInfo) + } + + static func backendResponseError( + withSubError subError: Error?, + extraContext: String?, + fileName: String = #fileID, functionName: String = #function, line: UInt = #line + ) -> PurchasesError { + var userInfo: [NSError.UserInfoKey: Any] = [:] + let describableSubError = subError as? DescribableError + let errorDescription = describableSubError?.description ?? ErrorCode.unexpectedBackendResponseError.description + userInfo[NSLocalizedDescriptionKey as NSError.UserInfoKey] = errorDescription + userInfo[NSUnderlyingErrorKey as NSError.UserInfoKey] = subError + userInfo[.readableErrorCode] = ErrorCode.unexpectedBackendResponseError.codeName + userInfo[.extraContext] = extraContext + userInfo[.file] = "\(fileName):\(line)" + userInfo[.function] = functionName + + return .init(error: .unexpectedBackendResponseError, userInfo: userInfo) + } + + static func backendUnderlyingError(backendCode: BackendErrorCode, + originalBackendErrorCode: Int, + backendMessage: String?) -> NSError { + return backendCode.addingUserInfo([ + NSLocalizedDescriptionKey as NSError.UserInfoKey: backendMessage ?? "", + .backendErrorCode: originalBackendErrorCode + ]) + } + + // swiftlint:disable:next function_body_length + private static func logErrorIfNeeded(_ code: ErrorCode, + localizedDescription: String, + fileName: String = #fileID, + functionName: String = #function, + line: UInt = #line) { + switch code { + case .networkError, + .unknownError, + .receiptAlreadyInUseError, + .unexpectedBackendResponseError, + .invalidReceiptError, + .invalidAppUserIdError, + .invalidCredentialsError, + .operationAlreadyInProgressForProductError, + .unknownBackendError, + .invalidSubscriberAttributesError, + .logOutAnonymousUserError, + .receiptInUseByOtherSubscriberError, + .configurationError, + .unsupportedError, + .emptySubscriberAttributes, + .productDiscountMissingIdentifierError, + .productDiscountMissingSubscriptionGroupIdentifierError, + .customerInfoError, + .systemInfoError, + .beginRefundRequestError, + .apiEndpointBlockedError, + .invalidPromotionalOfferError, + .offlineConnectionError, + .featureNotAvailableInCustomEntitlementsComputationMode, + .signatureVerificationFailed, + .featureNotSupportedWithStoreKit1, + .invalidWebPurchaseToken, + .purchaseBelongsToOtherUser, + .expiredWebPurchaseToken: + Logger.error( + localizedDescription, + fileName: fileName, + functionName: functionName, + line: line + ) + + case .purchaseCancelledError, + .storeProblemError, + .purchaseNotAllowedError, + .purchaseInvalidError, + .productNotAvailableForPurchaseError, + .productAlreadyPurchasedError, + .missingReceiptFileError, + .invalidAppleSubscriptionKeyError, + .ineligibleError, + .insufficientPermissionsError, + .paymentPendingError, + .productRequestTimedOut: + Logger.appleError( + localizedDescription, + fileName: fileName, + functionName: functionName, + line: line + ) + + case .testStoreSimulatedPurchaseError: + Logger.simulatedStoreError(localizedDescription, + fileName: fileName, + functionName: functionName, + line: line) + + @unknown default: + Logger.error( + localizedDescription, + fileName: fileName, + functionName: functionName, + line: line + ) + } + } +} + +extension Error { + + @_disfavoredOverload + func addingUserInfo(_ userInfo: [NSError.UserInfoKey: Any]) -> Result { + return self.addingUserInfo(userInfo as [String: Any]) + } + + func addingUserInfo(_ userInfo: [String: Any]) -> Result { + let nsError = self as NSError + return .init(domain: nsError.domain, + code: nsError.code, + userInfo: nsError.userInfo + userInfo) + } + +} + +/// Represents where an `Error` was created +struct ErrorSource { + + let file: String + let function: String + let line: UInt + +} + +/// `Equatable` conformance allows `Error` types that contain source information +/// to easily conform to `Equatable`. +extension ErrorSource: Equatable { + + /// However, for ease of testing, we don't actually care if the source of the errors matches + /// since expectations will be created in the test and therefore will never match. + static func == (lhs: ErrorSource, rhs: ErrorSource) -> Bool { + return true + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/PurchasesError.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/PurchasesError.swift new file mode 100644 index 00000000..ea119acc --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/PurchasesError.swift @@ -0,0 +1,168 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PurchasesError.swift +// +// Created by Nacho Soto on 8/31/22. + +import Foundation +import StoreKit + +/// An error returned by a `RevenueCat` public API. +public typealias PublicError = NSError + +extension PublicError { + /// Converts the current Error to a ``ErrorCode`` + public var asErrorCode: ErrorCode? { + self as? ErrorCode + } +} + +/// An internal error representation, containing an `ErrorCode` and additional `userInfo`. +/// +/// `ErrorCode` is essentially only domain (`ErrorCode.domain`) and a code, but can't contain any more information +/// unless it's converted into an `NSError`. +/// This serves that same purpose, but allows us to pass these around in a type-safe manner, +/// being able to distinguish them from any other `NSError`. +internal struct PurchasesError: Error { + + let error: ErrorCode + let userInfo: [String: Any] + +} + +extension PurchasesError { + + /// Converts this error into an error that can be used in a public API. + /// The error returned by this can be converted to ``ErrorCode``. + /// Example: + /// ``` + /// let error = ErrorUtils.unknownError().asPublicError + /// let errorCode = error as? ErrorCode + /// ``` + /// + /// Info about the root error can be accessed in userInfo. + /// Example: + /// ``` + /// let error = ErrorUtils.unknownError().asPublicError + /// let rootErrorInfo = error.userInfo["rc_root_error"] as? [String: Any] + /// let rootErrorCode = rootErrorInfo?["code"] as? Int + /// let rootErrorDomain = rootErrorInfo?["domain"] as? String + /// let rootErrorLocalizedDescription = rootErrorInfo?["localizedDescription"] as? String + /// ``` + /// + /// If the root error comes from StoreKit, some extra info will be added to the root error. + /// Example: + /// ``` + /// let error = ErrorUtils.unknownError().asPublicError + /// let rootErrorInfo = error.userInfo["rc_root_error"] as? [String: Any] + /// let storeKitErrorInfo = rootErrorInfo?["storeKitError"] as? [String: Any] + /// let storeKitErrorDescription = storeKitErrorInfo?["description"] as? String + /// // If it's a SKError: + /// let skErrorCode = storeKitErrorInfo?["skErrorCode"] as? Int + /// // If it's a StoreKitError.networkError: + /// let urlErrorCode = storeKitErrorInfo?["urlErrorCode"] as? Int + /// let urlErrorFailingUrl = storeKitErrorInfo?["urlErrorFailingUrl"] as? String + /// // If it's a StoreKitError.systemError: + /// let systemErrorDescription = storeKitErrorInfo?["systemErrorDescription"] as? Int + /// ``` + var asPublicError: PublicError { + let rootError: Error = self.rootError(from: self) + let rootNSError = rootError as NSError + var rootErrorInfo: [String: Any] = [ + "code": rootNSError.code, + "domain": rootNSError.domain, + "localizedDescription": rootNSError.localizedDescription + ] + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) { + if let storeKitErrorInfo = self.getStoreKitErrorInfoIfAny(error: rootError) { + rootErrorInfo = rootErrorInfo.merging(["storeKitError": storeKitErrorInfo]) + } + } + let userInfoToUse = self.userInfo.merging([ErrorDetails.rootErrorKey: rootErrorInfo]) + return NSError(domain: Self.errorDomain, code: self.errorCode, userInfo: userInfoToUse) + } + + private func rootError(from error: Error) -> Error { + let nsError = error as NSError + if let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? Error { + return rootError(from: underlyingError) + } else { + return error + } + } + +} + +// MARK: - + +extension PurchasesError: CustomNSError { + + static let errorDomain: String = ErrorCode.errorDomain + + var errorCode: Int { return (self.error as NSError).code } + var errorUserInfo: [String: Any] { return self.userInfo } + +} + +// MARK: - + +extension PurchasesError { + + /// Overload of the default initializer with `NSError.UserInfoKey` as user info key type. + init(error: ErrorCode, userInfo: [NSError.UserInfoKey: Any]) { + self.init(error: error, userInfo: userInfo as [String: Any]) + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +private extension PurchasesError { + + func getStoreKitErrorInfoIfAny(error: Error) -> [String: Any]? { + if let skError = error as? SKError { + return [ + "skErrorCode": skError.code.rawValue, + "description": skError.code.trackingDescription + ] + } else if let storeKitError = error as? StoreKitError { + let resultMap: [String: Any] = ["description": storeKitError.trackingDescription] + switch storeKitError { + case .unknown, + .userCancelled, + .notAvailableInStorefront, + .notEntitled: + return resultMap + + #if compiler(>=6.1) + // StoreKitError.unsupported was introduced in iOS 18.4, which shipped with Xcode 16.3 beta 1 / Swift 6.1 + case .unsupported: + return resultMap + #endif + case let .networkError(urlError): + return resultMap.merging([ + "urlErrorCode": urlError.errorCode, + "urlErrorFailingUrl": urlError.failureURLString ?? "missing_value" + ]) + case let .systemError(systemError): + return resultMap.merging([ + "systemErrorDescription": systemError.localizedDescription + ]) + + @unknown default: + Logger.warn(Strings.storeKit.unknown_storekit_error(storeKitError)) + return resultMap + } + } else if let storeKitError = error as? StoreKit.Product.PurchaseError { + return ["description": storeKitError.trackingDescription] + } else { + return nil + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/SKError+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/SKError+Extensions.swift new file mode 100644 index 00000000..397e9d85 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/SKError+Extensions.swift @@ -0,0 +1,182 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SKError+Extensions.swift +// +// Created by Madeline Beyl on 11/4/21. + +import Foundation +import StoreKit + +extension SKError: PurchasesErrorConvertible { + + var asPurchasesError: PurchasesError { + switch self.code { + case .cloudServiceNetworkConnectionFailed, + .cloudServiceRevoked, + .overlayTimeout, + .overlayPresentedInBackgroundScene: + return ErrorUtils.storeProblemError(error: self) + case .clientInvalid, + .paymentNotAllowed, + .cloudServicePermissionDenied, + .privacyAcknowledgementRequired: + return ErrorUtils.purchaseNotAllowedError(error: self) + case .paymentCancelled, + .overlayCancelled: + return ErrorUtils.purchaseCancelledError(error: self) + case .paymentInvalid, + .unauthorizedRequestData: + return ErrorUtils.purchaseInvalidError(error: self) + case .storeProductNotAvailable: + return ErrorUtils.productNotAvailableForPurchaseError(error: self) + case .overlayInvalidConfiguration, + .unsupportedPlatform: + return ErrorUtils.purchaseNotAllowedError(error: self) + case .ineligibleForOffer: + return ErrorUtils.ineligibleError(error: self) + case .missingOfferParams, + .invalidOfferPrice, + .invalidSignature, + .invalidOfferIdentifier: + return ErrorUtils.invalidPromotionalOfferError(error: self) + case .unknown: + if let error = self.userInfo[NSUnderlyingErrorKey] as? NSError { + switch error.domain { + case ASDServerError.domain: + switch ASDServerError.Code(rawValue: error.code) { + case .currentlySubscribed: + return ErrorUtils.productAlreadyPurchasedError(error: self) + + default: break + } + + default: break + } + } + + return ErrorUtils.storeProblemError(error: self) + + @unknown default: + switch SKError.UndocumentedCode(rawValue: self.code.rawValue) { + case .unhandledException: + if let error = self.userInfo[NSUnderlyingErrorKey] as? NSError { + switch error.domain { + case AMSError.domain: + switch AMSError.Code(rawValue: error.code) { + // See https://github.com/RevenueCat/purchases-ios/issues/1445 + // Cancellations sometimes show up as undocumented errors instead of regular cancellations + case .paymentSheetFailed: + return ErrorUtils.purchaseCancelledError(error: self) + + default: break + } + + default: break + } + } + + default: break + } + + return ErrorUtils.unknownError(error: self) + } + } + +} + +extension SKError.Code { + + var trackingDescription: String { + switch self { + case .unknown: + return "unknown" + case .clientInvalid: + return "client_invalid" + case .paymentCancelled: + return "payment_cancelled" + case .paymentInvalid: + return "payment_invalid" + case .paymentNotAllowed: + return "payment_not_allowed" + case .storeProductNotAvailable: + return "store_product_not_available" + case .cloudServicePermissionDenied: + return "cloud_service_permission_denied" + case .cloudServiceNetworkConnectionFailed: + return "cloud_service_network_connection_failed" + case .cloudServiceRevoked: + return "cloud_service_revoked" + case .privacyAcknowledgementRequired: + return "privacy_acknowledgement_required" + case .unauthorizedRequestData: + return "unauthorized_request_data" + case .invalidOfferIdentifier: + return "invalid_offer_identifier" + case .invalidSignature: + return "invalid_signature" + case .missingOfferParams: + return "missing_offer_parameters" + case .invalidOfferPrice: + return "invalid_offer_price" + case .overlayCancelled: + return "overlay_cancelled" + case .overlayInvalidConfiguration: + return "overlay_invalid_configuration" + case .overlayTimeout: + return "overlay_timeout" + case .ineligibleForOffer: + return "ineligible_for_offer" + case .unsupportedPlatform: + return "unsupported_platform" + case .overlayPresentedInBackgroundScene: + return "overlay_presented_in_background_scene" + @unknown default: + return "unknown_store_kit_error" + } + } + +} + +private extension SKError { + + enum UndocumentedCode: Int { + + // See https://github.com/RevenueCat/purchases-ios/issues/1445 + case unhandledException = 907 + + } + +} + +private enum ASDServerError { + + static let domain = "ASDServerErrorDomain" + + enum Code: Int { + + // See https://github.com/RevenueCat/purchases-ios/issues/392 + case currentlySubscribed = 3532 + + } + +} + +private enum AMSError { + + static let domain = "AMSErrorDomain" + + enum Code: Int { + + // See https://github.com/RevenueCat/purchases-ios/issues/1445 + case paymentSheetFailed = 6 + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/StoreKitError+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/StoreKitError+Extensions.swift new file mode 100644 index 00000000..2c988831 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/StoreKitError+Extensions.swift @@ -0,0 +1,134 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StoreKitError+Extensions.swift +// +// Created by Nacho Soto on 12/14/21. + +import StoreKit + +/// - SeeAlso: SKError+Extensions +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +extension StoreKitError: PurchasesErrorConvertible { + + var asPurchasesError: PurchasesError { + switch self { + case .userCancelled: + return ErrorUtils.purchaseCancelledError(error: self) + + case let .networkError(error): + return ErrorUtils.networkError(withUnderlyingError: error) + + case let .systemError(error): + return ErrorUtils.storeProblemError(error: error) + + case .notAvailableInStorefront: + return ErrorUtils.productNotAvailableForPurchaseError(error: self) + + case .notEntitled: + return ErrorUtils.storeProblemError(error: self) + + #if compiler(>=6.1) + // StoreKitError.unsupported was introduced in iOS 18.4, which shipped with Xcode 16.3 beta 1 / Swift 6.1 + case .unsupported: + return ErrorUtils.purchaseInvalidError(error: self) + #endif + + case .unknown: + /// See also https://github.com/RevenueCat/purchases-ios/issues/392 + /// `StoreKitError` doesn't conform to `CustomNSError` as of `iOS 15.2` + /// so we can't extract any additional information like we do on `SKError.toPurchasesErrorCode` + return ErrorUtils.storeProblemError(error: self) + + @unknown default: + return ErrorUtils.unknownError(error: self) + } + } + + var trackingDescription: String { + switch self { + case .unknown: + return "unknown" + case .userCancelled: + return "user_cancelled" + case .networkError(let urlError): + return "network_error_\(urlError.code.rawValue)" + case .systemError(let error): + return "system_error_\(String(describing: error))" + case .notAvailableInStorefront: + return "not_available_in_storefront" + case .notEntitled: + return "not_entitled" + + #if compiler(>=6.1) + // StoreKitError.unsupported was introduced in iOS 18.4, which shipped with Xcode 16.3 beta 1 / Swift 6.1 + case .unsupported: + return "unsupported" + #endif + + @unknown default: + return "unknown_store_kit_error" + } + } + +} + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +extension Product.PurchaseError: PurchasesErrorConvertible { + + var asPurchasesError: PurchasesError { + switch self { + case .invalidQuantity: + return ErrorUtils.storeProblemError(error: self) + + case .productUnavailable: + return ErrorUtils.productNotAvailableForPurchaseError(error: self) + + case .purchaseNotAllowed: + return ErrorUtils.purchaseNotAllowedError(error: self) + + case .ineligibleForOffer: + return ErrorUtils.ineligibleError(error: self) + + case + .invalidOfferIdentifier, + .invalidOfferPrice, + .invalidOfferSignature, + .missingOfferParameters: + return ErrorUtils.invalidPromotionalOfferError(error: self) + + @unknown default: + return ErrorUtils.unknownError(error: self) + } + } + + var trackingDescription: String { + switch self { + case .invalidQuantity: + return "invalid_quantity" + case .productUnavailable: + return "product_unavailable" + case .purchaseNotAllowed: + return "purchase_not_allowed" + case .ineligibleForOffer: + return "ineligible_for_offer" + case .invalidOfferIdentifier: + return "invalid_offer_identifier" + case .invalidOfferPrice: + return "invalid_offer_price" + case .invalidOfferSignature: + return "invalid_offer_signature" + case .missingOfferParameters: + return "missing_offer_parameters" + @unknown default: + return "unknown_store_kit_error" + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/StoreKitErrorHelper.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/StoreKitErrorHelper.swift new file mode 100644 index 00000000..6d18956b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Error Handling/StoreKitErrorHelper.swift @@ -0,0 +1,35 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StoreKitErrorHelper.swift +// +// Created by Cesar de la Vega on 19/9/24. + +import StoreKit + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +enum StoreKitErrorUtils { + + static func extractStoreKitErrorDescription(from error: Error?) -> String? { + guard let underlyingError = (error as NSError?)?.userInfo[NSUnderlyingErrorKey] as? Error else { + return nil + } + + if let skError = underlyingError as? SKError { + return skError.code.trackingDescription + } else if let storeKitError = underlyingError as? StoreKitError { + return storeKitError.trackingDescription + } else if let storeKitError = underlyingError as? StoreKit.Product.PurchaseError { + return storeKitError.trackingDescription + } else { + return Self.extractStoreKitErrorDescription(from: underlyingError) + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/EventsManager.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/EventsManager.swift new file mode 100644 index 00000000..d53e7cb2 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/EventsManager.swift @@ -0,0 +1,377 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// EventsManager.swift +// +// Created by Nacho Soto on 9/6/23. + +import Foundation + +#if os(iOS) || os(tvOS) || VISION_OS +import UIKit +#endif + +/// Listener for receiving tracked feature events. +/// This is an internal debug API for monitoring events tracked by RevenueCatUI. +@_spi(Internal) public protocol EventsListener: AnyObject { + /// Called when a feature event is tracked. + /// - Parameter event: A dictionary representation of the tracked event. + func onEventTracked(_ event: [String: Any]) +} + +protocol EventsManagerType: AnyObject { + + var eventsListener: EventsListener? { get set } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func track(featureEvent: FeatureEvent) async + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func track(adEvent: AdEvent) async + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func flushAllEventsWithBackgroundTask(batchSize: Int) + + /// - Throws: if posting feature events fails + /// - Returns: the number of feature events posted + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func flushFeatureEvents(batchSize: Int) async throws -> Int + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func flushFeatureEventsWithBackgroundTask(batchSize: Int) +} + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +actor EventsManager: EventsManagerType { + + static let defaultEventBatchSize = 50 + static let maxBatchesPerFlush = 10 + + private let internalAPI: InternalAPI + private let userProvider: CurrentUserProvider + private let store: FeatureEventStoreType + private var appSessionID: UUID + private let systemInfo: SystemInfo + private let _eventsListener = Atomic(nil) + + nonisolated var eventsListener: EventsListener? { + get { self._eventsListener.value } + set { self._eventsListener.value = newValue } + } + + private let adEventStore: AdEventStoreType? + private var adFlushInProgress = false + + private var flushInProgress = false + + init( + internalAPI: InternalAPI, + userProvider: CurrentUserProvider, + store: FeatureEventStoreType, + systemInfo: SystemInfo, + appSessionID: UUID = SystemInfo.appSessionID, + adEventStore: AdEventStoreType? = nil + ) { + self.internalAPI = internalAPI + self.userProvider = userProvider + self.store = store + self.systemInfo = systemInfo + self.appSessionID = appSessionID + self.adEventStore = adEventStore + } + + func track(featureEvent: FeatureEvent) async { + guard featureEvent.shouldStoreEvent else { + return + } + + guard let event: StoredFeatureEvent = .init(event: featureEvent, + userID: self.userProvider.currentAppUserID, + feature: featureEvent.feature, + appSessionID: self.appSessionID, + eventDiscriminator: featureEvent.eventDiscriminator) else { + Logger.error(Strings.paywalls.event_cannot_serialize) + return + } + await self.store.store(event) + self.eventsListener?.onEventTracked(featureEvent.toMap()) + } + + func track(adEvent: AdEvent) async { + guard let store = self.adEventStore else { + Logger.warn(EventsManagerStrings.ad_event_tracking_disabled) + return + } + + guard let event: StoredAdEvent = .init(event: adEvent, + userID: self.userProvider.currentAppUserID, + appSessionID: self.appSessionID) else { + Logger.error(EventsManagerStrings.ad_event_cannot_serialize) + return + } + await store.store(event) + } + + func flushAllEvents(batchSize: Int) async throws -> Int { + let featureEventsFlushed = try await self.flushFeatureEventsInternal(batchSize: batchSize) + + let adEventsFlushed = try await self.flushAdEvents(count: batchSize) + return featureEventsFlushed + adEventsFlushed + } + + func flushFeatureEvents(batchSize: Int) async throws -> Int { + return try await self.flushFeatureEventsInternal(batchSize: batchSize) + } + + private static let flushAllEventsBackgroundTaskName = "com.revenuecat.flushAllEvents" + private static let flushFeatureEventsBackgroundTaskName = "com.revenuecat.flushFeatureEvents" + + nonisolated func flushAllEventsWithBackgroundTask(batchSize: Int) { + self.withBackgroundTask(name: Self.flushAllEventsBackgroundTaskName) { + do { + _ = try await self.flushAllEvents(batchSize: batchSize) + } catch { + Logger.error(Strings.paywalls.event_flush_failed(error)) + } + } + } + + nonisolated func flushFeatureEventsWithBackgroundTask(batchSize: Int) { + self.withBackgroundTask(name: Self.flushFeatureEventsBackgroundTaskName) { + do { + _ = try await self.flushFeatureEvents(batchSize: batchSize) + } catch { + Logger.error(Strings.paywalls.event_flush_failed(error)) + } + } + } + +} + +// MARK: - Private Helpers + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +private extension EventsManager { + + func flushFeatureEventsInternal(batchSize: Int) async throws -> Int { + guard !self.flushInProgress else { + Logger.debug(Strings.paywalls.event_flush_already_in_progress) + return 0 + } + self.flushInProgress = true + defer { self.flushInProgress = false } + + var totalFlushed = 0 + var batchesSent = 0 + + while batchesSent < Self.maxBatchesPerFlush { + let events = await self.store.fetch(batchSize) + + guard !events.isEmpty else { + if totalFlushed == 0 { + Logger.verbose(Strings.paywalls.event_flush_with_empty_store) + } + return totalFlushed + } + + Logger.verbose(Strings.paywalls.event_flush_starting(count: events.count)) + + do { + try await self.internalAPI.postFeatureEvents(events: events) + Logger.debug(Strings.analytics.flush_events_success) + + await self.store.clear(events.count) + totalFlushed += events.count + batchesSent += 1 + } catch { + Logger.error(Strings.paywalls.event_sync_failed(error)) + + if let backendError = error as? BackendError, + backendError.successfullySynced { + await self.store.clear(events.count) + totalFlushed += events.count + batchesSent += 1 + } else { + throw error + } + } + } + + return totalFlushed + } + + func flushAdEvents(count: Int) async throws -> Int { + guard let store = self.adEventStore else { + Logger.warn(EventsManagerStrings.ad_event_tracking_disabled) + return 0 + } + + guard !self.adFlushInProgress else { + Logger.debug(EventsManagerStrings.ad_event_flush_already_in_progress) + return 0 + } + self.adFlushInProgress = true + defer { self.adFlushInProgress = false } + + let events = await store.fetch(count) + + guard !events.isEmpty else { + Logger.verbose(EventsManagerStrings.ad_event_flush_with_empty_store) + return 0 + } + + Logger.verbose(EventsManagerStrings.ad_event_flush_starting(events.count)) + + do { + try await self.internalAPI.postAdEvents(events: events) + Logger.debug(EventsManagerStrings.ad_events_flushed_successfully) + + await store.clear(count) + + return events.count + } catch { + Logger.error(EventsManagerStrings.ad_event_sync_failed(error)) + + if let backendError = error as? BackendError, + backendError.successfullySynced { + await store.clear(count) + } + + throw error + } + } + + nonisolated func withBackgroundTask(name: String, do work: @escaping () async -> Void) { + #if compiler(>=6) && (os(iOS) || os(tvOS) || VISION_OS) + let endBackgroundTask: (() -> Void)? + if !self.systemInfo.isAppExtension { + endBackgroundTask = Self.beginBackgroundTask(named: name) + } else { + endBackgroundTask = nil + } + #endif + + Task { + await work() + + #if compiler(>=6) && (os(iOS) || os(tvOS) || VISION_OS) + endBackgroundTask?() + #endif + } + } +} + +// MARK: - Private Helpers + +#if compiler(>=6) && (os(iOS) || os(tvOS) || VISION_OS) +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +private extension EventsManager { + + /// Begins a background task synchronously and returns a closure to end it. + /// This should be called BEFORE spawning async work to prevent the system from + /// suspending the app before the task starts executing. + /// + /// - Parameter taskName: A name for the background task for debugging purposes. + /// - Returns: A closure to end the background task, or `nil` if the task couldn't be started. + static func beginBackgroundTask(named taskName: String) -> (@Sendable () -> Void)? { + guard let application = SystemInfo.sharedUIApplication else { + Logger.warn(EventsManagerStrings.background_task_unavailable) + return nil + } + + let backgroundTaskID: Atomic = .init(nil) + backgroundTaskID.value = application.beginBackgroundTask(withName: taskName) { + Logger.warn(EventsManagerStrings.background_task_expired(taskName)) + if let taskID = backgroundTaskID.value { + application.endBackgroundTask(taskID) + backgroundTaskID.value = .invalid + } + } + + if backgroundTaskID.value == .invalid { + Logger.warn(EventsManagerStrings.background_task_failed(taskName)) + return nil + } + + Logger.debug(EventsManagerStrings.background_task_started(taskName)) + return { + if let taskID = backgroundTaskID.value { + application.endBackgroundTask(taskID) + } + } + } + +} +#endif + +// MARK: - Messages + +// swiftlint:disable identifier_name +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +enum EventsManagerStrings { + + case background_task_unavailable + case background_task_expired(String) + case background_task_failed(String) + case background_task_started(String) + + case ad_event_tracking_disabled + case ad_event_cannot_serialize + case ad_event_flush_already_in_progress + case ad_event_flush_with_empty_store + case ad_event_flush_starting(Int) + case ad_events_flushed_successfully + case ad_event_sync_failed(Error) + +} +// swiftlint:enable identifier_name + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension EventsManagerStrings: LogMessage { + + var description: String { + switch self { + case .background_task_unavailable: + return "Background task unavailable" + + case .background_task_expired(let taskName): + return "Background task expired: \(taskName)" + + case .background_task_failed(let taskName): + return "Background task failed to start: \(taskName)" + + case .background_task_started(let taskName): + return "Background task started: \(taskName)" + + case .ad_event_tracking_disabled: + return "Ad event tracking is disabled - no ad event store configured" + + case .ad_event_cannot_serialize: + return "Cannot serialize ad event" + + case .ad_event_flush_already_in_progress: + return "Ad event flush already in progress" + + case .ad_event_flush_with_empty_store: + return "Ad event flush with empty store" + + case let .ad_event_flush_starting(count): + return "Ad event flush starting with \(count) event(s)" + + case .ad_events_flushed_successfully: + return "Ad events flushed successfully" + + case let .ad_event_sync_failed(error): + return "Ad event sync failed: \(error)" + } + } + + var category: String { return "events_manager" } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/FeatureEvents/FeatureEvent.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/FeatureEvents/FeatureEvent.swift new file mode 100644 index 00000000..608907d4 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/FeatureEvents/FeatureEvent.swift @@ -0,0 +1,127 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// FeatureEvent.swift +// +// Created by Cesar de la Vega on 6/11/24. + +protocol FeatureEvent: Encodable, Sendable { + + var feature: Feature { get } + var eventDiscriminator: String? { get } + + /// Whether this event should be stored and sent to the backend. + /// WIP: Some PaywallEvents are not yet supported by the backend. + /// We should implement support for these events in the backend first + /// and then we can remove this `shouldStoreEvent` (as it will be always `true`) + var shouldStoreEvent: Bool { get } + +} + +extension FeatureEvent { + + /// By default, all events should be stored. + var shouldStoreEvent: Bool { true } + +} + +// MARK: - Dictionary Mapping + +extension FeatureEvent { + + /// Converts this event into a dictionary suitable for hybrid SDK consumption. + func toMap() -> [String: Any] { + switch self { + case let event as PaywallEvent: + return event.paywallMap() + case let event as CustomerCenterEvent: + return event.customerCenterImpressionMap() + case let event as CustomerCenterAnswerSubmittedEvent: + return event.customerCenterAnswerSubmittedMap() + default: + return [ + "discriminator": "unknown", + "type": "unknown", + "class_name": String(describing: type(of: self)) + ] + } + } + +} + +private extension PaywallEvent { + + func paywallMap() -> [String: Any] { + let typeName: String = { + switch self { + case .impression: return "paywall_impression" + case .cancel: return "paywall_cancel" + case .close: return "paywall_close" + case .exitOffer: return "paywall_exit_offer" + case .purchaseInitiated: return "paywall_purchase_initiated" + case .purchaseError: return "paywall_purchase_error" + } + }() + + return [ + "discriminator": "paywalls", + "type": typeName, + "id": self.creationData.id.uuidString, + "timestamp": self.creationData.date.millisecondsSince1970, + "offering_id": self.data.offeringIdentifier, + "paywall_revision": self.data.paywallRevision, + "session_id": self.data.sessionIdentifier.uuidString, + "display_mode": self.data.displayMode.identifier, + "locale": self.data.localeIdentifier, + "dark_mode": self.data.darkMode + ] + } + +} + +private extension CustomerCenterEvent { + + func customerCenterImpressionMap() -> [String: Any] { + return [ + "discriminator": "customer_center", + "type": "customer_center_impression", + "id": self.creationData.id.uuidString, + "timestamp": self.creationData.date.millisecondsSince1970, + "dark_mode": self.data.darkMode, + "locale": self.data.localeIdentifier, + "display_mode": self.data.displayMode.identifier + ] + } + +} + +private extension CustomerCenterAnswerSubmittedEvent { + + func customerCenterAnswerSubmittedMap() -> [String: Any] { + var result: [String: Any] = [ + "discriminator": "customer_center", + "type": "customer_center_survey_option_chosen", + "id": self.creationData.id.uuidString, + "timestamp": self.creationData.date.millisecondsSince1970, + "dark_mode": self.data.darkMode, + "locale": self.data.localeIdentifier, + "display_mode": self.data.displayMode.identifier, + "survey_option_id": self.data.surveyOptionID, + "path": self.data.path.rawValue, + "revision_id": self.data.revisionID + ] + + if let url = self.data.url { + result["url"] = url.absoluteString + } + + return result + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/FeatureEvents/FeatureEventStore.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/FeatureEvents/FeatureEventStore.swift new file mode 100644 index 00000000..7e1d675e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/FeatureEvents/FeatureEventStore.swift @@ -0,0 +1,260 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// FeatureEventStore.swift +// +// Created by Nacho Soto on 9/5/23. + +import Foundation + +protocol FeatureEventStoreType: Sendable { + + /// Stores `event` into the store. + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func store(_ storedEvent: StoredFeatureEvent) async + + /// - Returns: the first `count` events from the store. + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func fetch(_ count: Int) async -> [StoredFeatureEvent] + + /// Removes the first `count` events from the store. + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func clear(_ count: Int) async + +} + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +internal actor FeatureEventStore: FeatureEventStoreType { + + private let handler: FileHandlerType + + init(handler: FileHandlerType) { + self.handler = handler + } + + func store(_ storedEvent: StoredFeatureEvent) async { + do { + // Check if store is too big and clear old events if needed + if await self.isEventStoreTooBig() { + Logger.warn(FeatureEventStoreStrings.event_store_size_limit_reached) + await self.clear(Self.eventBatchSizeToClear) + } + + if let eventDescription = try? storedEvent.encodedEvent.prettyPrintedJSON { + Logger.verbose(FeatureEventStoreStrings.storing_event(eventDescription)) + } else { + Logger.verbose(FeatureEventStoreStrings.storing_event_without_json) + } + + let event = try StoredFeatureEventSerializer.encode(storedEvent) + try await self.handler.append(line: event) + } catch { + Logger.error(FeatureEventStoreStrings.error_storing_event(error)) + } + } + + func fetch(_ count: Int) async -> [StoredFeatureEvent] { + assert(count > 0, "Invalid count: \(count)") + + do { + return try await self.handler.readLines() + .prefix(count) + .compactMap { try? StoredFeatureEventSerializer.decode($0) } + .extractValues() + } catch { + Logger.error(FeatureEventStoreStrings.error_fetching_events(error)) + return [] + } + } + + // - Note: If removing these `count` events fails, it will attempt to + // remove the entire file. This ensures that the same events again aren't sent again. + func clear(_ count: Int) async { + assert(count > 0, "Invalid count: \(count)") + + do { + try await self.handler.removeFirstLines(count) + } catch { + Logger.error(FeatureEventStoreStrings.error_removing_first_lines(count: count, error)) + + do { + try await self.handler.emptyFile() + } catch { + Logger.error(FeatureEventStoreStrings.error_emptying_file(error)) + } + } + } + + private func isEventStoreTooBig() async -> Bool { + do { + return try await self.handler.fileSizeInKB() > Self.maxEventFileSizeInKB + } catch { + Logger.error(FeatureEventStoreStrings.error_checking_file_size(error)) + return false + } + } + + private static let maxEventFileSizeInKB: Double = 2048 + private static let eventBatchSizeToClear = 50 + +} + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +extension FeatureEventStore { + + static func createDefault( + applicationSupportDirectory: URL?, + documentsDirectory: URL? = nil + ) throws -> FeatureEventStore { + let url = Self.url(in: try applicationSupportDirectory ?? Self.applicationSupportDirectory) + Logger.verbose(FeatureEventStoreStrings.initializing(url)) + + let documentsDirectory = try documentsDirectory ?? Self.documentsDirectory + Self.removeLegacyDirectoryIfExists(documentsDirectory) + + return try .init(handler: FileHandler(url)) + } + + private static func revenueCatFolder(in container: URL) -> URL { + return container.appendingPathComponent("revenuecat") + } + + private static func url(in container: URL) -> URL { + return self.revenueCatFolder(in: container).appendingPathComponent("paywall_event_store") + } + + private static func removeLegacyDirectoryIfExists(_ documentsDirectory: URL) { + let url = Self.revenueCatFolder(in: documentsDirectory) + guard Self.fileManager.fileExists(atPath: url.relativePath) else { return } + + Logger.debug(FeatureEventStoreStrings.removing_old_documents_store(url)) + + do { + try Self.fileManager.removeItem(at: url) + } catch { + Logger.error(FeatureEventStoreStrings.error_removing_old_documents_store(error)) + } + } + + // See https://nemecek.be/blog/57/making-files-from-your-app-available-in-the-ios-files-app + // We don't want to store events in the documents directory in case app makes their documents + // accessible via the Files app. + // swiftlint:disable avoid_using_directory_apis_directly + private static var applicationSupportDirectory: URL { + get throws { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + return URL.applicationSupportDirectory + } else { + return try Self.fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + } + } + } + + private static var documentsDirectory: URL { + get throws { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + return URL.documentsDirectory + } else { + return try Self.fileManager.url( + for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + } + } + } + // swiftlint:enable avoid_using_directory_apis_directly + + private static let fileManager: FileManager = .default + +} + +// MARK: - Messages + +// swiftlint:disable identifier_name +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private enum FeatureEventStoreStrings { + + case initializing(URL) + + case removing_old_documents_store(URL) + case error_removing_old_documents_store(Error) + + case storing_event(String) + case storing_event_without_json + + case error_storing_event(Error) + case error_fetching_events(Error) + case error_removing_first_lines(count: Int, Error) + case error_emptying_file(Error) + case error_checking_file_size(Error) + + case event_store_size_limit_reached + +} +// swiftlint:enable identifier_name + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension FeatureEventStoreStrings: LogMessage { + + var description: String { + switch self { + case let .initializing(directory): + return "Initializing FeatureEventStore: \(directory.absoluteString)" + + case let .removing_old_documents_store(url): + return "Removing old store: \(url)" + + case let .error_removing_old_documents_store(error): + return "Failed removing old store: \((error as NSError).description)" + + case let .storing_event(eventDescription): + return "Storing event: \(eventDescription)" + + case .storing_event_without_json: + return "Storing an event. There was an error trying to print it" + + case let .error_storing_event(error): + return "Error storing event: \((error as NSError).description)" + + case let .error_fetching_events(error): + return "Error fetching events: \((error as NSError).description)" + + case let .error_removing_first_lines(count, error): + return "Error removing first \(count) events: \((error as NSError).description)" + + case let .error_emptying_file(error): + return "Error emptying file: \((error as NSError).description)" + + case let .error_checking_file_size(error): + return "Error checking file size: \((error as NSError).description)" + + case .event_store_size_limit_reached: + return "Event store size limit reached. Clearing oldest events to free up space." + } + } + + var category: String { return "paywall_event_store" } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private extension PaywallEvent { + + var debugDescription: String { + return "\(self)" + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/FeatureEvents/Networking/FeatureEventHTTPRequestPath.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/FeatureEvents/Networking/FeatureEventHTTPRequestPath.swift new file mode 100644 index 00000000..36c819bb --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/FeatureEvents/Networking/FeatureEventHTTPRequestPath.swift @@ -0,0 +1,28 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallHTTPRequestPath.swift +// +// Created by Nacho Soto on 9/5/23. + +import Foundation + +extension HTTPRequest.FeatureEventsPath: EventsHTTPRequestPath { + + // swiftlint:disable:next force_unwrapping + static let serverHostURL = URL(string: "https://api-paywalls.revenuecat.com")! + + var name: String { + switch self { + case .postEvents: + return "post_paywall_events" + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/FeatureEvents/Networking/FeatureEventsRequest.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/FeatureEvents/Networking/FeatureEventsRequest.swift new file mode 100644 index 00000000..8484ba06 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/FeatureEvents/Networking/FeatureEventsRequest.swift @@ -0,0 +1,53 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// FeatureEventsRequest.swift +// +// Created by Nacho Soto on 9/6/23. + +import Foundation + +/// The content of a request to the feature events endpoint. +struct FeatureEventsRequest { + + var events: [AnyEncodable] + + init(events: [AnyEncodable]) { + self.events = events + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + init(events: [StoredFeatureEvent]) { + self.init(events: events.compactMap { storedEvent in + switch storedEvent.feature { + case .paywalls: + guard let event = PaywallEvent(storedEvent: storedEvent) else { + return nil + } + return AnyEncodable(event) + case .customerCenter: + switch storedEvent.eventDiscriminator { + case CustomerCenterEventDiscriminator.answerSubmitted.rawValue: + guard let event = CustomerCenterAnswerSubmittedEventRequest.create(from: storedEvent) else { + return nil + } + return AnyEncodable(event) + default: + guard let event = CustomerCenterEventBaseRequest.createBase(from: storedEvent) else { + return nil + } + return AnyEncodable(event) + } + } + }) + } + +} + +extension FeatureEventsRequest: HTTPRequestBody {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/FeatureEvents/Networking/PostFeatureEventsOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/FeatureEvents/Networking/PostFeatureEventsOperation.swift new file mode 100644 index 00000000..9535a218 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/FeatureEvents/Networking/PostFeatureEventsOperation.swift @@ -0,0 +1,53 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PostFeatureEventsOperation.swift +// +// Created by RevenueCat on 1/20/25. + +import Foundation + +/// A `NetworkOperation` for posting feature events to the feature events endpoint. +final class PostFeatureEventsOperation: NetworkOperation { + + private let configuration: Configuration + private let request: FeatureEventsRequest + private let path: HTTPRequestPath + private let responseHandler: CustomerAPI.SimpleResponseHandler? + + init( + configuration: Configuration, + request: FeatureEventsRequest, + path: HTTPRequestPath, + responseHandler: CustomerAPI.SimpleResponseHandler? + ) { + self.request = request + self.configuration = configuration + self.path = path + self.responseHandler = responseHandler + + super.init(configuration: configuration) + } + + override func begin(completion: @escaping () -> Void) { + let httpRequest = HTTPRequest(method: .post(self.request), requestPath: self.path) + + self.httpClient.perform(httpRequest) { (response: VerifiedHTTPResponse.Result) in + defer { + completion() + } + + self.responseHandler?(response.error.map(BackendError.networkError)) + } + } + +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension PostFeatureEventsOperation: @unchecked Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/FeatureEvents/StoredFeatureEvent.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/FeatureEvents/StoredFeatureEvent.swift new file mode 100644 index 00000000..d53ba3f8 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/FeatureEvents/StoredFeatureEvent.swift @@ -0,0 +1,123 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StoredFeatureEvent.swift +// +// Created by Nacho Soto on 9/6/23. + +import Foundation + +/// Contains the necessary information for storing and sending events. +struct StoredFeatureEvent { + + private(set) var encodedEvent: String + private(set) var userID: String + private(set) var feature: Feature + private(set) var appSessionID: UUID? + private(set) var eventDiscriminator: String? + + init?(event: T, userID: String, feature: Feature, appSessionID: UUID?, eventDiscriminator: String?) { + guard let encodedJSON = try? event.encodedJSON else { + return nil + } + + self.encodedEvent = encodedJSON + self.userID = userID + self.feature = feature + self.appSessionID = appSessionID + self.eventDiscriminator = eventDiscriminator + } + +} + +enum Feature: String, Codable { + + case paywalls + case customerCenter + +} + +// MARK: - Extensions + +extension StoredFeatureEvent: Sendable {} + +extension StoredFeatureEvent: Codable { + + private enum CodingKeys: String, CodingKey { + + case encodedEvent = "event" + case userID = "userId" + case feature + case appSessionID = "appSessionId" + case eventDiscriminator = "eventDiscriminator" + + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Try to decode as string first (new format) + if let jsonString = try? container.decode(String.self, forKey: .encodedEvent) { + self.encodedEvent = jsonString + } else { + // Fall back to old format (direct dictionary) + if let oldEvent = try? container.decode(AnyEncodable.self, forKey: .encodedEvent), + let jsonString = try? oldEvent.encodedJSON { + self.encodedEvent = jsonString + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: [CodingKeys.encodedEvent], + debugDescription: "Could not convert old format to JSON string" + ) + ) + } + } + + self.userID = try container.decode(String.self, forKey: .userID) + if let featureString = try container.decodeIfPresent(String.self, forKey: .feature), + let feature = Feature(rawValue: featureString) { + self.feature = feature + } else { + self.feature = .paywalls + } + + if let appSessionID = try container.decodeIfPresent(UUID.self, forKey: .appSessionID) { + self.appSessionID = appSessionID + } + + if let eventDiscriminator = try container.decodeIfPresent(String.self, forKey: .eventDiscriminator) { + self.eventDiscriminator = eventDiscriminator + } + } + +} + +extension StoredFeatureEvent: Equatable { + + static func == (lhs: StoredFeatureEvent, rhs: StoredFeatureEvent) -> Bool { + guard lhs.userID == rhs.userID, + lhs.feature == rhs.feature, + lhs.appSessionID == rhs.appSessionID, + lhs.eventDiscriminator == rhs.eventDiscriminator else { + return false + } + + // Compare decoded events instead of raw JSON strings + guard let lhsData = lhs.encodedEvent.data(using: .utf8), + let rhsData = rhs.encodedEvent.data(using: .utf8), + let lhsDict = try? JSONSerialization.jsonObject(with: lhsData) as? [String: Any], + let rhsDict = try? JSONSerialization.jsonObject(with: rhsData) as? [String: Any] else { + return false + } + + return NSDictionary(dictionary: lhsDict).isEqual(rhsDict) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/FeatureEvents/StoredFeatureEventSerializer.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/FeatureEvents/StoredFeatureEventSerializer.swift new file mode 100644 index 00000000..76e8e84c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/FeatureEvents/StoredFeatureEventSerializer.swift @@ -0,0 +1,34 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StoredFeatureEventSerializer.swift +// +// Created by Nacho Soto on 9/5/23. + +import Foundation + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +enum StoredFeatureEventSerializer { + + private struct FailedEncodingEventError: Error {} + + /// Encodes a ``StoredFeatureEvent`` in a format suitable to be stored by `FeatureEventStore`. + static func encode(_ event: StoredFeatureEvent) throws -> String { + let data = try JSONEncoder.default.encode(value: event) + + return try String(data: data, encoding: .utf8) + .orThrow(FailedEncodingEventError()) + } + + /// Decodes a ``StoredFeatureEvent``. + static func decode(_ event: String) throws -> StoredFeatureEvent { + return try JSONDecoder.default.decode(jsonData: event.asData) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/Networking/EventsHTTPRequestPath.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/Networking/EventsHTTPRequestPath.swift new file mode 100644 index 00000000..51abfe24 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Events/Networking/EventsHTTPRequestPath.swift @@ -0,0 +1,43 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// EventsHTTPRequestPath.swift +// +// Created by RevenueCat on 10/30/25. + +import Foundation + +/// Protocol for events endpoints that share common configuration. +/// Both FeatureEvents and AdEvents use the same `/v1/events` endpoint +/// but with different domains. +protocol EventsHTTPRequestPath: HTTPRequestPath {} + +extension EventsHTTPRequestPath { + + var authenticated: Bool { + return true + } + + var shouldSendEtag: Bool { + return false + } + + var supportsSignatureVerification: Bool { + return false + } + + var needsNonceForSigning: Bool { + return false + } + + var relativePath: String { + return "/v1/events" + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Array+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Array+Extensions.swift new file mode 100644 index 00000000..b3ec208d --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Array+Extensions.swift @@ -0,0 +1,52 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Array+Extensions.swift +// +// Created by Nacho Soto on 2/17/22. + +import Foundation + +extension Array { + + /// Equivalent to `removeFirst()` but it returns `Optional` if the collection is empty. + mutating func popFirst() -> Element? { + guard !self.isEmpty else { return nil } + + return self.removeFirst() + } + +} + +extension Collection { + + /// - Returns: an element if and only if it's the only one in the collection + var onlyElement: Element? { + guard self.count == 1, let first = self.first else { + return nil + } + + return first + } + + /// Returns the element at the specified index if it exists, otherwise nil. + subscript(safe index: Index) -> Element? { + return self.indices.contains(index) ? self[index] : nil + } + +} + +extension Sequence where Element: AdditiveArithmetic { + + /// - Returns: the sum of the elements in the sequence. + func sum() -> Element { + return reduce(.zero, +) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/AsyncExtensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/AsyncExtensions.swift new file mode 100644 index 00000000..f6b0e99d --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/AsyncExtensions.swift @@ -0,0 +1,176 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// AsyncExtensions.swift +// +// Created by Nacho Soto on 9/27/22. + +import Foundation + +internal enum Async { + + /// Invokes an `async throws` method and calls `completion` with the result. + /// Ensures that the returned error is `PublicError`. + /// + /// Example: + /// ```swift + /// Async.call(with: completion) { + /// return try await asynchronousMethod() + /// } + /// ``` + static func call( + with completion: @escaping (Result) -> Void, + asyncMethod method: @escaping () async throws -> T + ) { + _ = Task { + do { + completion(.success(try await method())) + } catch { + completion(.failure(ErrorUtils.purchasesError(withUntypedError: error).asPublicError)) + } + } + } + + /// Invokes an `async throws` method and calls `completion` with the result. + /// Ensures that the returned error is `PurchasesError`. + /// + /// Example: + /// ```swift + /// Async.call(with: completion) { + /// return try await asynchronousMethod() + /// } + /// ``` + static func call( + with completion: @escaping (Result) -> Void, + asyncMethod method: @escaping () async throws -> T + ) { + _ = Task { + do { + completion(.success(try await method())) + } catch { + completion(.failure(ErrorUtils.purchasesError(withUntypedError: error))) + } + } + } + + /// Invokes an `async` non-throwing method and calls `completion` with the result. + static func call( + with completion: @escaping (T) -> Void, + asyncMethod method: @escaping () async -> T + ) { + _ = Task { + completion(await method()) + } + } + + /// Invokes a completion-block based API and returns the `throw`ing method `async`hronously. + /// + /// Example: + /// ```swift + /// let result = try await Async.call { completion in + /// completionBlockAPI(completion) + /// } + /// ``` + static func call( + method: (@escaping @Sendable (Result) -> Void) -> Void + ) async throws -> Value { + return try await withUnsafeThrowingContinuation { continuation in + @Sendable + func complete(_ result: Result) { + continuation.resume(with: result) + } + + method(complete) + } + } + + /// Invokes a completion-block based API and returns the method `async`hronously. + /// + /// Example: + /// ```swift + /// let result = await Async.call { completion in + /// completionBlockAPI(completion) + /// } + /// ``` + static func call( + method: (@escaping @Sendable (Value) -> Void) -> Void + ) async -> Value { + // Note: We're using UnsafeContinuation instead of Checked because + // of a crash in iOS 18.0 devices when CheckedContinuations are used. + // See: https://github.com/RevenueCat/purchases-ios/issues/4177 + return await withUnsafeContinuation { continuation in + @Sendable + func complete(_ value: Value) { + continuation.resume(with: .success(value)) + } + + method(complete) + } + } + + /// Runs the given block `maximumRetries` times at most, at `pollInterval` times until the + /// block returns a tuple where the first argument `shouldRetry` is false, and the second is the expected value. + /// After the maximum retries, returns the last seen value. + /// + /// Example: + /// ```swift + /// let receipt = await Async.retry { + /// let receipt = fetchReceipt() + /// if receipt.contains(transaction) { + /// return (shouldRetry: false, receipt) + /// } else { + /// return (shouldRetry: true, receipt) + /// } + /// } + /// ``` + static func retry( + maximumRetries: Int = 5, + pollInterval: DispatchTimeInterval = .milliseconds(300), + until value: @Sendable () async -> (shouldRetry: Bool, result: T) + ) async -> T { + var lastValue: T + var retries = 0 + + repeat { + retries += 1 + let (shouldRetry, result) = await value() + if shouldRetry { + lastValue = result + try? await Task.sleep(nanoseconds: UInt64(pollInterval.nanoseconds)) + } else { + return result + } + } while !(retries > maximumRetries) + + return lastValue + } + +} + +internal extension AsyncSequence { + + /// Returns the elements of the asynchronous sequence. + func extractValues() async rethrows -> [Element] { + return try await self.reduce(into: []) { + $0.append($1) + } + } + +} + +internal extension AsyncSequence { + + func toAsyncStream() -> AsyncStream { + var asyncIterator = self.makeAsyncIterator() + return AsyncStream { + try? await asyncIterator.next() + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Data+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Data+Extensions.swift new file mode 100644 index 00000000..50ae5cd6 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Data+Extensions.swift @@ -0,0 +1,150 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Data+Extensions.swift +// Purchases +// +// Created by Josh Holtz on 6/28/21. +// + +import CommonCrypto +import CryptoKit +import Foundation + +extension Data { + + var asString: String { + return Self.hexString( + self + .lazy + .map { $0 } // Extract byte + .makeIterator() + ) + } + + /// - Returns: `UUID` from the first 16 bytes of the underlying data. + var uuid: UUID? { + /// This implementation is equivalent to `return NSUUID(uuidBytes: [UInt8](self)) as UUID` + /// but ensures that the `Data` isn't unnecessarily copied in memory. + return self.dataWithMinLengthForUUID.withUnsafeBytes { + guard let baseAddress = $0.bindMemory(to: UInt8.self).baseAddress else { + return nil + } + + return NSUUID(uuidBytes: baseAddress) as UUID + } + } + + /// - Returns: a string representing a fetch token. + var asFetchToken: String { + return self.base64EncodedString() + } + + /// - Returns: a hash representation of the underlying bytes, using SHA256. + var hashString: String { + var sha256 = SHA256() + return self.hashString(with: &sha256) + } + + /// - Returns: the SHA1 hash of the underlying bytes. + var sha1: Data { + var sha1 = Insecure.SHA1() + return self.hash(with: &sha1) + } + + var sha256: Data { + var sha256 = SHA256() + return self.hash(with: &sha256) + } + + var sha384: Data { + var sha384 = SHA384() + return self.hash(with: &sha384) + } + + var sha512: Data { + var sha512 = SHA512() + return self.hash(with: &sha512) + } + + var sha1String: String { + var sha1 = Insecure.SHA1() + return self.hashString(with: &sha1) + } + + var sha256String: String { + var sha256 = SHA256() + return self.hashString(with: &sha256) + } + + var sha384String: String { + var sha384 = SHA384() + return self.hashString(with: &sha384) + } + + var sha512String: String { + var sha512 = SHA512() + return self.hashString(with: &sha512) + } + + var md5String: String { + var md5 = Insecure.MD5() + return self.hashString(with: &md5) + } + + fileprivate static func hexString(_ iterator: Array.Iterator) -> String { + return iterator + .lazy + .map { String(format: "%02x", $0) } + .joined() + } + + private var dataWithMinLengthForUUID: Data { + let uuidMemorySize = MemoryLayout.size + guard self.count >= uuidMemorySize else { + return self + Data(count: uuidMemorySize - self.count) + } + return self + } +} + +extension Data { + + static func randomNonce() -> Data { + return Data(ChaChaPoly.Nonce()) + } + + static let nonceLength = 12 + +} + +// MARK: - Hashing + +extension HashFunction { + + func toString() -> String { + return Data.hexString(self.finalize().makeIterator()) + } + +} + +private extension Data { + + func hashString(with digest: inout T) -> String { + digest.update(data: self) + return digest.toString() + } + + func hash(with digest: inout T) -> Data { + digest.update(data: self) + + return Data(digest.finalize()) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Date+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Date+Extensions.swift new file mode 100644 index 00000000..fcea8581 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Date+Extensions.swift @@ -0,0 +1,28 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Date+Extensions.swift +// +// Created by Josh Holtz on 6/28/21. +// + +import Foundation + +extension Date { + + init(millisecondsSince1970: UInt64) { + self.init(timeIntervalSince1970: TimeInterval(millisecondsSince1970) / 1000) + } + + /// - Important: this needs to be 64 bits because `Int` is 32 bits in watchOS + var millisecondsSince1970: UInt64 { + return UInt64(self.timeIntervalSince1970 * 1000) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Decoder+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Decoder+Extensions.swift new file mode 100644 index 00000000..3ba54f0c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Decoder+Extensions.swift @@ -0,0 +1,193 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Decoder+Extensions.swift +// +// Created by Joshua Liebowitz on 10/25/21. + +import Foundation + +enum CodableError: Error, CustomStringConvertible, LocalizedError { + + case unexpectedValue(Any.Type, Any) + case valueNotFound(value: Any.Type, context: DecodingError.Context) + case invalidJSONObject(value: [String: Any]) + + var description: String { + switch self { + case let .unexpectedValue(type, value): + return Strings.codable.unexpectedValueError(type: type, value: value).description + case let .valueNotFound(value, context): + return Strings.codable.valueNotFoundError(value: value, context: context).description + case let .invalidJSONObject(value): + return Strings.codable.invalid_json_error(jsonData: value).description + } + } + + var errorDescription: String? { return self.description } + +} + +extension Decoder { + + func valueNotFoundError(expectedType: Any.Type, message: String) -> CodableError { + let context = DecodingError.Context(codingPath: codingPath, + debugDescription: message, + underlyingError: nil) + return CodableError.valueNotFound(value: expectedType, context: context) + } + +} + +extension JSONDecoder { + + /// Decodes a top-level value of the given type from the given Data containing a JSON representation of `type`. + /// + /// - Parameters: + /// - type: The type of the value to decode. The default is `T.self`. + /// - data: The data to decode from. + /// - Returns: A value of the requested type. + /// - throws: `CodableError` or `DecodableError` if the data is invalid or can't be deserialized. + func decode( + _ type: T.Type = T.self, + jsonData: Data, + logErrors: Bool = true + ) throws -> T { + do { + return try self.decode(type, from: jsonData) + } catch { + if logErrors { + ErrorUtils.logDecodingError(error, type: type, data: jsonData) + } + throw error + } + } + + /// Decodes a top-level value of the given type from the given Dictionary. + /// + /// - Parameters: + /// - type: The type of the value to decode. The default is `T.self`. + /// - dictionary: The dictionary to decode from. + /// - Returns: A value of the requested type. + /// - Throws: `CodableError` or `DecodableError` if the data is invalid or can't be deserialized. + /// - Note: this method logs the error before throwing, so it's "safe" to use with `try?` + func decode( + _ type: T.Type = T.self, + dictionary: [String: Any], + logErrors: Bool = true + ) throws -> T { + if JSONSerialization.isValidJSONObject(dictionary) { + return try self.decode(type, + jsonData: try JSONSerialization.data(withJSONObject: dictionary), + logErrors: logErrors) + } else { + throw CodableError.invalidJSONObject(value: dictionary) + } + } + +} + +extension JSONEncoder { + + /// Encodes a top-level value containing a JSON representation of `type` + /// + /// - Parameters: + /// - type: The type of the value to encode. The default is `T.self`. + /// - data: The data to encode. + /// - Returns: The encoded `Data` + /// - throws: `CodableError` or `EncodableError` if the data is invalid or can't be deserialized. + func encode( + _ type: T.Type = T.self, + value: T, + logErrors: Bool = true + ) throws -> Data { + do { + return try self.encode(value) + } catch { + if logErrors { + ErrorUtils.logEncodingError(error, type: type) + } + throw error + } + } + +} + +// MARK: Decoding Error handling + +extension ErrorUtils { + + static func logDecodingError(_ error: Error, type: Any.Type, data: Data? = nil) { + if let data = data { + Logger.debug(Strings.codable.invalid_data_when_decoding(data, type)) + } + + guard let decodingError = error as? DecodingError else { + Logger.error(Strings.codable.decoding_error(error, type)) + return + } + + switch decodingError { + case let .dataCorrupted(context): + Logger.error(Strings.codable.corrupted_data_error(context: context)) + case let .keyNotFound(key, context): + // This is expected to happen occasionally, the backend doesn't always populate all key/values. + Logger.debug(Strings.codable.keyNotFoundError(type: type, key: key, context: context)) + case let .valueNotFound(value, context): + Logger.debug(Strings.codable.valueNotFoundError(value: value, context: context)) + case let .typeMismatch(type, context): + Logger.error(Strings.codable.typeMismatch(type: type, context: context)) + @unknown default: + Logger.error( + "Unhandled DecodingError: \(decodingError)\n" + + "\(Strings.codable.decoding_error(decodingError, type))" + ) + } + } + + static func logEncodingError(_ error: Error, type: Any.Type) { + guard let encodingError = error as? EncodingError else { + Logger.error(Strings.codable.encoding_error(error)) + return + } + + switch encodingError { + case .invalidValue: + Logger.error(Strings.codable.encoding_error(encodingError)) + @unknown default: + Logger.error("Unhandled EncodingError: \(encodingError)\n\(Strings.codable.encoding_error(encodingError))") + } + } + +} + +extension Encodable { + + func asJSONDictionary() throws -> [String: Any] { + return try JSONEncoder.default + .encode(self) + .asJSONDictionary() + + } + +} + +extension Data { + + func asJSONDictionary() throws -> [String: Any] { + let result = try JSONSerialization.jsonObject(with: self, options: []) + + guard let result = result as? [String: Any] else { + throw CodableError.unexpectedValue(type(of: result), result) + } + + return result + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Dictionary+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Dictionary+Extensions.swift new file mode 100644 index 00000000..ed3a3023 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Dictionary+Extensions.swift @@ -0,0 +1,139 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Dictionary+Extensions.swift +// +// Created by César de la Vega on 7/21/21. +// + +import Foundation + +extension Dictionary { + + var stringRepresentation: String { + compactMap { "\($0)=\($1)" } + .sorted() + .joined(separator: ",") + } + + func removingNSNullValues() -> Dictionary { + filter { !($0.value is NSNull) } + } + +} + +extension Dictionary { + + /// Merge strategy to use for any duplicate keys. + enum MergeStrategy { + + /// Keep the original value. + case keepOriginalValue + /// Overwrite the original value. + case overwriteValue + + var combine: (Value, Value) -> Value { + switch self { + case .keepOriginalValue: + return { original, _ in original } + case .overwriteValue: + return { _, overwrite in overwrite } + } + } + + } + + /// Creates a dictionary by merging the given dictionary into this + /// dictionary, using a merge strategy to determine the value for + /// duplicate keys. + /// + /// - Parameters: + /// - other: A dictionary to merge. + /// - strategy: The merge strategy to use for any duplicate keys. The strategy provides a + /// closure that returns the desired value for the final dictionary. The default is `overwriteValue`. + /// - Returns: A new dictionary with the combined keys and values of this + /// dictionary and `other`. + func merging(_ other: [Key: Value], strategy: MergeStrategy = .overwriteValue) -> [Key: Value] { + return self.merging(other, uniquingKeysWith: strategy.combine) + } + + /// Merges the given dictionary into this dictionary, + /// using a merge strategy to determine the value for duplicate keys. + /// + /// - Parameters: + /// - other: A dictionary to merge. + /// - strategy: The merge strategy to use for any duplicate keys. The strategy provides a + /// closure that returns the desired value for the final dictionary. The default is `overwriteValue`. + mutating func merge(_ other: [Key: Value], strategy: MergeStrategy = .overwriteValue) { + self.merge(other, uniquingKeysWith: strategy.combine) + } + + /// Merge the keys/values of two dictionaries. + /// + /// The merge strategy used is `overwriteValue`. + /// + /// - Parameters: + /// - lhs: A dictionary to merge. + /// - rhs: Another dictionary to merge. + /// - Returns: A dictionary with keys and values from both. + static func + (lhs: [Key: Value], rhs: [Key: Value]) -> [Key: Value] { + return lhs.merging(rhs) + } + + /// Adds values from rhs to lhs dictionary + /// + /// The merge strategy used is `overwriteValue`. + /// + /// - Parameters: + /// - lhs: A dictionary to merge. + /// - rhs: Another dictionary to merge. + /// - Returns: A dictionary with keys and values from both. + static func += (lhs: inout [Key: Value], rhs: [Key: Value]) { + lhs.merge(rhs) + } + +} + +extension Dictionary { + + func mapKeys(_ transformer: (Key) -> NewKey) -> [NewKey: Value] { + return self.compactMapKeys(transformer) + } + + func compactMapKeys(_ transformer: (Key) -> NewKey?) -> [NewKey: Value] { + var result = [NewKey: Value](minimumCapacity: self.count) + + for (key, value) in self { + if let newKey = transformer(key) { + result.updateValue(value, forKey: newKey) + } + } + + return result + } + +} + +extension Sequence { + + /// Creates a `Dictionary` with the values in the receiver sequence, and the keys provided by `key`. + /// - Precondition: The sequence must not have duplicate keys. + func dictionaryWithKeys(_ key: @escaping (Element) -> Key) -> [Key: Element] { + Dictionary(uniqueKeysWithValues: self.lazy.map { (key($0), $0) }) + } + + /// Creates a `Dictionary` with the values in the receiver sequence, and the keys provided by `key`. + func dictionaryAllowingDuplicateKeys(_ key: @escaping (Element) -> Key) -> [Key: Element] { + return Dictionary( + self.lazy.map { (key($0), $0) }, + uniquingKeysWith: { (_, last) in last } + ) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/DispatchTimeInterval+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/DispatchTimeInterval+Extensions.swift new file mode 100644 index 00000000..54b6dc12 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/DispatchTimeInterval+Extensions.swift @@ -0,0 +1,106 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DispatchTimeInterval+Extensions.swift +// +// Created by Nacho Soto on 12/2/21. + +// swiftlint:disable identifier_name +import Foundation + +extension DispatchTimeInterval { + + /// Creates a `DispatchTimeInterval` from a `TimeInterval` with millisecond precision. + init(_ timeInterval: TimeInterval) { + self = .milliseconds(Int(timeInterval * 1000)) + } + + static func minutes(_ minutes: Int) -> Self { + precondition(minutes >= 0, "Minutes must be positive: \(minutes)") + + return .seconds(minutes * 60) + } + + static func hours(_ hours: Int) -> Self { + precondition(hours >= 0, "Hours must be positive: \(hours)") + + return .seconds(hours * 60 * 60) + } + + static func days(_ days: Int) -> Self { + precondition(days >= 0, "Days must be positive: \(days)") + + return Self.hours(days * 24) + } + + /// `DispatchTimeInterval` can only be used by specifying a unit of time. + /// This allows us to easily convert any `DispatchTimeInterval` into nanoseconds. + /// - Important: It's likely that `x * 1_000_000_000` can't be represented in 32 bits. + var nanoseconds: UInt64 { + switch self { + case let .seconds(s): return UInt64(s) * UInt64(1_000_000_000) + case let .milliseconds(ms): return UInt64(ms) * UInt64(1_000_000) + case let .microseconds(ms): return UInt64(ms) * UInt64(1000) + case let .nanoseconds(ns): return UInt64(ns) + case .never: return 0 + @unknown default: fatalError("Unknown value: \(self)") + } + } + + /// - Note: this returns `Int`, so it might lose precision for `.milliseconds` and `.microseconds`. + var milliseconds: Int { + switch self { + case let .seconds(s): return s * 1_000 + case let .milliseconds(ms): return ms + case let .microseconds(ms): return Int((Double(ms) / 1_000).rounded()) + case let .nanoseconds(ns): return Int((Double(ns) / 1_000_000).rounded()) + case .never: return 0 + @unknown default: fatalError("Unknown value: \(self)") + } + } + + /// `DispatchTimeInterval` can only be used by specifying a unit of time. + /// This allows us to easily convert any `DispatchTimeInterval` into seconds. + var seconds: Double { + switch self { + case let .seconds(seconds): return Double(seconds) + case let .milliseconds(ms): return Double(ms) / 1_000 + case let .microseconds(ms): return Double(ms) / 1_000_000 + case let .nanoseconds(ns): return Double(ns) / 1_000_000_000 + case .never: return 0 + @unknown default: fatalError("Unknown value: \(self)") + } + } + + var days: Double { + return self.seconds / (60 * 60 * 24) + } + +} + +// swiftlint:enable identifier_name + +func + (lhs: DispatchTimeInterval, rhs: DispatchTimeInterval) -> DispatchTimeInterval { + // Note: `DispatchTimeInterval` uses `Int` for nanoseconds, which might overflow in 32 bits + // This loses some precision by using milliseconds, but avoids potential overflows. + return .milliseconds(lhs.milliseconds + rhs.milliseconds) +} + +func - (lhs: DispatchTimeInterval, rhs: DispatchTimeInterval) -> DispatchTimeInterval { + // Note: `DispatchTimeInterval` uses `Int` for nanoseconds, which might overflow in 32 bits + // This loses some precision by using milliseconds, but avoids potential overflows. + return .milliseconds(lhs.milliseconds - rhs.milliseconds) +} + +#if swift(<5.9) +// `DispatchTimeInterval` is not `Sendable` as of Swift 5.8. +// Its conformance is safe since it only represents data +// See https://github.com/apple/swift/issues/65044 +extension DispatchTimeInterval: @unchecked Sendable {} +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Error+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Error+Extensions.swift new file mode 100644 index 00000000..522594dd --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Error+Extensions.swift @@ -0,0 +1,49 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Error+Extensions.swift +// +// Created by Joshua Liebowitz on 8/6/21. + +import Foundation +import StoreKit + +extension NSError { + + var subscriberAttributesErrors: [String: String]? { + return self.userInfo[ErrorDetails.attributeErrorsKey] as? [String: String] + } + +} + +extension Error { + + var isCancelledError: Bool { + switch self { + case let error as ErrorCode: + switch error { + case .purchaseCancelledError: return true + default: return false + } + + case let purchasesError as PurchasesError: + return purchasesError.error.isCancelledError + + case let error as NSError: + switch (error.domain, error.code) { + case (SKErrorDomain, SKError.paymentCancelled.rawValue): return true + + default: return false + } + + default: return false + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Integer+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Integer+Extensions.swift new file mode 100644 index 00000000..94809afd --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Integer+Extensions.swift @@ -0,0 +1,30 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Integer+Extensions.swift +// +// Created by Nacho Soto on 6/26/23. + +import Foundation + +extension UInt32 { + + /// Converts 32 bits of little-endian `Data` into a `UInt32`. + init(littleEndian32Bits data: Data) { + assert(data.count == 4, "Data needs to be 32bits: \(data)") + + self.init(littleEndian: data.withUnsafeBytes { $0.load(as: UInt32.self) }) + } + + /// - Returns: the `Data` representation as little-endian 32 bits. + var littleEndianData: Data { + return Data(withUnsafeBytes(of: self.littleEndian, Array.init)) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Locale+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Locale+Extensions.swift new file mode 100644 index 00000000..4d24edd1 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Locale+Extensions.swift @@ -0,0 +1,59 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Locale+Extensions.swift +// +// Created by Nacho Soto on 6/21/23. + +import Foundation + +@_spi(Internal) +public extension Locale { + + /// Returns true if the language component of the Locale is equal to the one of self + func matchesLanguage(_ rhs: Locale) -> Bool { + self.removingRegion == rhs.removingRegion + } + + // swiftlint:disable identifier_name + /// The code of the currency used by the locale. + var rc_currencyCode: String? { + #if swift(>=5.9) + // `Locale.currencyCode` is deprecated + if #available(macOS 13, iOS 16, tvOS 16, watchOS 9, visionOS 1.0, *) { + return self.currency?.identifier + } else { + return self.currencyCode + } + #else + return self.currencyCode + #endif + } + + /// The language code that identifies the locale's language. + var rc_languageCode: String? { + #if swift(>=5.9) + // `Locale.languageCode` is deprecated + if #available(macOS 13, iOS 16, tvOS 16, watchOS 9, visionOS 1.0, *) { + return self.language.languageCode?.identifier + } else { + return self.languageCode + } + #else + return self.languageCode + #endif + } + // swiftlint:enable identifier_name + + /// - Returns: the same locale as `self` but removing its region. + var removingRegion: Self? { + return self.rc_languageCode.map(Locale.init(identifier:)) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/OperationQueue+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/OperationQueue+Extensions.swift new file mode 100644 index 00000000..fc436cbe --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/OperationQueue+Extensions.swift @@ -0,0 +1,42 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// OperationQueue+Extensions.swift +// +// Created by Joshua Liebowitz on 1/20/22. + +import Foundation + +extension OperationQueue { + + final func addCacheableOperation( + with factory: CacheableNetworkOperationFactory, + cacheStatus: CallbackCacheStatus + ) { + switch cacheStatus { + case .firstCallbackAddedToList: + self.addOperation(factory.create()) + + Logger.verbose(Strings.network.enqueing_operation(factory.operationType, + cacheKey: factory.cacheKey)) + + case .addedToExistingInFlightList: + Logger.debug( + Strings.network.reusing_existing_request_for_operation( + T.self, + Logger.verboseLogsEnabled + ? factory.cacheKey + : factory.cacheKey.prefix(15) + "…" + ) + ) + return + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Operators+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Operators+Extensions.swift new file mode 100644 index 00000000..22ce15ee --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Operators+Extensions.swift @@ -0,0 +1,24 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Operators+Extensions.swift +// +// Created by Nacho Soto on 5/23/22. + +infix operator ??? + +/// Equivalent to `??` but allows an `async` default value. +/// See https://github.com/apple/swift-evolution/blob/main/proposals/0296-async-await.md#future-directions +func ??? (value: T?, defaultValue: @autoclosure () async throws -> T) async rethrows -> T { + if let value = value { + return value + } else { + return try await defaultValue() + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Optional+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Optional+Extensions.swift new file mode 100644 index 00000000..f93e6459 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Optional+Extensions.swift @@ -0,0 +1,46 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Optional+Extensions.swift +// +// Created by Nacho Soto on 3/30/22. + +import Foundation + +/// Protocol definition to be able to use `Optional` as a type. +internal protocol OptionalType { + + associatedtype Wrapped + + init(optional: Wrapped?) + var asOptional: Wrapped? { get } + +} + +extension Optional: OptionalType { + + init(optional: Wrapped?) { self = optional } + + var asOptional: Wrapped? { return self } + +} + +extension OptionalType { + + /// - Returns: unwrapped value if present. + /// - Throws: `error` if the value is not present. + func orThrow(_ error: @autoclosure () -> Error) throws -> Wrapped { + if let value = self.asOptional { + return value + } else { + throw error() + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Result+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Result+Extensions.swift new file mode 100644 index 00000000..9ab201bc --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Result+Extensions.swift @@ -0,0 +1,86 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Result+Extensions.swift +// +// Created by Nacho Soto on 12/1/21. + +extension Result { + + /// Creates a `Result` from either a value or an error. + /// This is useful for converting from old Objective-C closures to new APIs. + init( _ value: Success?, _ error: @autoclosure () -> Failure?, file: StaticString = #fileID, line: UInt = #line) { + if let value = value { + self = .success(value) + } else if let error = error() { + self = .failure(error) + } else { + fatalError("Unexpected nil value and nil error", file: file, line: line) + } + } + + var value: Success? { + switch self { + case let .success(value): return value + case .failure: return nil + } + } + + var error: Failure? { + switch self { + case .success: return nil + case let .failure(error): return error + } + } + +} + +extension Result where Success == Void { + + /// Creates a `Result` with an optional `Error`. + init(_ error: Failure?) { + if let error = error { + self = .failure(error) + } else { + self = .success(()) + } + } + +} + +extension Result where Success: OptionalType { + + /// Converts a `Result` into `Result?` + var asOptionalResult: Result? { + switch self { + case let .success(optional): + if let value = optional.asOptional { + return .success(value) + } else { + return nil + } + case let .failure(error): + return .failure(error) + } + } + +} + +extension Result where Failure == Swift.Error { + + /// Equivalent to `Result.init(catching:)` but with an `async` closure. + init(catching block: () async throws -> Success) async { + do { + self = .success(try await block()) + } catch { + self = .failure(error) + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Set+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Set+Extensions.swift new file mode 100644 index 00000000..ba632643 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/Set+Extensions.swift @@ -0,0 +1,33 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Set+Extensions.swift +// +// Created by Nacho Soto on 12/15/21. + +import Foundation + +extension Set { + + /// Creates a `Dictionary` with the keys in the receiver `Set`, and the values provided by `value`. + func dictionaryWithValues(_ value: @escaping (Element) -> Value) -> [Element: Value] { + return Dictionary(uniqueKeysWithValues: self.lazy.map { ($0, value($0)) }) + } + + /// Merge the values of two `Set`s. + static func + (lhs: Set, rhs: Set) -> Set { + lhs.union(rhs) + } + + /// Adds values from `rhs` to `lhs`. + static func += (lhs: inout Set, rhs: Set) { + lhs.formUnion(rhs) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/String+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/String+Extensions.swift new file mode 100644 index 00000000..35ab47ac --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/String+Extensions.swift @@ -0,0 +1,133 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// String+Extensions.swift +// +// Created by Juanpe Catalán on 9/7/21. +// + +import Foundation + +extension String { + + func rot13() -> String { + ROT13.string(self) + } + + var trimmedAndEscaped: String { + return self + .trimmingWhitespacesAndNewLines + .addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "" + } + + /// Returns `nil` if `self` is an empty string. + var notEmpty: String? { + return self.isEmpty + ? nil + : self + } + + /// Returns `nil` if `self` is an empty string or it only contains whitespaces. + var notEmptyOrWhitespaces: String? { + return self.trimmingWhitespacesAndNewLines.isEmpty + ? nil + : self + } + + /// Returns `true` if it contains anything other than whitespaces. + var isNotEmpty: Bool { + return self.notEmptyOrWhitespaces != nil + } + + var trimmingWhitespacesAndNewLines: String { + return self.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var asData: Data { + return Data(self.utf8) + } + + func countOccurences(of character: Character) -> Int { + return self.reduce(0) { + return $1 == character ? $0 + 1 : $0 + } + } + + private static let remainderStartLength = 2 + private static let remainderEndLength = 4 + private static let redactionPlaceholder = "********" + + var asRedactedAPIKey: String { + let prefix: String.SubSequence + let remainder: String.SubSequence + + if let underscoreIndex = self.firstIndex(of: "_") { + // Prefix including underscore + prefix = self[..= minimumLengthToRedact else { + return self + } + + // Take first 2 and last 4 characters + let start = remainder.prefix(Self.remainderStartLength) + let end = remainder.suffix(Self.remainderEndLength) + + return "\(prefix)\(start)********\(end)" + } +} + +// MARK: - + +internal extension Optional where Wrapped == String { + + /// Returns `nil` if `self` is an empty string. + var notEmpty: String? { + return self.flatMap { $0.notEmpty } + } + +} + +private enum ROT13 { + + private static let key: [Character: Character] = { + let size = Self.lowercase.count + let halfSize: Int = size / 2 + + var result: [Character: Character] = .init(minimumCapacity: size) + + for number in 0 ..< size { + let index = (number + halfSize) % size + + result[Self.uppercase[number]] = Self.uppercase[index] + result[Self.lowercase[number]] = Self.lowercase[index] + } + + return result + }() + private static let lowercase: [Character] = Array("abcdefghijklmnopqrstuvwxyz") + // swiftlint:disable:next force_unwrapping + private static let uppercase: [Character] = Self.lowercase.map { $0.uppercased().first! } + + fileprivate static func string(_ string: String) -> String { + let transformed = string.map { Self.key[$0] ?? $0 } + return String(transformed) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/TimeInterval+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/TimeInterval+Extensions.swift new file mode 100644 index 00000000..2c4b7cd1 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/TimeInterval+Extensions.swift @@ -0,0 +1,22 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// TimeInterval+Extensions.swift +// +// Created by Will Taylor on 7/12/24. + +import Foundation + +extension TimeInterval { + + init(milliseconds: Double) { + self = milliseconds / 1000.0 + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/UIApplication+RCExtensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/UIApplication+RCExtensions.swift new file mode 100644 index 00000000..a25c489a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/UIApplication+RCExtensions.swift @@ -0,0 +1,42 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// UIApplication+RCExtensions.swift +// +// Created by Andrés Boedo on 8/20/21. + +#if os(iOS) || os(tvOS) || VISION_OS +import UIKit + +extension UIApplication { + + @available(macCatalyst 13.1, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(watchOSApplicationExtension, unavailable) + @MainActor + var currentWindowScene: UIWindowScene? { + var scenes = self + .connectedScenes + .filter { $0.activationState == .foregroundActive } + + #if DEBUG && targetEnvironment(simulator) + // Running StoreKitUnitTests might not always have an active scene + // Sporadically, the only scene will be `foregroundInactive` or `background` + if scenes.isEmpty, ProcessInfo.isRunningUnitTests { + scenes = self.connectedScenes + } + #endif + + return scenes.first as? UIWindowScene + } + +} + +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/UserDefaults+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/UserDefaults+Extensions.swift new file mode 100644 index 00000000..bfb1d5e1 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/FoundationExtensions/UserDefaults+Extensions.swift @@ -0,0 +1,53 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// UserDefaults+Extensions.swift +// +// Created by Nacho Soto on 11/9/22. + +import Foundation + +extension UserDefaults { + + // swiftlint:disable force_unwrapping + + // These are the only 2 documented reasons why `.init(suiteName:)` might return `nil`: + // - "Because a suite manages the defaults of a specified app group, a suite name + // must be distinct from your app’s main bundle identifier. + // - The globalDomain is also an invalid suite name, because it isn't writeable by apps. + // + // Because we know at compile time that it's neither of those, this is a safe force-unwrap. + static let revenueCatSuite: UserDefaults = .init(suiteName: UserDefaults.revenueCatSuiteName)! + + // swiftlint:enable force_unwrapping + + private static let revenueCatSuiteName = "com.revenuecat.user_defaults" + +} + +extension UserDefaults { + + /// Determines the "default" `UserDefaults` to use for the SDK. + /// + /// Moving foward, this default is `UserDefaults.revenueCatSuite`, + /// but existing users will continue using `UserDefaults.standard` for compatibility. + /// This is determined by the presence of an app user ID in `UserDefaults.standard`. + static func computeDefault() -> UserDefaults { + let standard: UserDefaults = .standard + + if standard.value(forKey: DeviceCache.CacheKeys.appUserDefaults.rawValue) != nil { + Logger.debug(Strings.configure.using_user_defaults_standard) + return standard + } else { + Logger.debug(Strings.configure.using_user_defaults_suite_name) + return .revenueCatSuite + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Identity/CustomerInfo+ActiveDates.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Identity/CustomerInfo+ActiveDates.swift new file mode 100644 index 00000000..c89c8e37 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Identity/CustomerInfo+ActiveDates.swift @@ -0,0 +1,106 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CustomerInfo+ActiveDates.swift +// +// Created by Nacho Soto on 3/7/23. + +import Foundation + +// MARK: - Internal + +extension CustomerInfo { + + /// This grace period allows apps to continue functioning if the backend is down, but for a limited time. + /// We don't want to continue granting entitlements with an outdated `requestDate` forever, + /// since that would allow a user to get a free trial, then go offline and keep the entitlement with no time limit. + static let requestDateGracePeriod: DispatchTimeInterval = .days(3) + + static func isDateActive(expirationDate: Date?, for requestDate: Date) -> Bool { + guard let expirationDate = expirationDate else { + return true + } + + let (referenceDate, inGracePeriod) = Self.referenceDate(for: requestDate) + let isActive = expirationDate.timeIntervalSince(referenceDate) >= 0 + + if !inGracePeriod && !isActive { + Logger.warn(Strings.purchase.entitlement_expired_outside_grace_period(expiration: expirationDate, + reference: requestDate)) + } + + return isActive + } + + func activeKeys(dates: [String: Date?]) -> Set { + return Set( + dates + .lazy + .filter { self.isDateActive($1) } + .map { key, _ in key } + ) + } + + static func extractExpirationDates(_ subscriber: CustomerInfoResponse.Subscriber) -> [String: Date?] { + return Dictionary( + uniqueKeysWithValues: subscriber + .subscriptions + .lazy + .map { productID, subscription in + let key = Self.extractProductIDAndBasePlan(from: productID, purchase: subscription) + let value = subscription.expiresDate + return (key, value) + } + ) + } + + static func extractPurchaseDates(_ subscriber: CustomerInfoResponse.Subscriber) -> [String: Date?] { + return Dictionary( + uniqueKeysWithValues: subscriber + .allPurchasesByProductId + .lazy + .map { productID, purchase in + let key = Self.extractProductIDAndBasePlan(from: productID, purchase: purchase) + let value = purchase.purchaseDate + return (key, value) + } + ) + } + +} + +// MARK: - Private + +private extension CustomerInfo { + + static func extractProductIDAndBasePlan(from productID: String, + purchase: CustomerInfoResponse.Subscription) -> String { + // Products purchased from Google Play will have a product plan identifier (base plan) + // These products get mapped as "productId:productPlanIdentifier" in the Android SDK + // so the same mapping needs to be handled here for cross platform purchases + if let productPlanIdentfier = purchase.productPlanIdentifier { + return "\(productID):\(productPlanIdentfier)" + } else { + return productID + } + } + + static func referenceDate(for requestDate: Date) -> (Date, inGracePeriod: Bool) { + if Date().timeIntervalSince(requestDate) <= Self.requestDateGracePeriod.seconds { + return (requestDate, true) + } else { + return (Date(), false) + } + } + + func isDateActive(_ date: Date?) -> Bool { + return Self.isDateActive(expirationDate: date, for: self.requestDate) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Identity/CustomerInfo+NonSubscriptions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Identity/CustomerInfo+NonSubscriptions.swift new file mode 100644 index 00000000..aac8c5ef --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Identity/CustomerInfo+NonSubscriptions.swift @@ -0,0 +1,24 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CustomerInfo+NonSubscriptions.swift +// +// Created by Nacho Soto on 7/18/23. + +import Foundation + +extension CustomerInfo { + + func containsNonSubscription(_ transation: StoreTransactionType) -> Bool { + return self.nonSubscriptions.contains { + $0.transactionIdentifier == transation.transactionIdentifier + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Identity/CustomerInfo.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Identity/CustomerInfo.swift new file mode 100644 index 00000000..bafffad5 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Identity/CustomerInfo.swift @@ -0,0 +1,562 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CustomerInfo.swift +// +// Created by Madeline Beyl on 7/9/21. +// + +// swiftlint:disable file_length +import Foundation + +/** + An identifier used to identify a product. + */ +public typealias ProductIdentifier = String + +/** + A container for the most recent customer info returned from `Purchases`. + These objects are non-mutable and do not update automatically. + */ +@objc(RCCustomerInfo) public final class CustomerInfo: NSObject { + + /// ``EntitlementInfos`` attached to this customer info. + @objc public let entitlements: EntitlementInfos + + /// All *subscription* product identifiers with expiration dates in the future. + @objc public var activeSubscriptions: Set { + self.activeKeys(dates: self.expirationDatesByProductId) + } + + /// All product identifiers purchases by the user regardless of expiration. + @objc public let allPurchasedProductIdentifiers: Set + + /// Returns the latest expiration date of all products, nil if there are none. + @objc public var latestExpirationDate: Date? { + let mostRecentDate = self.expirationDatesByProductId + .values + .compactMap { $0 } + .max { $0.timeIntervalSinceReferenceDate < $1.timeIntervalSinceReferenceDate } + + return mostRecentDate + } + + /** + * Returns all the non-subscription purchases a user has made. + * The purchases are ordered by purchase date in ascending order. + * + * This includes: + * - Consumables + * - Non-consumables + * - Non-renewing subscriptions + */ + @objc public let nonSubscriptions: [NonSubscriptionTransaction] + + /** + * Returns the fetch date of this CustomerInfo. + */ + @objc public let requestDate: Date + + /// The date this user was first seen in RevenueCat. + @objc public let firstSeen: Date + + /// The original App User Id recorded for this user. + @objc public let originalAppUserId: String + + /** + URL to manage the active subscription of the user. + * If this user has an active iOS subscription, this will point to the App Store. + * If the user has an active Play Store subscription it will point there. + * If there are no active subscriptions it will be null. + * If there are multiple for different platforms, it will point to the App Store. + */ + @objc public let managementURL: URL? + + /** + * Returns the purchase date for the version of the application when the user bought the app. + * Use this for grandfathering users when migrating to subscriptions. + * + * - Note: This can be `nil`, see ``Purchases/restorePurchases(completion:)`` + */ + @objc public let originalPurchaseDate: Date? + + /** + * The build number (in iOS) or the marketing version (in macOS) for the version of the application when the user + * bought the app. This corresponds to the value of CFBundleVersion (in iOS) or CFBundleShortVersionString + * (in macOS) in the Info.plist file when the purchase was originally made. Use this for grandfathering users + * when migrating to subscriptions. + * + * - Note: This can be nil, see -`Purchases.restorePurchases(completion:)` + */ + @objc public let originalApplicationVersion: String? + + /// Dictionary of all subscription product identifiers and their subscription info + @objc public let subscriptionsByProductIdentifier: [ProductIdentifier: SubscriptionInfo] + + /// Get the expiration date for a given product identifier. You should use Entitlements though! + /// - Parameter productIdentifier: Product identifier for product + /// - Returns: The expiration date for `productIdentifier`, `nil` if product never purchased + @objc public func expirationDate(forProductIdentifier productIdentifier: ProductIdentifier) -> Date? { + return expirationDatesByProductId[productIdentifier] ?? nil + } + + /// Get the latest purchase or renewal date for a given product identifier. You should use Entitlements though! + /// - Parameter productIdentifier: Product identifier for subscription product + /// - Returns: The purchase date for `productIdentifier`, `nil` if product never purchased + @objc public func purchaseDate(forProductIdentifier productIdentifier: ProductIdentifier) -> Date? { + return purchaseDatesByProductId[productIdentifier] ?? nil + } + + /// Get the expiration date for a given entitlement. + /// - Parameter entitlementIdentifier: The ID of the entitlement + /// - Returns: The expiration date for the passed in `entitlementIdentifier`, or `nil` + @objc public func expirationDate(forEntitlement entitlementIdentifier: String) -> Date? { + return entitlements[entitlementIdentifier]?.expirationDate + } + + /// Get the latest purchase or renewal date for a given entitlement identifier. + /// - Parameter entitlementIdentifier: Entitlement identifier for entitlement + /// - Returns: The purchase date for `entitlementIdentifier`, `nil` if product never purchased + @objc public func purchaseDate(forEntitlement entitlementIdentifier: String) -> Date? { + return entitlements[entitlementIdentifier]?.latestPurchaseDate + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? CustomerInfo else { + return false + } + + return (self.data.response == other.data.response && + self.data.entitlementVerification == other.data.entitlementVerification) + } + + public override var hash: Int { + var hasher = Hasher() + hasher.combine(self.data.response) + + return hasher.finalize() + } + + public override var description: String { + let activeSubsDescription = self.activeSubscriptions.reduce(into: [String: String]()) { dict, subId in + dict[subId] = "expiresDate: \(String(describing: self.expirationDate(forProductIdentifier: subId)))" + } + + let activeEntitlementsDescription = self.entitlements.active.mapValues { $0.description } + + let allEntitlementsDescription = self.entitlements.all.mapValues { $0.description } + + let verificationResult = self.entitlements.verification.debugDescription + + let subscriptionsDescription = self.subscriptionsByProductIdentifier.mapValues { $0.description } + + return """ + <\(String(describing: CustomerInfo.self)): + originalApplicationVersion=\(self.originalApplicationVersion ?? ""), + latestExpirationDate=\(String(describing: self.latestExpirationDate)), + activeEntitlements=\(activeEntitlementsDescription), + activeSubscriptions=\(activeSubsDescription), + nonSubscriptions=\(self.nonSubscriptions), + subscriptions=\(subscriptionsDescription), + requestDate=\(String(describing: self.requestDate)), + firstSeen=\(String(describing: self.firstSeen)), + originalAppUserId=\(self.originalAppUserId), + entitlements=\(allEntitlementsDescription) + verification=\(verificationResult) + > + """ + } + + /// Represents the original source of the ``CustomerInfo`` object. + internal var originalSource: OriginalSource { + return self.data.originalSource + } + + /// Whether the `CustomerInfo` instance was loaded from the device cache. + internal var isLoadedFromCache: Bool { + return self.data.loadedFromCache + } + + // MARK: - + + private let data: Contents + + /// Initializes a `CustomerInfo` with the underlying data in the current schema version + convenience init(response: CustomerInfoResponse, + entitlementVerification: VerificationResult, + sandboxEnvironmentDetector: SandboxEnvironmentDetector, + httpResponseOriginalSource: HTTPResponseOriginalSource?) { + let originalSource = OriginalSource(entitlementVerification: entitlementVerification, + httpResponseOriginalSource: httpResponseOriginalSource) + self.init(data: .init(response: response, + entitlementVerification: entitlementVerification, + schemaVersion: Self.currentSchemaVersion, + originalSource: originalSource ?? .main), + sandboxEnvironmentDetector: sandboxEnvironmentDetector) + } + + /// Initializes a ``CustomerInfo`` instance. + /// Useful for Unit testing purposes, since the other (internal) initializers require a backend response + public convenience init( + entitlements: EntitlementInfos, + expirationDatesByProductId: [String: Date] = [:], + purchaseDatesByProductId: [String: Date] = [:], + allPurchasedProductIds: Set = [], + requestDate: Date, + firstSeen: Date, + originalAppUserId: String, + originalPurchaseDate: Date? = nil, + managementURL: URL? = nil + ) { + let response = CustomerInfoResponse( + subscriber: .init( + originalAppUserId: originalAppUserId, + firstSeen: firstSeen + ), + requestDate: requestDate, + rawData: [:] + ) + let data = Contents( + response: response, + entitlementVerification: entitlements.verification, + schemaVersion: nil, + originalSource: .main + ) + + self.init( + data: data, + entitlements: entitlements, + nonSubscriptions: [], + requestDate: requestDate, + firstSeen: firstSeen, + originalAppUserId: originalAppUserId, + originalPurchaseDate: originalPurchaseDate, + originalApplicationVersion: nil, + managementURL: managementURL, + expirationDatesByProductId: expirationDatesByProductId, + purchaseDatesByProductId: purchaseDatesByProductId, + allPurchasedProductIdentifiers: allPurchasedProductIds, + subscriptionsByProductIdentifier: [:] + ) + } + + /// Initializes a `CustomerInfo` creating a copy. + convenience init(customerInfo: CustomerInfo, + sandboxEnvironmentDetector: SandboxEnvironmentDetector) { + self.init(data: customerInfo.data, sandboxEnvironmentDetector: sandboxEnvironmentDetector) + } + + // swiftlint:disable:next function_body_length + fileprivate convenience init( + data: Contents, + sandboxEnvironmentDetector: SandboxEnvironmentDetector = BundleSandboxEnvironmentDetector.default + ) { + let response = data.response + let subscriber = response.subscriber + + let nonSubscriptions = TransactionsFactory.nonSubscriptionTransactions( + withSubscriptionsData: subscriber.nonSubscriptions + ) + let expirationDatesByProductId = Self.extractExpirationDates(subscriber) + let purchaseDatesByProductId = Self.extractPurchaseDates(subscriber) + + let allPurchasedProductIdentifiers = Set(expirationDatesByProductId.keys) + .union(nonSubscriptions.map { $0.productIdentifier }) + + let subscriptionsByProductIdentifier = + Dictionary(uniqueKeysWithValues: subscriber.subscriptions.map { (key, subscriptionData) in + (key, SubscriptionInfo( + productIdentifier: key, + purchaseDate: subscriptionData.purchaseDate, + originalPurchaseDate: subscriptionData.originalPurchaseDate, + expiresDate: subscriptionData.expiresDate, + store: subscriptionData.store, + isSandbox: subscriptionData.isSandbox, + unsubscribeDetectedAt: subscriptionData.unsubscribeDetectedAt, + billingIssuesDetectedAt: subscriptionData.billingIssuesDetectedAt, + gracePeriodExpiresDate: subscriptionData.gracePeriodExpiresDate, + ownershipType: subscriptionData.ownershipType, + periodType: subscriptionData.periodType, + refundedAt: subscriptionData.refundedAt, + storeTransactionId: subscriptionData.storeTransactionId, + requestDate: response.requestDate, + price: subscriptionData.price.map { ProductPaidPrice(currency: $0.currency, amount: $0.amount) }, + managementURL: subscriptionData.managementUrl, + displayName: subscriptionData.displayName + )) + }) + + self.init( + data: data, + entitlements: EntitlementInfos( + entitlements: subscriber.entitlements, + purchases: subscriber.allPurchasesByProductId, + requestDate: response.requestDate, + sandboxEnvironmentDetector: sandboxEnvironmentDetector, + verification: data.entitlementVerification + ), + nonSubscriptions: nonSubscriptions, + requestDate: response.requestDate, + firstSeen: subscriber.firstSeen, + originalAppUserId: subscriber.originalAppUserId, + originalPurchaseDate: subscriber.originalPurchaseDate, + originalApplicationVersion: subscriber.originalApplicationVersion, + managementURL: subscriber.managementUrl, + expirationDatesByProductId: expirationDatesByProductId, + purchaseDatesByProductId: purchaseDatesByProductId, + allPurchasedProductIdentifiers: allPurchasedProductIdentifiers, + subscriptionsByProductIdentifier: subscriptionsByProductIdentifier + ) + } + + fileprivate init( + data: Contents, + entitlements: EntitlementInfos, + nonSubscriptions: [NonSubscriptionTransaction], + requestDate: Date, + firstSeen: Date, + originalAppUserId: String, + originalPurchaseDate: Date?, + originalApplicationVersion: String?, + managementURL: URL?, + expirationDatesByProductId: [String: Date?], + purchaseDatesByProductId: [String: Date?], + allPurchasedProductIdentifiers: Set, + subscriptionsByProductIdentifier: [String: SubscriptionInfo] + ) { + self.data = data + self.entitlements = entitlements + self.nonSubscriptions = nonSubscriptions + self.requestDate = requestDate + self.firstSeen = firstSeen + self.originalAppUserId = originalAppUserId + self.originalPurchaseDate = originalPurchaseDate + self.originalApplicationVersion = originalApplicationVersion + self.managementURL = managementURL + self.expirationDatesByProductId = expirationDatesByProductId + self.purchaseDatesByProductId = purchaseDatesByProductId + self.allPurchasedProductIdentifiers = allPurchasedProductIdentifiers + self.subscriptionsByProductIdentifier = subscriptionsByProductIdentifier + } + + private let expirationDatesByProductId: [String: Date?] + private let purchaseDatesByProductId: [String: Date?] +} + +// MARK: - Internal + +extension CustomerInfo { + + var subscriber: CustomerInfoResponse.Subscriber { + return self.data.response.subscriber + } + + var schemaVersion: String? { + return self.data.schemaVersion + } + + var schemaVersionIsCompatible: Bool { + guard let version = self.schemaVersion else { return false } + + return Self.compatibleSchemaVersions.contains(version) + } + + static let currentSchemaVersion = "3" + + private static let compatibleSchemaVersions: Set = [ + // Version 3 is virtually identical to 2 (only difference is `Codable` vs manual decoding). + "2", + CustomerInfo.currentSchemaVersion + ] + +} + +extension CustomerInfo { + + /// Creates a copy of this ``CustomerInfo`` modifying only the ``VerificationResult`` and the ``OriginalSource``. + func copy( + with entitlementVerification: VerificationResult, + httpResponseOriginalSource: HTTPResponseOriginalSource? + ) -> Self { + let originalSource = OriginalSource(entitlementVerification: entitlementVerification, + httpResponseOriginalSource: httpResponseOriginalSource) + guard entitlementVerification != self.data.entitlementVerification || + originalSource != self.data.originalSource + else { return self } + + var copy = self.data + copy.entitlementVerification = entitlementVerification + copy.originalSource = originalSource ?? .main + return .init(data: copy) + } + + /// Creates a copy of this ``CustomerInfo`` setting the `isLoadedFromCache` flag to `true`. + func loadedFromCache() -> Self { + guard !self.isLoadedFromCache else { return self } + + var copy = self.data + copy.loadedFromCache = true + return .init(data: copy) + } + +} + +extension CustomerInfo: RawDataContainer { + + // Docs inherited from protocol + // swiftlint:disable missing_docs + @objc + public var rawData: [String: Any] { + return self.data.response.rawData + } + +} + +extension CustomerInfo: Sendable {} + +/// `CustomerInfo`'s `Codable` implementation relies on `Data` +extension CustomerInfo: Codable { + + // swiftlint:disable:next missing_docs + public convenience init(from decoder: Decoder) throws { + do { + self.init(data: try Contents(from: decoder)) + } catch { + throw ErrorUtils.customerInfoError(error: error) + } + } + + // swiftlint:disable:next missing_docs + public func encode(to encoder: Encoder) throws { + try self.data.encode(to: encoder) + } + +} + +extension CustomerInfo: HTTPResponseBody { + + /// Creates a copy of this ``CustomerInfo`` modifying only the `requestDate`. + func copy(with newRequestDate: Date) -> CustomerInfo { + Logger.verbose(Strings.customerInfo.updating_request_date(self, newRequestDate)) + + var copy = self.data + copy.response.requestDate = newRequestDate + return .init(data: copy) + } + +} + +// MARK: - + +extension CustomerInfo: Identifiable { + + // swiftlint:disable:next missing_docs + public var id: String { + return self.originalAppUserId + } + +} + +// MARK: - Private + +private extension CustomerInfo { + + /// The actual contents of a ``CustomerInfo``: the response with the associated version and other metadata. + struct Contents { + + var response: CustomerInfoResponse + var entitlementVerification: VerificationResult + var schemaVersion: String? + var originalSource: CustomerInfo.OriginalSource + var loadedFromCache: Bool = false + + init(response: CustomerInfoResponse, + entitlementVerification: VerificationResult, + schemaVersion: String?, + originalSource: CustomerInfo.OriginalSource) { + self.response = response + self.entitlementVerification = entitlementVerification + self.schemaVersion = schemaVersion + self.originalSource = originalSource + } + + } + +} + +/// `Codable` implementation that puts the content of`response`, `schemaVersion` and `originalSource` +/// at the same level instead of nested. +extension CustomerInfo.Contents: Codable { + + private enum CodingKeys: String, CodingKey { + + case response + case entitlementVerification + case schemaVersion + case originalSource + + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try self.response.encode(to: encoder) + try container.encode(self.entitlementVerification, forKey: .entitlementVerification) + // Always use current schema version when encoding + try container.encode(CustomerInfo.currentSchemaVersion, forKey: .schemaVersion) + try container.encode(self.originalSource, forKey: .originalSource) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.response = try CustomerInfoResponse(from: decoder) + self.entitlementVerification = try container.decodeIfPresent( + VerificationResult.self, + forKey: .entitlementVerification + ) ?? .notRequested + self.schemaVersion = try container.decodeIfPresent(String.self, forKey: .schemaVersion) + self.originalSource = try container.decodeIfPresent(CustomerInfo.OriginalSource.self, + forKey: .originalSource) ?? .main + } + +} + +extension CustomerInfo { + + /// Internal enum representing the original source of the ``CustomerInfo`` object. + enum OriginalSource: String, Codable { + /// Main server + case main + + /// Load shedder server + case loadShedder = "load_shedder" + + /// Computed on device from offline entitlements + case offlineEntitlements = "offline_entitlements" + + init?(entitlementVerification: VerificationResult, httpResponseOriginalSource: HTTPResponseOriginalSource?) { + if entitlementVerification == .verifiedOnDevice { + self = .offlineEntitlements + } else { + switch httpResponseOriginalSource { + case .mainServer: + self = .main + case .loadShedder: + self = .loadShedder + case .fallbackUrl: + return nil // CustomerInfo not supported from fallback URL + case .none: + return nil + } + } + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Identity/CustomerInfoManager.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Identity/CustomerInfoManager.swift new file mode 100644 index 00000000..dc0f4868 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Identity/CustomerInfoManager.swift @@ -0,0 +1,711 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CustomerInfoManager.swift +// +// Created by Joshua Liebowitz on 8/5/21. + +import Foundation + +// swiftlint:disable file_length +// swiftlint:disable:next type_body_length +class CustomerInfoManager { + + typealias CustomerInfoCompletion = @MainActor @Sendable (Result) -> Void + + private let offlineEntitlementsManager: OfflineEntitlementsManager + private let operationDispatcher: OperationDispatcher + private let backend: Backend + private let deviceCache: DeviceCache + private let systemInfo: SystemInfo + private let transactionFetcher: StoreKit2TransactionFetcherType + private let transactionPoster: TransactionPosterType + + private var diagnosticsTracker: DiagnosticsTrackerType? + private let dateProvider: DateProvider + + /// Underlying synchronized data for in-memory-only mutable state. + private let data: Atomic + + init(offlineEntitlementsManager: OfflineEntitlementsManager, + operationDispatcher: OperationDispatcher, + deviceCache: DeviceCache, + backend: Backend, + transactionFetcher: StoreKit2TransactionFetcherType, + transactionPoster: TransactionPosterType, + systemInfo: SystemInfo, + dateProvider: DateProvider = DateProvider() + ) { + self.offlineEntitlementsManager = offlineEntitlementsManager + self.operationDispatcher = operationDispatcher + self.backend = backend + self.transactionFetcher = transactionFetcher + self.transactionPoster = transactionPoster + self.systemInfo = systemInfo + self.dateProvider = dateProvider + self.deviceCache = deviceCache + + self.data = .init(.init()) + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + convenience init(offlineEntitlementsManager: OfflineEntitlementsManager, + operationDispatcher: OperationDispatcher, + deviceCache: DeviceCache, + backend: Backend, + transactionFetcher: StoreKit2TransactionFetcherType, + transactionPoster: TransactionPosterType, + systemInfo: SystemInfo, + diagnosticsTracker: DiagnosticsTrackerType?, + dateProvider: DateProvider = DateProvider() + ) { + self.init(offlineEntitlementsManager: offlineEntitlementsManager, + operationDispatcher: operationDispatcher, + deviceCache: deviceCache, + backend: backend, + transactionFetcher: transactionFetcher, + transactionPoster: transactionPoster, + systemInfo: systemInfo, + dateProvider: dateProvider) + self.diagnosticsTracker = diagnosticsTracker + } + + func fetchAndCacheCustomerInfo(appUserID: String, + isAppBackgrounded: Bool, + completion: CustomerInfoCompletion?) { + let mappedCompetion: CustomerInfoDataCompletion? + if let completion { + mappedCompetion = { customerInfoData in + completion(customerInfoData.result) + } + } else { + mappedCompetion = nil + } + self.fetchAndCacheCustomerInfoData(appUserID: appUserID, + isAppBackgrounded: isAppBackgrounded, + completion: mappedCompetion) + } + + func fetchAndCacheCustomerInfoIfStale(appUserID: String, + isAppBackgrounded: Bool, + completion: CustomerInfoCompletion?) { + let mappedCompetion: CustomerInfoDataCompletion? + if let completion { + mappedCompetion = { customerInfoData in + completion(customerInfoData.result) + } + } else { + mappedCompetion = nil + } + self.fetchAndCacheCustomerInfoDataIfStale(appUserID: appUserID, + isAppBackgrounded: isAppBackgrounded, + completion: mappedCompetion) + } + + // swiftlint:disable:next function_body_length + func customerInfo( + appUserID: String, + fetchPolicy: CacheFetchPolicy, + trackDiagnostics: Bool = false, + completion: CustomerInfoCompletion? + ) { + self.trackGetCustomerInfoStartedIfNeeded(trackDiagnostics: trackDiagnostics) + let startTime = self.dateProvider.now() + + switch fetchPolicy { + case .fromCacheOnly: + self.operationDispatcher.dispatchOnMainActor { + let result = Result { try self.cachedCustomerInfo(appUserID: appUserID) } + + // We want the specific error for diagnostics + let resultForDiagnostics = Result(result.value as? CustomerInfo, + result.error ?? BackendError.missingCachedCustomerInfo()) + self.trackGetCustomerInfoResultIfNeeded(trackDiagnostics: trackDiagnostics, + startTime: startTime, + cacheFetchPolicy: fetchPolicy, + hadUnsyncedPurchasesBefore: nil, + usedOfflineEntitlements: false, + result: resultForDiagnostics) + + // But for callers we only pass `.missingCachedCustomerInfo()` error + completion?( + Result(result.value as? CustomerInfo, .missingCachedCustomerInfo()) + ) + } + + case .fetchCurrent: + self.systemInfo.isApplicationBackgrounded { isAppBackgrounded in + self.fetchAndCacheCustomerInfoData( + appUserID: appUserID, + isAppBackgrounded: isAppBackgrounded + ) { [weak self] customerInfoData in + self?.trackGetCustomerInfoResultIfNeeded( + trackDiagnostics: trackDiagnostics, + startTime: startTime, + cacheFetchPolicy: fetchPolicy, + customerInfoDataResult: customerInfoData) + completion?(customerInfoData.result) + } + } + + case .cachedOrFetched: + let completionCalled: Bool + if let infoFromCache = try? self.cachedCustomerInfo(appUserID: appUserID) { + Logger.debug(Strings.customerInfo.vending_cache) + completionCalled = true + + self.trackGetCustomerInfoResultIfNeeded(trackDiagnostics: trackDiagnostics, + startTime: startTime, + cacheFetchPolicy: fetchPolicy, + hadUnsyncedPurchasesBefore: nil, + usedOfflineEntitlements: false, + result: .success(infoFromCache)) + if let completion = completion { + self.operationDispatcher.dispatchOnMainActor { + completion(.success(infoFromCache)) + } + } + } else { + completionCalled = false + } + + // Prevent calling completion twice. + let completionIfNotCalledAlready: CustomerInfoDataCompletion? + if completionCalled { + completionIfNotCalledAlready = nil + } else { + completionIfNotCalledAlready = { [weak self] customerInfoData in + self?.trackGetCustomerInfoResultIfNeeded( + // Only track diagnostics upon calling completion + trackDiagnostics: trackDiagnostics && !completionCalled, + startTime: startTime, + cacheFetchPolicy: fetchPolicy, + customerInfoDataResult: customerInfoData) + completion?(customerInfoData.result) + } + } + + self.systemInfo.isApplicationBackgrounded { isAppBackgrounded in + self.fetchAndCacheCustomerInfoDataIfStale(appUserID: appUserID, + isAppBackgrounded: isAppBackgrounded, + completion: completionIfNotCalledAlready) + } + + case .notStaleCachedOrFetched: + let infoFromCache = try? self.cachedCustomerInfo(appUserID: appUserID) + + self.systemInfo.isApplicationBackgrounded { isAppBackgrounded in + let isCacheStale = self.deviceCache.isCustomerInfoCacheStale( + appUserID: appUserID, + isAppBackgrounded: isAppBackgrounded + ) + + if let infoFromCache = infoFromCache, !isCacheStale { + Logger.debug(Strings.customerInfo.vending_cache) + self.trackGetCustomerInfoResultIfNeeded(trackDiagnostics: trackDiagnostics, + startTime: startTime, + cacheFetchPolicy: fetchPolicy, + hadUnsyncedPurchasesBefore: nil, + usedOfflineEntitlements: false, + result: .success(infoFromCache)) + if let completion = completion { + self.operationDispatcher.dispatchOnMainActor { + completion(.success(infoFromCache)) + } + } + } else { + self.fetchAndCacheCustomerInfoData( + appUserID: appUserID, + isAppBackgrounded: isAppBackgrounded + ) { [weak self] customerInfoData in + self?.trackGetCustomerInfoResultIfNeeded( + trackDiagnostics: trackDiagnostics, + startTime: startTime, + cacheFetchPolicy: fetchPolicy, + customerInfoDataResult: customerInfoData) + completion?(customerInfoData.result) + } + } + } + } + } + + func cachedCustomerInfo(appUserID: String) throws -> CustomerInfo? { + guard !self.systemInfo.dangerousSettings.uiPreviewMode else { + return Self.createPreviewCustomerInfo() + } + + let cachedCustomerInfoData = self.deviceCache.cachedCustomerInfoData(appUserID: appUserID) + guard let customerInfoData = cachedCustomerInfoData else { return nil } + + do { + let info: CustomerInfo = try JSONDecoder.default.decode(jsonData: customerInfoData) + + if info.schemaVersionIsCompatible { + return info.loadedFromCache() + } else { + let msg = Strings.customerInfo.cached_customerinfo_incompatible_schema.description + throw ErrorUtils.customerInfoError(withMessage: msg) + } + } catch { + Logger.error("Error loading customer info from cache:\n \(error.localizedDescription)") + throw error + } + } + + func cache(customerInfo: CustomerInfo, appUserID: String) { + guard !self.systemInfo.dangerousSettings.uiPreviewMode else { + return + } + + if customerInfo.shouldCache { + do { + let jsonData = try JSONEncoder.default.encode(customerInfo) + self.deviceCache.cache(customerInfo: jsonData, appUserID: appUserID) + } catch { + Logger.error(Strings.customerInfo.error_encoding_customerinfo(error)) + } + } else { + Logger.debug(Strings.customerInfo.not_caching_offline_customer_info) + self.clearCustomerInfoCache(forAppUserID: appUserID) + } + + self.sendUpdateIfChanged(customerInfo: customerInfo) + } + + func clearCustomerInfoCache(forAppUserID appUserID: String) { + self.deviceCache.clearCustomerInfoCache(appUserID: appUserID) + } + + func setLastSentCustomerInfo(_ info: CustomerInfo) { + self.modifyData { + $0.lastSentCustomerInfo = info + } + } + + var customerInfoStream: AsyncStream { + return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + if let lastSentCustomerInfo = self.lastSentCustomerInfo { + continuation.yield(lastSentCustomerInfo) + } + + let disposable = self.monitorChanges { _, new in continuation.yield(new) } + + continuation.onTermination = { @Sendable _ in disposable() } + } + } + + typealias CustomerInfoChangeClosure = (_ old: CustomerInfo?, _ new: CustomerInfo) -> Void + + /// Allows monitoring changes to the active `CustomerInfo`. + /// - Returns: closure that removes the created observation. + func monitorChanges(_ changes: @escaping CustomerInfoChangeClosure) -> () -> Void { + self.modifyData { + let lastIdentifier = $0.customerInfoObserversByIdentifier.keys + .sorted() + .last + let nextIdentifier = lastIdentifier + .map { $0 + 1 } // Next index + ?? 0 // Or default to 0 + + $0.customerInfoObserversByIdentifier[nextIdentifier] = changes + + return { [weak self] in + self?.removeObserver(with: nextIdentifier) + } + } + } + + // Visible for tests + var lastSentCustomerInfo: CustomerInfo? { return self.data.value.lastSentCustomerInfo } + + private func removeObserver(with identifier: Int) { + self.modifyData { + $0.customerInfoObserversByIdentifier.removeValue(forKey: identifier) + } + } + + private func sendUpdateIfChanged(customerInfo: CustomerInfo) { + return self.modifyData { + let lastSentCustomerInfo = $0.lastSentCustomerInfo + + if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) { + if let tracker = self.diagnosticsTracker, lastSentCustomerInfo != customerInfo { + tracker.trackCustomerInfoVerificationResultIfNeeded(customerInfo) + } + } + + guard !$0.customerInfoObserversByIdentifier.isEmpty, lastSentCustomerInfo != customerInfo else { + return + } + + if $0.lastSentCustomerInfo != nil { + Logger.debug(Strings.customerInfo.sending_updated_customerinfo_to_delegate) + } else { + Logger.debug(Strings.customerInfo.sending_latest_customerinfo_to_delegate) + } + + $0.lastSentCustomerInfo = customerInfo + + // This must be async to prevent deadlocks if the observer calls a method that ends up reading + // this class' data. By making it async, the closure is invoked outside of the lock. + self.operationDispatcher.dispatchAsyncOnMainThread { [observers = $0.customerInfoObserversByIdentifier] in + for closure in observers.values { + closure(lastSentCustomerInfo, customerInfo) + } + } + } + } + +} + +// MARK: - async extensions + +extension CustomerInfoManager { + + func fetchAndCacheCustomerInfo(appUserID: String, isAppBackgrounded: Bool) async throws -> CustomerInfo { + return try await Async.call { completion in + return self.fetchAndCacheCustomerInfo(appUserID: appUserID, + isAppBackgrounded: isAppBackgrounded, + completion: completion) + } + } + + func customerInfo( + appUserID: String, + trackDiagnostics: Bool = false, + fetchPolicy: CacheFetchPolicy + ) async throws -> CustomerInfo { + return try await Async.call { completion in + return self.customerInfo(appUserID: appUserID, + fetchPolicy: fetchPolicy, + trackDiagnostics: trackDiagnostics, + completion: completion) + } + } + +} + +// MARK: - + +private extension CustomerInfoManager { + + private typealias CustomerInfoDataCompletion = @MainActor @Sendable (CustomerInfoDataResult) -> Void + + /// Wrapper around `Result` to hold some additional information + /// useful for diagnostics. + private struct CustomerInfoDataResult { + let result: Result + let hadUnsyncedPurchasesBefore: Bool + let usedOfflineEntitlements: Bool + + init(result: Result, + hadUnsyncedPurchasesBefore: Bool = false, + usedOfflineEntitlements: Bool = false) { + self.result = result + self.hadUnsyncedPurchasesBefore = hadUnsyncedPurchasesBefore + self.usedOfflineEntitlements = usedOfflineEntitlements + } + + } + + private func getCustomerInfoData(appUserID: String, + isAppBackgrounded: Bool, + completion: @escaping @Sendable (CustomerInfoDataResult) -> Void) { + guard !self.systemInfo.dangerousSettings.uiPreviewMode else { + let previewCustomerInfo = Self.createPreviewCustomerInfo() + completion(CustomerInfoDataResult(result: .success(previewCustomerInfo))) + return + } + if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) { + _ = Task { + let transactions = await self.transactionFetcher.unfinishedVerifiedTransactions + + if let transactionToPost = transactions.first { + Logger.debug( + Strings.customerInfo.posting_transactions_in_lieu_of_fetching_customerinfo(transactions) + ) + + let transactionData = PurchasedTransactionData( + presentedOfferingContext: nil, + unsyncedAttributes: [:], + storeCountry: await Storefront.currentStorefront?.countryCode + ) + + // Post everything but the first transaction in the background + // in parallel so they can be de-duped + let otherTransactionsToPostInParalel = Array(transactions.dropFirst()) + Task.detached(priority: .background) { + await self.postTransactions( + otherTransactionsToPostInParalel, + transactionData, + postReceiptSource: Self.sourceForUnfinishedTransaction, + appUserID: appUserID + ) + } + + // Return the result of posting the first transaction. + // The posted receipt will include the content of every other transaction + // so we don't need to wait for those. + let result = await self.transactionPoster.handlePurchasedTransaction( + transactionToPost, + data: transactionData, + postReceiptSource: Self.sourceForUnfinishedTransaction, + currentUserID: appUserID + ) + completion(CustomerInfoDataResult(result: result, hadUnsyncedPurchasesBefore: true)) + } else { + self.requestCustomerInfo(appUserID: appUserID, + isAppBackgrounded: isAppBackgrounded) { result in + completion(CustomerInfoDataResult(result: result, hadUnsyncedPurchasesBefore: false)) + } + } + } + } else { + return self.requestCustomerInfo(appUserID: appUserID, + isAppBackgrounded: isAppBackgrounded) { result in + completion(CustomerInfoDataResult(result: result)) + } + } + } + + private func requestCustomerInfo(appUserID: String, + isAppBackgrounded: Bool, + completion: @escaping CustomerAPI.CustomerInfoResponseHandler) { + let allowComputingOffline = self.offlineEntitlementsManager.shouldComputeOfflineCustomerInfo( + appUserID: appUserID + ) + + self.backend.getCustomerInfo(appUserID: appUserID, + isAppBackgrounded: isAppBackgrounded, + allowComputingOffline: allowComputingOffline, + completion: completion) + } + + private func fetchAndCacheCustomerInfoData(appUserID: String, + isAppBackgrounded: Bool, + completion: CustomerInfoDataCompletion?) { + self.getCustomerInfoData(appUserID: appUserID, + isAppBackgrounded: isAppBackgrounded) { customerInfoDataResult in + switch customerInfoDataResult.result { + case let .failure(error): + self.deviceCache.clearCustomerInfoCacheTimestamp(appUserID: appUserID) + Logger.warn(Strings.customerInfo.customerinfo_updated_from_network_error(error)) + + case let .success(info): + self.cache(customerInfo: info, appUserID: appUserID) + Logger.rcSuccess( + info.isComputedOffline + ? Strings.customerInfo.customerinfo_updated_offline + : Strings.customerInfo.customerinfo_updated_from_network + ) + } + + if let completion = completion { + self.operationDispatcher.dispatchOnMainActor { + completion(customerInfoDataResult) + } + } + } + } + + private func fetchAndCacheCustomerInfoDataIfStale(appUserID: String, + isAppBackgrounded: Bool, + completion: CustomerInfoDataCompletion?) { + let isCacheStale = self.deviceCache.isCustomerInfoCacheStale( + appUserID: appUserID, + isAppBackgrounded: isAppBackgrounded + ) + + guard !isCacheStale, let customerInfo = try? self.cachedCustomerInfo(appUserID: appUserID) else { + Logger.debug(isAppBackgrounded + ? Strings.customerInfo.customerinfo_stale_updating_in_background + : Strings.customerInfo.customerinfo_stale_updating_in_foreground) + self.fetchAndCacheCustomerInfoData(appUserID: appUserID, + isAppBackgrounded: isAppBackgrounded, + completion: completion) + return + } + + if let completion = completion { + self.operationDispatcher.dispatchOnMainActor { + completion(CustomerInfoDataResult(result: .success(customerInfo))) + } + } + } + + /// Posts all `transactions` in parallel. + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + private func postTransactions( + _ transactions: [StoreTransaction], + _ data: PurchasedTransactionData, + postReceiptSource: PostReceiptSource, + appUserID: String + ) async { + await withTaskGroup(of: Void.self) { group in + for transaction in transactions { + group.addTask { + _ = await self.transactionPoster.handlePurchasedTransaction( + transaction, + data: data, + postReceiptSource: postReceiptSource, + currentUserID: appUserID + ) + } + } + } + } + + // Note: this is just a best guess. + private static let sourceForUnfinishedTransaction: PostReceiptSource = .init( + isRestore: false, + // This might have been in theory a `.purchase`. The only downside of this is that the server + // won't validate that the product is present in the receipt. + initiationSource: .queue + ) +} + +// MARK: - For UI Preview mode + +extension CustomerInfoManager { + + /// Generates a dummy `CustomerInfo` with hardcoded information exclusively for UI Preview mode. + static func createPreviewCustomerInfo() -> CustomerInfo { + let previewSubscriber = CustomerInfoResponse.Subscriber( + originalAppUserId: IdentityManager.uiPreviewModeAppUserID, + firstSeen: Date(), + subscriptions: [:], + nonSubscriptions: [:], + entitlements: [:] + ) + let previewCustomerInfoResponse = CustomerInfoResponse(subscriber: previewSubscriber, + requestDate: Date(), + rawData: [:]) + let previewCustomerInfo = CustomerInfo(response: previewCustomerInfoResponse, + entitlementVerification: .verified, + sandboxEnvironmentDetector: BundleSandboxEnvironmentDetector.default, + httpResponseOriginalSource: .mainServer) + return previewCustomerInfo + } + +} + +// MARK: - Diagnostics + +private extension CustomerInfoManager { + + private func trackGetCustomerInfoStartedIfNeeded(trackDiagnostics: Bool) { + if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *), trackDiagnostics { + self.diagnosticsTracker?.trackGetCustomerInfoStarted() + } + } + + private func trackGetCustomerInfoResultIfNeeded(trackDiagnostics: Bool, + startTime: Date, + cacheFetchPolicy: CacheFetchPolicy, + customerInfoDataResult: CustomerInfoDataResult) { + self.trackGetCustomerInfoResultIfNeeded( + trackDiagnostics: trackDiagnostics, + startTime: startTime, + cacheFetchPolicy: cacheFetchPolicy, + hadUnsyncedPurchasesBefore: customerInfoDataResult.hadUnsyncedPurchasesBefore, + usedOfflineEntitlements: customerInfoDataResult.usedOfflineEntitlements, + result: customerInfoDataResult.result.mapError({ $0 as Error }) + ) + } + + // swiftlint:disable:next function_parameter_count + private func trackGetCustomerInfoResultIfNeeded(trackDiagnostics: Bool, + startTime: Date, + cacheFetchPolicy: CacheFetchPolicy, + hadUnsyncedPurchasesBefore: Bool?, + usedOfflineEntitlements: Bool, + result: Swift.Result) { + if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *), trackDiagnostics { + + let error: PurchasesError? + switch result.error { + case let purchasesError as PurchasesError: + error = purchasesError + case let purchasesErrorConvertible as PurchasesErrorConvertible: + error = purchasesErrorConvertible.asPurchasesError + case let otherError: + error = otherError.map { PurchasesError(error: .unknownError, userInfo: ($0 as NSError).userInfo) } + } + let customerInfo = result.value + let responseTime = self.dateProvider.now().timeIntervalSince(startTime) + + self.diagnosticsTracker?.trackGetCustomerInfoResult( + cacheFetchPolicy: cacheFetchPolicy, + verificationResult: customerInfo?.entitlements.verification, + hadUnsyncedPurchasesBefore: hadUnsyncedPurchasesBefore, + errorMessage: error?.localizedDescription, + errorCode: error?.errorCode, + responseTime: responseTime + ) + } + } +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension CustomerInfoManager: @unchecked Sendable {} + +// MARK: - + +private extension CustomerInfoManager { + + /// Underlying in-memory mutable data for `CustomerInfoManager`. + /// `DeviceCache` is intentionally **not** stored here to avoid holding this lock + /// during `UserDefaults` I/O, which can deadlock with the main thread. + struct Data { + + var lastSentCustomerInfo: CustomerInfo? + /// Observers keyed by a monotonically increasing identifier. + /// This allows cancelling observations by deleting them from this dictionary. + /// These observers are used both for ``Purchases/customerInfoStream`` and + /// `PurchasesDelegate/purchases(_:receivedUpdated:)``. + var customerInfoObserversByIdentifier: [Int: CustomerInfoManager.CustomerInfoChangeClosure] + + init() { + self.lastSentCustomerInfo = nil + self.customerInfoObserversByIdentifier = [:] + } + + } + + func withData(_ action: (Data) -> Result) -> Result { + return self.data.withValue(action) + } + + @discardableResult + func modifyData(_ action: (inout Data) -> Result) -> Result { + return self.data.modify(action) + } + +} + +private extension CustomerInfo { + + var shouldCache: Bool { + return self.entitlements.verification.shouldCache + } + +} + +private extension VerificationResult { + + var shouldCache: Bool { + switch self { + case .failed, .verified, .notRequested: return true + case .verifiedOnDevice: return false + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Identity/IdentityManager.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Identity/IdentityManager.swift new file mode 100644 index 00000000..2d780242 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Identity/IdentityManager.swift @@ -0,0 +1,217 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// IdentityManager.swift +// +// Created by Joshua Liebowitz on 8/9/21. + +import Foundation + +protocol CurrentUserProvider: Sendable { + + var currentAppUserID: String { get } + var currentUserIsAnonymous: Bool { get } + +} + +protocol AttributeSyncing: Sendable { + + func syncSubscriberAttributes(currentAppUserID: String, completion: @escaping @Sendable () -> Void) +} + +class IdentityManager: CurrentUserProvider { + + private let deviceCache: DeviceCache + private let backend: Backend + private let customerInfoManager: CustomerInfoManager + private let attributeSyncing: AttributeSyncing + + private static let anonymousRegex = #"\$RCAnonymousID:([a-z0-9]{32})$"# + + init( + deviceCache: DeviceCache, + systemInfo: SystemInfo, + backend: Backend, + customerInfoManager: CustomerInfoManager, + attributeSyncing: AttributeSyncing, + appUserID: String? + ) { + self.deviceCache = deviceCache + self.backend = backend + self.customerInfoManager = customerInfoManager + self.attributeSyncing = attributeSyncing + + let finalAppUserID: String + if systemInfo.dangerousSettings.uiPreviewMode { + Logger.debug(Strings.identity.logging_in_with_preview_mode_appuserid) + finalAppUserID = Self.uiPreviewModeAppUserID + } else { + if appUserID?.isEmpty == true { + Logger.warn(Strings.identity.logging_in_with_empty_appuserid) + } + finalAppUserID = appUserID?.notEmptyOrWhitespaces + ?? deviceCache.cachedAppUserID + ?? deviceCache.cachedLegacyAppUserID + ?? Self.generateRandomID() + } + + Logger.user(Strings.identity.identifying_app_user_id) + + deviceCache.cache(appUserID: finalAppUserID) + deviceCache.cleanupSubscriberAttributes() + self.invalidateCachesIfNeeded(appUserID: finalAppUserID) + } + + var currentAppUserID: String { + guard let appUserID = self.deviceCache.cachedAppUserID else { + fatalError(Strings.identity.null_currentappuserid.description) + } + + return appUserID + } + + var currentUserIsAnonymous: Bool { + let userID = self.currentAppUserID + + lazy var currentAppUserIDLooksAnonymous = Self.userIsAnonymous(userID) + lazy var isLegacyAnonymousAppUserID = userID == self.deviceCache.cachedLegacyAppUserID + + return currentAppUserIDLooksAnonymous || isLegacyAnonymousAppUserID + } + + func logIn(appUserID: String, completion: @escaping IdentityAPI.LogInResponseHandler) { + guard self.currentAppUserID != Self.uiPreviewModeAppUserID && appUserID != Self.uiPreviewModeAppUserID else { + completion(.failure(.unsupportedInUIPreviewMode())) + return + } + + self.attributeSyncing.syncSubscriberAttributes(currentAppUserID: self.currentAppUserID) { + self.performLogIn(appUserID: appUserID, completion: completion) + } + } + + func logOut(completion: @escaping (PurchasesError?) -> Void) { + guard self.currentAppUserID != Self.uiPreviewModeAppUserID else { + completion(ErrorUtils.unsupportedInUIPreviewModeError()) + return + } + + self.attributeSyncing.syncSubscriberAttributes(currentAppUserID: self.currentAppUserID) { + self.performLogOut(completion: completion) + } + } + + func switchUser(to newAppUserID: String) { + Logger.debug(Strings.identity.switching_user(newUserID: newAppUserID)) + self.resetCacheAndSave(newUserID: newAppUserID) + } + + static func generateRandomID() -> String { + "$RCAnonymousID:\(UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased())" + } + + static let uiPreviewModeAppUserID: String = "$RC_PREVIEW_MODE_USER" +} + +extension IdentityManager { + + static func userIsAnonymous(_ appUserId: String) -> Bool { + let anonymousFoundRange = appUserId.range(of: IdentityManager.anonymousRegex, + options: .regularExpression) + return anonymousFoundRange != nil + } + +} + +private extension IdentityManager { + + func performLogIn(appUserID: String, completion: @escaping IdentityAPI.LogInResponseHandler) { + let oldAppUserID = self.currentAppUserID + let newAppUserID = appUserID.trimmingWhitespacesAndNewLines + guard !newAppUserID.isEmpty else { + Logger.error(Strings.identity.logging_in_with_empty_appuserid) + completion(.failure(.missingAppUserID())) + return + } + + guard newAppUserID != oldAppUserID else { + Logger.warn(Strings.identity.logging_in_with_same_appuserid) + self.customerInfoManager.customerInfo(appUserID: oldAppUserID, + fetchPolicy: .cachedOrFetched) { @Sendable result in + completion( + result.map { (info: $0, created: false) } + ) + } + return + } + + self.backend.identity.logIn(currentAppUserID: oldAppUserID, newAppUserID: newAppUserID) { result in + if case let .success((customerInfo, _)) = result { + self.deviceCache.clearCaches(oldAppUserID: oldAppUserID, andSaveWithNewUserID: newAppUserID) + self.customerInfoManager.cache(customerInfo: customerInfo, appUserID: newAppUserID) + self.copySubscriberAttributesToNewUserIfOldIsAnonymous(oldAppUserID: oldAppUserID, + newAppUserID: newAppUserID) + } + + completion(result) + } + } + + func performLogOut(completion: (PurchasesError?) -> Void) { + Logger.info(Strings.identity.log_out_called_for_user) + + if self.currentUserIsAnonymous { + completion(ErrorUtils.logOutAnonymousUserError()) + return + } + + self.resetCacheAndSave(newUserID: Self.generateRandomID()) + Logger.info(Strings.identity.log_out_success) + completion(nil) + } +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension IdentityManager: @unchecked Sendable {} + +// MARK: - Private + +private extension IdentityManager { + + func resetCacheAndSave(newUserID: String) { + self.deviceCache.clearCaches(oldAppUserID: currentAppUserID, andSaveWithNewUserID: newUserID) + self.deviceCache.clearLatestNetworkAndAdvertisingIdsSent(appUserID: currentAppUserID) + self.backend.clearHTTPClientCaches() + } + + func copySubscriberAttributesToNewUserIfOldIsAnonymous(oldAppUserID: String, newAppUserID: String) { + guard Self.userIsAnonymous(oldAppUserID) else { + return + } + self.deviceCache.copySubscriberAttributes(oldAppUserID: oldAppUserID, newAppUserID: newAppUserID) + } + + func invalidateCachesIfNeeded(appUserID: String) { + if self.shouldInvalidateCaches(for: appUserID) { + Logger.info(Strings.identity.invalidating_http_cache) + self.backend.clearHTTPClientCaches() + } + } + + private func shouldInvalidateCaches(for appUserID: String) -> Bool { + guard self.backend.signatureVerificationEnabled, + let info = try? self.customerInfoManager.cachedCustomerInfo(appUserID: appUserID) else { + return false + } + + return info.entitlements.verification == .notRequested + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Identity/ProductPaidPrice.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Identity/ProductPaidPrice.swift new file mode 100644 index 00000000..bfc4c241 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Identity/ProductPaidPrice.swift @@ -0,0 +1,81 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ProductPaidPrice.swift +// +// Created by Facundo Menzella on 15/1/25. + +import Foundation + +/// Price paid for the product +@objc(RCProductPaidPrice) public final class ProductPaidPrice: NSObject, Sendable { + + /// Currency paid + @objc public let currency: String + + /// Amount paid + @objc public let amount: Double + + /// Formatted price of the item, including its currency sign. For example $3.00. + @objc public let formatted: String + + private static let formatterCache = NSCache() + + /// ProductPaidPrice initializer (maintains backward compatibility) + /// - Parameters: + /// - currency: Currency paid + /// - amount: Amount paid + public convenience init(currency: String, amount: Double) { + self.init(currency: currency, amount: amount, locale: .current) + } + + /// ProductPaidPrice initialiser + /// - Parameters: + /// - currency: Currency paid + /// - amount: Amount paid + /// - formatted: Formatted price string with currency symbol + init(currency: String, amount: Double, formatted: String) { + self.currency = currency + self.amount = amount + self.formatted = formatted + } + + /// Convenience initializer that formats the price using the provided locale + /// - Parameters: + /// - currency: Currency code (e.g., "USD", "EUR") + /// - amount: Amount as a decimal value + /// - locale: Locale for formatting (defaults to current locale) + public convenience init(currency: String, amount: Double, locale: Locale = .current) { + let formatted = Self.formatPrice(currency: currency, amount: amount, locale: locale) + self.init(currency: currency, amount: amount, formatted: formatted) + } + + /// Formats a price with currency using NumberFormatter + /// - Parameters: + /// - currency: The currency code + /// - amount: The price amount + /// - locale: The locale for formatting + /// - Returns: Formatted price string (e.g., "$3.00", "€7.99") + static func formatPrice(currency: String, amount: Double, locale: Locale = .current) -> String { + let cacheKey = "\(locale.identifier)-\(currency)" as NSString + + let formatter: NumberFormatter + if let cachedFormatter = formatterCache.object(forKey: cacheKey) { + formatter = cachedFormatter + } else { + formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.locale = locale + formatter.currencyCode = currency + formatterCache.setObject(formatter, forKey: cacheKey) + } + + return formatter.string(from: NSNumber(value: amount)) ?? "\(amount)" + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Identity/SubscriptionInfo.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Identity/SubscriptionInfo.swift new file mode 100644 index 00000000..517be739 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Identity/SubscriptionInfo.swift @@ -0,0 +1,152 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SubscriptionInfo.swift +// +// Created by Cesar de la Vega on 21/11/24. + +import Foundation + +/// Subscription purchases of the Customer +@objc(RCSubscriptionInfo) public final class SubscriptionInfo: NSObject { + + /// The product identifier. + @objc public let productIdentifier: ProductIdentifier + + /// Date when the last subscription period started. + @objc public let purchaseDate: Date + + /// Date when this subscription first started. This property does not update with renewals. + /// This property also does not update for product changes within a subscription group or + /// resubscriptions by lapsed subscribers. + @objc public let originalPurchaseDate: Date? + + /// Date when the subscription expires/expired + @objc public let expiresDate: Date? + + /// Store where the subscription was purchased. + @objc public let store: Store + + /// Whether or not the purchase was made in sandbox mode. + @objc public let isSandbox: Bool + + /// Date when RevenueCat detected that auto-renewal was turned off for this subsription. + /// Note the subscription may still be active, check the ``expiresDate`` attribute. + @objc public let unsubscribeDetectedAt: Date? + + /// Date when RevenueCat detected any billing issues with this subscription. + /// If and when the billing issue gets resolved, this field is set to nil. + /// Note the subscription may still be active, check the ``expiresDate`` attribute. + @objc public let billingIssuesDetectedAt: Date? + + /// Date when any grace period for this subscription expires/expired. + /// nil if the customer has never been in a grace period. + @objc public let gracePeriodExpiresDate: Date? + + /// How the Customer received access to this subscription: + /// - ``PurchaseOwnershipType/purchased``: The customer bought the subscription. + /// - ``PurchaseOwnershipType/familyShared``: The Customer has access to the product via their family. + @objc public let ownershipType: PurchaseOwnershipType + + /// Type of the current subscription period: + /// - ``PeriodType/normal``: The product is in a normal period (default) + /// - ``PeriodType/trial``: The product is in a free trial period + /// - ``PeriodType/intro``: The product is in an introductory pricing period + @objc public let periodType: PeriodType + + /// Date when RevenueCat detected a refund of this subscription. + @objc public let refundedAt: Date? + + /// The transaction id in the store of the subscription. + @objc public let storeTransactionId: String? + + /// Whether the subscription is currently active. + @objc public let isActive: Bool + + /// Whether the subscription will renew at the next billing period. + @objc public let willRenew: Bool + + /// The display name of the subscription as configured in the RevenueCat dashboard. + @objc public let displayName: String? + + /// Paid price for the subscription + @objc public let price: ProductPaidPrice? + + /// Management purchase URL + @objc public let managementURL: URL? + + init(productIdentifier: String, + purchaseDate: Date, + originalPurchaseDate: Date?, + expiresDate: Date?, + store: Store, + isSandbox: Bool, + unsubscribeDetectedAt: Date?, + billingIssuesDetectedAt: Date?, + gracePeriodExpiresDate: Date?, + ownershipType: PurchaseOwnershipType, + periodType: PeriodType, + refundedAt: Date?, + storeTransactionId: String?, + requestDate: Date, + price: ProductPaidPrice?, + managementURL: URL?, + displayName: String?) { + self.productIdentifier = productIdentifier + self.purchaseDate = purchaseDate + self.originalPurchaseDate = originalPurchaseDate + self.expiresDate = expiresDate + self.store = store + self.isSandbox = isSandbox + self.unsubscribeDetectedAt = unsubscribeDetectedAt + self.billingIssuesDetectedAt = billingIssuesDetectedAt + self.gracePeriodExpiresDate = gracePeriodExpiresDate + self.ownershipType = ownershipType + self.periodType = periodType + self.refundedAt = refundedAt + self.storeTransactionId = storeTransactionId + self.isActive = CustomerInfo.isDateActive(expirationDate: expiresDate, for: requestDate) + self.willRenew = EntitlementInfo.willRenewWithExpirationDate(expirationDate: expiresDate, + store: store, + unsubscribeDetectedAt: unsubscribeDetectedAt, + billingIssueDetectedAt: billingIssuesDetectedAt, + periodType: periodType) + self.price = price + self.managementURL = managementURL + self.displayName = displayName + + super.init() + } + + public override var description: String { + return """ + SubscriptionInfo { + purchaseDate: \(String(describing: purchaseDate)), + originalPurchaseDate: \(String(describing: originalPurchaseDate)), + expiresDate: \(String(describing: expiresDate)), + store: \(store), + isSandbox: \(isSandbox), + unsubscribeDetectedAt: \(String(describing: unsubscribeDetectedAt)), + billingIssuesDetectedAt: \(String(describing: billingIssuesDetectedAt)), + gracePeriodExpiresDate: \(String(describing: gracePeriodExpiresDate)), + ownershipType: \(ownershipType), + periodType: \(String(describing: periodType)), + refundedAt: \(String(describing: refundedAt)), + storeTransactionId: \(String(describing: storeTransactionId)), + isActive: \(isActive), + willRenew: \(willRenew), + managementURL: \(String(describing: managementURL)), + displayName: \(String(describing: displayName)) + } + """ + } + +} + +extension SubscriptionInfo: Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/BasicTypes/ASN1Container.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/BasicTypes/ASN1Container.swift new file mode 100644 index 00000000..b27a2dbc --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/BasicTypes/ASN1Container.swift @@ -0,0 +1,89 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ASN1Container.swift +// +// Created by Andrés Boedo on 7/28/20. +// + +import Foundation + +enum ASN1Class: UInt8 { + + case universal, application, contextSpecific, `private` + +} + +enum ASN1Identifier: UInt8, CaseIterable { + + case endOfContent = 0 + case boolean = 1 + case integer = 2 + case bitString = 3 + case octetString = 4 + case null = 5 + case objectIdentifier = 6 + case objectDescriptor = 7 + case external = 8 + case real = 9 + case enumerated = 10 + case embeddedPdv = 11 + case utf8String = 12 + case relativeOid = 13 + case sequence = 16 + case set = 17 + case numericString = 18 + case printableString = 19 + case t61String = 20 + case videotexString = 21 + case ia5String = 22 + case utcTime = 23 + case generalizedTime = 24 + case graphicString = 25 + case visibleString = 26 + case generalString = 27 + case universalString = 28 + case characterString = 29 + case bmpString = 30 + +} + +enum ASN1EncodingType: UInt8 { + + case primitive, constructed + +} + +struct ASN1Length: Equatable { + + let value: Int + let bytesUsedForLength: Int + let definition: LengthDefinition + + enum LengthDefinition: Int { + + case definite + case indefinite + + } + +} + +struct ASN1Container: Equatable { + + let containerClass: ASN1Class + let containerIdentifier: ASN1Identifier + let encodingType: ASN1EncodingType + let length: ASN1Length + let internalPayload: ArraySlice + let bytesUsedForIdentifier = 1 + var totalBytesUsed: Int { bytesUsedForIdentifier + length.value + length.bytesUsedForLength } + let internalContainers: [ASN1Container] + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/BasicTypes/ASN1ObjectIdentifier.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/BasicTypes/ASN1ObjectIdentifier.swift new file mode 100644 index 00000000..27611e10 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/BasicTypes/ASN1ObjectIdentifier.swift @@ -0,0 +1,27 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ASN1ObjectIdentifier.swift +// +// Created by Andrés Boedo on 7/29/20. +// + +import Foundation + +// http://www.umich.edu/~x509/ssleay/asn1-oids.html +enum ASN1ObjectIdentifier: String { + + case data = "1.2.840.113549.1.7.1" + case signedData = "1.2.840.113549.1.7.2" + case envelopedData = "1.2.840.113549.1.7.3" + case signedAndEnvelopedData = "1.2.840.113549.1.7.4" + case digestedData = "1.2.840.113549.1.7.5" + case encryptedData = "1.2.840.113549.1.7.6" + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/BasicTypes/AppleReceipt.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/BasicTypes/AppleReceipt.swift new file mode 100644 index 00000000..2a9005ec --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/BasicTypes/AppleReceipt.swift @@ -0,0 +1,144 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// AppleReceipt.swift +// +// Created by Andrés Boedo on 7/22/20. +// + +import Foundation + +/// The contents of a parsed IAP receipt parsed by ``PurchasesReceiptParser``. +/// - Seealso: [the official documentation](https://rev.cat/apple-receipt-fields). +public struct AppleReceipt: Equatable { + + /// The receipt's environment. + public let environment: Environment + + /// The app's bundle identifier. + /// This corresponds to the value of `CFBundleIdentifier` in the `Info.plist` file. + /// Use this value to validate if the receipt was indeed generated for your app. + public let bundleId: String + + /// The app's version number. + /// This corresponds to the value of `CFBundleVersion` (in `iOS`) + /// or `CFBundleShortVersionString` (in `macOS`) in the `Info.plist`. + public let applicationVersion: String + + /// The version of the app that was originally purchased. + /// This corresponds to the value of `CFBundleVersion` (in `iOS`) + /// or `CFBundleShortVersionString` (in `macOS`) in the `Info.plist` file + /// when the purchase was originally made. + /// In the sandbox environment, the value of this field is always “1.0”. + public let originalApplicationVersion: String? + + /// An opaque value used, with other data, to compute the SHA-1 hash during validation. + public let opaqueValue: Data + + /// A SHA-1 hash, used to validate the receipt. + public let sha1Hash: Data + + /// The date when the app receipt was created. + /// When validating a receipt, use this date to validate the receipt’s signature. + /// + /// - Note: Many cryptographic libraries default to using the device’s current time and date when validating + /// a PKCS7 package, but this may not produce the correct results when validating a receipt’s signature. + /// For example, if the receipt was signed with a valid certificate, but the certificate has since expired, + /// using the device’s current date incorrectly returns an invalid result. + /// Therefore, make sure your app always uses the date from + /// the Receipt Creation Date field to validate the receipt’s signature. + public let creationDate: Date + + /// The date that the app receipt expires. + /// This key is present only for apps purchased through the Volume Purchase Program. + /// If this key is not present, the receipt does not expire. + /// When validating a receipt, compare this date to the current date to determine whether the receipt is expired. + /// Do not try to use this date to calculate any other information, such as the time remaining before expiration. + public let expirationDate: Date? + + /// Individual purchases contained in this receipt. + public let inAppPurchases: [InAppPurchase] + +} + +extension AppleReceipt { + + /// The server environment a receipt belongs to. + public enum Environment: String { + /// Apps downloaded from the App Store + case production = "Production" + + /// Development build or downloaded from TestFlight + case sandbox = "ProductionSandbox" + + /// StoreKit Testing in Xcode + case xcode = "Xcode" + + /// Unknown environment + case unknown = "Unknown" + } + +} + +extension AppleReceipt: Sendable {} +extension AppleReceipt.Environment: Sendable {} + +// MARK: - Extensions + +extension AppleReceipt { + + var activeSubscriptionsProductIdentifiers: Set { + return Set( + self.inAppPurchases + .lazy + .filter(\.isActiveSubscription) + .map(\.productId) + ) + } + + var expiredTrialProductIdentifiers: Set { + return Set( + self.inAppPurchases + .lazy + .filter(\.isExpiredSubscription) + .filter { $0.isInIntroOfferPeriod == true || $0.isInTrialPeriod == true } + .map(\.productId) + ) + } + + func containsActivePurchase(forProductIdentifier identifier: String) -> Bool { + return ( + self.inAppPurchases.contains { $0.isActiveSubscription } || + self.inAppPurchases.contains { !$0.isSubscription && $0.productId == identifier } + ) + } + + /// Returns the most recent subscription (see `InAppPurchase.isActiveSubscription`). + var mostRecentActiveSubscription: InAppPurchase? { + return self.inAppPurchases + .lazy + .filter { $0.isActiveSubscription } + .min { $0.purchaseDate > $1.purchaseDate } + } + +} + +// MARK: - Conformances + +extension AppleReceipt: Codable {} +extension AppleReceipt.Environment: Codable {} + +extension AppleReceipt: CustomDebugStringConvertible { + + /// swiftlint:disable:next missing_docs + public var debugDescription: String { + return (try? self.encodedJSON) ?? "" + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/BasicTypes/InAppPurchase.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/BasicTypes/InAppPurchase.swift new file mode 100644 index 00000000..b7fe9ae3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/BasicTypes/InAppPurchase.swift @@ -0,0 +1,208 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// InAppPurchase.swift +// +// Created by Andrés Boedo on 7/29/20. +// + +import Foundation + +extension AppleReceipt { + + /// An individual purchase inside a receipt. + public struct InAppPurchase: Equatable { + + /// The number of items purchased. + /// + /// This value corresponds to the `quantity` property of the `SKPayment` object + /// stored in the transaction’s payment property. + public let quantity: Int + + /// The product identifier of the item that was purchased. + /// + /// This value corresponds to the `productIdentifier` property of the `SKPayment` object + /// stored in the transaction’s payment property. + public let productId: String + + /// The transaction identifier of the item that was purchased. + /// + /// This value corresponds to the transaction’s `transactionIdentifier` property. + /// For a transaction that restores a previous transaction, + /// this value is different from the transaction identifier + /// of the original purchase transaction. + /// In an auto-renewable subscription receipt, a new value for the transaction identifier is generated + /// every time the subscription automatically renews or is restored on a new device. + public let transactionId: String + + /// For a transaction that restores a previous transaction, the transaction identifier + /// of the original transaction. + /// Otherwise, identical to the transaction identifier. + /// + /// This value corresponds to the original transaction’s `transactionIdentifier` property. + /// This value is the same for all receipts that have been generated for a specific subscription. + /// This value is useful for relating together multiple iOS 6 style transaction receipts + /// for the same individual customer’s subscription. + public let originalTransactionId: String? + + /// The type of product that this purchase represents. + public let productType: ProductType + + /// The date and time that the item was purchased. + /// + /// This value corresponds to the transaction’s `transactionDate` property. + /// For a transaction that restores a previous transaction, the purchase date is + /// the same as the original purchase date. + /// Use Original Purchase Date to get the date of the original transaction. + /// In an auto-renewable subscription receipt, the purchase date is the date when the subscription was either + /// purchased or renewed (with or without a lapse). + /// For an automatic renewal that occurs on the expiration date of the current period, + /// the purchase date is the start date of the next period, + /// which is identical to the end date of the current period. + public let purchaseDate: Date + + /// For a transaction that restores a previous transaction, the date of the original transaction. + /// + /// This value corresponds to the original transaction’s `transactionDate` property. + /// In an auto-renewable subscription receipt, this indicates the beginning of the subscription period, + /// even if the subscription has been renewed. + public let originalPurchaseDate: Date? + + /// The expiration date for the subscription. + /// + /// This is only present for auto-renewable subscription receipts. + /// Use this value to identify the date when the subscription will renew or expire, to determine if a customer + /// should have access to content or service. + /// After validating the latest receipt, if the subscription expiration date + /// for the latest renewal transaction is a past date, it is safe to assume that the subscription has expired. + public let expiresDate: Date? + + /// For a transaction that was canceled by Apple customer support, the time and date of the cancellation. + /// For an auto-renewable subscription plan that was upgraded, the time and date of the upgrade transaction. + /// Treat a canceled receipt the same as if no purchase had ever been made. + /// + /// - Note: A canceled in-app purchase remains in the receipt indefinitely. + /// Only applicable if the refund was for a non-consumable product, an auto-renewable subscription, + /// a non-renewing subscription, or for a free subscription. + public let cancellationDate: Date? + + /// For a subscription, whether or not it is in the free trial period. + /// + /// This is only present for auto-renewable subscription receipts. + /// `true` if the customer’s subscription is currently in the free trial period, or `false` if not. + /// + /// - Note: If a previous subscription period in the receipt has the value `true` for either + /// the ``isInTrialPeriod`` or the ``isInIntroOfferPeriod`` key, + /// the user is not eligible for a free trial or introductory price within that subscription group. + public let isInTrialPeriod: Bool? + + /// For an auto-renewable subscription, whether or not it is in the introductory price period. + /// + /// This is only present for auto-renewable subscription receipts. + /// The value for this key is `true` if the customer’s subscription is currently in + /// an introductory price period, or `false` if not. + /// + /// - Note: If a previous subscription period in the receipt has the value `true` for either + /// the is``isInTrialPeriod`` or the ``isInIntroOfferPeriod`` key, the user is not eligible for + /// a free trial or introductory price within that subscription group. + public let isInIntroOfferPeriod: Bool? + + /// The primary key for identifying subscription purchases. + /// + /// This value is a unique ID that identifies purchase events across devices, + /// including subscription renewal purchase events. + public let webOrderLineItemId: Int64? + + /// The identifier for the ``PromotionalOffer`` used when purchasing this item. + public let promotionalOfferIdentifier: String? + + } + +} + +extension AppleReceipt.InAppPurchase { + + var isActiveSubscription: Bool { + guard self.isSubscription, let expiration = self.expiresDate else { return false } + + return expiration >= Date() + } + + var isExpiredSubscription: Bool { + guard self.isSubscription, let expiration = self.expiresDate else { return false } + + return expiration < Date() + } + + var purchaseDateEqualsExpiration: Bool { + guard self.isSubscription, let expiration = self.expiresDate else { return false } + + return abs(self.purchaseDate.timeIntervalSince(expiration)) <= Self.purchaseAndExpirationEqualThreshold + } + + /// Seconds between purchase and expiration to consider both equal. + /// 5 provides some margin for error, while still covering the shortest possible + /// subscription length (weekly subscriptions with `TimeRate.monthlyRenewalEveryThirtySeconds`. + private static let purchaseAndExpirationEqualThreshold: TimeInterval = 5 + +} + +extension AppleReceipt.InAppPurchase { + + /// The type of product that a ``AppleReceipt/InAppPurchase`` represents. + public enum ProductType: Int { + + /// Unable to determine product type. + case unknown = -1 + + /// A non-consumable in-app purchase. + case nonConsumable = 0 + + /// A consumable in-app purchase. + case consumable = 1 + + /// A non-renewing subscription. + case nonRenewingSubscription = 2 + + /// An auto-renewable subscription. + case autoRenewableSubscription = 3 + + } + +} + +extension AppleReceipt.InAppPurchase { + + var isSubscription: Bool { + switch self.productType { + case .unknown: return self.expiresDate != nil + case .nonConsumable, .consumable: return false + case .nonRenewingSubscription, .autoRenewableSubscription: return true + } + } + +} + +// MARK: - + +extension AppleReceipt.InAppPurchase.ProductType: Sendable {} + +extension AppleReceipt.InAppPurchase: Sendable {} + +extension AppleReceipt.InAppPurchase.ProductType: Codable {} +extension AppleReceipt.InAppPurchase: Codable {} + +extension AppleReceipt.InAppPurchase: CustomDebugStringConvertible { + + // swiftlint:disable:next missing_docs + public var debugDescription: String { + return (try? self.prettyPrintedJSON) ?? "" + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Builders/ASN1ContainerBuilder.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Builders/ASN1ContainerBuilder.swift new file mode 100644 index 00000000..ff92ce6a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Builders/ASN1ContainerBuilder.swift @@ -0,0 +1,172 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ASN1ContainerBuilder.swift +// +// Created by Andrés Boedo on 7/29/20. +// + +import Foundation + +class ASN1ContainerBuilder { + + func build(fromPayload payload: ArraySlice) throws -> ASN1Container { + guard payload.count >= 2, + let firstByte = payload.first else { + throw PurchasesReceiptParser.Error.asn1ParsingError( + description: "payload needs to be at least 2 bytes long" + ) + } + let containerClass = try extractClass(byte: firstByte) + let encodingType = try extractEncodingType(byte: firstByte) + let containerIdentifier = try extractIdentifier(byte: firstByte) + let isConstructed = encodingType == .constructed + let (length, internalContainers) = try extractLengthAndInternalContainers(data: payload.dropFirst(), + isConstructed: isConstructed) + let bytesUsedForIdentifier = 1 + let bytesUsedForMetadata = bytesUsedForIdentifier + length.bytesUsedForLength + + guard payload.count - bytesUsedForMetadata >= length.value else { + throw PurchasesReceiptParser.Error.asn1ParsingError(description: "payload is shorter than length value") + } + let internalPayload = payload.dropFirst(bytesUsedForMetadata).prefix(length.value) + + return ASN1Container(containerClass: containerClass, + containerIdentifier: containerIdentifier, + encodingType: encodingType, + length: length, + internalPayload: internalPayload, + internalContainers: internalContainers) + } +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension ASN1ContainerBuilder: @unchecked Sendable {} + +private extension ASN1ContainerBuilder { + + func buildInternalContainers(payload: ArraySlice) throws -> [ASN1Container] { + var internalContainers = [ASN1Container]() + var currentPayload = payload + while currentPayload.count > 0 { + let internalContainer = try build(fromPayload: currentPayload) + internalContainers.append(internalContainer) + if internalContainer.containerIdentifier == .endOfContent { + break + } + currentPayload = currentPayload.dropFirst(internalContainer.totalBytesUsed) + } + return internalContainers + } + + /// - Throws ``PurchasesReceiptParser/Error`` + func extractClass(byte: UInt8) throws -> ASN1Class { + let firstTwoBits: UInt8 + do { + firstTwoBits = try byte.valueInRange(from: 0, to: 1) + } catch { + throw PurchasesReceiptParser.Error.asn1ParsingError(description: error.localizedDescription) + } + + guard let asn1Class = ASN1Class(rawValue: firstTwoBits) else { + throw PurchasesReceiptParser.Error.asn1ParsingError(description: "couldn't determine asn1 class") + } + return asn1Class + } + + /// - Throws ``PurchasesReceiptParser/Error`` + func extractEncodingType(byte: UInt8) throws -> ASN1EncodingType { + let thirdBit: UInt8 + + do { + thirdBit = try byte.bitAtIndex(2) + } catch { + throw PurchasesReceiptParser.Error.asn1ParsingError(description: error.localizedDescription) + } + + guard let encodingType = ASN1EncodingType(rawValue: thirdBit) else { + throw PurchasesReceiptParser.Error.asn1ParsingError(description: "couldn't determine encoding type") + } + return encodingType + } + + /// - Throws ``PurchasesReceiptParser/Error`` + func extractIdentifier(byte: UInt8) throws -> ASN1Identifier { + let lastFiveBits: UInt8 + do { + lastFiveBits = try byte.valueInRange(from: 3, to: 7) + } catch { + throw PurchasesReceiptParser.Error.asn1ParsingError(description: error.localizedDescription) + } + + guard let asn1Identifier = ASN1Identifier(rawValue: lastFiveBits) else { + throw PurchasesReceiptParser.Error.asn1ParsingError(description: "couldn't determine identifier") + } + return asn1Identifier + } + + /// - Throws ``PurchasesReceiptParser/Error`` + func extractLengthAndInternalContainers(data: ArraySlice, + isConstructed: Bool) throws -> (ASN1Length, [ASN1Container]) { + guard let firstByte = data.first else { + throw PurchasesReceiptParser.Error.asn1ParsingError(description: "length needs to be at least one byte") + } + + let isShortLength: Bool + let firstByteValue: Int + + do { + let lengthBit = try firstByte.bitAtIndex(0) + + isShortLength = lengthBit == 0 + firstByteValue = Int(try firstByte.valueInRange(from: 1, to: 7)) + } catch { + throw PurchasesReceiptParser.Error.asn1ParsingError(description: error.localizedDescription) + } + + var bytesUsedForLength = 1 + + var lengthValue: Int + if isShortLength { + lengthValue = firstByteValue + } else { + let totalLengthBytes = firstByteValue + bytesUsedForLength += totalLengthBytes + let lengthBytes = data.dropFirst().prefix(totalLengthBytes) + lengthValue = lengthBytes.toInt() + } + + var innerContainers: [ASN1Container] = [] + // StoreKitTest receipts report a length of zero for Constructed elements. + // This is called indefinite-length in ASN1 containers. + // When length == 0, the element's contents end when there's a container with .endOfContent identifier + // To get the length, we build the internal containers until we run into .endOfContent and sum up the bytes used + let lengthDefinition: ASN1Length.LengthDefinition = (isConstructed && lengthValue == 0) + ? .indefinite : .definite + + if lengthDefinition == .indefinite { + innerContainers = try buildInternalContainers(payload: data.dropFirst(bytesUsedForLength)) + let innerContainersOverallLength = innerContainers + .lazy // Avoid creating intermediate arrays + .map { $0.totalBytesUsed } + .reduce(0, +) + lengthValue = innerContainersOverallLength + } else if isConstructed { + let innerContainerData = data.dropFirst(bytesUsedForLength).prefix(lengthValue) + innerContainers = try buildInternalContainers(payload: innerContainerData) + } + let length = ASN1Length(value: lengthValue, + bytesUsedForLength: bytesUsedForLength, + definition: lengthDefinition) + + return (length, innerContainers) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Builders/ASN1ObjectIdentifierBuilder.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Builders/ASN1ObjectIdentifierBuilder.swift new file mode 100644 index 00000000..d380b5e1 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Builders/ASN1ObjectIdentifierBuilder.swift @@ -0,0 +1,57 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ASN1ObjectIdentifierBuilder.swift +// +// Created by Andrés Boedo on 7/28/20. +// + +import Foundation + +enum ASN1ObjectIdentifierBuilder { + + // info on the format: https://docs.microsoft.com/en-us/windows/win32/seccertenroll/about-object-identifier + static func build(fromPayload payload: ArraySlice) throws -> ASN1ObjectIdentifier? { + guard let firstByte = payload.first else { return nil } + + var objectIdentifierNumbers: [UInt] = [] + objectIdentifierNumbers.append(UInt(firstByte / 40)) + objectIdentifierNumbers.append(UInt(firstByte % 40)) + + let trailingPayload = payload.dropFirst() + let variableLengthQuantityNumbers = try decodeVariableLengthQuantity(payload: trailingPayload) + objectIdentifierNumbers += variableLengthQuantityNumbers + + let objectIdentifierString = objectIdentifierNumbers.map { String($0) } + .joined(separator: ".") + return ASN1ObjectIdentifier(rawValue: objectIdentifierString) + } +} + +private extension ASN1ObjectIdentifierBuilder { + + // https://en.wikipedia.org/wiki/Variable-length_quantity + static func decodeVariableLengthQuantity(payload: ArraySlice) throws -> [UInt] { + var decodedNumbers = [UInt]() + + var currentBuffer: UInt = 0 + var isShortLength = false + for byte in payload { + isShortLength = try byte.bitAtIndex(0) == 0 + let byteValue = UInt(try byte.valueInRange(from: 1, to: 7)) + + currentBuffer = (currentBuffer << 7) | byteValue + if isShortLength { + decodedNumbers.append(currentBuffer) + currentBuffer = 0 + } + } + return decodedNumbers + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Builders/AppleReceiptBuilder.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Builders/AppleReceiptBuilder.swift new file mode 100644 index 00000000..c040e0d8 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Builders/AppleReceiptBuilder.swift @@ -0,0 +1,157 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// AppleReceiptBuilder.swift +// +// Created by Andrés Boedo on 7/29/20. +// + +import Foundation + +class AppleReceiptBuilder { + + private let containerBuilder: ASN1ContainerBuilder + private let inAppPurchaseBuilder: InAppPurchaseBuilder + + private let typeContainerIndex = 0 + private let versionContainerIndex = 1 // unused + private let attributeTypeContainerIndex = 2 + private let expectedInternalContainersCount = 3 // type + version + attribute + + init(containerBuilder: ASN1ContainerBuilder = ASN1ContainerBuilder(), + inAppPurchaseBuilder: InAppPurchaseBuilder = InAppPurchaseBuilder()) { + self.containerBuilder = containerBuilder + self.inAppPurchaseBuilder = inAppPurchaseBuilder + } + + /// - Throws: ``PurchasesReceiptParser/Error`` + // swiftlint:disable:next cyclomatic_complexity function_body_length + func build(fromContainer container: ASN1Container) throws -> AppleReceipt { + var environment: AppleReceipt.Environment = .unknown + var bundleId: String? + var applicationVersion: String? + var originalApplicationVersion: String? + var opaqueValue: Data? + var sha1Hash: Data? + var creationDate: Date? + var expirationDate: Date? + var inAppPurchases: [AppleReceipt.InAppPurchase] = [] + + guard let internalContainer = container.internalContainers.first else { + throw PurchasesReceiptParser.Error.receiptParsingError + } + var receiptContainer = try containerBuilder.build(fromPayload: internalContainer.internalPayload) + + // StoreKitTest receipts have their data embedded into 2 levels of octetString containers, + // Regular receipts have it in only one. At this point we've already unwrapped the upper level + // so we check whether we need to go one deeper. + let isStoreKitTestReceipt = receiptContainer.encodingType == .primitive + && receiptContainer.containerIdentifier == .octetString + if isStoreKitTestReceipt { + receiptContainer = try containerBuilder.build(fromPayload: receiptContainer.internalPayload) + } + + for receiptAttribute in receiptContainer.internalContainers { + guard receiptAttribute.internalContainers.count == expectedInternalContainersCount else { + throw PurchasesReceiptParser.Error.receiptParsingError + } + let typeContainer = receiptAttribute.internalContainers[typeContainerIndex] + let valueContainer = receiptAttribute.internalContainers[attributeTypeContainerIndex] + + guard let attributeType = AppleReceipt.Attribute.AttributeType( + rawValue: typeContainer.internalPayload.toInt() + ) else { continue } + + let payload = valueContainer.internalPayload + + switch attributeType { + case .environment: + let internalContainer = try containerBuilder.build(fromPayload: payload) + if let environmentString = internalContainer.internalPayload.toString() { + environment = .init(rawValue: environmentString) ?? .unknown + } + case .opaqueValue: + opaqueValue = payload.toData() + case .sha1Hash: + sha1Hash = payload.toData() + case .applicationVersion: + let internalContainer = try containerBuilder.build(fromPayload: payload) + applicationVersion = internalContainer.internalPayload.toString() + case .originalApplicationVersion: + let internalContainer = try containerBuilder.build(fromPayload: payload) + originalApplicationVersion = internalContainer.internalPayload.toString() + case .bundleId: + let internalContainer = try containerBuilder.build(fromPayload: payload) + bundleId = internalContainer.internalPayload.toString() + case .creationDate: + let internalContainer = try containerBuilder.build(fromPayload: payload) + creationDate = internalContainer.internalPayload.toDate() + case .expirationDate: + let internalContainer = try containerBuilder.build(fromPayload: payload) + expirationDate = internalContainer.internalPayload.toDate() + case .inAppPurchase: + let internalContainer = try containerBuilder.build(fromPayload: payload) + inAppPurchases.append(try inAppPurchaseBuilder.build(fromContainer: internalContainer)) + } + } + + guard let nonOptionalBundleId = bundleId, + let nonOptionalApplicationVersion = applicationVersion, + let nonOptionalOpaqueValue = opaqueValue, + let nonOptionalSha1Hash = sha1Hash, + let nonOptionalCreationDate = creationDate else { + throw PurchasesReceiptParser.Error.receiptParsingError + } + + let receipt = AppleReceipt(environment: environment, + bundleId: nonOptionalBundleId, + applicationVersion: nonOptionalApplicationVersion, + originalApplicationVersion: originalApplicationVersion, + opaqueValue: nonOptionalOpaqueValue, + sha1Hash: nonOptionalSha1Hash, + creationDate: nonOptionalCreationDate, + expirationDate: expirationDate, + inAppPurchases: inAppPurchases) + return receipt + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension AppleReceiptBuilder: @unchecked Sendable {} + +// swiftlint:disable nesting + +extension AppleReceipt { + + /// See docs: https://rev.cat/apple-receipt-fields + struct Attribute { + + enum AttributeType: Int { + + case environment = 0, + bundleId = 2, + applicationVersion = 3, + opaqueValue = 4, + sha1Hash = 5, + creationDate = 12, + inAppPurchase = 17, + originalApplicationVersion = 19, + expirationDate = 21 + + } + + let type: AttributeType + let version: Int + let value: String + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Builders/InAppPurchaseBuilder.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Builders/InAppPurchaseBuilder.swift new file mode 100644 index 00000000..165eee7c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Builders/InAppPurchaseBuilder.swift @@ -0,0 +1,127 @@ +// +// Created by Andrés Boedo on 7/29/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation + +class InAppPurchaseBuilder { + + typealias InAppPurchase = AppleReceipt.InAppPurchase + + // swiftlint:disable:next line_length + // https://developer.apple.com/library/archive/releasenotes/General/ValidateAppStoreReceipt/Chapters/ReceiptFields.html + enum AttributeType: Int { + + case quantity = 1701, + productId = 1702, + transactionId = 1703, + purchaseDate = 1704, + originalTransactionId = 1705, + originalPurchaseDate = 1706, + productType = 1707, + expiresDate = 1708, + webOrderLineItemId = 1711, + cancellationDate = 1712, + isInTrialPeriod = 1713, + isInIntroOfferPeriod = 1719, + promotionalOfferIdentifier = 1721 + + } + + private let containerBuilder: ASN1ContainerBuilder + private let typeContainerIndex = 0 + private let versionContainerIndex = 1 // unused + private let attributeTypeContainerIndex = 2 + private let expectedInternalContainersCount = 3 // type + version + attribute + + init() { + self.containerBuilder = ASN1ContainerBuilder() + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + func build(fromContainer container: ASN1Container) throws -> InAppPurchase { + var quantity: Int? + var productId: String? + var transactionId: String? + var originalTransactionId: String? + var productType: InAppPurchase.ProductType = .unknown + var purchaseDate: Date? + var originalPurchaseDate: Date? + var expiresDate: Date? + var cancellationDate: Date? + var isInTrialPeriod: Bool? + var isInIntroOfferPeriod: Bool? + var webOrderLineItemId: Int64? + var promotionalOfferIdentifier: String? + + for internalContainer in container.internalContainers { + guard internalContainer.internalContainers.count == expectedInternalContainersCount else { + throw PurchasesReceiptParser.Error.inAppPurchaseParsingError + } + let typeContainer = internalContainer.internalContainers[typeContainerIndex] + let valueContainer = internalContainer.internalContainers[attributeTypeContainerIndex] + + guard let attributeType = AttributeType(rawValue: typeContainer.internalPayload.toInt()) + else { continue } + + let internalContainer = try containerBuilder.build(fromPayload: valueContainer.internalPayload) + guard internalContainer.length.value > 0 else { continue } + + switch attributeType { + case .quantity: + quantity = internalContainer.internalPayload.toInt() + case .webOrderLineItemId: + webOrderLineItemId = internalContainer.internalPayload.toInt64() + case .productType: + productType = .init(rawValue: internalContainer.internalPayload.toInt()) ?? .unknown + case .isInIntroOfferPeriod: + isInIntroOfferPeriod = internalContainer.internalPayload.toBool() + case .isInTrialPeriod: + isInTrialPeriod = internalContainer.internalPayload.toBool() + case .productId: + productId = internalContainer.internalPayload.toString() + case .transactionId: + transactionId = internalContainer.internalPayload.toString() + case .originalTransactionId: + originalTransactionId = internalContainer.internalPayload.toString() + case .promotionalOfferIdentifier: + promotionalOfferIdentifier = internalContainer.internalPayload.toString() + case .cancellationDate: + cancellationDate = internalContainer.internalPayload.toDate() + case .expiresDate: + expiresDate = internalContainer.internalPayload.toDate() + case .originalPurchaseDate: + originalPurchaseDate = internalContainer.internalPayload.toDate() + case .purchaseDate: + purchaseDate = internalContainer.internalPayload.toDate() + } + } + + guard let nonOptionalQuantity = quantity, + let nonOptionalProductId = productId, + let nonOptionalTransactionId = transactionId, + let nonOptionalPurchaseDate = purchaseDate else { + throw PurchasesReceiptParser.Error.inAppPurchaseParsingError + } + + return InAppPurchase(quantity: nonOptionalQuantity, + productId: nonOptionalProductId, + transactionId: nonOptionalTransactionId, + originalTransactionId: originalTransactionId, + productType: productType, + purchaseDate: nonOptionalPurchaseDate, + originalPurchaseDate: originalPurchaseDate, + expiresDate: expiresDate, + cancellationDate: cancellationDate, + isInTrialPeriod: isInTrialPeriod, + isInIntroOfferPeriod: isInIntroOfferPeriod, + webOrderLineItemId: webOrderLineItemId, + promotionalOfferIdentifier: promotionalOfferIdentifier) + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension InAppPurchaseBuilder: @unchecked Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/DataConverters/ArraySlice_UInt8+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/DataConverters/ArraySlice_UInt8+Extensions.swift new file mode 100644 index 00000000..237b30ee --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/DataConverters/ArraySlice_UInt8+Extensions.swift @@ -0,0 +1,55 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ArraySlice_UInt8+Extensions.swift +// +// Created by Andrés Boedo on 7/29/20. +// + +import Foundation + +extension ArraySlice where Element == UInt8 { + + func toUInt64() -> UInt64 { + let array = Array(self) + var result: UInt64 = 0 + for idx in 0..<(array.count) { + let shiftAmount = UInt((array.count) - idx - 1) * 8 + result += UInt64(array[idx]) << shiftAmount + } + return result + } + + func toInt() -> Int { + return Int(self.toUInt64()) + } + + func toInt64() -> Int64 { + return Int64(self.toUInt64()) + } + + func toBool() -> Bool { + return self.toUInt64() == 1 + } + + func toString() -> String? { + return String(bytes: self, encoding: .utf8) + } + + func toDate() -> Date? { + guard let dateString = String(bytes: Array(self), encoding: .ascii) else { return nil } + + return ISO8601DateFormatter.default.date(from: dateString) + } + + func toData() -> Data { + return Data(self) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/DataConverters/Codable+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/DataConverters/Codable+Extensions.swift new file mode 100644 index 00000000..1ffbadb4 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/DataConverters/Codable+Extensions.swift @@ -0,0 +1,119 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Codable+Extensions.swift +// +// Created by Nacho Soto on 11/29/22. + +import Foundation + +extension Encodable { + + /// - Throws: if encoding failed + /// - Returns: `nil` if the encoded `Data` can't be serialized into a `String`. + var prettyPrintedJSON: String? { + get throws { + return String(data: try self.prettyPrintedData, encoding: .utf8) + } + } + + /// - Throws: if encoding failed + /// - Returns: `nil` if the encoded `Data` can't be serialized into a `String`. + var encodedJSON: String? { + get throws { + return String(data: try self.jsonEncodedData, encoding: .utf8) + } + } + + // MARK: - + + var prettyPrintedData: Data { + get throws { + return try JSONEncoder.prettyPrinted.encode(self) + } + } + + /// - Note: beginning with iOS 17, the output of this is not guaranteed to be consistent due to key ordering. + /// For tests, it's better to compare `prettyPrintedData` which does sort keys. + var jsonEncodedData: Data { + get throws { + return try JSONEncoder.default.encode(self) + } + } + +} + +extension JSONEncoder { + + static let `default`: JSONEncoder = { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + encoder.dateEncodingStrategy = .iso8601 + + return encoder + }() + + /// JSONEncoder with sorted keys (compact format) + static let sortedKeys: JSONEncoder = { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + + return encoder + }() + + /// JSONEncoder (just like the default one, but prettyPrinted and sortedKeys) + static let prettyPrinted: JSONEncoder = { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys, .prettyPrinted] + + return encoder + }() + +} + +extension JSONDecoder { + + static let iso8601WithFractionalSeconds: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + static let iso8601WithoutFractionalSeconds: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + static let `default`: JSONDecoder = { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + + if let date = iso8601WithFractionalSeconds.date(from: raw) { + return date + } + + if let date = iso8601WithoutFractionalSeconds.date(from: raw) { + return date + } + + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO8601 date: \(raw)") + } + + return decoder + }() + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/DataConverters/DateFormatter+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/DataConverters/DateFormatter+Extensions.swift new file mode 100644 index 00000000..43b2d20e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/DataConverters/DateFormatter+Extensions.swift @@ -0,0 +1,78 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DateFormatter+Extensions.swift +// +// Created by Nacho Soto on 12/14/22. + +import Foundation + +/// A type that can convert from and to `Dates`. +protocol DateFormatterType { + + func string(from date: Date) -> String + func date(from string: String) -> Date? + +} + +extension DateFormatter: DateFormatterType {} +extension ISO8601DateFormatter: DateFormatterType {} + +extension DateFormatterType { + + func date(from dateString: String?) -> Date? { + guard let dateString = dateString else { return nil } + return date(from: dateString) + } + +} + +extension ISO8601DateFormatter { + + /// This behaves like a traditional `DateFormatter` with format + /// `yyyy-MM-dd'T'HH:mm:ssZ"`, so milliseconds are optional. + static let `default`: DateFormatterType = { + final class Formatter: DateFormatterType { + func date(from string: String) -> Date? { + return ISO8601DateFormatter.withMilliseconds.date(from: string) + ?? ISO8601DateFormatter.noMilliseconds.date(from: string) + } + + func string(from date: Date) -> String { + return ISO8601DateFormatter.noMilliseconds.string(from: date) + } + } + + return Formatter() + }() + +} + +private extension ISO8601DateFormatter { + + static let withMilliseconds: DateFormatterType = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [ + .withInternetDateTime, + .withFractionalSeconds + ] + + return formatter + }() + + static let noMilliseconds: DateFormatterType = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [ + .withInternetDateTime + ] + + return formatter + }() + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/DataConverters/UInt8+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/DataConverters/UInt8+Extensions.swift new file mode 100644 index 00000000..138b676b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/DataConverters/UInt8+Extensions.swift @@ -0,0 +1,80 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// UInt8+Extensions.swift +// +// Created by Andrés Boedo on 7/24/20. +// + +import Foundation + +// swiftlint:disable identifier_name +enum BitShiftError: Error { + + case invalidIndex(_ index: UInt8) + case rangeFlipped(from: UInt8, to: UInt8) + case rangeLargerThanByte + case unhandledRange + +} + +extension BitShiftError: CustomStringConvertible { + + var description: String { + switch self { + case .invalidIndex(let index): + return "invalid index: \(index)" + case .rangeFlipped(let from, let to): + return "from: \(from) can't be greater than to: \(to)" + case .rangeLargerThanByte: + return "range must be between 1 and 8" + case .unhandledRange: + return "unhandled range" + } + } + +} + +extension UInt8 { + + /// - Throws: `BitShiftError` + func bitAtIndex(_ index: UInt8) throws -> UInt8 { + guard index <= 7 else { throw BitShiftError.invalidIndex(index) } + let shifted = self >> (7 - index) + return shifted & 0b1 + } + + /// - Throws: `BitShiftError` + func valueInRange(from: UInt8, to: UInt8) throws -> UInt8 { + guard to <= 7 else { throw BitShiftError.invalidIndex(to) } + guard from <= to else { throw BitShiftError.rangeFlipped(from: from, to: to) } + + let range: UInt8 = to - from + 1 + let shifted = self >> (7 - to) + let mask = try self.maskForRange(range) + return shifted & mask + } + +} + +private extension UInt8 { + + /// - Returns: 2^range - 1 + /// - Throws: `BitShiftError` + func maskForRange(_ range: UInt8) throws -> UInt8 { + guard 0 <= range && range <= 8 else { throw BitShiftError.rangeLargerThanByte } + + /// Returns 2 ^ range - 1 (a number with range 1s) + /// Example: + /// - range 1: 0b1 + /// - range 2: 0b11 + return 0b11111111 >> (8 - range) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Helpers/FileReader.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Helpers/FileReader.swift new file mode 100644 index 00000000..1cb685fb --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Helpers/FileReader.swift @@ -0,0 +1,31 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// FileReader.swift +// +// Created by Nacho Soto on 1/10/23. + +import Foundation + +/// A type that can read data from disk +/// Useful for mocking. +protocol FileReader { + + func contents(of url: URL) throws -> Data + +} + +/// Default implementation of `FileReader` that simply uses `Data`'s implementation. +final class DefaultFileReader: FileReader { + + func contents(of url: URL) throws -> Data { + return try Data(contentsOf: url) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Helpers/LoggerType.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Helpers/LoggerType.swift new file mode 100644 index 00000000..3dc1fd4f --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Helpers/LoggerType.swift @@ -0,0 +1,210 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// LoggerType.swift +// +// Created by Nacho Soto on 11/29/22. + +import Foundation +import os + +// swiftlint:disable force_unwrapping + +/// A type that can receive logs of different levels. +protocol LoggerType { + + func verbose(_ message: LogMessage, + fileName: String?, + functionName: String?, + line: UInt) + func debug(_ message: LogMessage, + fileName: String?, + functionName: String?, + line: UInt) + func info(_ message: LogMessage, + fileName: String?, + functionName: String?, + line: UInt) + func warn(_ message: LogMessage, + fileName: String?, + functionName: String?, + line: UInt) + func error(_ message: LogMessage, + fileName: String, + functionName: String, + line: UInt) + +} + +/// Contains a message that can be output by ``os.Logger``. +protocol LogMessage: CustomStringConvertible { + + var description: String { get } + var category: String { get } + +} + +/// Enumeration of the different verbosity levels. +/// +/// #### Related Symbols +/// - ``Purchases/logLevel`` +@objc(RCLogLevel) public enum LogLevel: Int, CustomStringConvertible, CaseIterable, Sendable { + + // swiftlint:disable missing_docs + + case verbose = 4 + case debug = 0 + case info = 1 + case warn = 2 + case error = 3 + + public var description: String { + switch self { + case .verbose: return "VERBOSE" + case .debug: return "DEBUG" + case .info: return "INFO" + case .warn: return "WARN" + case .error: return "ERROR" + } + } + + // swiftlint:enable missing_docs +} + +/// An in-memory cache of ``os.Logger`` instances based on their category. +@available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) +final class LoggerStore { + + private var loggersByCategory: [String: os.Logger] = [:] + + func logger(for category: String) -> os.Logger { + return self.loggersByCategory[category, default: Self.create(for: category)] + } + + private static func create(for category: String) -> os.Logger { + return .init(subsystem: Self.subsystem, category: category) + } + + private static let subsystem = Bundle.main.bundleIdentifier ?? "com.revenuecat.Purchases" + +} + +@available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) +private let store = LoggerStore() + +// swiftlint:disable:next function_parameter_count +func defaultLogHandler( + framework: String, + verbose: Bool, + level: LogLevel, + category: String, + message: String, + file: String?, + function: String?, + line: UInt +) { + let fileContext: String + if verbose, let file = file, let function = function { + let fileName = (file as NSString) + .lastPathComponent + .replacingOccurrences(of: ".swift", with: "") + .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + + fileContext = "\t\(fileName).\(function):\(line)" + } else { + fileContext = "" + } + + if #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) { + store + .logger(for: category) + .log( + level: level.logType, + "\(level.description, privacy: .public)\(fileContext, privacy: .public): \(message, privacy: .public)" + ) + } else { + NSLog("%@", "[\(framework)] - \(level.description)\(fileContext): \(message)") + } +} + +// MARK: - + +/// Default overloads to allow implicit values +extension LoggerType { + + func verbose(_ message: @autoclosure () -> LogMessage, + _ fileName: String? = #fileID, + _ functionName: String? = #function, + _ line: UInt = #line) { + self.verbose(message(), fileName: fileName, functionName: functionName, line: line) + } + + func debug(_ message: @autoclosure () -> LogMessage, + _ fileName: String? = #fileID, + _ functionName: String? = #function, + _ line: UInt = #line) { + self.debug(message(), fileName: fileName, functionName: functionName, line: line) + } + + func info(_ message: @autoclosure () -> LogMessage, + _ fileName: String? = #fileID, + _ functionName: String? = #function, + _ line: UInt = #line) { + self.info(message(), fileName: fileName, functionName: functionName, line: line) + } + + func warn(_ message: @autoclosure () -> LogMessage, + _ fileName: String? = #fileID, + _ functionName: String? = #function, + _ line: UInt = #line) { + self.warn(message(), fileName: fileName, functionName: functionName, line: line) + } + + func error(_ message: @autoclosure () -> LogMessage, + _ fileName: String = #fileID, + _ functionName: String = #function, + _ line: UInt = #line) { + self.error(message(), fileName: fileName, functionName: functionName, line: line) + } + +} + +private extension LogLevel { + + var logType: OSLogType { + return Self.logTypes[self]! + } + + private func calculateLogType() -> OSLogType { + switch self { + case .verbose, .debug: + #if DEBUG + if ProcessInfo.isRunningIntegrationTests { + // See https://github.com/RevenueCat/purchases-ios/pull/3108 + // With `.debug` we'd lose these logs when running integration tests on CI. + return .info + } else { + return .debug + } + #else + return .debug + #endif + + case .info: return .info + case .warn: return .error + case .error: return .error + } + } + + private static let logTypes: [Self: OSLogType] = + .init(uniqueKeysWithValues: Self.allCases.lazy.map { + ($0, $0.calculateLogType()) + }) + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Helpers/ProcessInfo+Extensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Helpers/ProcessInfo+Extensions.swift new file mode 100644 index 00000000..0a11500b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Helpers/ProcessInfo+Extensions.swift @@ -0,0 +1,120 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ProcessInfo+Extensions.swift +// +// Created by Nacho Soto on 11/29/22. + +import Foundation + +#if canImport(UIKit) +import UIKit +#endif + +#if DEBUG + +enum EnvironmentKey: String { + + case XCTestConfigurationFile = "XCTestConfigurationFilePath" + case RCRunningTests = "RCRunningTests" + case RCRunningIntegrationTests = "RCRunningIntegrationTests" + case RCMockAdServicesToken = "RCMockAdServicesToken" + case XCCloud = "XCODE_CLOUD" + case xcodeRunningForPreviews = "XCODE_RUNNING_FOR_PREVIEWS" + case emergeIsRunningForSnapshots = "EMERGE_IS_RUNNING_FOR_SNAPSHOTS" + +} + +extension ProcessInfo { + + static subscript(key: EnvironmentKey) -> String? { + return Self.processInfo.environment[key.rawValue] + } + +} + +extension ProcessInfo { + + static var isRunningUnitTests: Bool { + return self[.XCTestConfigurationFile] != nil + } + + /// `true` when running unit or integration tests (configured in .xctestplan files). + @_spi(Internal) public static var isRunningRevenueCatTests: Bool { + return self[.RCRunningTests] == "1" + } + + /// `true` when running integration tests (configured in .xctestplan files). + static var isRunningIntegrationTests: Bool { + return self[.RCRunningIntegrationTests] == "1" + } + + static var mockAdServicesToken: String? { + guard let token = self[.RCMockAdServicesToken], !token.isEmpty else { return nil } + + return token + } + + static var isXcodeCloud: Bool { + return self[.XCCloud] == "1" + } + + /// `true` when running as part of an Xcode Preview (either in Xcode or on Emerge Tool's servers) + @_spi(Internal) public static var isRunningForPreviews: Bool { + return self[.xcodeRunningForPreviews] == "1" || self[.emergeIsRunningForSnapshots] == "1" + } + + /// Returns a string identifying the platform and environment + /// the app is running on (iOS, Mac Catalyst, visionOS, etc.). + @_spi(Internal) public var platformString: String { + #if os(macOS) + return "Native Mac" + #elseif os(tvOS) + return "tvOS" + #elseif os(watchOS) + return "watchOS" + #elseif os(visionOS) + // May want to distinguish between iPad apps running on visionOS and native visionOS apps in the future + return "visionOS" + #elseif os(iOS) + if isMacCatalystApp { + if #available(iOS 14.0, *), isiOSAppOnMac { + switch UIDevice.current.userInterfaceIdiom { + case .phone: + return "iPhone App on Mac" + case .pad: + return "iPad App on Mac" + default: + return "Unexpected iOS App on Mac" + } + } else { + switch UIDevice.current.userInterfaceIdiom { + case .mac: + return "Mac Catalyst Optimized for Mac" + case .pad: + return "Mac Catalyst Scaled to iPad" + default: + return "Unexpected Platform on Mac Catalyst" + } + } + } else { + switch UIDevice.current.userInterfaceIdiom { + case .phone: + return "iOS" + case .pad: + return "iPad OS" + default: + return "Unexpected iOS Platform" + } + } + #endif + } +} + +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Helpers/ReceiptParserLogger.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Helpers/ReceiptParserLogger.swift new file mode 100644 index 00000000..36898854 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Helpers/ReceiptParserLogger.swift @@ -0,0 +1,112 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ReceiptParserLogger.swift +// +// Created by Nacho Soto on 12/5/22. + +import Foundation + +final class ReceiptParserLogger: LoggerType { + + func verbose( + _ message: LogMessage, + fileName: String?, + functionName: String?, + line: UInt + ) { + Self.log( + level: .verbose, + message: message, + fileName: fileName, + functionName: functionName, + line: line + ) + } + + func debug( + _ message: LogMessage, + fileName: String?, + functionName: String?, + line: UInt + ) { + Self.log( + level: .debug, + message: message, + fileName: fileName, + functionName: functionName, + line: line + ) + } + + func info( + _ message: LogMessage, + fileName: String?, + functionName: String?, + line: UInt + ) { + Self.log( + level: .info, + message: message, + fileName: fileName, + functionName: functionName, + line: line + ) + } + + func warn( + _ message: LogMessage, + fileName: String?, + functionName: String?, + line: UInt + ) { + Self.log( + level: .warn, + message: message, + fileName: fileName, + functionName: functionName, + line: line + ) + } + + func error( + _ message: LogMessage, + fileName: String, + functionName: String, + line: UInt + ) { + Self.log( + level: .error, + message: message, + fileName: fileName, + functionName: functionName, + line: line + ) + } + + private static func log(level: LogLevel, + message: LogMessage, + fileName: String? = #fileID, + functionName: String? = #function, + line: UInt = #line) { + defaultLogHandler( + framework: Self.framework, + verbose: false, + level: level, + category: message.category, + message: message.description, + file: fileName, + function: functionName, + line: line + ) + } + + private static let framework = "ReceiptParser" + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Helpers/ReceiptStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Helpers/ReceiptStrings.swift new file mode 100644 index 00000000..8d1dd55f --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/Helpers/ReceiptStrings.swift @@ -0,0 +1,124 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ReceiptStrings.swift +// +// Created by Nacho Soto on 11/29/22. + +import Foundation + +// swiftlint:disable identifier_name +enum ReceiptStrings { + + case data_object_identifier_not_found_receipt + case force_refreshing_receipt + case throttling_force_refreshing_receipt + case loaded_receipt(url: URL) + case no_sandbox_receipt_intro_eligibility + case no_sandbox_receipt_restore + case parse_receipt_locally_error(error: Error) + case parsing_receipt_failed(fileName: String, functionName: String) + case parsing_receipt_success + case parsing_receipt + case refreshing_empty_receipt + case unable_to_load_receipt(Error) + case posting_receipt(AppleReceipt, initiationSource: String) + case posting_jws(String, initiationSource: String) + case posting_sk2_receipt(String, initiationSource: String) + case receipt_subscription_purchase_equals_expiration( + productIdentifier: String, + purchase: Date, + expiration: Date? + ) + case local_receipt_missing_purchase(AppleReceipt, forProductIdentifier: String) + case retrying_receipt_fetch_after(sleepDuration: TimeInterval) + case error_validating_bundle_signature + +} + +extension ReceiptStrings: LogMessage { + + var description: String { + switch self { + + case .data_object_identifier_not_found_receipt: + return "The data object identifier couldn't be found on the receipt." + + case .force_refreshing_receipt: + return "Force refreshing the receipt to get latest transactions from Apple." + + case .throttling_force_refreshing_receipt: + return "Throttled request to refresh receipt." + + case .loaded_receipt(let url): + return "Loaded receipt from url \(url.absoluteString)" + + case .no_sandbox_receipt_intro_eligibility: + return "App running on sandbox without a receipt file. " + + "Unable to determine intro eligibility unless you've purchased " + + "before and there is a receipt available." + + case .no_sandbox_receipt_restore: + return "App running in sandbox without a receipt file. Restoring " + + "transactions won't work until a purchase is made to generate a receipt. " + + "This should not happen in production unless user is logged out of Apple account." + + case .parse_receipt_locally_error(let error): + return "There was an error when trying to parse the receipt " + + "locally, details: \(error.localizedDescription)" + + case .parsing_receipt_failed(let fileName, let functionName): + return "\(fileName)-\(functionName): Could not parse receipt, conservatively returning true" + + case .parsing_receipt_success: + return "Receipt parsed successfully" + + case .parsing_receipt: + return "Parsing receipt" + + case .refreshing_empty_receipt: + return "Receipt empty, refreshing" + + case let .unable_to_load_receipt(error): + return "Unable to load receipt, ensure you are logged in to a valid Apple account.\n" + + "Error: \(error)" + + case let .posting_receipt(receipt, initiationSource): + return "Posting receipt (source: '\(initiationSource)') (note: the contents might not be up-to-date, " + + "but it will be refreshed with Apple's servers):\n\(receipt.debugDescription)" + + case let .posting_jws(token, initiationSource): + return "Posting JWS token (source: '\(initiationSource)'):\n\(token)" + + case let .posting_sk2_receipt(receipt, initiationSource): + return "Posting StoreKit 2 receipt (source: '\(initiationSource)'):\n\(receipt)" + + case let .receipt_subscription_purchase_equals_expiration( + productIdentifier, + purchase, + expiration + ): + return "Receipt for product '\(productIdentifier)' has the same purchase (\(purchase)) " + + "and expiration (\(expiration?.description ?? "")) dates. This is likely a StoreKit bug." + + case let .local_receipt_missing_purchase(receipt, productIdentifier): + return "Local receipt is still missing purchase for '\(productIdentifier)': \n" + + "\((try? receipt.prettyPrintedJSON) ?? "")" + + case let .retrying_receipt_fetch_after(sleepDuration): + return String(format: "Retrying receipt fetch after %2.f seconds", sleepDuration) + + case .error_validating_bundle_signature: + return "Error validating app bundle signature." + } + } + + var category: String { return "receipt" } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/LocalReceiptFetcher.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/LocalReceiptFetcher.swift new file mode 100644 index 00000000..ffb4f0b2 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/LocalReceiptFetcher.swift @@ -0,0 +1,68 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// LocalReceiptFetcher.swift +// +// Created by Mark Villacampa on 8/1/24. + +import Foundation + +internal protocol LocalReceiptFetcherType: Sendable { + + func fetchAndParseLocalReceipt() throws -> AppleReceipt + +} + +internal final class LocalReceiptFetcher: LocalReceiptFetcherType { + + // Note: this is a simplified version of `ReceiptFetcher` + // available for public use. + + /// Fetches and parses the local receipt + /// - Returns: ``AppleReceipt`` of the parsed local receipt. + /// - Throws: ``PurchasesReceiptParser/Error`` if fetching or parsing failed. + /// + /// - Note: this method won't use ``SKReceiptRefreshRequest`` to + /// fetch the receipt if it's not already available. + /// + /// ### Related Symbols + /// - ``SKReceiptRefreshRequest`` + /// - ``Bundle/appStoreReceiptURL`` + /// - ``PurchasesReceiptParser/parse(from:)`` + func fetchAndParseLocalReceipt() throws -> AppleReceipt { + return try self.fetchAndParseLocalReceipt(reader: DefaultFileReader(), + bundle: .main, + receiptParser: .default) + } + + internal func fetchAndParseLocalReceipt( + reader: FileReader, + bundle: Bundle, + receiptParser: PurchasesReceiptParser + ) throws -> AppleReceipt { + return try receiptParser.parse(from: self.fetchReceipt(reader, bundle)) + } + +} + +private extension LocalReceiptFetcher { + + func fetchReceipt(_ reader: FileReader, _ bundle: Bundle) throws -> Data { + guard let url = bundle.appStoreReceiptURL else { + throw PurchasesReceiptParser.Error.receiptNotPresent + } + + do { + return try reader.contents(of: url) + } catch { + throw PurchasesReceiptParser.Error.failedToLoadLocalReceipt(error) + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/PurchasesReceiptParser.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/PurchasesReceiptParser.swift new file mode 100644 index 00000000..ac16d21c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/PurchasesReceiptParser.swift @@ -0,0 +1,135 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PurchasesReceiptParser.swift +// +// Created by Andrés Boedo on 7/22/20. +// + +import Foundation + +/// A type that can parse Apple receipts from a device. +/// This implements parsing based on [Apple's documentation](https://rev.cat/apple-receipt-fields). +/// +/// To use this class you must access ``PurchasesReceiptParser/default``: +/// ```swift +/// let parser = PurchasesReceiptParser.default +/// let receipt = try parser.parse(from: data) +/// ``` +public class PurchasesReceiptParser: NSObject { + + private let logger: LoggerType + private let containerBuilder: ASN1ContainerBuilder + private let receiptBuilder: AppleReceiptBuilder + + internal init(logger: LoggerType, + containerBuilder: ASN1ContainerBuilder = ASN1ContainerBuilder(), + receiptBuilder: AppleReceiptBuilder = AppleReceiptBuilder()) { + self.logger = logger + self.containerBuilder = containerBuilder + self.receiptBuilder = receiptBuilder + } + + /// Returns the result of parsing the receipt from `receiptData` + /// - Throws: ``PurchasesReceiptParser/Error``. + public func parse(from receiptData: Data) throws -> AppleReceipt { + #if DEBUG + Self.ensureRunningOutsideOfMainThread() + #endif + + self.logger.info(ReceiptStrings.parsing_receipt) + + let asn1Container = try self.containerBuilder.build(fromPayload: ArraySlice(receiptData)) + guard let receiptASN1Container = try self.findASN1Container(withObjectId: ASN1ObjectIdentifier.data, + inContainer: asn1Container) else { + self.logger.error(ReceiptStrings.data_object_identifier_not_found_receipt) + throw Error.dataObjectIdentifierMissing + } + + let receipt = try self.receiptBuilder.build(fromContainer: receiptASN1Container) + self.logger.info(ReceiptStrings.parsing_receipt_success) + return receipt + } + +} + +public extension PurchasesReceiptParser { + + /// Returns the result of parsing the receipt from a base64 encoded string. + /// - Throws: ``PurchasesReceiptParser/Error``. + func parse(base64String string: String) throws -> AppleReceipt { + guard let data = Data(base64Encoded: string) else { + throw Error.failedToDecodeBase64String + } + + return try self.parse(from: data) + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension PurchasesReceiptParser: @unchecked Sendable {} + +// MARK: - Internal + +extension PurchasesReceiptParser { + + @objc + func receiptHasTransactions(receiptData: Data) -> Bool { + if let receipt = try? self.parse(from: receiptData) { + return !receipt.inAppPurchases.isEmpty + } + + self.logger.warn(ReceiptStrings.parsing_receipt_failed(fileName: #fileID, functionName: #function)) + return true + } + +} + +// MARK: - Private + +private extension PurchasesReceiptParser { + + func findASN1Container(withObjectId objectId: ASN1ObjectIdentifier, + inContainer container: ASN1Container) throws -> ASN1Container? { + if container.encodingType == .constructed { + for (index, internalContainer) in container.internalContainers.enumerated() { + if internalContainer.containerIdentifier == .objectIdentifier { + let objectIdentifier = try ASN1ObjectIdentifierBuilder.build( + fromPayload: internalContainer.internalPayload) + if objectIdentifier == objectId && index < container.internalContainers.count - 1 { + // the container that holds the data comes right after the one with the object identifier + return container.internalContainers[index + 1] + } + } else { + let receipt = try self.findASN1Container(withObjectId: objectId, inContainer: internalContainer) + if receipt != nil { + return receipt + } + } + } + } + return nil + } + + #if DEBUG + static func ensureRunningOutsideOfMainThread() { + // Only checking on integration tests. + // Unit tests might run on the main thread when testing this class directly. + if ProcessInfo.processInfo.environment["RCRunningIntegrationTests"] == "1" { + precondition( + !Thread.isMainThread, + "Receipt parsing should not run on the main thread." + ) + } + } + #endif + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/ReceiptParsingError.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/ReceiptParsingError.swift new file mode 100644 index 00000000..9ba50435 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/LocalReceiptParsing/ReceiptParsingError.swift @@ -0,0 +1,75 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ReceiptParsingError.swift +// Purchases +// +// Created by Andrés Boedo on 7/30/20. +// + +import Foundation + +extension PurchasesReceiptParser { + + /// An error thrown by ``PurchasesReceiptParser`` + public enum Error: Swift.Error { + + /// The data object identifier couldn't be found on the receipt. + case dataObjectIdentifierMissing + + /// Unable to parse ASN1 container. + case asn1ParsingError(description: String) + + /// Internal container was empty. + case receiptParsingError + + /// Failed to parse IAP. + case inAppPurchaseParsingError + + /// ``PurchasesReceiptParser/parse(base64String:)`` was unable + /// to decode the base64 string. + case failedToDecodeBase64String + + /// `Bundle.appStoreReceiptURL` returned `nil`. + case receiptNotPresent + + /// Fetching the local receipt failed with an underlying error + case failedToLoadLocalReceipt(Swift.Error) + + /// The receipt found on the device was found empty. + case foundEmptyLocalReceipt + + } +} + +extension PurchasesReceiptParser.Error: LocalizedError { + + // swiftlint:disable:next missing_docs + public var errorDescription: String? { + switch self { + case .dataObjectIdentifierMissing: + return "Couldn't find an object identifier of type data in the receipt" + case let .asn1ParsingError(description): + return "Error while parsing, payload can't be interpreted as ASN1. details: \(description)" + case .receiptParsingError: + return "Error while parsing the receipt. One or more attributes are missing." + case .inAppPurchaseParsingError: + return "Error while parsing in-app purchase. One or more attributes are missing or in the wrong format." + case .failedToDecodeBase64String: + return "Error decoding base64 string." + case .receiptNotPresent: + return "Error loading local receipt: Bundle.appStoreReceiptURL was nil." + case let .failedToLoadLocalReceipt(error): + return "Error loading local receipt: \(error)" + case .foundEmptyLocalReceipt: + return "Loaded receipt was found empty." + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/LogIntent.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/LogIntent.swift new file mode 100644 index 00000000..bf760690 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/LogIntent.swift @@ -0,0 +1,47 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// LogIntent.swift +// +// Created by Tina Nguyen on 12/08/20. +// + +import Foundation + +enum LogIntent { + + case verbose + case info + case purchase + case appleWarning + case appleError + case rcError + case rcPurchaseSuccess + case rcSuccess + case user + case warning + case simulatedStore + + var prefix: String { + switch self { + case .verbose: return "" + case .info: return "ℹ️" + case .purchase: return "💰" + case .appleWarning: return "🍎⚠️" + case .appleError: return "🍎‼️" + case .rcError: return "😿‼️" + case .rcPurchaseSuccess: return "😻💰" + case .rcSuccess: return "😻" + case .user: return "👤" + case .warning: return "⚠️" + case .simulatedStore: return "[Test Store]" + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Logger.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Logger.swift new file mode 100644 index 00000000..7ecc556d --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Logger.swift @@ -0,0 +1,321 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Logger.swift +// +// Created by Andrés Boedo on 11/13/20. +// + +import Foundation + +/// A function that can handle a log message including file and method information. +public typealias VerboseLogHandler = (_ level: LogLevel, + _ message: String, + _ file: String?, + _ function: String?, + _ line: UInt) -> Void + +/// A function that can handle a log message. +public typealias LogHandler = (_ level: LogLevel, + _ message: String) -> Void + +internal typealias InternalLogHandler = (_ level: LogLevel, + _ message: String, + _ category: String, + _ file: String?, + _ function: String?, + _ line: UInt) -> Void + +// MARK: - Logger + +// This is a `struct` instead of `enum` so that +// we can use `Logger()` as a `LoggerType`. +// swiftlint:disable:next convenience_type +struct Logger { + + static var logLevel: LogLevel = Self.defaultLogLevel + static var internalLogHandler: InternalLogHandler = Self.defaultLogHandler + + static let defaultLogHandler: InternalLogHandler = { level, message, category, file, functionName, line in + RCDefaultLogHandler( + Self.frameworkDescription, + Logger.verbose, + level, + category, + message, + file, + functionName, + line + ) + } + + static var verbose: Bool = false + + private static let defaultLogLevel: LogLevel = { + #if DEBUG + return .debug + #else + return .info + #endif + }() + + internal static let frameworkDescription = "Purchases" + +} + +extension Logger { + + static var verboseLogsEnabled: Bool { self.logLevel == .verbose } + +} + +// MARK: - LoggerType implementation + +/// `Logger` can be used both with static or instance methods. +/// This allows us to use it directly (`Logger.info("...")`), or inject it: +/// ```swift +/// let logger: LoggerType +/// logger.info("...") +/// ``` +extension Logger: LoggerType { + + func verbose(_ message: LogMessage, + fileName: String? = #fileID, + functionName: String? = #function, + line: UInt = #line) { + Self.verbose(message, fileName: fileName, functionName: functionName, line: line) + } + + func debug(_ message: LogMessage, + fileName: String? = #fileID, + functionName: String? = #function, + line: UInt = #line) { + Self.debug(message, fileName: fileName, functionName: functionName, line: line) + } + + func info(_ message: LogMessage, + fileName: String? = #fileID, + functionName: String? = #function, + line: UInt = #line) { + Self.info(message, fileName: fileName, functionName: functionName, line: line) + } + + func warn(_ message: LogMessage, + fileName: String? = #fileID, + functionName: String? = #function, + line: UInt = #line) { + Self.warn(message, fileName: fileName, functionName: functionName, line: line) + } + + func error(_ message: LogMessage, + fileName: String = #fileID, + functionName: String = #function, + line: UInt = #line) { + Self.error(message, fileName: fileName, functionName: functionName, line: line) + } + +} + +// MARK: - Static implementation + +extension Logger { + + static func verbose(_ message: LogMessage, + fileName: String? = #fileID, + functionName: String? = #function, + line: UInt = #line) { + Self.log(level: .verbose, intent: .verbose, message: message, + fileName: fileName, functionName: functionName, line: line) + } + + static func debug(_ message: LogMessage, + fileName: String? = #fileID, + functionName: String? = #function, + line: UInt = #line) { + Self.log(level: .debug, intent: .info, message: message, + fileName: fileName, functionName: functionName, line: line) + } + + static func info(_ message: LogMessage, + fileName: String? = #fileID, + functionName: String? = #function, + line: UInt = #line) { + Self.log(level: .info, intent: .info, message: message, + fileName: fileName, functionName: functionName, line: line) + } + + static func warn(_ message: LogMessage, + fileName: String? = #fileID, + functionName: String? = #function, + line: UInt = #line) { + Self.log(level: .warn, intent: .warning, message: message, + fileName: fileName, functionName: functionName, line: line) + } + + static func error(_ message: String, + fileName: String = #fileID, + functionName: String = #function, + line: UInt = #line) { + Self.error( + ErrorMessage(description: message), + fileName: fileName, + functionName: functionName, + line: line + ) + } + + static func error(_ message: LogMessage, + fileName: String = #fileID, + functionName: String = #function, + line: UInt = #line) { + Self.log(level: .error, intent: .rcError, message: message, + fileName: fileName, functionName: functionName, line: line) + } + +} + +extension Logger { + + static func appleError(_ message: String, + fileName: String = #fileID, + functionName: String = #function, + line: UInt = #line) { + Self.appleError( + ErrorMessage(description: message), + fileName: fileName, + functionName: functionName, + line: line + ) + } + + static func appleError(_ message: LogMessage, + fileName: String = #fileID, + functionName: String = #function, + line: UInt = #line) { + Self.log(level: .error, intent: .appleError, message: message, + fileName: fileName, functionName: functionName, line: line) + } + + static func appleWarning(_ message: LogMessage, + fileName: String = #fileID, + functionName: String = #function, + line: UInt = #line) { + Self.log(level: .warn, intent: .appleError, message: message, + fileName: fileName, functionName: functionName, line: line) + } + + static func purchase(_ message: LogMessage, + fileName: String = #fileID, + functionName: String = #function, + line: UInt = #line) { + Self.log(level: .info, intent: .purchase, message: message, + fileName: fileName, functionName: functionName, line: line) + } + + static func rcPurchaseSuccess(_ message: LogMessage, + fileName: String = #fileID, + functionName: String = #function, + line: UInt = #line) { + Self.log(level: .info, intent: .rcPurchaseSuccess, message: message, + fileName: fileName, functionName: functionName, line: line) + } + + static func rcPurchaseError(_ message: LogMessage, + fileName: String = #fileID, + functionName: String = #function, + line: UInt = #line) { + Self.log(level: .error, intent: .purchase, message: message, + fileName: fileName, functionName: functionName, line: line) + } + + static func rcSuccess(_ message: LogMessage, + fileName: String = #fileID, + functionName: String = #function, + line: UInt = #line) { + Self.log(level: .debug, intent: .rcSuccess, message: message, + fileName: fileName, functionName: functionName, line: line) + } + + static func user(_ message: LogMessage, + fileName: String? = #fileID, + functionName: String? = #function, + line: UInt = #line) { + Self.log(level: .debug, intent: .user, message: message, + fileName: fileName, functionName: functionName, line: line) + } + + static func simulatedStoreError(_ message: String, + fileName: String = #fileID, + functionName: String = #function, + line: UInt = #line) { + Self.log(level: .error, intent: .simulatedStore, message: ErrorMessage(description: message), + fileName: fileName, functionName: functionName, line: line) + } + + static func log(level: LogLevel, + intent: LogIntent, + message: LogMessage, + fileName: String? = #fileID, + functionName: String? = #function, + line: UInt = #line) { + guard self.logLevel <= level else { return } + + let message = message + let content = [intent.prefix.notEmpty, message.description] + .compactMap { $0 } + .joined(separator: " ") + + Self.internalLogHandler( + level, + content, + message.category, + fileName, + functionName, + line + ) + } + +} + +// MARK: - LogLevel comparable conformance + +extension LogLevel: Comparable { + + // swiftlint:disable:next missing_docs + public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { + // Tests ensure that this can't happen + guard let lhs = Self.order[lhs], let rhs = Self.order[rhs] else { return false } + + return lhs < rhs + } + + private static let orderedLevels: [LogLevel] = [ + .verbose, + .debug, + .info, + .warn, + .error + ] + static let order: [LogLevel: Int] = Dictionary(uniqueKeysWithValues: + Self.orderedLevels + .enumerated() + .lazy + .map { ($1, $0) } + ) + +} + +// MARK: - + +private struct ErrorMessage: LogMessage { + + let description: String + let category: String = "error" + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/AnalyticsStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/AnalyticsStrings.swift new file mode 100644 index 00000000..450ef9a7 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/AnalyticsStrings.swift @@ -0,0 +1,31 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Analytics.swift +// +// Created by Facundo Menzella on 27/2/25. + +import Foundation + +// swiftlint:disable identifier_name +enum AnalyticsStrings { + + case flush_events_success +} + +extension AnalyticsStrings: LogMessage { + var category: String { return "analytics" } + + var description: String { + switch self { + case .flush_events_success: + return "Events flush succeeded" + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/AttributionStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/AttributionStrings.swift new file mode 100644 index 00000000..242f5ebd --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/AttributionStrings.swift @@ -0,0 +1,155 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// AttributionStrings.swift +// +// Created by Andrés Boedo on 9/14/20. +// + +import Foundation + +// swiftlint:disable identifier_name +enum AttributionStrings { + + case appsflyer_id_deprecated + case attributes_sync_error(error: NSError?) + case attributes_sync_success(appUserID: String) + case empty_subscriber_attributes + case marking_attributes_synced(appUserID: String, attributes: SubscriberAttribute.Dictionary) + case setting_reserved_attribute(_ reservedAttribute: ReservedSubscriberAttribute) + case setting_attributes(attributes: [String]) + case networkuserid_required_for_appsflyer + case no_instance_configured_caching_attribution + case instance_configured_posting_attribution + case search_ads_attribution_cancelled_missing_att_framework + case att_framework_present_but_couldnt_call_tracking_authorization_status + case search_ads_attribution_cancelled_missing_ad_framework + case search_ads_attribution_cancelled_not_authorized + case skip_same_attributes + case subscriber_attributes_error(errors: [String: String]) + case unsynced_attributes_count(unsyncedAttributesCount: Int, appUserID: String) + case unsynced_attributes(unsyncedAttributes: SubscriberAttribute.Dictionary) + case attribute_set_locally(attribute: String) + case missing_advertiser_identifiers + case adservices_not_supported + case adservices_mocking_token(String) + case adservices_token_fetch_failed(error: Error) + case adservices_token_post_failed(error: BackendError) + case adservices_token_post_succeeded + case adservices_marking_as_synced(appUserID: String) + case adservices_token_unavailable_in_simulator + case latest_attribution_sent_user_defaults_invalid(networkKey: String) + case copying_attributes(oldAppUserID: String, newAppUserID: String) + +} + +extension AttributionStrings: LogMessage { + + var description: String { + switch self { + case .appsflyer_id_deprecated: + return "The parameter key rc_appsflyer_id is deprecated." + + " Pass networkUserId to addAttribution instead." + + case .attributes_sync_error(let error): + return "Error when syncing subscriber attributes. Details: \(error?.localizedDescription ?? "")" + + " \nUserInfo: \(error?.userInfo ?? [:])" + + case .attributes_sync_success(let appUserID): + return "Subscriber attributes synced successfully for App User ID: \(appUserID)" + + case .empty_subscriber_attributes: + return "Called post subscriber attributes with an empty attributes dictionary!" + + case .marking_attributes_synced(let appUserID, let attributes): + return "Marking attributes as synced for App User ID: \(appUserID):\n attributes: \(attributes.description)" + + case .setting_reserved_attribute(let reservedAttribute): + return "setting reserved attribute: \(reservedAttribute.key)" + + case .setting_attributes(let attributes): + return "setting values for attributes: \(attributes)" + + case .networkuserid_required_for_appsflyer: + return "The parameter networkUserId is REQUIRED for AppsFlyer." + + case .no_instance_configured_caching_attribution: + return "There is no purchase instance configured, caching attribution" + + case .instance_configured_posting_attribution: + return "There is a purchase instance configured, posting attribution" + + case .search_ads_attribution_cancelled_missing_att_framework: + return "Tried to post Apple Search Ads Attribution, " + + "but ATT Framework is required on this OS and it isn't included" + + case .att_framework_present_but_couldnt_call_tracking_authorization_status: + return "ATT Framework was found but it didn't respond to authorization status selector!" + + case .search_ads_attribution_cancelled_missing_ad_framework: + return "Tried to post Apple Search Ads Attribution, " + + "but Apple Ad Framework is is required for it and it isn't included" + + case .search_ads_attribution_cancelled_not_authorized: + return "Tried to post Apple Search Ads Attribution, but " + + "authorization hasn't been granted. Will automatically retry if authorization gets granted." + + case .skip_same_attributes: + return "Attribution data is the same as latest. Skipping." + + case let .subscriber_attributes_error(errors): + return "Subscriber attributes errors: \(errors.description))" + + case .unsynced_attributes_count(let unsyncedAttributesCount, let appUserID): + return "Found \(unsyncedAttributesCount) unsynced attributes for App User ID: \(appUserID)" + + case .unsynced_attributes(let unsyncedAttributes): + return "Unsynced attributes: \(unsyncedAttributes)" + + case .attribute_set_locally(let attribute): + return "Attribute set locally: \(attribute). It will be synced to the backend" + + " when the app backgrounds/foregrounds or when a purchase is made." + + case .missing_advertiser_identifiers: + return "Attribution error: identifierForAdvertisers is missing" + + case .adservices_not_supported: + return "Tried to fetch AdServices attribution token on device without " + + "AdServices support." + + case let .adservices_mocking_token(token): + return "AdServices: mocking token: \(token) for tests" + + case .adservices_token_fetch_failed(let error): + return "Fetching AdServices attribution token failed with error: \(error.localizedDescription)" + + case .adservices_token_post_failed(let error): + return "Posting AdServices attribution token failed with error: \(error.localizedDescription)" + + case .adservices_token_post_succeeded: + return "AdServices attribution token successfully posted" + + case let .adservices_marking_as_synced(userID): + return "Marking AdServices attribution token as synced for App User ID: \(userID)" + + case .adservices_token_unavailable_in_simulator: + return "AdServices attribution token is not available in the simulator" + + case .latest_attribution_sent_user_defaults_invalid(let networkKey): + return "Attribution data stored in UserDefaults has invalid format for network key: \(networkKey)" + + case .copying_attributes(let oldAppUserID, let newAppUserID): + return "Copying unsynced subscriber attributes from user \(oldAppUserID) to user \(newAppUserID)" + + } + } + + var category: String { return "attribution" } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/BackendErrorStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/BackendErrorStrings.swift new file mode 100644 index 00000000..e01aad9a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/BackendErrorStrings.swift @@ -0,0 +1,49 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// BackendErrorStrings.swift +// +// Created by Joshua Liebowitz on 10/26/21. + +import Foundation + +// swiftlint:disable identifier_name +enum BackendErrorStrings { + + // Backend tried to instantiate a CustomerInfo but for some reason it couldn't. + case customer_info_instantiation_error(response: [String: Any]?) + + // getOfferings response contained no offerings. + case offerings_response_no_offerings + + // Posting offerIdForSigning failed due to a signature problem. + case signature_error(signatureDataString: Any?) + +} + +extension BackendErrorStrings: LogMessage { + + var description: String { + switch self { + case .customer_info_instantiation_error(let response): + var message = "Login failed, unable to instantiate \(CustomerInfo.self)" + if let response = response { + message += " from:\n \(response.debugDescription)" + } + return message + case .offerings_response_no_offerings: + return "Offerings response contained no offerings" + case .signature_error(let signatureDataString): + return "Missing 'signatureData' or its structure changed:\n\(String(describing: signatureDataString))" + } + } + + var category: String { return "backend" } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/CacheStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/CacheStrings.swift new file mode 100644 index 00000000..19aa68cf --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/CacheStrings.swift @@ -0,0 +1,41 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CacheStrings.swift +// +// Created by Rick van der Linden on 12/01/2026. + +import Foundation + +// swiftlint:disable identifier_name +enum CacheStrings { + + case cache_url_not_available + case failed_to_save_codable_to_cache(Error) + case failed_to_delete_old_cache_directory(Error) + case failed_to_migrate_file(String, Error) + +} + +extension CacheStrings: LogMessage { + var description: String { + switch self { + case .cache_url_not_available: + return "Cache URL is not available" + case .failed_to_save_codable_to_cache(let error): + return "Failed to save codable to cache: \(error)" + case .failed_to_delete_old_cache_directory(let error): + return "Failed to delete old cache directory: \(error)" + case .failed_to_migrate_file(let path, let error): + return "Failed to migrate file from \(path): \(error)" + } + } + + var category: String { return "cache" } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/CodableStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/CodableStrings.swift new file mode 100644 index 00000000..9e5dd94e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/CodableStrings.swift @@ -0,0 +1,71 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CodableStrings.swift +// +// Created by Juanpe Catalán on 29/8/21. + +import Foundation + +// swiftlint:disable identifier_name +enum CodableStrings { + + case invalid_data_when_decoding(Data, _ type: Any.Type) + case unexpectedValueError(type: Any.Type, value: Any) + case valueNotFoundError(value: Any.Type, context: DecodingError.Context) + case keyNotFoundError(type: Any.Type, key: CodingKey, context: DecodingError.Context) + case invalid_json_error(jsonData: [String: Any]) + case encoding_error(_ error: Error) + case decoding_error(_ error: Error, _ type: Any.Type) + case corrupted_data_error(context: DecodingError.Context) + case typeMismatch(type: Any, context: DecodingError.Context) + +} + +extension CodableStrings: LogMessage { + + var description: String { + switch self { + case let .invalid_data_when_decoding(data, type): + let content = String(data: data, encoding: .utf8) ?? "" + return "Encountered error when decoding JSON for '\(type)': \(content)" + case let .unexpectedValueError(type, value): + return "Found unexpected value '\(value)' for type '\(type)'" + case let .valueNotFoundError(value, context): + let description = context.debugDescription + return "No value found for: \(value), codingPath: \(context.codingPath), description:\n\(description)" + case let .keyNotFoundError(type, key, context): + let description = context.debugDescription + return "Error deserializing `\(type)`. " + + "Key '\(key)' not found, codingPath: \(context.codingPath), description:\n\(description)" + case let .invalid_json_error(jsonData): + return "The given json data was not valid: \n\(jsonData)" + case let .encoding_error(error): + return "Couldn't encode data into json. Error:\n\(error.localizedDescription)" + case let .decoding_error(error, type): + let underlyingErrorMessage: String + if let underlyingError = (error as NSError).userInfo[NSUnderlyingErrorKey] as? NSError { + underlyingErrorMessage = "\nUnderlying error: \(underlyingError.debugDescription)" + } else { + underlyingErrorMessage = "" + } + + return "Couldn't decode '\(type)' from json.\nError: \((error as NSError).description)" + + underlyingErrorMessage + case let .corrupted_data_error(context): + return "Couldn't decode data from json, it was corrupted: \(context)" + case let .typeMismatch(type, context): + let description = context.debugDescription + return "Type '\(type)' mismatch, codingPath:\(context.codingPath), description:\n\(description)" + } + } + + var category: String { return "codable" } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/ConfigureStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/ConfigureStrings.swift new file mode 100644 index 00000000..81563148 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/ConfigureStrings.swift @@ -0,0 +1,218 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ConfigureStrings.swift +// +// Created by Tina Nguyen on 12/11/20. +// + +import Foundation + +// swiftlint:disable identifier_name +enum ConfigureStrings { + + case purchases_init(Purchases, EitherPaymentQueueWrapper) + + case purchases_deinit(Purchases) + + case adsupport_not_imported + + case application_foregrounded + + case configuring_purchases_proxy_url_set(url: String) + + case debug_enabled + + case observer_mode_enabled + + case response_verification_mode(Signing.ResponseVerificationMode) + + case storekit_version(StoreKitVersion) + + case delegate_set + + case purchase_instance_already_set + + case initial_app_user_id(isSet: Bool) + + case no_singleton_instance + + case sdk_version(String) + + case bundle_id(String) + + case system_version(String) + + case is_simulator(Bool) + + case simulatedStoreAPIKey + + case legacyAPIKey + + case invalidAPIKey + + case autoSyncPurchasesDisabled + + case using_custom_user_defaults + + case using_user_defaults_standard + + case using_user_defaults_suite_name + + case public_key_could_not_be_found(fileName: String) + + case custom_entitlements_computation_enabled + + case custom_entitlements_computation_enabled_but_no_app_user_id + + case timeout_lower_than_minimum(timeout: TimeInterval, minimum: TimeInterval) + + case sk2_required_for_swiftui_paywalls + + case record_purchase_requires_purchases_made_by_my_app + + case sk2_required + + case sk2_invalid_inapp_purchase_key +} + +extension ConfigureStrings: LogMessage { + + var description: String { + switch self { + case let .purchases_init(purchases, wrapper): + return "Purchases.init: created new Purchases instance: " + + "\(Strings.objectDescription(purchases))\nStoreKit Wrapper: \(wrapper)" + case let .purchases_deinit(purchases): + return "Purchases.deinit: " + + "\(Strings.objectDescription(purchases))" + case .adsupport_not_imported: + return "AdSupport framework not imported. Attribution data incomplete." + case .application_foregrounded: + return "applicationWillEnterForeground" + case .configuring_purchases_proxy_url_set(let url): + return "Purchases is being configured using a proxy for RevenueCat " + + "with URL: \(url)" + case .debug_enabled: + return "Debug logging enabled" + case .observer_mode_enabled: + return "Purchases is configured with purchasesAreCompletedBy set to .myApp" + case let .response_verification_mode(mode): + switch mode { + case .disabled: + return "Purchases is configured with response verification disabled" + case .informational: + return "Purchases is configured with informational response verification" + case .enforced: + return "Purchases is configured with enforced response verification" + } + case let .storekit_version(version): + return "Purchases is configured with StoreKit version \(version)" + case .delegate_set: + return "Delegate set" + case .purchase_instance_already_set: + return "Purchases instance already set. Did you mean to configure two Purchases objects?" + case .initial_app_user_id(let isSet): + return isSet + ? "Initial App User ID set" + : "No initial App User ID" + case .no_singleton_instance: + return "There is no singleton instance. Make sure you configure Purchases before " + + "trying to get the default instance. More info here: https://errors.rev.cat/configuring-sdk" + case let .sdk_version(sdkVersion): + return "SDK Version - \(sdkVersion)" + case let .bundle_id(bundleID): + return "Bundle ID - \(bundleID)" + case let .system_version(osVersion): + return "System Version - \(osVersion)" + case let .is_simulator(isSimulator): + return isSimulator + ? "Using a simulator. Ensure you have a StoreKit Config " + + "file set up before trying to fetch products or make purchases.\n" + + "See https://errors.rev.cat/testing-in-simulator for more details." + : "Not using a simulator." + case .simulatedStoreAPIKey: + return "Using a Test Store API key.\n" + + "The Test Store is for development only. Never use a Test Store API key in production. " + + "Test Store purchases are simulated, do not use StoreKit, and generate no revenue. " + + "Apps submitted with a Test Store API key will be rejected during App Review." + case .legacyAPIKey: + return "Looks like you're using a legacy API key.\n" + + "This is still supported, but it's recommended to migrate to using platform-specific API key, " + + "which should look like 'appl_1a2b3c4d5e6f7h'.\n" + + "See https://rev.cat/auth for more details." + case .invalidAPIKey: + return "The specified API Key is not recognized.\n" + + "Ensure that you are using the public app-specific API key, " + + " which should look like 'appl_1a2b3c4d5e6f7h'.\n" + + "See https://rev.cat/auth for more details." + + case .autoSyncPurchasesDisabled: + return "Automatic syncing of purchases has been disabled. \n" + + "RevenueCat won’t observe the StoreKit queue, and it will not sync any purchase \n" + + "automatically. Call syncPurchases whenever a new transaction is completed so the \n" + + "receipt is sent to RevenueCat’s backend. Consumables disappear from the receipt \n" + + "after the transaction is finished, so make sure purchases are synced before \n" + + "finishing any consumable transaction, otherwise RevenueCat won’t register the \n" + + "purchase." + + case .using_custom_user_defaults: + return "Configuring SDK using provided UserDefaults." + + case .using_user_defaults_standard: + return "Configuring SDK using UserDefaults.standard because we found existing data in it." + + case .using_user_defaults_suite_name: + return "Configuring SDK using RevenueCat's UserDefaults suite." + + case let .public_key_could_not_be_found(fileName): + return "Could not find public key '\(fileName)'" + + case .custom_entitlements_computation_enabled: + return "Entering customEntitlementComputation mode. CustomerInfo cache will not be " + + "automatically fetched. Anonymous user IDs will be disallowed, logOut will be disabled, " + + "and the PurchasesDelegate's customerInfo listener will only get called after a receipt is posted, " + + "getCustomerInfo is called or logIn is called." + + case .custom_entitlements_computation_enabled_but_no_app_user_id: + return "customEntitlementComputation mode is enabled, but appUserID is nil. " + + "When using customEntitlementComputation, you must set the appUserID to prevent anonymous IDs from " + + "being generated." + + case let .timeout_lower_than_minimum(timeout, minimum): + return """ + Timeout value: \(timeout) is lower than the minimum, setting it + to the mimimum: (\(minimum)) + """ + + case .sk2_required_for_swiftui_paywalls: + return "Purchases is not configured with StoreKit 2 enabled. This is required in order to detect " + + "transactions coming from SwiftUI paywalls. You must use `.with(storeKitVersion: .storeKit2)` " + + "when configuring the SDK." + + case .record_purchase_requires_purchases_made_by_my_app: + return "Attempted to manually handle transactions with purchasesAreCompletedBy not set to .myApp. " + + "You must use `.with(purchasesAreCompletedBy: .myApp, storeKitVersion: .storeKit2)` " + + "when configuring the SDK." + + case .sk2_required: + return "StoreKit 2 must be enabled. You must use `.with(storeKitVersion: .storeKit2)` " + + "when configuring the SDK." + + case .sk2_invalid_inapp_purchase_key: + return "Failed to post the transaction to RevenueCat's backend because your Apple In-App Purchase Key is " + + "invalid or not present. This error is thrown only in debug builds; in production, it will fail " + + "silently. You must configure an In-App Purchase Key. Please see " + + "https://rev.cat/in-app-purchase-key-configuration for more info." + } + } + + var category: String { return "configure" } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/CustomerInfoStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/CustomerInfoStrings.swift new file mode 100644 index 00000000..dd2181b1 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/CustomerInfoStrings.swift @@ -0,0 +1,98 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CustomerInfoStrings.swift +// +// Created by Tina Nguyen on 12/11/20. +// + +import Foundation + +// swiftlint:disable identifier_name +enum CustomerInfoStrings { + + case checking_intro_eligibility_locally_error(error: Error) + case checking_intro_eligibility_locally_result(productIdentifiers: [String: IntroEligibilityStatus]) + case checking_intro_eligibility_locally + case checking_intro_eligibility_locally_from_receipt(AppleReceipt) + case invalidating_customerinfo_cache + case no_cached_customerinfo + case cached_customerinfo_incompatible_schema + case not_caching_offline_customer_info + case customerinfo_stale_updating_in_background + case customerinfo_stale_updating_in_foreground + case customerinfo_updated_from_network + case customerinfo_updated_from_network_error(BackendError) + case customerinfo_updated_offline + case posting_transactions_in_lieu_of_fetching_customerinfo([StoreTransaction]) + case updating_request_date(CustomerInfo, Date) + case sending_latest_customerinfo_to_delegate + case sending_updated_customerinfo_to_delegate + case vending_cache + case error_encoding_customerinfo(Error) + +} + +extension CustomerInfoStrings: LogMessage { + + var description: String { + switch self { + case .checking_intro_eligibility_locally_error(let error): + return "Couldn't check intro eligibility locally, error: \(error.localizedDescription)" + case .checking_intro_eligibility_locally_result(let productIdentifiers): + return "Local intro eligibility computed locally. Result: \(productIdentifiers)" + case .checking_intro_eligibility_locally: + return "Attempting to check intro eligibility locally" + case let .checking_intro_eligibility_locally_from_receipt(receipt): + return "Checking intro eligibility locally from receipt: \((try? receipt.prettyPrintedJSON) ?? "")" + case .invalidating_customerinfo_cache: + return "Invalidating CustomerInfo cache." + case .no_cached_customerinfo: + return "No cached CustomerInfo, fetching from network." + case .cached_customerinfo_incompatible_schema: + return "Cached CustomerInfo has incompatible schema." + case .not_caching_offline_customer_info: + return "CustomerInfo was computed offline. Won't be stored in cache." + case .customerinfo_stale_updating_in_background: + return "CustomerInfo cache is stale, updating from network in background." + case .customerinfo_stale_updating_in_foreground: + return "CustomerInfo cache is stale, updating from network in foreground." + case .customerinfo_updated_from_network: + return "CustomerInfo updated from network." + case let .customerinfo_updated_from_network_error(error): + var result = "Attempt to update CustomerInfo from network failed.\n\(error.localizedDescription)" + + if let underlyingError = error.underlyingError { + result += "\nUnderlying error: \(underlyingError.localizedDescription)" + } + + return result + case .customerinfo_updated_offline: + return "There was an error communicating with RevenueCat servers. " + + "CustomerInfo was temporarily computed offline, and it will be posted again as soon as possible." + case let .posting_transactions_in_lieu_of_fetching_customerinfo(transactions): + return "Found \(transactions.count) unfinished transactions, will post receipt in lieu " + + "of fetching CustomerInfo:\n\(transactions)" + case let .updating_request_date(info, newRequestDate): + return "Updating CustomerInfo '\(info.originalAppUserId)' request date: \(newRequestDate)" + case .sending_latest_customerinfo_to_delegate: + return "Sending latest CustomerInfo to delegate." + case .sending_updated_customerinfo_to_delegate: + return "Sending updated CustomerInfo to delegate." + case .vending_cache: + return "Vending CustomerInfo from cache." + case let .error_encoding_customerinfo(error): + return "Couldn't encode CustomerInfo:\n\(error)" + } + + } + + var category: String { return "customer" } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/DiagnosticsStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/DiagnosticsStrings.swift new file mode 100644 index 00000000..8cfc780c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/DiagnosticsStrings.swift @@ -0,0 +1,111 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DiagnosticsStrings.swift +// +// Created by Nacho Soto on 6/8/23. + +import Foundation + +// swiftlint:disable identifier_name + +enum DiagnosticsStrings { + + case timing_message(message: String, duration: TimeInterval) + case could_not_create_diagnostics_tracker + + case event_sync_already_in_progress + case event_sync_with_empty_store + case event_sync_starting(count: Int) + + case syncing_events_due_to_enough_file_size_reached + + case error_fetching_events(error: Error) + + case could_not_synchronize_diagnostics(error: Error) + + case invalid_sent_diagnostics_count(count: Int) + case failed_to_clean_sent_diagnostics(error: Error) + case failed_to_empty_diagnostics_file(error: Error) + case failed_check_diagnostics_size(error: Error) + case failed_to_store_diagnostics_event(error: Error) + case failed_to_create_diagnostics_file_url + case failed_to_initialize_file_handler(error: Error) + case failed_to_delete_old_diagnostics_file(error: Error) + case failed_to_serialize_diagnostic_event(error: Error) + + case failed_diagnostics_sync_more_than_max_retries + +} + +extension DiagnosticsStrings: LogMessage { + + var description: String { + switch self { + case let .timing_message(message, duration): + let roundedDuration = (duration * 100).rounded(.down) / 100 + return String(format: "%@ (%.2f seconds)", message.description, roundedDuration) + + case .could_not_create_diagnostics_tracker: + return "Could not create DiagnosticsTracker" + + case .event_sync_already_in_progress: + return "Diagnostics event flushing already in progress. Skipping." + + case .event_sync_with_empty_store: + return "Diagnostics event flushing requested with empty store." + + case let .event_sync_starting(count): + return "Diagnostics event flush: posting \(count) events." + + case .syncing_events_due_to_enough_file_size_reached: + return "Syncing diagnostics events since enough file size reached" + + case let .error_fetching_events(error): + return "Failed to read lines from file: \(error.localizedDescription)" + + case let .could_not_synchronize_diagnostics(error): + return "Failed to synchronize diagnostics: \(error.localizedDescription)" + + case let .invalid_sent_diagnostics_count(count): + return "Invalid sent diagnostics count: \(count)" + + case let .failed_to_clean_sent_diagnostics(error): + return "Failed to clean sent diagnostics: \(error.localizedDescription)" + + case let .failed_to_empty_diagnostics_file(error): + return "Failed to empty diagnostics file: \(error.localizedDescription)" + + case let .failed_check_diagnostics_size(error): + return "Failed to check whether diagnostics file is too big: \(error.localizedDescription)" + + case let.failed_to_store_diagnostics_event(error): + return "Failed to store diagnostics event: \(error.localizedDescription)" + + case let .failed_to_delete_old_diagnostics_file(error): + return "Failed to delete old diagnostics file: \(error.localizedDescription)" + + case .failed_to_create_diagnostics_file_url: + return "Failed to create diagnostics file directory" + + case .failed_to_initialize_file_handler(error: let error): + return "Initialization error: \(error.localizedDescription)" + + case .failed_to_serialize_diagnostic_event: + return "Failed to serialize diagnostics event to JSON" + + case .failed_diagnostics_sync_more_than_max_retries: + return "Failed to sync diagnostics more than max retries. Clearing entire diagnostics file." + + } + } + + var category: String { return "diagnostics" } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/ETagStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/ETagStrings.swift new file mode 100644 index 00000000..4235d9b3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/ETagStrings.swift @@ -0,0 +1,71 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ETagStrings.swift +// +// Created by Nacho Soto on 6/15/23. + +import Foundation + +// swiftlint:disable identifier_name + +enum ETagStrings { + + case clearing_cache + case found_no_etag(URLRequest) + case could_not_find_cached_response_in_already_retried(response: String) + case storing_response(URLRequest, ETagManager.Response) + case not_storing_etag(VerifiedHTTPResponse) + case using_etag(URLRequest, String, Date?) + case not_using_etag(URLRequest, + VerificationResult, + needsSignatureVerification: Bool) + +} + +extension ETagStrings: LogMessage { + + var description: String { + switch self { + case .clearing_cache: + return "Clearing ETagManager cache" + + case let .found_no_etag(request): + return "Found no etag for request to '\(request.urlDescription)'" + + case let .could_not_find_cached_response_in_already_retried(response): + return "We can't find the cached response, but call has already been retried. " + + "Returning result from backend \(response)" + case let .storing_response(request, response): + return "Storing etag '\(response.eTag)' for request to '\(request.urlDescription)' (\(response.statusCode))" + + case let .not_storing_etag(response): + return "Not storing etag for: '\(response.description)'" + + case let .using_etag(request, etag, validationTime): + return "Using etag '\(etag)' for request to '\(request.urlDescription)'. " + + "Validation time: \(validationTime?.description ?? "")" + + case let .not_using_etag(request, storedVerificationResult, needsSignatureVerification): + return "Not using etag for '\(request.urlDescription)'. " + + "Requested verification: \(needsSignatureVerification). Stored result: \(storedVerificationResult)" + } + } + + var category: String { return "etags" } + +} + +private extension URLRequest { + + var urlDescription: String { + return self.url?.absoluteString ?? "" + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/EligibilityStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/EligibilityStrings.swift new file mode 100644 index 00000000..545f480a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/EligibilityStrings.swift @@ -0,0 +1,61 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// EligibilityStrings.swift +// +// Created by Nacho Soto on 10/31/22. + +import Foundation + +// swiftlint:disable identifier_name + +enum EligibilityStrings { + + case found_cached_eligibility_for_products(_ identifiers: Set) + case caching_intro_eligibility_for_products(_ identifiers: Set) + case clearing_intro_eligibility_cache + case unable_to_get_intro_eligibility_for_user(error: Error) + case check_eligibility_no_identifiers + case check_eligibility_failed(productIdentifier: String, error: Error) + case sk2_intro_eligibility_too_slow + +} + +extension EligibilityStrings: LogMessage { + + var description: String { + switch self { + case let .found_cached_eligibility_for_products(identifiers): + return "Found cached trial or intro eligibility for products: \(identifiers)" + + case let .caching_intro_eligibility_for_products(identifiers): + return "Caching trial or intro eligibility for products: \(identifiers)" + + case .clearing_intro_eligibility_cache: + return "Detected active subscriptions changed. Clearing trial or intro eligibility cache." + + case let .unable_to_get_intro_eligibility_for_user(error): + return "Unable to get intro eligibility for appUserID: \(error.localizedDescription)" + + case .check_eligibility_no_identifiers: + return "Requested trial or introductory price eligibility with no identifiers. " + + "This is likely a program error." + + case let .check_eligibility_failed(productIdentifier, error): + return "Error checking discount eligibility for product '\(productIdentifier)': \(error).\n" + + "Will be considered not eligible." + + case .sk2_intro_eligibility_too_slow: + return "StoreKit 2 intro eligibility took longer than expected to determine" + } + } + + var category: String { return "eligibility" } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/FileRepositoryStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/FileRepositoryStrings.swift new file mode 100644 index 00000000..8e4ad83b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/FileRepositoryStrings.swift @@ -0,0 +1,40 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// FileRepositoryStrings.swift +// +// Created by Jacob Zivan Rakidzich on 8/14/25. + +import Foundation + +enum FileRepositoryStrings { + + case failedToSaveCachedFile(URL, Error) + case failedToFetchFileFromRemoteSource(URL, Error) + case failedToCreateCacheDirectory(URL) + case failedToCreateTemporaryFile(URL) + +} + +extension FileRepositoryStrings: LogMessage { + var description: String { + switch self { + case .failedToSaveCachedFile(let url, let error): + return "Failed to save file to \(url.absoluteString): \(error)" + case .failedToFetchFileFromRemoteSource(let url, let error): + return "Failed to download file from \(url.absoluteString): \(error)" + case .failedToCreateCacheDirectory(let url): + return "Failed to create cache directory for \(url.absoluteString)" + case .failedToCreateTemporaryFile(let url): + return "Failed to create a temporary file for \(url.absoluteString)" + } + } + + var category: String { return "file_repository" } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/IdentityStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/IdentityStrings.swift new file mode 100644 index 00000000..c392204e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/IdentityStrings.swift @@ -0,0 +1,92 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// IdentityStrings.swift +// +// Created by Tina Nguyen on 12/11/20. + +import Foundation + +// swiftlint:disable identifier_name +enum IdentityStrings { + + case logging_in_with_empty_appuserid + + case logging_in_with_same_appuserid + + case logging_in_with_static_string + + case logging_in_with_preview_mode_appuserid + + case login_success + + case log_out_called_for_user + + case log_out_success + + case identifying_app_user_id + + case null_currentappuserid + + case deleting_attributes_none_found + + case invalidating_http_cache + + case switching_user(newUserID: String) + + case switching_user_same_app_user_id(newUserID: String) + + case sync_attributes_and_offerings_rate_limit_reached(maxCalls: Int, period: Int) + +} + +extension IdentityStrings: LogMessage { + + var description: String { + switch self { + case .logging_in_with_empty_appuserid: + return "The appUserID is empty. " + + "This method should only be called with non-empty values." + case .logging_in_with_same_appuserid: + return "The appUserID passed to logIn is the same as the one " + + "already cached. No action will be taken." + case .logging_in_with_static_string: + return "The appUserID passed to logIn is a constant string known at compile time. " + + "This is likely a programmer error. This ID is used to identify the current user. " + + "See https://docs.revenuecat.com/docs/user-ids for more information." + case .logging_in_with_preview_mode_appuserid: + return "Using the default preview mode appUserID. The passed appUserID was ignored." + case .login_success: + return "Log in successful" + case .log_out_called_for_user: + return "Log out called for user" + case .log_out_success: + return "Log out successful" + case .identifying_app_user_id: + return "Identifying App User ID" + case .null_currentappuserid: + return "currentAppUserID is nil. This might happen if the cache in UserDefaults is unintentionally cleared." + case .deleting_attributes_none_found: + return "Attempt to delete attributes for user, but there were none to delete" + case .invalidating_http_cache: + return "Detected unverified cached CustomerInfo but verification is enabled. Invalidating ETag cache." + case let .switching_user(newUserID): + return "Switching to user '\(newUserID)'." + case let .switching_user_same_app_user_id(newUserID): + return "switchUser(to:) called with the same appUserID as the current user (\(newUserID)). " + + "This has no effect." + case let .sync_attributes_and_offerings_rate_limit_reached(maxCalls, period): + return "Sync attributes and offerings rate limit reached:\(maxCalls) per \(period) seconds. " + + "Returning offerings from cache." + } + } + + var category: String { return "identity" } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/ManageSubscriptionsStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/ManageSubscriptionsStrings.swift new file mode 100644 index 00000000..3eaff2c5 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/ManageSubscriptionsStrings.swift @@ -0,0 +1,51 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ManageSubscriptionsStrings.swift +// +// Created by Andrés Boedo on 13/12/21. + +import Foundation + +// swiftlint:disable identifier_name + +extension ManageSubscriptionsHelper { + + enum Strings { + + case error_from_appstore_show_manage_subscription(error: Error) + case failed_to_get_management_url_error_unknown(error: Error) + case management_url_nil_opening_default + case show_manage_subscriptions_called_in_unsupported_platform + case susbscription_management_sheet_dismissed + + } + +} + +extension ManageSubscriptionsHelper.Strings: LogMessage { + + var description: String { + switch self { + case .error_from_appstore_show_manage_subscription(let error): + return "Error when trying to show manage subscription: \(error.localizedDescription)" + case .failed_to_get_management_url_error_unknown(let error): + return "Failed to get managementURL from CustomerInfo. Details: \(error.localizedDescription)" + case .management_url_nil_opening_default: + return "managementURL is nil, opening Apple's subscription management page" + case .susbscription_management_sheet_dismissed: + return "Subscription management sheet dismissed." + case .show_manage_subscriptions_called_in_unsupported_platform: + return "tried to call AppStore.showManageSubscriptions in a platform that doesn't support it!" + } + } + + var category: String { return "manage_subscriptions" } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/NetworkStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/NetworkStrings.swift new file mode 100644 index 00000000..bba3c707 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/NetworkStrings.swift @@ -0,0 +1,190 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// NetworkStrings.swift +// +// Created by Tina Nguyen on 12/11/20. +// + +import Foundation + +// swiftlint:disable identifier_name +enum NetworkStrings { + + case api_request_started(HTTPRequest) + case api_request_completed( + _ request: HTTPRequest, + httpCode: HTTPStatusCode, + metadata: HTTPClient.ResponseMetadata? + ) + case api_request_failed(_ request: HTTPRequest, + httpCode: HTTPStatusCode?, + error: NetworkError, + metadata: HTTPClient.ResponseMetadata?) + case api_request_failed_status_code(HTTPStatusCode) + case api_request_queued_for_retry(httpMethod: String, + retryNumber: UInt, + path: String, + backoffInterval: TimeInterval) + case api_request_failed_all_retries(httpMethod: String, path: String, retryCount: UInt) + case reusing_existing_request_for_operation(CacheableNetworkOperation.Type, String) + case enqueing_operation(CacheableNetworkOperation.Type, cacheKey: String) + case creating_json_error(error: String) + case json_data_received(dataString: String) + case parsing_json_error(error: Error) + case serial_request_done(httpMethod: String?, path: String?, queuedRequestsCount: Int) + case serial_request_queued(httpMethod: String, path: String, queuedRequestsCount: Int) + case starting_next_request(request: String) + case starting_request(httpMethod: String, path: String) + case retrying_request(httpMethod: String, path: String) + case retrying_request_with_fallback_path(httpMethod: String, path: String) + case failing_url_resolved_to_host(url: URL, resolvedHost: String) + case blocked_network(url: URL, newHost: String?) + case api_request_redirect(from: URL, to: URL) + case operation_state(NetworkOperation.Type, state: String) + case request_handled_by_load_shedder(HTTPRequestPath) + + #if DEBUG + case api_request_forcing_server_error(HTTPRequest) + case api_request_faking_error_response(HTTPRequest) + case api_request_forcing_signature_failure(HTTPRequest) + case api_request_disabling_header_parameter_signature_verification(HTTPRequest) + case api_request_response_both_fallback_and_load_shedder(HTTPRequest) + #endif + +} + +extension NetworkStrings: LogMessage { + + var description: String { + switch self { + case let .api_request_started(request): + return "API request started: \(request.description)" + + case let .api_request_completed(request, httpCode, metadata): + let prefix = "API request completed: \(request.description) (\(httpCode.rawValue))" + + if let metadata { + return prefix + "\n" + metadata.description + } else { + return prefix + } + + case let .api_request_failed(request, statusCode, error, metadata): + let prefix = "API request failed: \(request.description) (\(statusCode?.rawValue.description ?? "<>")): " + + "\(error.description)" + + if let metadata { + return prefix + "\n" + metadata.description + } else { + return prefix + } + + case let .api_request_failed_status_code(statusCode): + return "API request failed with status code \(statusCode.rawValue)" + + case let .reusing_existing_request_for_operation(operationType, cacheKey): + return "Network operation '\(operationType)' found with the same cache key " + + "'\(cacheKey)'. Skipping request." + + case let .enqueing_operation(operationType, cacheKey): + return "Enqueing network operation '\(operationType)' with cache key: '\(cacheKey)'" + + case let .creating_json_error(error): + return "Error creating request with body: \(error)" + + case .json_data_received(let dataString): + return "Data received: \(dataString)" + + case .parsing_json_error(let error): + return "Error parsing JSON \(error.localizedDescription)" + + case let .serial_request_done(httpMethod, path, queuedRequestsCount): + return "Serial request done: \(httpMethod ?? "") \(path ?? ""), " + + "\(queuedRequestsCount) requests left in the queue" + + case let .serial_request_queued(httpMethod, path, queuedRequestsCount): + return "There's a request currently running and \(queuedRequestsCount) requests left in the queue, " + + "queueing \(httpMethod) \(path)" + + case .starting_next_request(let request): + return "Starting the next request in the queue, \(request)" + + case let .starting_request(httpMethod, path): + return "There are no requests currently running, starting request \(httpMethod) \(path)" + + case let .retrying_request(httpMethod, path): + return "Retrying request \(httpMethod) \(path)" + + case let .retrying_request_with_fallback_path(httpMethod, path): + return "Retrying request using fallback host: \(httpMethod) \(path)" + + case let .failing_url_resolved_to_host(url, resolvedHost): + return "Failing url '\(url)' resolved to host '\(resolvedHost)'" + + case let .blocked_network(url, newHost): + return "It looks like requests to RevenueCat are being blocked. Context: We're attempting to connect " + + "to \(url.absoluteString) host: (\(newHost ?? "")), " + + "see: https://rev.cat/dnsBlocking for more info." + + case let .api_request_redirect(from, to): + return "Performing redirect from '\(from.absoluteString)' to '\(to.absoluteString)'" + + case let .operation_state(operation, state): + return "\(operation): \(state)" + + case let .request_handled_by_load_shedder(path): + return "Request was handled by load shedder: \(path.relativePath)" + + case let .api_request_queued_for_retry(httpMethod, retryNumber, path, backoffInterval): + return "Queued request \(httpMethod) \(path) for retry number \(retryNumber) in \(backoffInterval) seconds." + + case let .api_request_failed_all_retries(httpMethod, path, retryCount): + return "Request \(httpMethod) \(path) failed all \(retryCount) retries." + + #if DEBUG + case let .api_request_forcing_server_error(request): + return "Forcing server error for request \(request.description)" + + case let .api_request_faking_error_response(request): + return "Faking error response for request \(request.description)" + + case let .api_request_forcing_signature_failure(request): + return "Returning fake signature verification failure for '\(request.description)'" + + case let .api_request_disabling_header_parameter_signature_verification(request): + return "Disabling header parameter signature verification for '\(request.description)'" + + case let .api_request_response_both_fallback_and_load_shedder(request): + return "Request to fallback URL was handled by load shedder, " + + "which should never happen. Request: '\(request.description)'" + #endif + } + } + + var category: String { return "network" } + +} + +private extension HTTPRequest { + + var description: String { + return "\(self.method.httpMethod) '\(self.path.relativePath)'" + } + +} + +private extension HTTPClient.ResponseMetadata { + + var description: String { + return "Request-ID: '\(self.requestID ?? "")'; " + + "Amzn-Trace-ID: '\(self.amazonTraceID ?? "")'" + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/OfferingStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/OfferingStrings.swift new file mode 100644 index 00000000..c72accc0 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/OfferingStrings.swift @@ -0,0 +1,223 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// OfferingStrings.swift +// +// Created by Tina Nguyen on 12/11/20. +// + +import Foundation +import StoreKit + +// swiftlint:disable identifier_name +enum OfferingStrings { + + case cannot_find_product_configuration_error(identifiers: Set) + case fetching_offerings_error(error: OfferingsManager.Error, underlyingError: Error?) + case error_fetching_offerings_using_disk_cache + case found_existing_product_request(identifiers: Set) + case no_cached_offerings_fetching_from_network + case offerings_stale_updated_from_network + case offerings_stale_updating_in_background + case offerings_stale_updating_in_foreground + case products_already_cached(identifiers: Set) + case product_cache_invalid_for_storefront_change + case vending_offerings_cache_from_memory + case vending_offerings_cache_from_disk + case retrieved_products(products: [SKProduct]) + case list_products(productIdentifier: String, product: SKProduct) + case invalid_product_identifiers(identifiers: Set) + case fetching_products_finished + case fetching_products(identifiers: Set) + case completion_handlers_waiting_on_products(handlersCount: Int) + case configuration_error_products_not_found + case configuration_error_no_products_for_offering(apiKeyValidationResult: Configuration.APIKeyValidationResult) + case offering_empty(offeringIdentifier: String) + case product_details_empty_title(productIdentifier: String) + case unknown_package_type(Package) + case custom_package_type(Package) + case overriding_package(old: String, new: String) + case known_issue_ios_18_4_simulator_products_not_found + + // Custom Variables + case ui_config_no_custom_variables + case ui_config_custom_variables_decoded(keys: [String]) + case ui_config_custom_variables_decode_error(error: Error) + case ui_config_custom_variables_status(keyPresent: Bool, count: Int, keys: [String]) + +} + +extension OfferingStrings: LogMessage { + + var description: String { + switch self { + case .cannot_find_product_configuration_error(let identifiers): + return "Could not find products with identifiers: \(identifiers)." + + "\nThere is a problem with your configuration in App Store Connect. " + + "\nMore info here: https://errors.rev.cat/configuring-products" + + case let .fetching_offerings_error(error, underlyingError): + var result = "Error fetching offerings - \(error.localizedDescription)" + + if let message = error.errorDescription { + result += "\n\(message)" + } + + if let underlyingError = underlyingError { + result += "\nUnderlying error: \(underlyingError.localizedDescription)" + } + + return result + + case .error_fetching_offerings_using_disk_cache: + return "Error fetching offerings. Using disk cache" + + case .found_existing_product_request(let identifiers): + return "Found an existing request for products: \(identifiers), appending " + + "to completion" + + case .no_cached_offerings_fetching_from_network: + return "No cached Offerings, fetching from network" + + case .offerings_stale_updated_from_network: + return "Offerings updated from network." + + case .offerings_stale_updating_in_background: + return "Offerings cache is stale, updating from " + + "network in background" + + case .offerings_stale_updating_in_foreground: + return "Offerings cache is stale, updating from " + + "network in foreground" + + case let .products_already_cached(identifiers): + return "Skipping products request for these products because they were already " + + "cached: \(identifiers)" + + case .product_cache_invalid_for_storefront_change: + return "Storefront change detected. Invalidating and re-fetching product cache." + + case .vending_offerings_cache_from_memory: + return "Vending Offerings from memory cache" + + case .vending_offerings_cache_from_disk: + return "Vending Offerings from disk cache" + + case .retrieved_products(let products): + return "Retrieved SKProducts: \(products)" + + case let .list_products(productIdentifier, product): + return "\(productIdentifier) - \(product)" + + case .invalid_product_identifiers(let identifiers): + return "Invalid Product Identifiers - \(identifiers)" + + case .fetching_products_finished: + return "Products request finished." + + case .fetching_products(let identifiers): + return "Requesting products from the store with identifiers: \(identifiers)" + + case .completion_handlers_waiting_on_products(let handlersCount): + return "\(handlersCount) completion handlers waiting on products" + + case .configuration_error_products_not_found: + return "There's a problem with your configuration. None of the products registered in the RevenueCat " + + "dashboard could be fetched from App Store Connect (or the StoreKit Configuration file " + + "if one is being used). \nMore information: https://rev.cat/why-are-offerings-empty" + + case .configuration_error_no_products_for_offering(let apiKeyValidationResult): + var description: String + if let storeNameForLogging = apiKeyValidationResult.storeNameForLogging { + description = "You have configured the SDK with \(apiKeyValidationResult.indefiniteArticle) " + + "\(storeNameForLogging) API key, but there are no \(storeNameForLogging) products registered in the " + + "RevenueCat dashboard for your offerings." + } else { + description = "You have configured the SDK with an API key from a store that has no products " + + "registered in the RevenueCat dashboard for your offerings." + } + description += " If you don't want to use the offerings system, you can safely ignore this message. " + + "To configure offerings and their products, follow the instructions in " + + "https://rev.cat/how-to-configure-offerings.\nMore information: https://rev.cat/why-are-offerings-empty" + return description + + case .offering_empty(let offeringIdentifier): + return "There's a problem with your configuration. No packages could be found for offering with " + + "identifier \(offeringIdentifier). This could be due to Products not being configured correctly in the " + + "RevenueCat dashboard, App Store Connect (or the StoreKit Configuration file " + + "if one is being used). \nTo configure products, follow the instructions in " + + "https://rev.cat/how-to-configure-offerings. \nMore information: https://rev.cat/why-are-offerings-empty" + + case let .product_details_empty_title(identifier): + return "Empty Product titles are not supported. Found in product with identifier: \(identifier)" + + case let .unknown_package_type(package): + return "Package '\(package.identifier)' in offering " + + "'\(package.presentedOfferingContext.offeringIdentifier)' has an unknown duration." + + "\nYou can reference this package by its identifier ('\(package.identifier)') directly." + + "\nMore information: https://rev.cat/displaying-products" + + case let .custom_package_type(package): + return "Package '\(package.identifier)' in offering " + + "'\(package.presentedOfferingContext.offeringIdentifier)' has a custom duration." + + "\nYou can reference this package by its identifier ('\(package.identifier)') directly." + + "\nMore information: https://rev.cat/displaying-products" + + case let .overriding_package(old, new): + return "Package: \(old) already exists, overwriting with: \(new)" + + case .known_issue_ios_18_4_simulator_products_not_found: + return "None of the products registered in the RevenueCat dashboard could be fetched from App Store " + + "Connect (or the StoreKit Configuration file if one is being used)." + + "\nThis issue is widely reported by iOS 18.4 simulator users. Try using a different iOS version with " + + "your simulator." + + "\nMore information: https://rev.cat/ios-18-4-simulator-issue" + + case .ui_config_no_custom_variables: + return "UIConfig decoded with no custom_variables. " + + "If you expected default custom variables, ensure they are configured in the RevenueCat dashboard." + + case .ui_config_custom_variables_decoded(let keys): + return "UIConfig decoded with custom_variables: \(keys)" + + case .ui_config_custom_variables_decode_error(let error): + return "Failed to decode custom_variables from UIConfig: \(error.localizedDescription)" + + case .ui_config_custom_variables_status(let keyPresent, let count, let keys): + // swiftlint:disable:next line_length + return "UIConfig custom_variables - key present in JSON: \(keyPresent), decoded count: \(count), keys: \(keys)" + } + } + + var category: String { return "offering" } + +} + +private extension Configuration.APIKeyValidationResult { + + var storeNameForLogging: String? { + switch self { + case .validApplePlatform, .legacy: + return "App Store" + case .simulatedStore: + return "Test Store" + case .otherPlatforms: + return nil + } + } + + var indefiniteArticle: String { + switch self { + case .validApplePlatform, .legacy: + return "an" // "an App Store API key" + case .otherPlatforms, .simulatedStore: + return "a" // "a Test Store API key" + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/OfflineEntitlementsStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/OfflineEntitlementsStrings.swift new file mode 100644 index 00000000..182ed6a1 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/OfflineEntitlementsStrings.swift @@ -0,0 +1,121 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// OfflineEntitlementsStrings.swift +// +// Created by Nacho Soto on 3/22/23. + +import Foundation +import StoreKit + +// swiftlint:disable identifier_name + +enum OfflineEntitlementsStrings { + + case offline_entitlements_not_available + + case product_entitlement_mapping_stale_updating + case product_entitlement_mapping_updated_from_network + case product_entitlement_mapping_unavailable + case product_entitlement_mapping_fetching_error(BackendError) + case found_unverified_transactions_in_sk2(transactionID: UInt64, Error) + + case computing_offline_customer_info_with_no_entitlement_mapping + case computing_offline_customer_info_for_consumable_product + case computing_offline_customer_info + case computing_offline_customer_info_failed(Error) + case computed_offline_customer_info([PurchasedSK2Product], EntitlementInfos) + case computed_offline_customer_info_details([PurchasedSK2Product], EntitlementInfos) + + case purchased_products_fetching + case purchased_products_fetched(count: Int) + case purchased_products_fetching_too_slow + case purchased_products_returning_cache(count: Int) + case purchased_products_invalidating_cache + +} + +extension OfflineEntitlementsStrings: LogMessage { + + var description: String { + switch self { + case .offline_entitlements_not_available: + return "Offline entitlements not available." + + case .product_entitlement_mapping_stale_updating: + return "ProductEntitlementMapping cache is stale, updating from network." + + case .product_entitlement_mapping_updated_from_network: + return "ProductEntitlementMapping cache updated from network." + + case .product_entitlement_mapping_unavailable: + return "Offline entitlements aren't available, won't fetch ProductEntitlementMapping." + + case let .product_entitlement_mapping_fetching_error(error): + return "Failed updating ProductEntitlementMapping from network: \(error.localizedDescription)" + + case let .found_unverified_transactions_in_sk2(transactionID, error): + return """ + Found an unverified transaction. It will be ignored and will not be a part of CustomerInfo. + Details: + Error: \(error.localizedDescription) + Transaction ID: \(transactionID) + """ + + case .computing_offline_customer_info_with_no_entitlement_mapping: + return "Unable to compute offline CustomerInfo with no product entitlement mapping." + + case .computing_offline_customer_info_for_consumable_product: + return "Unable to compute offline CustomerInfo when purchasing consumable products." + + case .computing_offline_customer_info: + return "Encountered a server error. Will attempt to compute an offline CustomerInfo from local purchases." + + case let .computing_offline_customer_info_failed(error): + return "Error computing offline CustomerInfo. Will return original error.\n" + + "Creation error: \(error.localizedDescription)" + + case let .computed_offline_customer_info(products, entitlements): + return "Computed offline CustomerInfo from \(products.count) products " + + "with \(entitlements.active.count) active entitlements." + + case let .computed_offline_customer_info_details(products, entitlements): + let productIDs = products + .lazy + .map(\.productIdentifier) + .joined(separator: ", ") + let entitlements = entitlements + .active + .values + .lazy + .map(\.identifier) + .joined(separator: ", ") + + return "Purchased products: [\(productIDs)]. Active entitlements: [\(entitlements)]." + + case .purchased_products_fetching: + return "PurchasedProductsFetcher: fetching products from StoreKit" + + case let .purchased_products_fetched(count): + return "PurchasedProductsFetcher: fetched \(count) products from StoreKit" + + case .purchased_products_fetching_too_slow: + return "PurchasedProductsFetcher: fetching products took too long" + + case let .purchased_products_returning_cache(count): + return "PurchasedProductsFetcher: returning \(count) cached products" + + case .purchased_products_invalidating_cache: + return "PurchasedProductsFetcher: invalidating cache" + } + } + + var category: String { return "offline_entitlements" } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/PaywallsStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/PaywallsStrings.swift new file mode 100644 index 00000000..0d483f14 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/PaywallsStrings.swift @@ -0,0 +1,182 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallsStrings.swift +// +// Created by Nacho Soto on 08/7/23. + +import Foundation + +// swiftlint:disable identifier_name + +enum PaywallsStrings { + + case warming_up_eligibility_cache(products: Set) + case warming_up_images(imageURLs: Set) + case warming_up_fonts(fontsURLS: Set) + case warming_up_videos(videoURLs: Set) + case error_prefetching_image(URL, Error) + case font_download_already_in_progress(name: String, fontURL: URL) + case font_downloaded_sucessfully(name: String, fontURL: URL) + case triggering_font_download(fontURL: URL) + case error_creating_fonts_directory(Error) + case error_installing_font(URL, Error) + case error_prefetching_font_invalid_url(name: String, invalidURLString: String) + + case caching_purchase_initiated_paywall + case clearing_purchase_initiated_paywall + case missing_product_id_for_paywall_event + + // MARK: - Localization + + case empty_localization + case looking_up_localization(preferred: [Locale], search: [Locale]) + case found_localization(Locale) + case default_localization(localeIdentifier: String) + case fallback_localization(localeIdentifier: String) + + // MARK: - Events + + case event_manager_initialized + case event_manager_not_initialized_not_available + case event_manager_failed_to_initialize(Error) + + case event_flush_already_in_progress + case event_flush_with_empty_store + case event_flush_starting(count: Int) + case event_sync_failed(Error) + case event_flush_failed(Error) + case event_cannot_serialize + case event_cannot_get_encoded_event + case event_cannot_deserialize(Error) + case event_missing_app_session_id + + case background_task_started(String) + case background_task_expired(String) + case background_task_failed(String) + case background_task_unavailable + +} + +extension PaywallsStrings: LogMessage { + + var description: String { + switch self { + case let .warming_up_eligibility_cache(products): + return "Warming up intro eligibility cache for \(products.count) products" + + case let .warming_up_images(imageURLs): + return "Warming up paywall images cache: \(imageURLs)" + + case let .warming_up_fonts(fontsURLS): + return "Warming up paywall fonts cache: \(fontsURLS)" + + case let .error_prefetching_image(url, error): + return "Error pre-fetching paywall image '\(url)': \((error as NSError).description)" + + case let .font_download_already_in_progress(fontName, fontURL): + return "Font '\(fontName)' download already in progress with url: \(fontURL.absoluteString)" + + case let .font_downloaded_sucessfully(fontName, fontURL): + return "Successfully downloaded and cached font '\(fontName)' from url: \(fontURL.absoluteString)" + + case let .triggering_font_download(fontURL): + return "Downloading remote font from url: \(fontURL.absoluteString)" + + case let .error_creating_fonts_directory(error): + return "Failed to create fonts directory: \((error as NSError).description)" + + case let .error_installing_font(url, error): + return "Error installing font with url: '\(url)': \((error as NSError).description)" + + case let .error_prefetching_font_invalid_url(name, invalidURLString): + return "Error installing font \(name). Malformed url: \(invalidURLString)" + + case .caching_purchase_initiated_paywall: + return "PurchasesOrchestrator: caching paywall from purchase initiated event" + + case .clearing_purchase_initiated_paywall: + return "PurchasesOrchestrator: clearing paywall from purchase initiated event" + + case .missing_product_id_for_paywall_event: + return "PurchasesOrchestrator: cancel or purchaseError event is missing productId. " + + "This should never happen." + + case .empty_localization: + return "Looking up localization but found no strings" + + case let .looking_up_localization(preferred, search): + return "Looking up localized configuration for \(preferred.map(\.identifier)), " + + "searching for \(search.map(\.identifier))" + + case let .found_localization(locale): + return "Found localized configuration for '\(locale.identifier)'" + + case let .default_localization(localeIdentifier): + return "No localized configuration found, using default: \(localeIdentifier)" + + case let .fallback_localization(localeIdentifier): + return "Failed looking up localization, using fallback: \(localeIdentifier)" + + // MARK: - Events + + case .event_manager_initialized: + return "EventsManager initialized" + + case .event_manager_not_initialized_not_available: + return "Won't initialize EventsManager: not available on current device." + + case let .event_manager_failed_to_initialize(error): + return "EventsManager won't be initialized, event store failed to create " + + "with error: \((error as NSError).localizedDescription)" + + case .event_flush_already_in_progress: + return "Paywall event flushing already in progress. Skipping." + + case .event_flush_with_empty_store: + return "Paywall event flushing requested with empty store." + + case let .event_flush_starting(count): + return "Paywall event flush: posting \(count) events." + + case let .event_sync_failed(error): + return "Paywall event flushing failed, will retry. Error: \((error as NSError).localizedDescription)" + + case let .event_flush_failed(error): + return "Paywall event flush failed: \((error as NSError).localizedDescription)" + + case .event_cannot_serialize: + return "Couldn't serialize PaywallEvent to storage." + + case .event_cannot_get_encoded_event: + return "Couldn't get encoded event from storage." + + case let .event_cannot_deserialize(error): + return "Couldn't deserialize PaywallEvent from storage. Error: \((error as NSError).description)" + + case .event_missing_app_session_id: + return "Event is missing the app session ID." + case .warming_up_videos(videoURLs: let videoURLs): + return "Warming up paywall video cache: \(videoURLs)" + + case let .background_task_started(taskName): + return "Background task started: \(taskName)" + case let .background_task_expired(taskName): + return "Background task expired: \(taskName)" + case let .background_task_failed(taskName): + return "Background task failed to start: \(taskName)" + case .background_task_unavailable: + return "Background tasks unavailable (app extension or no UIApplication access)" + + } + } + + var category: String { return "paywalls" } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/PurchaseStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/PurchaseStrings.swift new file mode 100644 index 00000000..da373d4f --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/PurchaseStrings.swift @@ -0,0 +1,400 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PurchaseStrings.swift +// +// Created by Tina Nguyen on 12/11/20. +// + +import Foundation +import StoreKit + +// swiftlint:disable identifier_name + +enum PurchaseStrings { + + case storekit1_wrapper_init(StoreKit1Wrapper) + case storekit1_wrapper_deinit(StoreKit1Wrapper) + case device_cache_init(DeviceCache) + case device_cache_deinit(DeviceCache) + case purchases_orchestrator_init(PurchasesOrchestrator) + case purchases_orchestrator_deinit(PurchasesOrchestrator) + case updating_all_caches + case not_updating_caches_while_products_are_in_progress + case cannot_purchase_product_appstore_configuration_error + case entitlements_revoked_syncing_purchases(productIdentifiers: [String]) + case entitlement_expired_outside_grace_period(expiration: Date, reference: Date) + case finishing_transaction(StoreTransactionType) + case finish_transaction_skipped_because_its_missing_in_non_subscriptions(StoreTransactionType, + [NonSubscriptionTransaction]) + case purchasing_with_observer_mode_and_finish_transactions_false_warning + case paymentqueue_revoked_entitlements_for_product_identifiers(productIdentifiers: [String]) + case paymentqueue_adding_payment(SKPaymentQueue, SKPayment) + case paymentqueue_removed_transaction(SKPaymentTransactionObserver, + SKPaymentTransaction) + case paymentqueue_removed_transaction_no_callbacks_found(SKPaymentTransactionObserver, + SKPaymentTransaction, + observerMode: Bool) + case paymentqueue_updated_transaction(SKPaymentTransactionObserver, + SKPaymentTransaction) + case presenting_code_redemption_sheet + case unable_to_present_redemption_sheet + case purchases_synced + case purchasing_product(StoreProduct, Package?, PromotionalOffer.SignedData?, [String: String]?) + + case purchased_product(productIdentifier: String) + case product_purchase_failed(productIdentifier: String, error: Error) + case skpayment_missing_from_skpaymenttransaction + case skpayment_missing_product_identifier + case sktransaction_missing_transaction_date(SKPaymentTransactionState) + case sktransaction_missing_transaction_identifier + case could_not_purchase_product_id_not_found + case payment_identifier_nil + case purchases_nil + case purchases_delegate_set_multiple_times + case purchases_delegate_set_to_nil + case requested_products_not_found(request: SKRequest) + case promo_purchase_product_not_found(productIdentifier: String) + case callback_not_found_for_request(request: SKRequest) + case duplicate_refund_request(details: String) + case failed_refund_request(details: String) + case unknown_refund_request_error(details: String) + case unknown_refund_request_error_type(details: String) + case unknown_refund_request_status + case product_unpurchased_or_missing + case transaction_unverified(productID: String, errorMessage: String) + case unknown_purchase_result(result: String) + case begin_refund_no_entitlement_found(entitlementID: String?) + case begin_refund_no_active_entitlement + case begin_refund_multiple_active_entitlements + case begin_refund_customer_info_error(entitlementID: String?) + case missing_cached_customer_info + case sk2_transactions_update_received_transaction(productID: String) + case transaction_poster_handling_transaction(transactionID: String, + productID: String, + transactionDate: Date, + offeringID: String?, + placementID: String?, + paywallSessionID: UUID?) + case caching_presented_offering_identifier(offeringID: String, productID: String) + case payment_queue_wrapper_delegate_call_sk1_enabled + case restorepurchases_called_with_allow_sharing_appstore_account_false + case sk2_observer_mode_error_processing_transaction(Error) + + case unable_to_find_root_view_controller_for_simulated_purchase + case invalid_quantity(quantity: Int) + + // Test Store + case sync_purchases_simulated_store + case restore_purchases_simulated_store + case simulating_purchase_success + + // Cached metadata + case posting_remaining_cached_metadata(count: Int) + case posting_cached_metadata(transactionId: String) + case cached_transaction_metadata_sync_already_in_progress + case no_cached_transaction_metadata_to_post + case finished_posting_cached_metadata +} + +extension PurchaseStrings: LogMessage { + + var description: String { + switch self { + case let .storekit1_wrapper_init(instance): + return "StoreKit1Wrapper.init: \(Strings.objectDescription(instance))" + + case let .storekit1_wrapper_deinit(instance): + return "StoreKit1Wrapper.deinit: \(Strings.objectDescription(instance))" + + case let .device_cache_init(instance): + return "DeviceCache.init: \(Strings.objectDescription(instance))" + + case let .device_cache_deinit(instance): + return "DeviceCache.deinit: \(Strings.objectDescription(instance))" + + case let .purchases_orchestrator_init(instance): + return "PurchasesOrchestrator.init: \(Strings.objectDescription(instance))" + + case let .purchases_orchestrator_deinit(instance): + return "PurchasesOrchestrator.deinit: \(Strings.objectDescription(instance))" + + case .updating_all_caches: + return "Updating all caches" + + case .not_updating_caches_while_products_are_in_progress: + return "Detected purchase in progress: will skip cache updates" + + case .cannot_purchase_product_appstore_configuration_error: + return "Could not purchase SKProduct. " + + "There is a problem with your configuration in App Store Connect. " + + "More info here: https://errors.rev.cat/configuring-products" + + case .entitlements_revoked_syncing_purchases(let productIdentifiers): + return "Entitlements revoked for product " + + "identifiers: \(productIdentifiers). \nsyncing purchases" + + case let .entitlement_expired_outside_grace_period(expiration, reference): + return "Entitlement is no longer active (expired \(expiration)) " + + "and it's outside grace period window (last updated \(reference))" + + case let .finishing_transaction(transaction): + return "Finishing transaction '\(transaction.transactionIdentifier)' " + + "for product '\(transaction.productIdentifier)'" + + case let .finish_transaction_skipped_because_its_missing_in_non_subscriptions(transaction, nonSubscriptions): + return "Transaction '\(transaction.transactionIdentifier)' will not be finished: " + + "it's a non-subscription and it's missing in CustomerInfo list: \(nonSubscriptions)" + + case .purchasing_with_observer_mode_and_finish_transactions_false_warning: + return "purchasesAreCompletedBy is not set to .myApp and " + + "purchase has been initiated. RevenueCat will not finish the " + + "transaction, are you sure you want to do this?" + + case .paymentqueue_revoked_entitlements_for_product_identifiers(let productIdentifiers): + return "PaymentQueue didRevokeEntitlementsForProductIdentifiers: \(productIdentifiers)" + + case let .paymentqueue_adding_payment(queue, payment): + return "Adding payment for product '\(payment.productIdentifier)'. " + + "\(queue.transactions.count) transactions already in the queue." + + case let .paymentqueue_removed_transaction(observer, transaction): + let errorUserInfo = (transaction.error as NSError?)?.userInfo ?? [:] + + return "\(observer.debugName) removedTransaction: \(transaction.payment.productIdentifier) " + + [ + transaction.transactionIdentifier, + transaction.original?.transactionIdentifier, + (transaction.error?.localizedDescription).map { "(\($0))" }, + !errorUserInfo.isEmpty ? errorUserInfo.description : nil, + transaction.transactionState.rawValue.description + ] + .compactMap { $0 } + .joined(separator: " ") + + case let .paymentqueue_removed_transaction_no_callbacks_found(observer, transaction, observerMode): + // Transactions finished with observer mode won't have a callback because they're being finished + // by the developer and not our SDK. + let shouldIncludeCompletionBlockMessage = !observerMode + + let prefix = "\(observer.debugName) removedTransaction for \(transaction.payment.productIdentifier) " + + "but no callbacks to notify." + let completionBlockMessage = "If the purchase completion block is not being invoked after this, " + + "it likely means that some other code outside of the RevenueCat SDK is calling " + + "`SKPaymentQueue.finishTransaction`, which is interfering with RevenueCat purchasing state handling." + + return shouldIncludeCompletionBlockMessage + ? prefix + "\n" + completionBlockMessage + : prefix + + case let .paymentqueue_updated_transaction(observer, transaction): + return "\(observer.debugName) updatedTransaction: \(transaction.payment.productIdentifier) " + + [ + transaction.transactionIdentifier, + (transaction.error?.localizedDescription).map { "(\($0))" }, + transaction.original?.transactionIdentifier ?? nil, + transaction.transactionState.rawValue.description + ] + .compactMap { $0 } + .joined(separator: " ") + + case .presenting_code_redemption_sheet: + return "Presenting code redemption sheet." + + case .unable_to_present_redemption_sheet: + return "SKPaymentQueue.presentCodeRedemptionSheet is not available in the current platform, " + + "this is an Apple bug." + + case .purchases_synced: + return "Purchases synced." + + case let .purchasing_product(product, package, discount, metadata): + var message = "Purchasing Product '\(product.productIdentifier)'" + if let package = package { + message += " from package in Offering " + + "'\(package.presentedOfferingContext.offeringIdentifier)'" + } + if let discount = discount { + message += " with Offer '\(discount.identifier)'" + } + if let metadata = metadata { + message += " with metadata: \(metadata)" + } + return message + + case let .purchased_product(productIdentifier): + return "Purchased product - '\(productIdentifier)'" + + case let .product_purchase_failed(productIdentifier, error): + return "Product purchase for '\(productIdentifier)' failed with error: \(error)" + + case .skpayment_missing_from_skpaymenttransaction: + return """ + The SKPaymentTransaction has a nil value for SKPayment - this is an bug in StoreKit. + Transactions in the backend and in webhooks are unaffected. + """ + + case .skpayment_missing_product_identifier: + return "There is a problem with the SKPayment missing " + + "a product identifier - this is an issue with the App Store." + + case let .sktransaction_missing_transaction_date(transactionState): + return """ + The SKPaymentTransaction has a nil value for transaction date - this is a bug in StoreKit. + Unix Epoch will be used instead for the transaction within the app. + Transactions in the backend and in webhooks are unaffected and will have the correct timestamps. + Transaction state: \(transactionState) + """ + + case .sktransaction_missing_transaction_identifier: + return """ + The SKPaymentTransaction has a nil value for transaction identifier - this is a bug in StoreKit. + Transactions in the backend and in webhooks are unaffected and will have the correct identifier. + """ + + case .could_not_purchase_product_id_not_found: + return "makePurchase - Could not purchase SKProduct. " + + "Couldn't find its product identifier. This is possibly an App Store quirk." + + case .payment_identifier_nil: + return "Apple returned a payment where the productIdentifier is nil, " + + "this is possibly an App Store quirk" + + case .purchases_nil: + return "Purchases has not been configured. Please call Purchases.configure()" + + case .purchases_delegate_set_multiple_times: + return "Purchases delegate has already been configured." + + case .purchases_delegate_set_to_nil: + return "Purchases delegate is being set to nil, " + + "you probably don't want to do this." + + case .requested_products_not_found(let request): + return "requested products not found for request: \(request)" + + case let .promo_purchase_product_not_found(productIdentifier): + return "Unable to perform promotional purchase from App Store: product '\(productIdentifier)' not found" + + case .callback_not_found_for_request(let request): + return "callback not found for failing request: \(request)" + + case .duplicate_refund_request(let details): + return "Refund already requested for this product and is either pending, already denied, " + + "or already approved: \(details)" + case .failed_refund_request(let details): + return "Refund request submission failed: \(details)" + case .unknown_refund_request_error_type(let details): + return "Unknown RefundRequestError type from the AppStore: \(details)" + case .unknown_refund_request_error(let details): + return "Unknown error type returned from AppStore: \(details)" + case .unknown_refund_request_status: + return "Unknown RefundRequestStatus returned from AppStore" + case .product_unpurchased_or_missing: + return "Product hasn't been purchased or doesn't exist." + case .transaction_unverified(let productID, let errorMessage): + return "Transaction for productID \(productID) is unverified by AppStore.\n" + + "Verification error: \(errorMessage)" + case let .unknown_purchase_result(result): + return "Received unknown purchase result: \(result)" + case .begin_refund_no_entitlement_found(let entitlementID): + return "Could not find \(entitlementID.flatMap { "entitlement with ID " + $0 } ?? "active entitlement")" + + " for refund." + case .begin_refund_no_active_entitlement: + return "Could not begin refund request. No active entitlement." + case .begin_refund_multiple_active_entitlements: + return "Could not begin refund request. There are multiple active entitlements. Use" + + " `beginRefundRequest(forEntitlement:)` to specify a single entitlement instead." + case .begin_refund_customer_info_error(let entitlementID): + return "Failed to get CustomerInfo to proceed with refund for " + + "\(entitlementID.flatMap { "entitlement with ID " + $0 } ?? "active entitlement")." + case .missing_cached_customer_info: + return "Requested a cached CustomerInfo but it's not available." + + case let .sk2_transactions_update_received_transaction(productID): + return "StoreKit.Transaction.updates: received transaction for product '\(productID)'" + + case let .transaction_poster_handling_transaction(transactionID, + productID, + date, + offeringID, + placementID, + paywallSessionID): + var message = "TransactionPoster: handling transaction '\(transactionID)' " + + "for product '\(productID)' (date: \(date))" + + if let offeringIdentifier = offeringID { + message += " in Offering '\(offeringIdentifier)'" + } + + if let placementIdentifier = placementID { + message += " with Placement '\(placementIdentifier)'" + } + + if let paywallSessionID { + message += " with paywall session '\(paywallSessionID)'" + } + + return message + + case let .caching_presented_offering_identifier(offeringID, productID): + return "Caching presented offering identifier '\(offeringID)' for product '\(productID)'" + + case .payment_queue_wrapper_delegate_call_sk1_enabled: + return "Unexpectedly received PaymentQueueWrapperDelegate call with SK1 enabled" + + case .restorepurchases_called_with_allow_sharing_appstore_account_false: + return "allowSharingAppStoreAccount is set to false and restorePurchases has been called. " + + "Are you sure you want to do this?" + case let .sk2_observer_mode_error_processing_transaction(error): + return "RevenueCat could not process transaction completed by your app: \(error)" + + case .unable_to_find_root_view_controller_for_simulated_purchase: + return "Unable to find root view controller to present Test Store purchase alert." + + case let .invalid_quantity(quantity): + return "Quantity must be between 1 and 10, but got \(quantity)." + + case .sync_purchases_simulated_store: + return "Syncing purchases not available in Test Store. Returning current CustomerInfo." + + case .restore_purchases_simulated_store: + return "Restoring purchases not available in Test Store. Returning current CustomerInfo." + + case .simulating_purchase_success: + return "[Test Store] Performing test purchase. This purchase won't appear in production." + + case let .posting_remaining_cached_metadata(count): + return "Posting \(count) remaining cached transaction metadata entries" + + case let .posting_cached_metadata(transactionId): + return "Posting cached metadata for transaction '\(transactionId)'" + + case .cached_transaction_metadata_sync_already_in_progress: + return "Cached transaction metadata sync already in progress, skipping" + + case .no_cached_transaction_metadata_to_post: + return "No cached transaction metadata to sync" + + case .finished_posting_cached_metadata: + return "Finished syncing all cached transaction metadata" + } + } + + var category: String { return "purchases" } + +} + +private extension SKPaymentTransactionObserver { + + var debugName: String { + return Strings.objectDescription(self) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/SigningStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/SigningStrings.swift new file mode 100644 index 00000000..ef0e4e32 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/SigningStrings.swift @@ -0,0 +1,135 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SigningStrings.swift +// +// Created by Nacho Soto on 2/7/23. + +import Foundation + +// swiftlint:disable identifier_name +enum SigningStrings { + + case invalid_public_key(String) + + case signature_not_base64(String) + + case signature_invalid_size(Data) + + case signature_failed_verification + case signature_passed_verification + + case request_failed_verification(HTTPRequest) + + case intermediate_key_failed_verification(signature: Data) + case intermediate_key_failed_creation(Error) + case intermediate_key_expired(Date, Data) + case intermediate_key_invalid(Data) + case intermediate_key_creating(expiration: Date, data: Data) + + case signature_was_requested_but_not_provided(HTTPRequest) + + case request_date_missing_from_headers(HTTPRequest) + + #if DEBUG + case verifying_signature(signature: Data, + publicKey: Data, + parameters: Signing.SignatureParameters, + salt: Data, + payload: Data, + message: Data?) + case invalid_signature_data(HTTPRequest, Data?, HTTPClient.ResponseHeaders, HTTPStatusCode) + #endif + +} + +extension SigningStrings: LogMessage { + + var description: String { + switch self { + case let .invalid_public_key(key): + return "Public key could not be loaded: \(key)" + + case let .signature_not_base64(signature): + return "Signature is not base64: \(signature)" + + case let .signature_invalid_size(signature): + return "Signature '\(signature)' does not have expected size (\(Signing.SignatureComponent.totalSize)). " + + "Verification failed." + + case .signature_failed_verification: + return "Signature failed verification" + + case .signature_passed_verification: + return "Signature passed verification" + + case let .request_failed_verification(request): + return "Request to \(request.path) failed verification. This is likely due to " + + "a malicious user intercepting and modifying requests." + + case let .intermediate_key_failed_verification(signature): + return "Intermediate key failed verification: \(signature.asString)" + + case let .intermediate_key_failed_creation(error): + return "Failed initializing intermediate key: \(error.localizedDescription)\n" + + "This will be reported as a verification failure." + + case let .intermediate_key_expired(date, data): + return "Intermediate key expired at '\(date)' (parsed from '\(data.asString)'). " + + "This will be reported as a verification failure." + + case let .intermediate_key_invalid(expirationDate): + return "Found invalid intermediate key expiration date: \(expirationDate.asString). " + + "This will be reported as a verification failure." + + case let .intermediate_key_creating(expiration, data): + return "Creating intermediate key with expiration '\(expiration)': \(data.asString)" + + case let .request_date_missing_from_headers(request): + return "Request to '\(request.path)' required a request date but none was provided. " + + "This will be reported as a verification failure." + + case let .signature_was_requested_but_not_provided(request): + return "Request to '\(request.path)' required a signature but none was provided. " + + "This will be reported as a verification failure." + + #if DEBUG + case let .invalid_signature_data(request, data, responseHeaders, statusCode): + return """ + INVALID SIGNATURE DETECTED: + Request: \(request.method.httpMethod) \(request.path.relativePath) + Response: \(statusCode.rawValue) + Headers: \(responseHeaders.map { "\($0.key.base): \($0.value)" }) + Body (length: \(data?.count ?? 0)): \(data?.hashString ?? "<>") + """ + + case let .verifying_signature( + signature, + publicKey, + parameters, + salt, + payload, + message + ): + return """ + Verifying signature '\(signature.base64EncodedString())' + Public key: '\(publicKey.asString)' + Parameters: \(parameters) + Salt: \(salt.base64EncodedString()), + Payload: \(payload.base64EncodedString()), + Message: \(message?.base64EncodedString() ?? "") + """ + + #endif + } + } + + var category: String { return "signing" } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/StoreKitStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/StoreKitStrings.swift new file mode 100644 index 00000000..b662b4bb --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/StoreKitStrings.swift @@ -0,0 +1,253 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StoreKitStrings.swift +// +// Created by Juanpe Catalán on 8/9/21. + +import Foundation +import StoreKit + +// swiftlint:disable identifier_name +enum StoreKitStrings { + + case sk_receipt_request_started + + case sk_receipt_request_finished + + case skrequest_failed(NSError) + + case store_products_request_failed(NSError) + + case skproductsrequest_timed_out(after: Int) + + case store_product_request_finished + + case store_product_request_received_response + + case skunknown_payment_mode(String) + + case sk1_product_with_sk2_enabled + + case sk2_purchasing_added_promotional_offer_option(String) + + case sk2_purchasing_added_winback_offer_option(String) + + case sk2_purchasing_added_custom_introductory_offer_eligibility_jws + + case sk2_purchasing_added_custom_promotional_offer_jws(offerID: String) + + case sk2_purchasing_added_uuid_option(UUID) + + case sk2_unknown_product_type(String) + + case sk1_no_known_product_type + + case sk1_unknown_transaction_state(SKPaymentTransactionState) + + case unknown_sk2_product_discount_type(rawValue: String) + + case sk1_discount_missing_locale + + case no_cached_products_starting_store_products_request(identifiers: Set) + + case sk1_payment_queue_too_many_transactions(count: Int, isSandbox: Bool) + + case sk1_finish_transaction_called_with_existing_completion(SKPaymentTransaction) + + case sk1_product_request_too_slow + + case sk2_product_request_too_slow + + case sk2_observing_transaction_updates + + case sk2_observing_purchase_intents + + case sk2_unknown_environment(String) + + case sk2_unknown_transaction_reason(String) + + case sk2_error_encoding_receipt(Error) + + case sk2_error_fetching_app_transaction(Error) + + case sk2_error_fetching_subscription_status(subscriptionGroupId: String, Error) + + case sk2_app_transaction_unavailable + + case sk2_unverified_transaction(identifier: String, Error) + + case sk2_unverified_renewal_info(productIdentifier: String) + + case sk2_receipt_missing_purchase(transactionId: String) + + #if DEBUG + + case sk1_wrapper_notifying_delegate_of_existing_transactions(count: Int) + + #endif + + case could_not_defer_store_messages(Error) + + case error_displaying_store_message(Error) + + case unknown_storekit_error(Error) + + case skunknown_purchase_result(String) + + case sk2_sync_purchases_no_transaction_or_apptransaction_found + +} + +extension StoreKitStrings: LogMessage { + + var description: String { + switch self { + case .sk_receipt_request_started: + return "SKReceiptRefreshRequest started" + + case .sk_receipt_request_finished: + return "SKReceiptRefreshRequest finished" + + case .skrequest_failed(let error): + return "SKRequest failed: \(error.description)" + + case .store_products_request_failed(let error): + return "Store products request failed! Error: \(error.description)" + + case .skproductsrequest_timed_out(let afterTimeInSeconds): + return "SKProductsRequest took longer than \(afterTimeInSeconds) seconds, " + + "cancelling request and returning an empty set. This seems to be an App Store quirk. " + + "If this is happening to you consistently, you might want to try using a new Sandbox account. " + + "More information: https://rev.cat/skproductsrequest-hangs" + + case .store_product_request_finished: + return "Store products request finished" + + case .store_product_request_received_response: + return "Store products request received response" + + case let .skunknown_payment_mode(name): + return "Unrecognized PaymentMode: \(name)" + + case .sk1_product_with_sk2_enabled: + return "This StoreProduct represents an SK1 product, but SK2 was expected." + + case let .sk2_purchasing_added_promotional_offer_option(discountIdentifier): + return "Adding Product.PurchaseOption for discount '\(discountIdentifier)'" + + case let .sk2_purchasing_added_winback_offer_option(winBackOfferID): + return "Adding Product.PurchaseOption for win-back offer with ID '\(winBackOfferID)'" + + case .sk2_purchasing_added_custom_introductory_offer_eligibility_jws: + return "Adding Product.PurchaseOption for developer-provided introductoryOfferEligibilityJWS" + + case let .sk2_purchasing_added_custom_promotional_offer_jws(offerID): + return "Adding Product.PurchaseOption for developer-provided promotionalOfferJWS with offer ID '\(offerID)'" + + case let .sk2_purchasing_added_uuid_option(uuid): + return "Adding Product.PurchaseOption for .appAccountToken '\(uuid)'" + + case let .sk2_unknown_product_type(type): + return "Product.ProductType '\(type)' unknown, the product type will be undefined." + + case .sk1_no_known_product_type: + return "This StoreProduct represents an SK1 product, the type of product cannot be determined, " + + "the value will be undefined. Use `StoreProduct.productCategory` instead." + + case let .sk1_unknown_transaction_state(state): + return "Received unknown transaction state: \(state.rawValue)" + + case .unknown_sk2_product_discount_type(let rawValue): + return "Failed to create StoreProductDiscount.DiscountType with unknown value: \(rawValue)" + + case .sk1_discount_missing_locale: + return "There is an issue with the App Store, this SKProductDiscount is missing a Locale - " + + "The current device Locale will be used instead." + + case .no_cached_products_starting_store_products_request(let identifiers): + return "No existing products cached, starting store products request for: \(identifiers)" + + case let .sk1_payment_queue_too_many_transactions(count, isSandbox): + let messageSuffix = isSandbox + ? "This high number is unexpected and is likely due to using an old sandbox account on a new device. " + + "If this is impacting performance, using a new sandbox account is recommended." + : "This is a very high number and might impact performance." + + return "SKPaymentQueue sent \(count) updated transactions. " + messageSuffix + + case let .sk1_finish_transaction_called_with_existing_completion(transaction): + return "StoreKit1Wrapper.finishTransaction was called for '\(transaction.productIdentifier ?? "")' " + + "but found an existing completion block." + + case .sk1_product_request_too_slow: + return "StoreKit 1 product request took longer than expected" + + case .sk2_product_request_too_slow: + return "StoreKit 2 product request took longer than expected" + + case .sk2_observing_transaction_updates: + return "Observing StoreKit.Transaction.updates" + + case .sk2_observing_purchase_intents: + return "Observing StoreKit.PurchaseIntent.intents" + + case let .sk2_unknown_environment(environment): + return "Unrecognized StoreKit Environment: \(environment)" + + case let .sk2_unknown_transaction_reason(reason): + return "Unrecognized StoreKit Transaction Reason: \(reason)" + + case let .sk2_error_encoding_receipt(error): + return "Error encoding SK2 receipt: '\(error)'" + + case let .sk2_error_fetching_app_transaction(error): + return "Error fetching AppTransaction: '\(error)'" + + case let .sk2_error_fetching_subscription_status(subscriptionGroupId, error): + return "Error fetching status for subscription group with id '\(subscriptionGroupId)': '\(error)'" + + case .sk2_app_transaction_unavailable: + return "Not fetching AppTransaction because it is not available" + + case let .sk2_unverified_transaction(id, error): + return "Found unverified transaction with ID: '\(id)' Error: '\(error)'" + + case let .sk2_unverified_renewal_info(productIdentifier): + return "Found unverified renewal info for product with identifier: '\(productIdentifier)'" + + case let .sk2_receipt_missing_purchase(transactionId): + return "SK2 receipt is still missing transaction with id '\(transactionId)'" + + #if DEBUG + case let .sk1_wrapper_notifying_delegate_of_existing_transactions(count): + return "StoreKit1Wrapper: sending delegate \(count) existing transactions " + + "for Integration Tests." + #endif + + case let .could_not_defer_store_messages(error): + return "Tried to defer store messages but an error occured: '\(error)'." + + case let .error_displaying_store_message(error): + return "Error displaying StoreKit message: '\(error)'" + + case let .unknown_storekit_error(error): + return "Unknown StoreKit error. Error: '\(error.localizedDescription)'" + + case let .skunknown_purchase_result(name): + return "Unrecognized Product.PurchaseResult: \(name)" + + case .sk2_sync_purchases_no_transaction_or_apptransaction_found: + return "Couldn't find previous transactions or an AppTransaction." + } + } + + var category: String { return "store_kit" } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/Strings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/Strings.swift new file mode 100644 index 00000000..d71772a3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/Strings.swift @@ -0,0 +1,56 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Created by Andrés Boedo on 9/14/20. +// + +import Foundation + +enum Strings { + + static let attribution = AttributionStrings.self + static let analytics = AnalyticsStrings.self + static let cache = CacheStrings.self + static let codable = CodableStrings.self + static let configure = ConfigureStrings.self + static let backendError = BackendErrorStrings.self + static let customerInfo = CustomerInfoStrings.self + static let diagnostics = DiagnosticsStrings.self + static let eligibility = EligibilityStrings.self + static let etag = ETagStrings.self + static let fileRepository = FileRepositoryStrings.self + static let identity = IdentityStrings.self + static let network = NetworkStrings.self + static let offering = OfferingStrings.self + static let offlineEntitlements = OfflineEntitlementsStrings.self + static let paywalls = PaywallsStrings.self + static let purchase = PurchaseStrings.self + static let webRedemption = WebRedemptionStrings.self + static let receipt = ReceiptStrings.self + static let signing = SigningStrings.self + static let storeKit = StoreKitStrings.self + static let virtualCurrencies = VirtualCurrencyStrings.self + static let transactionMetadata = TransactionMetadataStrings.self + +} + +extension Strings { + + /// Returns the type and address of the given object, useful for debugging. + /// Example: StoreKit1Wrapper (0x0000600000e36480) + static func objectDescription(_ object: AnyObject) -> String { + return "\(type(of: object)) (\(Strings.address(for: object)))" + } + + /// Returns the address of the given object, useful for debugging. + private static func address(for object: AnyObject) -> String { + return Unmanaged.passUnretained(object).toOpaque().debugDescription + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/TransactionMetadataStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/TransactionMetadataStrings.swift new file mode 100644 index 00000000..3ea7691a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/TransactionMetadataStrings.swift @@ -0,0 +1,37 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// TransactionMetadataStrings.swift +// +// Created by Antonio Pallares on 8/1/26. + +import Foundation + +// swiftlint:disable identifier_name +enum TransactionMetadataStrings { + + case metadata_already_exists_for_transaction(transactionId: String) + case metadata_not_found_to_clear_for_transaction(transactionId: String) + +} + +extension TransactionMetadataStrings: LogMessage { + + var description: String { + switch self { + case let .metadata_already_exists_for_transaction(transactionId): + return "Purchase data already cached for transaction identifier: \(transactionId). Skipping cache." + + case let .metadata_not_found_to_clear_for_transaction(transactionId): + return "Purchase data not found in cache for transaction identifier \(transactionId) when trying to clear" + } + } + + var category: String { return "transactionMetadata" } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/VirtualCurrencyStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/VirtualCurrencyStrings.swift new file mode 100644 index 00000000..1e4561cd --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/VirtualCurrencyStrings.swift @@ -0,0 +1,50 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// VirtualCurrencyStrings.swift +// +// Created by Will Taylor on 6/18/25. + +import Foundation + +// swiftlint:disable identifier_name +enum VirtualCurrencyStrings { + + case invalidating_virtual_currencies_cache + case vending_from_cache + case no_cached_virtual_currencies + case virtual_currencies_stale_updating_from_network + case virtual_currencies_updated_from_network + case virtual_currencies_updated_from_network_error(Error) + case error_decoding_cached_virtual_currencies(Error) + +} + +extension VirtualCurrencyStrings: LogMessage { + var description: String { + switch self { + case .invalidating_virtual_currencies_cache: + return "Invalidating VirtualCurrencies cache." + case .no_cached_virtual_currencies: + return "There are no cached VirtualCurrencies." + case .virtual_currencies_stale_updating_from_network: + return "VirtualCurrencies cache is stale, updating from network." + case .vending_from_cache: + return "Vending VirtualCurrencies from cache." + case .virtual_currencies_updated_from_network: + return "VirtualCurrencies updated from the network." + case let .virtual_currencies_updated_from_network_error(error): + return "Attempt to update VirtualCurrencies from network failed.\n\(error.localizedDescription)" + case let .error_decoding_cached_virtual_currencies(error): + return "Couldn't decode cached VirtualCurrencies:\n\(error)" + } + } + + var category: String { return "virtual_currency" } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/WebRedemptionStrings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/WebRedemptionStrings.swift new file mode 100644 index 00000000..a0507b41 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Logging/Strings/WebRedemptionStrings.swift @@ -0,0 +1,41 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// WebRedemptionStrings.swift +// +// Created by Antonio Rico Diez on 2024-10-17. + +import Foundation + +// swiftlint:disable identifier_name + +enum WebRedemptionStrings { + + case redeeming_web_purchase + case redeemed_web_purchase + case error_redeeming_web_purchase(_ error: BackendError) + +} + +extension WebRedemptionStrings: LogMessage { + + var description: String { + switch self { + case .redeeming_web_purchase: + return "Redeeming web purchase." + case .redeemed_web_purchase: + return "Web purchase redeemed successfully." + case let .error_redeeming_web_purchase(error): + return "Error redeeming web purchase: \(error.localizedDescription)" + } + } + + var category: String { return "web_redemption" } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Box.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Box.swift new file mode 100644 index 00000000..ed7c6584 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Box.swift @@ -0,0 +1,35 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Box.swift +// +// Created by Nacho Soto on 8/19/22. + +import Foundation + +// Workaround for https://openradar.appspot.com/radar?id=4970535809187840 / https://github.com/apple/swift/issues/58099 +/// Holds a reference to a value. +final class Box { + + let value: T + + init(_ value: T) { self.value = value } + +} + +/// Holds a weak reference to an object. +final class WeakBox { + + private(set) weak var value: T? + + init(_ value: T) { self.value = value } + +} + +extension Box: Sendable where T: Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Codable/AnyDecodable.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Codable/AnyDecodable.swift new file mode 100644 index 00000000..3b98ccae --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Codable/AnyDecodable.swift @@ -0,0 +1,143 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// AnyDecodable.swift +// +// Created by Nacho Soto on 5/11/22. + +import Foundation + +/// Type erased `Any` that conforms to `Decodable` +enum AnyDecodable { + + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case object([String: AnyDecodable]) + case array([AnyDecodable]) + case null + +} + +extension AnyDecodable: Decodable { + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode([String: AnyDecodable].self) { + self = .object(value) + } else if let value = try? container.decode([AnyDecodable].self) { + self = .array(value) + } else { + throw DecodingError.typeMismatch( + AnyDecodable.self, + .init( + codingPath: container.codingPath, + debugDescription: "Unexpected type at \(container.codingPath.description)" + ) + ) + } + } + +} + +extension AnyDecodable { + + var asAny: Any { + switch self { + case let .string(value): return value + case let .int(value): return value + case let .double(value): return value + case let .bool(value): return value + case let .object(value): return value.mapValues { $0.asAny } + case let .array(value): return value.map { $0.asAny } + case .null: return NSNull() + } + } + +} + +extension AnyDecodable: Encodable { + + func encode(to encoder: Encoder) throws { + try AnyEncodable(self.asAny).encode(to: encoder) + } + +} + +extension AnyDecodable: Hashable {} + +// MARK: - Expressible by Literal + +extension AnyDecodable: ExpressibleByNilLiteral { + + init(nilLiteral: ()) { + self = .null + } + +} + +extension AnyDecodable: ExpressibleByStringLiteral { + + init(stringLiteral value: StringLiteralType) { + self = .string(value) + } + +} + +extension AnyDecodable: ExpressibleByIntegerLiteral { + + init(integerLiteral value: IntegerLiteralType) { + self = .int(value) + } + +} + +extension AnyDecodable: ExpressibleByBooleanLiteral { + + init(booleanLiteral value: BooleanLiteralType) { + self = .bool(value) + } + +} + +extension AnyDecodable: ExpressibleByFloatLiteral { + + init(floatLiteral value: FloatLiteralType) { + self = .double(value) + } + +} + +extension AnyDecodable: ExpressibleByDictionaryLiteral { + + init(dictionaryLiteral elements: (String, AnyDecodable)...) { + self = .object(Dictionary(uniqueKeysWithValues: elements)) + } + +} + +extension AnyDecodable: ExpressibleByArrayLiteral { + + init(arrayLiteral elements: AnyDecodable...) { + self = .array(elements) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Codable/AnyEncodable.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Codable/AnyEncodable.swift new file mode 100644 index 00000000..fbebad8d --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Codable/AnyEncodable.swift @@ -0,0 +1,112 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// AnyEncodable.swift +// +// Created by Nacho Soto on 3/2/22. + +import Foundation + +// Inspired by https://github.com/Flight-School/AnyCodable + +struct AnyEncodable { + + let value: Any + + init(_ value: T?) { self.value = value ?? () } + +} + +// swiftlint:disable cyclomatic_complexity + +extension AnyEncodable: Encodable { + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self.value { + case is NSNull: + try container.encodeNil() + case is Void: + try container.encodeNil() + case let bool as Bool: + try container.encode(bool) + case let int as Int: + try container.encode(int) + case let uint as UInt: + try container.encode(uint) + case let float as Float: + try container.encode(float) + case let double as Double: + try container.encode(double) + case let string as String: + try container.encode(string) + case let date as Date: + try container.encode(date) + case let url as URL: + try container.encode(url) + case let array as [Any?]: + try container.encode(array.map(AnyEncodable.init)) + case let dictionary as [String: Any?]: + try container.encode(dictionary.mapValues(AnyEncodable.init)) + case let encodable as Encodable: + try encodable.encode(to: encoder) + + default: + throw EncodingError.invalidValue( + self.value, + .init( + codingPath: container.codingPath, + debugDescription: "AnyEncodable value cannot be encoded" + ) + ) + } + } + +} + +/// `AnyEncodable` can also be decoded +extension AnyEncodable: Decodable { + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self.value = () + } else if let bool = try? container.decode(Bool.self) { + self.value = bool + } else if let int = try? container.decode(Int.self) { + self.value = int + } else if let uint = try? container.decode(UInt.self) { + self.value = uint + } else if let float = try? container.decode(Float.self) { + self.value = float + } else if let double = try? container.decode(Double.self) { + self.value = double + } else if let string = try? container.decode(String.self) { + self.value = string + } else if let date = try? container.decode(Date.self) { + self.value = date + } else if let url = try? container.decode(URL.self) { + self.value = url + } else if let array = try? container.decode([AnyEncodable].self) { + self.value = array.map { $0.value } + } else if let dictionary = try? container.decode([String: AnyEncodable].self) { + self.value = dictionary.mapValues { $0.value } + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "AnyEncodable value cannot be decoded" + ) + } + } + +} + +// swiftlint:enable cyclomatic_complexity diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Codable/DefaultDecodable.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Codable/DefaultDecodable.swift new file mode 100644 index 00000000..d1061113 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Codable/DefaultDecodable.swift @@ -0,0 +1,253 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DefaultDecodable.swift +// +// Created by Nacho Soto on 5/23/23. + +import Foundation + +// swiftlint:disable nesting + +// MARK: - DefaultValueProvider + +/// A type that can provide a default value. +protocol DefaultValueProvider { + + associatedtype Value + + static var defaultValue: Value { get } + +} + +// MARK: - DefaultValue + +/// A property wrapper for providing a default value to properties that conform to `DefaultValueProvider`. +/// This is similar to `@IgnoreDecodeErrors` but it will not ignore decoding errors. +/// - Example: +/// ``` +/// struct Data { +/// @DefaultValue var e: E +/// } +/// ``` +@propertyWrapper +struct DefaultValue { + + typealias Value = Source.Value + + var wrappedValue = Source.defaultValue + +} + +extension DefaultValue: Equatable where Value: Equatable {} +extension DefaultValue: Hashable where Value: Hashable {} + +extension DefaultValue: Decodable where Value: Decodable { + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.wrappedValue = try container.decode(Value.self) + } + +} + +extension DefaultValue: Encodable where Value: Encodable { + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.wrappedValue) + } + +} + +extension Optional: DefaultValueProvider { + + static var defaultValue: Wrapped? { return .none } + +} + +// MARK: - IgnoreEncodable + +/// A property wrapper that allows not encoding a value. +/// - Example: +/// ``` +/// struct Data { +/// @IgnoreEncodable var data: String // this value won't be encoded +/// } +/// ``` +@propertyWrapper +struct IgnoreEncodable { + + var wrappedValue: Value + +} + +extension IgnoreEncodable: Decodable where Value: Decodable { + + init(from decoder: Decoder) throws { + self.wrappedValue = try decoder.singleValueContainer().decode(Value.self) + } + +} + +extension IgnoreEncodable: Encodable { + + func encode(to encoder: Encoder) throws {} + +} + +extension IgnoreEncodable: Equatable where Value: Equatable {} +extension IgnoreEncodable: Hashable where Value: Hashable {} + +extension KeyedEncodingContainer { + + mutating func encode(_ value: IgnoreEncodable, forKey key: K) throws {} + +} + +// MARK: - IgnoreDecodeErrors + +/// A property wrapper that allows ignoring decoding errors for `DefaultValueProvider` properties (like `Optional`) +/// This is similar to `@DefaultValue` but it will also decode as `Source.defaultValue` if there are any errors +/// - Example: +/// ``` +/// struct Data { +/// @IgnoreDecodingErrors var url: URL? // becomes `nil` if url is invalid +/// } +/// ``` +@propertyWrapper +struct IgnoreDecodeErrors { + + typealias Value = Source.Value + + var wrappedValue: Value = Source.defaultValue + +} + +extension IgnoreDecodeErrors: Equatable where Value: Equatable {} +extension IgnoreDecodeErrors: Hashable where Value: Hashable {} + +extension IgnoreDecodeErrors: Decodable where Value: Decodable { + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.wrappedValue = try container.decode(Value.self) + } + +} + +extension IgnoreDecodeErrors: Encodable where Value: Encodable { + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.wrappedValue) + } + +} + +extension KeyedDecodingContainer { + + func decode( + _ type: IgnoreDecodeErrors.Type, + forKey key: Key + ) -> IgnoreDecodeErrors where T.Value: Decodable { + do { + return try self.decodeIfPresent(type, forKey: key) ?? .init() + } catch { + Logger.debug(Strings.codable.decoding_error(error, T.self)) + return .init() + } + } + +} + +// MARK: - DefaultDecodable + +// Inspired by https://swiftbysundell.com/tips/default-decoding-values/ + +extension KeyedDecodingContainer { + + func decode(_ type: DefaultValue.Type, forKey key: Key) throws -> DefaultValue where T.Value: Decodable { + return try self.decodeIfPresent(type, forKey: key) ?? .init() + } + +} + +/// Empty namespace for default decodable wrappers. +enum DefaultDecodable { + + typealias List = Decodable & ExpressibleByArrayLiteral + typealias Map = Decodable & ExpressibleByDictionaryLiteral + + enum Sources { + + enum True: DefaultValueProvider { + static var defaultValue: Bool { true } + } + + enum False: DefaultValueProvider { + static var defaultValue: Bool { false } + } + + enum EmptyString: DefaultValueProvider { + static var defaultValue: String { "" } + } + + enum Zero: DefaultValueProvider { + static var defaultValue: Int { 0 } + } + + enum ZeroDouble: DefaultValueProvider { + static var defaultValue: Double { 0 } + } + + enum EmptyArray: DefaultValueProvider { + static var defaultValue: T { [] } + } + + enum EmptyDictionary: DefaultValueProvider { + static var defaultValue: T { [:] } + } + + enum Now: DefaultValueProvider { + static var defaultValue: Date { Date() } + } + + } + +} + +/** + * Property wrappers to allow providing default values to properties in `Decodable` types. + * Example usage: + * ``` + * struct Data { + * @DefaultDecodable.True var bool1: Bool + * @DefaultDecodable.False var bool2: Bool + * @DefaultDecodable.EmptyString var identifier: String + * @DefaultDecodable.EmptyArray var values: [String] + * @DefaultDecodable.EmptyDictionary var dictionary: [String: Int] + * @DefaultDecodable.Now var date: Date + * @DefaultDecodable.Zero var number: Int + * @DefaultDecodable.Zero var number: Double + * } + * ``` + */ +extension DefaultDecodable { + + typealias True = DefaultValue + typealias False = DefaultValue + typealias EmptyString = DefaultValue + typealias EmptyArray = DefaultValue> + typealias EmptyDictionary = DefaultValue> + typealias Now = DefaultValue + typealias Zero = DefaultValue + typealias ZeroDouble = DefaultValue + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Codable/EnsureNonEmptyCollectionDecodable.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Codable/EnsureNonEmptyCollectionDecodable.swift new file mode 100644 index 00000000..3ce87d2c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Codable/EnsureNonEmptyCollectionDecodable.swift @@ -0,0 +1,88 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// EnsureNonEmptyCollectionDecodable.swift +// +// Created by Nacho Soto on 7/17/23. + +import Foundation + +/// A property wrapper that ensures decoded collections aren't empty. +/// - Example: +/// ``` +/// struct Data { +/// @EnsureNonEmptyCollectionDecodable var values: [String] // fails to decode if array is empty +/// @EnsureNonEmptyCollectionDecodable var dictionary: [String: String] // fails to decode if dictionary is empty +/// } +/// ``` +@propertyWrapper +struct EnsureNonEmptyCollectionDecodable where Value: Codable { + + struct Error: LocalizedError { + + var path: String? + + init(codingPath: String? = nil) { + self.path = codingPath + } + + /// Error message explaining that the collection cannot be empty + public var localizedDescription: String? { + "Collection cannot be empty" + } + + /// Error message that includes the path that contains the empty collection + public var failureReason: String? { + "A collection at \(path ?? "unknown") was unexpectedly empty." + } + + } + + var wrappedValue: Value + +} + +extension EnsureNonEmptyCollectionDecodable: Equatable where Value: Equatable {} +extension EnsureNonEmptyCollectionDecodable: Hashable where Value: Hashable {} + +extension EnsureNonEmptyCollectionDecodable: Decodable { + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let array = try container.decode(Value.self) + + if array.isEmpty { + throw Error(codingPath: "\(decoder.codingPath)") + } else { + self.wrappedValue = array + } + } + +} + +extension EnsureNonEmptyCollectionDecodable: Encodable { + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.wrappedValue) + } + +} + +extension KeyedDecodingContainer { + + func decode( + _ type: EnsureNonEmptyCollectionDecodable.Type, + forKey key: Key + ) throws -> EnsureNonEmptyCollectionDecodable { + return try self.decodeIfPresent(type, forKey: key) + .orThrow(EnsureNonEmptyCollectionDecodable.Error(codingPath: "\(self.codingPath)")) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Codable/IgnoreHashable.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Codable/IgnoreHashable.swift new file mode 100644 index 00000000..42faadbd --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Codable/IgnoreHashable.swift @@ -0,0 +1,54 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// IgnoreHashable.swift +// +// Created by Nacho Soto on 5/24/22. + +import Foundation + +/// A property wrapper that allows ignoring a value from the `Hashable` / `Equatable` implementation +/// - Example: +/// ``` +/// struct Data { +/// var string1: String // Data equality / hash only uses this value +/// @IgnoreHashable var string2: String +/// } +/// ``` +@propertyWrapper +struct IgnoreHashable { + + var wrappedValue: Value + +} + +extension IgnoreHashable: Hashable { + + static func == (lhs: Self, rhs: Self) -> Bool { return true } + + func hash(into hasher: inout Hasher) {} + +} + +extension IgnoreHashable: Decodable where Value: Decodable { + + init(from decoder: Decoder) throws { + self.init(wrappedValue: try .init(from: decoder)) + } + +} + +extension IgnoreHashable: Encodable where Value: Encodable { + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.wrappedValue) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Codable/NonEmptyStringDecodable.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Codable/NonEmptyStringDecodable.swift new file mode 100644 index 00000000..48536c6a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Codable/NonEmptyStringDecodable.swift @@ -0,0 +1,59 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// NonEmptyStringDecodable.swift +// +// Created by Nacho Soto on 7/14/23. + +import Foundation + +/// A property wrapper that ensures decoded strings aren't empty +/// - Example: +/// ``` +/// struct Data { +/// @NonEmptyStringDecodable var value: String? // becomes `nil` if value is empty or has only whitespaces +/// } +/// ``` +@propertyWrapper +struct NonEmptyStringDecodable { + + var wrappedValue: String? + +} + +extension NonEmptyStringDecodable: Equatable, Hashable {} + +extension NonEmptyStringDecodable: Decodable { + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.wrappedValue = try container.decode(String?.self)?.notEmptyOrWhitespaces + } + +} + +extension NonEmptyStringDecodable: Encodable { + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.wrappedValue) + } + +} + +extension KeyedDecodingContainer { + + func decode( + _ type: NonEmptyStringDecodable.Type, + forKey key: Key + ) throws -> NonEmptyStringDecodable { + return try self.decodeIfPresent(type, forKey: key) ?? .init() + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Codable/RawDataContainer.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Codable/RawDataContainer.swift new file mode 100644 index 00000000..a7b34d97 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Codable/RawDataContainer.swift @@ -0,0 +1,48 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// RawDataContainer.swift +// +// Created by Nacho Soto on 11/16/21. + +/// A type which exposes its underlying content for debugging purposes or for getting access +/// to future data while using an older version of the SDK. +public protocol RawDataContainer { + + /// The type of the ``RawDataContainer/rawData`` for this type. + associatedtype Content + + /// The underlying content for debugging purposes or for getting access + /// to future data while using an older version of the SDK. + var rawData: Content { get } + +} + +extension Decoder { + + /// Decodes the entire content of this `Decoder` into `[String: Any]` to be used for a `RawDataContainer` type. + func decodeRawData() -> [String: Any] { + do { + let value = try self.singleValueContainer() + .decode(AnyDecodable.self) + .asAny + + guard let dictionary = value as? [String: Any] else { + Logger.warn(Strings.codable.unexpectedValueError(type: type(of: value), value: value)) + return [:] + } + + return dictionary + } catch { + Logger.warn(Strings.codable.decoding_error(error, AnyDecodable.self)) + return [:] + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Concurrency/Atomic.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Concurrency/Atomic.swift new file mode 100644 index 00000000..63947275 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Concurrency/Atomic.swift @@ -0,0 +1,109 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Atomic.swift +// +// Created by Nacho Soto on 11/20/21. + +import Foundation + +/** + * A generic object that performs all write and read operations atomically. + * Use it to prevent data races when accessing an object. + * + * - Important: The closures aren't re-entrant. + * In other words, `Atomic` instances cannot be used from within the `modify` and `withValue` closures + * + * Usage: + * ```swift + * let foo = Atomic + * + * // read values + * foo.withValue { + * let currentBar = $0.bar + * let currentX = $0.x + * } + * + * // write value + * foo.modify { + * $0.bar = 2 + * $0.x = "new X" + * } + * + * // write value and get previous value + * let oldValue = foo.getAndSet(newValue) + * ``` + * + * Or for single-line read/writes: + * ```swift + * let currentX = foo.value.x + * foo.value = MyClass() + * ``` + **/ +internal final class Atomic { + + private let lock: Lock + private var _value: T + + var value: T { + get { withValue { $0 } } + set { modify { $0 = newValue } } + } + + init(_ value: T) { + self._value = value + self.lock = Lock() + } + + @discardableResult + func modify(_ action: (inout T) throws -> Result) rethrows -> Result { + return try lock.perform { + try action(&_value) + } + } + + @discardableResult + func getAndSet(_ newValue: T) -> T { + return self.modify { currentValue in + defer { currentValue = newValue } + return currentValue + } + } + + @discardableResult + func withValue(_ action: (T) throws -> Result) rethrows -> Result { + return try lock.perform { + try action(_value) + } + } + +} + +// Syntactic sugar that allows initializing an `Atomic` optional value by directly assigning `nil`, +// i.e.: `let foo: Atomic = nil` instead of the more indirect `let foo: Atomic = .init(nil)` +extension Atomic: ExpressibleByNilLiteral where T: OptionalType { + + convenience init(nilLiteral: ()) { + self.init(.init(optional: nil)) + } + +} + +// Syntactic sugar that allows initializing an `Atomic` `Bool` by directly assigning its value, +// i.e.: `let foo: Atomic = false` instead of the more indirect `let foo: Atomic = .init(false)` +extension Atomic: ExpressibleByBooleanLiteral where T == Bool { + + convenience init(booleanLiteral value: BooleanLiteralType) { + self.init(value) + } + +} + +// `@unchecked` because of the mutable `_value`, but it's thread-safety is guaranteed with `Lock`. +extension Atomic: @unchecked Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Concurrency/Lock.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Concurrency/Lock.swift new file mode 100644 index 00000000..32c61b62 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Concurrency/Lock.swift @@ -0,0 +1,71 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Lock.swift +// +// Created by Nacho Soto on 11/15/21. + +import Foundation + +/// A lock abstraction over an instance of `NSLocking` +internal final class Lock { + + enum LockType { + + /// A lock backed by an `NSLock` + case nonRecursive + + /// A lock backed by an `NSRecursiveLock` + case recursive + + } + + private typealias UnderlyingType = NSLocking & Sendable + + private let lock: UnderlyingType + private init(_ lock: UnderlyingType) { self.lock = lock } + + /// Creates an instance based on `LockType` + convenience init(_ type: LockType = .nonRecursive) { + self.init(type.create()) + } + + @discardableResult + func perform(_ block: () throws -> T) rethrows -> T { + self.lock.lock() + defer { self.lock.unlock() } + + return try block() + } + +} + +extension Lock: Sendable {} + +private extension Lock.LockType { + + func create() -> NSLocking { + return { + switch self { + case .recursive: + let lock = NSRecursiveLock() + lock.name = "com.revenuecat.purchases.recursive_lock" + + return lock + + case .nonRecursive: + let lock = NSLock() + lock.name = "com.revenuecat.purchases.lock" + + return lock + } + }() + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Concurrency/OperationDispatcher.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Concurrency/OperationDispatcher.swift new file mode 100644 index 00000000..0da630cb --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Concurrency/OperationDispatcher.swift @@ -0,0 +1,143 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// OperationDispatcher.swift +// +// Created by Andrés Boedo on 8/5/20. +// + +import Foundation + +/// Represents a delay for asynchonous operations. +/// +/// These delays prevent DDOS if a notification leads to many users opening an app at the same time, +/// by spreading asynchronous operations over time. +enum JitterableDelay: Equatable { + + case none + case `default` + case long + case timeInterval(TimeInterval) + + static func `default`(forBackgroundedApp inBackground: Bool) -> Self { + return inBackground ? .default : .none + } + +} + +class OperationDispatcher { + + private let mainQueue: DispatchQueue = .main + private let workerQueue: DispatchQueue = .init(label: "OperationDispatcherWorkerQueue") + + static let `default`: OperationDispatcher = .init() + + /// Invokes `block` on the main thread asynchronously + /// or synchronously if called from the main thread. + func dispatchOnMainThread(_ block: @escaping @Sendable () -> Void) { + if Thread.isMainThread { + block() + } else { + self.mainQueue.async(execute: block) + } + } + + /// Dispatch block on main thread asynchronously. + func dispatchAsyncOnMainThread(_ block: @escaping @Sendable () -> Void) { + self.mainQueue.async(execute: block) + } + + func dispatchOnMainActor(_ block: @MainActor @escaping @Sendable () -> Void) { + Self.dispatchOnMainActor(block) + } + + func dispatchOnWorkerThread(jitterableDelay delay: JitterableDelay = .none, block: @escaping @Sendable () -> Void) { + if delay.hasDelay { + self.workerQueue.asyncAfter(deadline: .now() + delay.random(), execute: block) + } else { + self.workerQueue.async(execute: block) + } + } + + func dispatchOnWorkerThread(jitterableDelay delay: JitterableDelay = .none, + block: @escaping @Sendable () async -> Void) { + Task.detached(priority: .background) { + if delay.hasDelay { + try? await Task.sleep(nanoseconds: DispatchTimeInterval(delay.random()).nanoseconds) + } + + await block() + } + } + + func dispatchOnWorkerThread(after timeInterval: TimeInterval, block: @escaping @Sendable () -> Void) { + self.workerQueue.asyncAfter(deadline: .now() + timeInterval, execute: block) + } + +} + +extension OperationDispatcher { + + static func dispatchOnMainActor(_ block: @MainActor @escaping @Sendable () -> Void) { + Task { @MainActor in + block() + } + } + +} + +// MARK: - + +/// Visible for testing +extension JitterableDelay { + + var hasDelay: Bool { + return self.maximum > 0 + } + + var range: Range { + return self.minimum.. TimeInterval { + Double.random(in: self.range) + } + + private static let maxJitter: TimeInterval = 5 + +} + +// MARK: - + +// `DispatchQueue` is not `Sendable` as of Swift 5.8, but this class performs no mutations. +extension OperationDispatcher: @unchecked Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Concurrency/Purchases+async.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Concurrency/Purchases+async.swift new file mode 100644 index 00000000..b5c7cc7d --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Concurrency/Purchases+async.swift @@ -0,0 +1,235 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Purchases+async.swift +// +// Created by Andrés Boedo on 24/11/21. + +import Foundation + +/// This extension holds the biolerplate logic to convert methods with completion blocks into async / await syntax. +extension Purchases { + + // Note: We're using UnsafeContinuation instead of Checked because + // of a crash in iOS 18.0 devices when CheckedContinuations are used. + // See: https://github.com/RevenueCat/purchases-ios/issues/4177 + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + func getStorefrontAsync() async -> Storefront? { + return await withUnsafeContinuation { continuation in + getStorefront { storefrontCountryCode in + continuation.resume(returning: storefrontCountryCode) + } + } + } + + func logInAsync(_ appUserID: String) async throws -> (customerInfo: CustomerInfo, created: Bool) { + return try await withUnsafeThrowingContinuation { continuation in + logIn(appUserID) { customerInfo, created, error in + continuation.resume(with: Result(customerInfo, error) + .map { ($0, created) }) + } + } + } + + func logOutAsync() async throws -> CustomerInfo { + return try await withUnsafeThrowingContinuation { continuation in + logOut { customerInfo, error in + continuation.resume(with: Result(customerInfo, error)) + } + } + } + + func syncAttributesAndOfferingsIfNeededAsync() async throws -> Offerings? { + return try await withUnsafeThrowingContinuation { continuation in + syncAttributesAndOfferingsIfNeeded { offerings, error in + continuation.resume(with: Result(offerings, error)) + } + } + } + + #endif + + func offeringsAsync(fetchPolicy: OfferingsManager.FetchPolicy) async throws -> Offerings { + return try await withUnsafeThrowingContinuation { continuation in + self.getOfferings(fetchPolicy: fetchPolicy) { offerings, error in + continuation.resume(with: Result(offerings, error)) + } + } + } + + func productsAsync(_ productIdentifiers: [String]) async -> [StoreProduct] { + return await withUnsafeContinuation { continuation in + getProducts(productIdentifiers) { result in + continuation.resume(returning: result) + } + } + } + + func purchaseAsync(product: StoreProduct) async throws -> PurchaseResultData { + return try await withUnsafeThrowingContinuation { continuation in + purchase(product: product) { transaction, customerInfo, error, userCancelled in + continuation.resume(with: Result(customerInfo, error) + .map { PurchaseResultData(transaction, $0, userCancelled) }) + } + } + } + + func purchaseAsync(package: Package) async throws -> PurchaseResultData { + return try await withUnsafeThrowingContinuation { continuation in + purchase(package: package) { transaction, customerInfo, error, userCancelled in + continuation.resume(with: Result(customerInfo, error) + .map { PurchaseResultData(transaction, $0, userCancelled) }) + } + } + } + + func restorePurchasesAsync() async throws -> CustomerInfo { + return try await withUnsafeThrowingContinuation { continuation in + self.restorePurchases { customerInfo, error in + continuation.resume(with: Result(customerInfo, error)) + } + } + } + + func purchaseAsync(_ params: PurchaseParams) async throws -> PurchaseResultData { + return try await withUnsafeThrowingContinuation { continuation in + purchase(params, + completion: { transaction, customerInfo, error, userCancelled in + continuation.resume(with: Result(customerInfo, error) + .map { PurchaseResultData(transaction, $0, userCancelled) }) + }) + } + } + + func purchaseAsync(product: StoreProduct, promotionalOffer: PromotionalOffer) async throws -> PurchaseResultData { + return try await withUnsafeThrowingContinuation { continuation in + purchase(product: product, + promotionalOffer: promotionalOffer) { transaction, customerInfo, error, userCancelled in + continuation.resume(with: Result(customerInfo, error) + .map { PurchaseResultData(transaction, $0, userCancelled) }) + } + } + } + + func purchaseAsync(package: Package, promotionalOffer: PromotionalOffer) async throws -> PurchaseResultData { + return try await withUnsafeThrowingContinuation { continuation in + purchase(package: package, + promotionalOffer: promotionalOffer) { transaction, customerInfo, error, userCancelled in + continuation.resume(with: Result(customerInfo, error) + .map { PurchaseResultData(transaction, $0, userCancelled) }) + } + } + } + + func promotionalOfferAsync(forProductDiscount discount: StoreProductDiscount, + product: StoreProduct) async throws -> PromotionalOffer { + return try await withUnsafeThrowingContinuation { continuation in + getPromotionalOffer(forProductDiscount: discount, product: product) { offer, error in + continuation.resume(with: Result(offer, error)) + } + } + } + + func eligiblePromotionalOffersAsync(forProduct product: StoreProduct) async -> [PromotionalOffer] { + let discounts = product.discounts + + return await withTaskGroup(of: Optional.self) { group in + for discount in discounts { + group.addTask { + do { + return try await self.promotionalOffer( + forProductDiscount: discount, + product: product + ) + } catch RCErrorCode.ineligibleError { + return nil + } catch { + Logger.error( + Strings.eligibility.check_eligibility_failed( + productIdentifier: product.productIdentifier, + error: error + ) + ) + return nil + } + } + } + + var result: [PromotionalOffer] = [] + + for await offer in group { + if let offer = offer { + result.append(offer) + } + } + + return result + } + } + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + func syncPurchasesAsync() async throws -> CustomerInfo { + return try await withUnsafeThrowingContinuation { continuation in + syncPurchases { customerInfo, error in + continuation.resume(with: Result(customerInfo, error)) + } + } + } + + func customerInfoAsync(fetchPolicy: CacheFetchPolicy) async throws -> CustomerInfo { + return try await withUnsafeThrowingContinuation { continuation in + getCustomerInfo(fetchPolicy: fetchPolicy) { customerInfo, error in + continuation.resume(with: Result(customerInfo, error)) + } + } + } + + #endif + + func checkTrialOrIntroductoryDiscountEligibilityAsync(_ product: StoreProduct) async + -> IntroEligibilityStatus { + return await withUnsafeContinuation { continuation in + checkTrialOrIntroDiscountEligibility(product: product) { status in + continuation.resume(returning: status) + } + } + } + + func checkTrialOrIntroductoryDiscountEligibilityAsync(_ productIdentifiers: [String]) async + -> [String: IntroEligibility] { + return await withUnsafeContinuation { continuation in + checkTrialOrIntroDiscountEligibility(productIdentifiers: productIdentifiers) { result in + continuation.resume(returning: result) + } + } + } + +#if os(iOS) || os(macOS) || VISION_OS + + @available(iOS 13.0, macOS 10.15, *) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func showManageSubscriptionsAsync() async throws { + return try await withUnsafeThrowingContinuation { continuation in + showManageSubscriptions { error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + } + } + } + +#endif + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Concurrency/Purchases+nonasync.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Concurrency/Purchases+nonasync.swift new file mode 100644 index 00000000..c920b4a3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Concurrency/Purchases+nonasync.swift @@ -0,0 +1,121 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Purchases+nonasync.swift +// +// Created by Nacho Soto on 8/22/22. + +import Foundation + +// Docs inherited from `PurchasesSwiftType`. +// swiftlint:disable missing_docs + +/// This extension holds the biolerplate logic to convert async methods to completion blocks APIs. +/// Because `async` APIs are implicitly available in Objective-C, these can be Swift only. +public extension Purchases { + + #if os(iOS) || VISION_OS + + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func beginRefundRequest( + forProduct productID: String, + completion: @escaping (Result) -> Void + ) { + Async.call(with: completion) { + try await self.beginRefundRequest(forProduct: productID) + } + } + + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func beginRefundRequest( + forEntitlement entitlementID: String, + completion: @escaping (Result) -> Void + ) { + Async.call(with: completion) { + try await self.beginRefundRequest(forEntitlement: entitlementID) + } + } + + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func beginRefundRequestForActiveEntitlement( + completion: @escaping (Result) -> Void + ) { + Async.call(with: completion) { + try await self.beginRefundRequestForActiveEntitlement() + } + } + + #endif + + #if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + + @available(iOS 16.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func showStoreMessages( + for types: Set = Set(StoreMessageType.allCases), + completion: @escaping () -> Void + ) { + _ = Task { + await self.showStoreMessages(for: types) + completion() + } + } + + @available(iOS 16.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @objc(showStoreMessagesWithCompletion:) + func showStoreMessages(completion: @escaping () -> Void) { + _ = Task { + await self.showStoreMessages(for: Set(StoreMessageType.allCases)) + completion() + } + } + + /** + Calls `showStoreMessages(for:completion:)` with a set of store message types. + + - Parameter types: An `NSSet` containing items representing store message types. + Each item should be either a `StoreMessageType` (backed by `NSNumber`) or an `NSNumber` + representing the raw value of a `StoreMessageType`. + - Parameter completion: A closure called once store messages have been shown. + + If an item in `types` cannot be interpreted as a `StoreMessageType` raw value, it will be ignored. + */ + @available(iOS 16.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @objc(showStoreMessagesForTypes:completion:) + func showStoreMessages(forTypes types: NSSet, completion: @escaping () -> Void) { + let storeMessageTypes = Set( + types.compactMap { ($0 as? NSNumber)?.intValue } + .compactMap { StoreMessageType(rawValue: $0) } + ) + _ = Task { + await self.showStoreMessages(for: storeMessageTypes) + completion() + } + } + + #endif + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Concurrency/SynchronizedLargeItemCache.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Concurrency/SynchronizedLargeItemCache.swift new file mode 100644 index 00000000..32eb46f9 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Concurrency/SynchronizedLargeItemCache.swift @@ -0,0 +1,141 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SynchronizedLargeItemCache.swift +// +// Created by Jacob Zivan Rakidzich on 10/9/25. + +import Foundation + +/// A thread-safe wrapper around `LargeItemCacheType` for synchronized file-based caching operations. +/// +/// - Important: Cache keys must not contain path separators (`/`). Keys are used directly as file names, +/// so including path separators would create nested directories and break key retrieval via `allKeys()`. +internal final class SynchronizedLargeItemCache { + + private let cache: LargeItemCacheType + private let lock: Lock + private let cacheURL: URL? + + init( + cache: LargeItemCacheType, + basePath: String, + directoryType: DirectoryHelper.DirectoryType = .cache + ) { + self.cache = cache + self.lock = Lock(.nonRecursive) + + self.cacheURL = cache.createDirectoryIfNeeded( + basePath: basePath, + directoryType: directoryType, + inAppSpecificDirectory: true + ) + } + + @inline(__always) + private func withLock( + _ action: (_ cache: LargeItemCacheType, _ documentURL: URL?) throws -> T + ) rethrows -> T { + return try self.lock.perform { + return try action(self.cache, self.cacheURL) + } + } + + /// Get the file URL for a specific cache key + private func getFileURL(for key: String) -> URL? { + assert(!key.contains("/"), "Cache key must not contain path separators: \(key)") + + guard let cacheURL = self.cacheURL else { + return nil + } + return cacheURL.appendingPathComponent(key) + } + + /// Save a codable value to the cache + @discardableResult + func set(codable value: T, forKey key: String) -> Bool { + guard let fileURL = self.getFileURL(for: key) else { + Logger.error(Strings.cache.cache_url_not_available) + return false + } + + guard let data = try? JSONEncoder.default.encode(value: value, logErrors: true) else { + return false + } + + do { + try self.withLock { cache, _ in + try cache.saveData(data, to: fileURL) + } + return true + } catch { + Logger.error(Strings.cache.failed_to_save_codable_to_cache(error)) + return false + } + } + + /// Load a codable value from the cache + /// - Throws: If the file cannot be loaded or decoded + func value(forKey key: String) throws -> T? { + guard let fileURL = self.getFileURL(for: key) else { + return nil + } + + return try self.withLock { cache, _ in + guard cache.cachedContentExists(at: fileURL) else { + return nil + } + + let data = try cache.loadFile(at: fileURL) + return try JSONDecoder.default.decode(jsonData: data, logErrors: true) + } + } + + /// Remove a cached item + func removeObject(forKey key: String) { + guard let fileURL = self.getFileURL(for: key) else { + return + } + + self.withLock { cache, _ in + try? cache.remove(fileURL) + } + } + + /// Get all keys in the cache + func allKeys() -> [String] { + guard let cacheURL = self.cacheURL else { + return [] + } + + return self.withLock { cache, _ in + do { + let fileURLs = try cache.contentsOfDirectory(at: cacheURL) + return fileURLs.map { $0.lastPathComponent } + } catch { + Logger.error("Failed to read cache contents: \(error)") + return [] + } + } + } + + func clear() { + self.withLock { cache, cacheURL in + // Clear the cache directory + if let cacheURL = cacheURL { + try? cache.remove(cacheURL) + } + } + } +} + +// @unchecked because: +// - The cache property is of type LargeItemCacheType which doesn't conform to Sendable +// - However, all access to the cache is synchronized through the Lock, ensuring thread-safety +extension SynchronizedLargeItemCache: @unchecked Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Concurrency/SynchronizedUserDefaults.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Concurrency/SynchronizedUserDefaults.swift new file mode 100644 index 00000000..11e3ce59 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Concurrency/SynchronizedUserDefaults.swift @@ -0,0 +1,107 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SynchronizedUserDefaults.swift +// +// Created by Nacho Soto on 11/20/21. + +import Foundation + +/// A `UserDefaults` wrapper that provides a consistent API for reading and writing. +/// +/// `UserDefaults` is already thread-safe according to Apple's documentation: +/// "The UserDefaults type is thread-safe, and you can use the same object in multiple threads or tasks +/// simultaneously." +/// https://developer.apple.com/documentation/foundation/userdefaults#overview +/// +/// This wrapper previously used a lock (`Atomic`) for synchronization, but this caused deadlocks +/// in scenarios where: +/// 1. Main thread tries to acquire the lock for a read operation +/// 2. A background thread holding the lock writes to UserDefaults, which posts +/// a `didChangeNotification` to the main queue +/// 3. The background thread waits for the notification to complete +/// 4. Deadlock: main thread waiting for lock, background thread waiting for main thread +/// +/// Since `UserDefaults` handles thread-safety internally, we no longer wrap it with additional locking. +/// +/// - SeeAlso: https://github.com/RevenueCat/purchases-ios/issues/4137 +/// - SeeAlso: https://github.com/RevenueCat/purchases-ios/issues/5729 +internal final class SynchronizedUserDefaults { + + private let userDefaults: UserDefaults + + init(userDefaults: UserDefaults) { + self.userDefaults = userDefaults + } + + func read(_ action: (UserDefaults) throws -> T) rethrows -> T { + return try action(self.userDefaults) + } + + func write(_ action: (UserDefaults) throws -> Void) rethrows { + try action(self.userDefaults) + + // While Apple states `this method is unnecessary and shouldn't be used` + // https://developer.apple.com/documentation/foundation/userdefaults/1414005-synchronize + // It didn't become unnecessary until iOS 12 and macOS 10.14 (Mojave): + // https://developer.apple.com/documentation/macos-release-notes/foundation-release-notes + // there are reports it is still needed if you save to defaults then immediately kill the app. + // Also, it has not been marked deprecated... yet. + self.userDefaults.synchronize() + } + +} + +// `UserDefaults` is thread-safe per Apple docs and this class only holds an immutable reference, +// so `@unchecked Sendable` is safe here. `nonisolated(unsafe)` requires Swift 5.10+. +extension SynchronizedUserDefaults: @unchecked Sendable {} + +/// A `UserDefaults` wrapper that uses a lock to synchronize access. +/// +/// This is the original implementation that provides thread-safe read-modify-write operations. +/// Use this for operations that need atomicity (e.g., subscriber attributes). +/// +/// - Warning: This can cause deadlocks in scenarios where the main thread is waiting for the lock +/// while a background thread holds it and writes to UserDefaults (which posts `didChangeNotification` +/// to the main queue). Use `SynchronizedUserDefaults` for simple read/write operations that don't +/// require atomicity. +/// +/// - SeeAlso: `SynchronizedUserDefaults` for a lock-free alternative. +/// - SeeAlso: https://github.com/RevenueCat/purchases-ios/issues/4137 +internal final class LockingSynchronizedUserDefaults { + + private let atomic: Atomic + + init(userDefaults: UserDefaults) { + self.atomic = .init(userDefaults) + } + + func read(_ action: (UserDefaults) throws -> T) rethrows -> T { + return try self.atomic.withValue { + return try action($0) + } + } + + func write(_ action: (UserDefaults) throws -> Void) rethrows { + return try self.atomic.withValue { + try action($0) + + // While Apple states `this method is unnecessary and shouldn't be used` + // https://developer.apple.com/documentation/foundation/userdefaults/1414005-synchronize + // It didn't become unnecessary until iOS 12 and macOS 10.14 (Mojave): + // https://developer.apple.com/documentation/macos-release-notes/foundation-release-notes + // there are reports it is still needed if you save to defaults then immediately kill the app. + // Also, it has not been marked deprecated... yet. + $0.synchronize() + } + } + +} + +extension LockingSynchronizedUserDefaults: Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/DangerousSettings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/DangerousSettings.swift new file mode 100644 index 00000000..736371e9 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/DangerousSettings.swift @@ -0,0 +1,193 @@ +// +// DangerousSetting.swift +// PurchasesCoreSwift +// +// Created by Cesar de la Vega on 1/25/22. +// Copyright © 2022 Purchases. All rights reserved. +// + +import Foundation + +/** + Only use a Dangerous Setting if suggested by RevenueCat support team. + */ +@objc(RCDangerousSettings) public final class DangerousSettings: NSObject { + + internal struct Internal: InternalDangerousSettingsType { + + let enableReceiptFetchRetry: Bool + + #if DEBUG + let forceServerErrorStrategy: ForceServerErrorStrategy? + let forceSignatureFailures: Bool + let disableHeaderSignatureVerification: Bool + let testReceiptIdentifier: String? + + init( + enableReceiptFetchRetry: Bool = false, + forceServerErrorStrategy: ForceServerErrorStrategy? = nil, + forceSignatureFailures: Bool = false, + disableHeaderSignatureVerification: Bool = false, + testReceiptIdentifier: String? = nil + ) { + self.enableReceiptFetchRetry = enableReceiptFetchRetry + self.forceServerErrorStrategy = forceServerErrorStrategy + self.forceSignatureFailures = forceSignatureFailures + self.disableHeaderSignatureVerification = disableHeaderSignatureVerification + self.testReceiptIdentifier = testReceiptIdentifier + } + #else + init( + enableReceiptFetchRetry: Bool = false + ) { + self.enableReceiptFetchRetry = enableReceiptFetchRetry + } + + #endif + + static let `default`: Self = .init() + } + + /** + * Disable or enable subscribing to the StoreKit queue. If this is disabled, RevenueCat won't observe + * the StoreKit queue, and it will not sync any purchase automatically. + * Call syncPurchases whenever a new transaction is completed so the receipt is sent to RevenueCat's backend. + * Consumables disappear from the receipt after the transaction is finished, so make sure purchases are + * synced before finishing any consumable transaction, otherwise RevenueCat won't register the purchase. + * Auto syncing of purchases is enabled by default. + */ + @objc public let autoSyncPurchases: Bool + + /** + * if `true`, the SDK will return a set of mock products instead of the + * products obtained from StoreKit. This is useful for testing or preview purposes. + */ + @_spi(Internal) public let uiPreviewMode: Bool + + /** + * A property meant for apps that do their own entitlements computation, separated from RevenueCat. + * It: + * - disables automatic CustomerInfo cache updates + * - disables ``Purchases/logOut()`` and ``Purchases/logOut(completion:)`` + * - disallows configuration of the SDK without an appUserID + * - disables automatic firing of the PurchasesDelegate's CustomerInfo listener when setting the delegate. + * It will only be called when the SDK posts a receipt or after customerInfo on device changes. + * + * - Important: This is a dangerous setting and should only be used if you intend to do your own entitlement + * granting, separate from RevenueCat. + */ + @objc public let customEntitlementComputation: Bool + + internal let internalSettings: InternalDangerousSettingsType + + @objc public override convenience init() { + self.init(autoSyncPurchases: true) + } + + /** + * Only use a Dangerous Setting if suggested by RevenueCat support team. + * + * - Parameter autoSyncPurchases: Disable or enable subscribing to the StoreKit queue. + * If this is disabled, RevenueCat won't observe the StoreKit queue, and it will not sync any purchase + * automatically. + */ + @objc public convenience init(autoSyncPurchases: Bool = true) { + self.init(autoSyncPurchases: autoSyncPurchases, + customEntitlementComputation: false) + + } + + /// - Note: this is `internal` only so the only `public` way to enable `customEntitlementComputation` + /// is through ``Purchases/configureInCustomEntitlementsComputationMode(apiKey:appUserID:)``. + @objc internal convenience init(autoSyncPurchases: Bool = true, + customEntitlementComputation: Bool) { + self.init(autoSyncPurchases: autoSyncPurchases, + customEntitlementComputation: customEntitlementComputation, + internalSettings: Internal.default) + + } + + /** + * Used to initialize the SDK in UI preview mode. + * + * - Parameter uiPreviewMode: if `true`, the SDK will return a set of mock products instead + * of the products obtained from StoreKit. This is useful for testing or preview purposes. + */ + @_spi(Internal) public convenience init(uiPreviewMode: Bool) { + self.init(autoSyncPurchases: false, internalSettings: Internal.default, uiPreviewMode: uiPreviewMode) + } + + /// Designated initializer + internal init(autoSyncPurchases: Bool, + customEntitlementComputation: Bool = false, + internalSettings: InternalDangerousSettingsType, + uiPreviewMode: Bool = false) { + self.autoSyncPurchases = autoSyncPurchases + self.internalSettings = internalSettings + self.customEntitlementComputation = customEntitlementComputation + self.uiPreviewMode = uiPreviewMode + } + +} + +extension DangerousSettings: Sendable {} + +/// Dangerous settings not exposed outside of the SDK. +internal protocol InternalDangerousSettingsType: Sendable { + + /// Whether `ReceiptFetcher` can retry fetching receipts. + var enableReceiptFetchRetry: Bool { get } + + #if DEBUG + /// The strategy for the `HTTPClient` to fake server errors. Meant for tests only. + /// `nil` means no server errors are forced. + /// + /// This is done by routing the requests to https://api.revenuecat.com/force-server-failure, + /// which returns a 502 status code with a HTML response body. + var forceServerErrorStrategy: ForceServerErrorStrategy? { get } + + /// Whether `HTTPClient` will fake invalid signatures. + var forceSignatureFailures: Bool { get } + + /// Used to verify that the backend signs correctly without this part of the signature. + var disableHeaderSignatureVerification: Bool { get } + + /// Allows defining the receipt identifier for `PostReceiptDataOperation`. + /// This allows the backend to disambiguate between receipts created across separate test invocations. + var testReceiptIdentifier: String? { get } + + #endif + +} + +#if DEBUG + +struct ForceServerErrorStrategy { + + // swiftlint:disable:next force_unwrapping + static let defaultServerErrorURL = URL(string: "https://api.revenuecat.com/force-server-failure")! + + let serverErrorURL: URL + + /// If this returns a non-nil pair of `(HTTPURLResponse, Data)`, the `HTTPClient` will not perform the request + /// and will just return the fake response. + /// + /// Takes precedence over `shouldForceServerError`. + let fakeResponseWithoutPerformingRequest: (HTTPClient.Request) -> (HTTPURLResponse, Data)? + + /// If this returns `true`, the `HTTPClient` will route the request to `forceServerErrorURL`. + let shouldForceServerError: (HTTPClient.Request) -> Bool + + init( + serverErrorURL: URL = Self.defaultServerErrorURL, + fakeResponseWithoutPerformingRequest: @escaping (HTTPClient.Request) -> (HTTPURLResponse, Data)? = { _ in nil }, + shouldForceServerError: @escaping (HTTPClient.Request) -> Bool + ) { + self.serverErrorURL = serverErrorURL + self.fakeResponseWithoutPerformingRequest = fakeResponseWithoutPerformingRequest + self.shouldForceServerError = shouldForceServerError + } + +} + +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/DateAndTime/Clock.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/DateAndTime/Clock.swift new file mode 100644 index 00000000..99a2e27f --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/DateAndTime/Clock.swift @@ -0,0 +1,44 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Clock.swift +// +// Created by Nacho Soto on 12/13/22. + +import Foundation + +// swiftlint:disable missing_docs +@_spi(Internal) public protocol ClockType: Sendable { + + var now: Date { get } + var currentTime: DispatchTime { get } + +} + +@_spi(Internal) public final class Clock: ClockType { + + @_spi(Internal) public var now: Date { return Date() } + @_spi(Internal) public var currentTime: DispatchTime { return .now() } + + @_spi(Internal) public static let `default`: Clock = .init() + +} +// swiftlint:enable missing_docs + +extension ClockType { + + func durationSince(_ startTime: DispatchTime) -> TimeInterval { + return startTime.distance(to: self.currentTime).seconds + } + + func durationSince(_ date: Date) -> TimeInterval { + return date.distance(to: self.now) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/DateAndTime/DateExtensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/DateAndTime/DateExtensions.swift new file mode 100644 index 00000000..d13192a0 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/DateAndTime/DateExtensions.swift @@ -0,0 +1,50 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Created by Andrés Boedo on 8/7/20. +// + +import Foundation + +enum DateExtensionsError: Error { + + case invalidDateComponents(_ dateComponents: DateComponents) + +} + +extension DateExtensionsError: CustomStringConvertible { + + var description: String { + switch self { + case .invalidDateComponents(let dateComponents): + return "invalid date components: \(dateComponents.description)" + } + } + +} + +extension Date { + + // swiftlint:disable:next function_parameter_count + static func from(year: Int, month: Int, day: Int, hour: Int, minute: Int, second: Int) throws -> Date { + let calendar = Calendar(identifier: .gregorian) + var dateComponents = DateComponents() + dateComponents.year = year + dateComponents.month = month + dateComponents.day = day + dateComponents.hour = hour + dateComponents.minute = minute + dateComponents.second = second + guard let date = calendar.date(from: dateComponents) else { + throw DateExtensionsError.invalidDateComponents(dateComponents) + } + return date + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/DateAndTime/DateProvider.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/DateAndTime/DateProvider.swift new file mode 100644 index 00000000..be34beb0 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/DateAndTime/DateProvider.swift @@ -0,0 +1,27 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DateProvider.swift +// +// Created by Josh Holtz on 6/28/21. +// + +import Foundation + +class DateProvider { + + func now() -> Date { + return Date() + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension DateProvider: @unchecked Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/DateAndTime/ISODurationFormatter.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/DateAndTime/ISODurationFormatter.swift new file mode 100644 index 00000000..75d4c689 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/DateAndTime/ISODurationFormatter.swift @@ -0,0 +1,155 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ISODurationFormatter.swift +// +// Created by Facundo Menzella on 10/2/25. + +import Foundation + +/// A representation of an ISO 8601 duration. +/// +/// This struct represents both date and time-based components of an ISO 8601 duration string. +/// ISO 8601 durations use the format `PnYnMnWnDTnHnMnS`, where each part is optional: +/// - `P` indicates the duration starts. +/// - `nY` for years. +/// - `nM` for months. +/// - `nW` for weeks. +/// - `nD` for days. +/// - `T` separates the date part from the time part. +/// - `nH` for hours. +/// - `nM` for minutes. +/// - `nS` for seconds. +/// +/// Example duration strings: +/// - `"P1Y2M3DT4H5M6S"`: 1 year, 2 months, 3 days, 4 hours, 5 minutes, 6 seconds. +/// - `"P3W"`: 3 weeks. +/// - `"PT15M"`: 15 minutes. +@_spi(Internal) public struct ISODuration: Equatable { + /// The number of years in the duration. + /// + /// Example: For `"P1Y"`, this will be `1`. + @_spi(Internal) public let years: Int + + /// The number of months in the duration. + /// + /// Example: For `"P2M"`, this will be `2`. + @_spi(Internal) public let months: Int + + /// The number of weeks in the duration. + /// + /// Example: For `"P3W"`, this will be `3`. + @_spi(Internal) public let weeks: Int + + /// The number of days in the duration. + /// + /// Example: For `"P4D"`, this will be `4`. + @_spi(Internal) public let days: Int + + /// The number of hours in the duration. + /// + /// Example: For `"PT5H"`, this will be `5`. + @_spi(Internal) public let hours: Int + + /// The number of minutes in the duration. + /// + /// Example: For `"PT6M"`, this will be `6`. + @_spi(Internal) public let minutes: Int + + /// The number of seconds in the duration. + /// + /// Example: For `"PT7S"`, this will be `7`. + @_spi(Internal) public let seconds: Int + + // swiftlint:disable:next missing_docs + @_spi(Internal) public init( + years: Int, + months: Int, + weeks: Int, + days: Int, + hours: Int, + minutes: Int, + seconds: Int + ) { + self.years = years + self.months = months + self.weeks = weeks + self.days = days + self.hours = hours + self.minutes = minutes + self.seconds = seconds + } +} + +@available(iOS 11.2, macOS 10.13.2, tvOS 11.2, *) +enum ISODurationFormatter { + + // swiftlint:disable:next line_length + static let pattern = #"([-+]?)P(?:([-+]?\d+)Y)?(?:([-+]?\d+)M)?(?:([-+]?\d+)W)?(?:([-+]?\d+)D)?(?:T(?:([-+]?\d+)H)?(?:([-+]?\d+)M)?(?:([-+]?\d+)S)?)?"# + + /// Parses an ISO 8601 duration string and returns an `ISODuration` object. + static func parse(from periodString: String) -> ISODuration? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { + return nil + } + + let nsString = periodString as NSString + let match = regex.firstMatch( + in: periodString, + options: [], + range: NSRange(location: 0, length: nsString.length)) + + guard let match = match else { + print("Failed to parse ISO duration: \(periodString)") + return nil + } + + let negate = nsString.substring(with: match.range(at: 1)) == "-" ? -1 : 1 + + let years = getIntValue(from: nsString, match: match, at: 2) * negate + let months = getIntValue(from: nsString, match: match, at: 3) * negate + let weeks = getIntValue(from: nsString, match: match, at: 4) * negate + let days = getIntValue(from: nsString, match: match, at: 5) * negate + let hours = getIntValue(from: nsString, match: match, at: 6) * negate + let minutes = getIntValue(from: nsString, match: match, at: 7) * negate + let seconds = getIntValue(from: nsString, match: match, at: 8) * negate + + return ISODuration( + years: years, + months: months, + weeks: weeks, + days: days, + hours: hours, + minutes: minutes, + seconds: seconds) + } + + /// Converts an `ISODuration` object back to an ISO 8601 duration string. + static func string(from duration: ISODuration) -> String { + var result = "P" + if duration.years != 0 { result += "\(duration.years)Y" } + if duration.months != 0 { result += "\(duration.months)M" } + if duration.weeks != 0 { result += "\(duration.weeks)W" } + if duration.days != 0 { result += "\(duration.days)D" } + if duration.hours != 0 || duration.minutes != 0 || duration.seconds != 0 { + result += "T" + if duration.hours != 0 { result += "\(duration.hours)H" } + if duration.minutes != 0 { result += "\(duration.minutes)M" } + if duration.seconds != 0 { result += "\(duration.seconds)S" } + } + return result + } + + private static func getIntValue(from nsString: NSString, match: NSTextCheckingResult, at index: Int) -> Int { + guard match.range(at: index).location != NSNotFound else { + return 0 + } + return Int(nsString.substring(with: match.range(at: index))) ?? 0 + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/DateAndTime/ISOPeriodFormatter.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/DateAndTime/ISOPeriodFormatter.swift new file mode 100644 index 00000000..f948bcd3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/DateAndTime/ISOPeriodFormatter.swift @@ -0,0 +1,38 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ISOPeriodFormatter.swift +// +// Created by Joshua Liebowitz on 6/30/21. +// + +import Foundation +import StoreKit + +@available(iOS 11.2, macOS 10.13.2, tvOS 11.2, *) +enum ISOPeriodFormatter { + + static func string(fromProductSubscriptionPeriod period: SubscriptionPeriod) -> String { + ISODurationFormatter.string(from: period.isoDuration) + } +} + +extension SubscriptionPeriod { + var isoDuration: ISODuration { + ISODuration( + years: unit == .year ? value : 0, + months: unit == .month ? value : 0, + weeks: unit == .week ? value : 0, + days: unit == .day ? value : 0, + hours: 0, + minutes: 0, + seconds: 0 + ) + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/DateAndTime/TimingUtil.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/DateAndTime/TimingUtil.swift new file mode 100644 index 00000000..3fb3922c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/DateAndTime/TimingUtil.swift @@ -0,0 +1,315 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// TimingUtil.swift +// +// Created by Nacho Soto on 11/15/22. + +import Foundation + +internal enum TimingUtil { + + typealias Duration = TimeInterval + +} + +// MARK: - async API + +extension TimingUtil { + + /// Measures the time to execute `work` and returns the result and the duration. + /// Example: + /// ```swift + /// let (result, duration) = try await TimingUtil.measure { + /// return try await asyncMethod() + /// } + /// ``` + static func measure( + _ clock: ClockType = Clock.default, + _ work: @Sendable () async throws -> Value + ) async rethrows -> (result: Value, duration: Duration) { + let start = clock.currentTime + let result = try await work() + + return ( + result: result, + duration: clock.durationSince(start) + ) + } + + /// Measures the time to execute `work`, returns the result, + /// and logs `message` if duration exceeded `threshold`. + /// Example: + /// ```swift + /// let result = try await TimingUtil.measureAndLogIfTooSlow( + /// threshold: 2, + /// message: "Computation too slow", + /// level: .warn, + /// intent: .appleWarning + /// ) { + /// try await asyncMethod() + /// } + /// ``` + static func measureAndLogIfTooSlow( + threshold: Duration, + message: CustomStringConvertible, + level: LogLevel = .warn, + intent: LogIntent = .appleWarning, + clock: ClockType = Clock.default, + work: @Sendable () async throws -> Value + ) async rethrows -> Value { + precondition(threshold > 0, "Invalid threshold: \(threshold)") + + let (result, duration) = try await self.measure(clock, work) + + Self.logIfRequired(duration: duration, + threshold: threshold, + message: message, + level: level, + intent: intent) + + return result + } + + /// Measures the time to execute `work`, returns the result, + /// and logs `message` if duration exceeded `threshold`. + /// Example: + /// ```swift + /// let result = try await TimingUtil.measureAndLogIfTooSlow( + /// threshold: .productRequest, + /// message: "Computation too slow", + /// level: .warn, + /// intent: .appleWarning + /// ) { + /// try await asyncMethod() + /// } + /// ``` + static func measureAndLogIfTooSlow( + threshold: Configuration.TimingThreshold, + message: Message, + level: LogLevel = .warn, + intent: LogIntent = .appleWarning, + clock: ClockType = Clock.default, + _ work: @Sendable () async throws -> Value + ) async rethrows -> Value { + return try await self.measureAndLogIfTooSlow( + threshold: threshold.rawValue, + message: message, + level: level, + intent: intent, + work: work + ) + } + +} + +// MARK: - Synchronous API + +extension TimingUtil { + + /// Measures the time to execute `work` and returns the result and the duration. + /// Example: + /// ```swift + /// let (result, duration) = try TimingUtil.measure { + /// return try method() + /// } + /// ``` + static func measureSync( + _ clock: ClockType = Clock.default, + _ work: () throws -> Value + ) rethrows -> (result: Value, duration: Duration) { + let start = clock.currentTime + let result = try work() + + return ( + result: result, + duration: clock.durationSince(start) + ) + } + + /// Measures the time to execute `work`, returns the result, + /// and logs `message` if duration exceeded `threshold`. + /// Example: + /// ```swift + /// let result = try TimingUtil.measureAndLogIfTooSlow( + /// threshold: .productRequest, + /// message: "Computation too slow", + /// level: .warn, + /// intent: .appleWarning + /// ) { + /// try method() + /// } + /// ``` + static func measureSyncAndLogIfTooSlow( + threshold: Configuration.TimingThreshold, + message: Message, + level: LogLevel = .warn, + intent: LogIntent = .appleWarning, + clock: ClockType = Clock.default, + _ work: () throws -> Value + ) rethrows -> Value { + return try self.measureSyncAndLogIfTooSlow( + threshold: threshold.rawValue, + message: message, + level: level, + intent: intent, + work) + } + + /// Measures the time to execute `work`, returns the result, + /// and logs `message` if duration exceeded `threshold`. + /// Example: + /// ```swift + /// let result = try TimingUtil.measureAndLogIfTooSlow( + /// threshold: .productRequest, + /// message: "Computation too slow", + /// level: .warn, + /// intent: .appleWarning + /// ) { + /// try asyncMethod() + /// } + /// ``` + static func measureSyncAndLogIfTooSlow( + threshold: Duration, + message: Message, + level: LogLevel = .warn, + intent: LogIntent = .appleWarning, + clock: ClockType = Clock.default, + _ work: () throws -> Value + ) rethrows -> Value { + let (result, duration) = try self.measureSync(clock, work) + + Self.logIfRequired(duration: duration, + threshold: threshold, + message: message, + level: level, + intent: intent) + + return result + } + +} + +// MARK: - completion-block API + +extension TimingUtil { + + /// Measures the time to execute `work` and returns the `Result` and the duration. + /// Example: + /// ```swift + /// TimingUtil.measure { completion in + /// work { completion($0) } + /// } result: { result, duration in + /// print("Result: \(result) calculated in \(duration) seconds") + /// } + /// ``` + static func measure( + _ clock: ClockType = Clock.default, + _ work: (@escaping @Sendable (Value) -> Void) -> Void, + result: @escaping (Value, Duration) -> Void + ) { + let start = clock.currentTime + + work { value in + result(value, clock.durationSince(start)) + } + } + + /// Measures the time to execute `work`, returns the result, + /// and logs `message` if duration exceeded `threshold`. + /// Example: + /// ```swift + /// TimingUtil.measureAndLogIfTooSlow( + /// threshold: 2, + /// message: "Computation too slow", + /// level: .warn, + /// intent: .appleWarning + /// ) { completion in + /// work { completion($0) } + /// } result: { result in + /// print("Finished computing: \(result)") + /// } + /// ``` + static func measureAndLogIfTooSlow( + threshold: Duration, + message: CustomStringConvertible, + level: LogLevel = .warn, + intent: LogIntent = .appleWarning, + clock: ClockType = Clock.default, + work: (@escaping @Sendable (Value) -> Void) -> Void, + result: @escaping (Value) -> Void + ) { + Self.measure(clock, work) { value, duration in + Self.logIfRequired(duration: duration, + threshold: threshold, + message: message, + level: level, + intent: intent) + + result(value) + } + } + + /// Measures the time to execute `work`, returns the result, + /// and logs `message` if duration exceeded `threshold`. + /// Example: + /// ```swift + /// TimingUtil.measureAndLogIfTooSlow( + /// threshold: .productRequest, + /// message: "Computation too slow", + /// level: .warn, + /// intent: .appleWarning + /// ) { completion in + /// work { completion($0) } + /// } result: { result in + /// print("Finished computing: \(result)") + /// } + /// ``` + static func measureAndLogIfTooSlow( + threshold: Configuration.TimingThreshold, + message: CustomStringConvertible, + level: LogLevel = .warn, + intent: LogIntent = .appleWarning, + clock: ClockType = Clock.default, + work: (@escaping @Sendable (Value) -> Void) -> Void, + result: @escaping (Value) -> Void + ) { + Self.measureAndLogIfTooSlow(threshold: threshold.rawValue, + message: message, + level: level, + intent: intent, + clock: clock, + work: work, + result: result) + } + +} + +// MARK: - Private + +private extension TimingUtil { + + static func logIfRequired( + duration: Duration, + threshold: Duration, + message: CustomStringConvertible, + level: LogLevel, + intent: LogIntent + ) { + if duration >= threshold { + Logger.log( + level: level, + intent: intent, + message: Strings.diagnostics.timing_message(message: message.description, + duration: duration) + ) + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Deprecations.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Deprecations.swift new file mode 100644 index 00000000..d09f85ba --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Deprecations.swift @@ -0,0 +1,352 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Deprecations.swift +// +// Created by Nacho Soto on 3/8/22. + +import Foundation +import StoreKit + +// swiftlint:disable line_length missing_docs + +#if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + +public extension Purchases { + + @available(iOS, deprecated: 1, renamed: "checkTrialOrIntroDiscountEligibility(productIdentifiers:)") + @available(tvOS, deprecated: 1, renamed: "checkTrialOrIntroDiscountEligibility(productIdentifiers:)") + @available(watchOS, deprecated: 1, renamed: "checkTrialOrIntroDiscountEligibility(productIdentifiers:)") + @available(macOS, deprecated: 1, renamed: "checkTrialOrIntroDiscountEligibility(productIdentifiers:)") + @available(macCatalyst, deprecated: 1, renamed: "checkTrialOrIntroDiscountEligibility(productIdentifiers:)") + func checkTrialOrIntroDiscountEligibility(_ productIdentifiers: [String], + completion: @escaping ([String: IntroEligibility]) -> Void) { + self.checkTrialOrIntroDiscountEligibility(productIdentifiers: productIdentifiers, completion: completion) + } + + @available(iOS, introduced: 13.0, deprecated: 1, renamed: "checkTrialOrIntroDiscountEligibility(productIdentifiers:)") + @available(tvOS, introduced: 13.0, deprecated: 1, renamed: "checkTrialOrIntroDiscountEligibility(productIdentifiers:)") + @available(watchOS, introduced: 6.2, deprecated: 1, renamed: "checkTrialOrIntroDiscountEligibility(productIdentifiers:)") + @available(macOS, introduced: 10.15, deprecated: 1, renamed: "checkTrialOrIntroDiscountEligibility(productIdentifiers:)") + @available(macCatalyst, introduced: 13.0, deprecated: 1, renamed: "checkTrialOrIntroDiscountEligibility(productIdentifiers:)") + func checkTrialOrIntroDiscountEligibility(_ productIdentifiers: [String]) async -> [String: IntroEligibility] { + return await self.checkTrialOrIntroDiscountEligibility(productIdentifiers: productIdentifiers) + } + + @available(iOS, introduced: 13.0, deprecated, renamed: "promotionalOffer(forProductDiscount:product:)") + @available(tvOS, introduced: 13.0, deprecated, renamed: "promotionalOffer(forProductDiscount:product:)") + @available(watchOS, introduced: 6.2, deprecated, renamed: "promotionalOffer(forProductDiscount:product:)") + @available(macOS, introduced: 10.15, deprecated, renamed: "promotionalOffer(forProductDiscount:product:)") + @available(macCatalyst, introduced: 13.0, deprecated, renamed: "promotionalOffer(forProductDiscount:product:)") + func getPromotionalOffer(forProductDiscount discount: StoreProductDiscount, + product: StoreProduct) async throws -> PromotionalOffer { + return try await self.promotionalOffer(forProductDiscount: discount, product: product) + } + + @available(iOS, introduced: 13.0, deprecated, renamed: "eligiblePromotionalOffers(forProduct:)") + @available(tvOS, introduced: 13.0, deprecated, renamed: "eligiblePromotionalOffers(forProduct:)") + @available(watchOS, introduced: 6.2, deprecated, renamed: "eligiblePromotionalOffers(forProduct:)") + @available(macOS, introduced: 10.15, deprecated, renamed: "eligiblePromotionalOffers(forProduct:)") + @available(macCatalyst, introduced: 13.0, deprecated, renamed: "eligiblePromotionalOffers(forProduct:)") + func getEligiblePromotionalOffers(forProduct product: StoreProduct) async -> [PromotionalOffer] { + return await eligiblePromotionalOffers(forProduct: product) + } +} + +public extension Purchases { + + @available(iOS, deprecated, renamed: "attribution.collectDeviceIdentifiers()") + @available(tvOS, deprecated, renamed: "attribution.collectDeviceIdentifiers()") + @available(watchOS, deprecated, renamed: "attribution.collectDeviceIdentifiers()") + @available(macOS, deprecated, renamed: "attribution.collectDeviceIdentifiers()") + @available(macCatalyst, deprecated, renamed: "attribution.collectDeviceIdentifiers()") + @objc func collectDeviceIdentifiers() { + self.attribution.collectDeviceIdentifiers() + } + + @available(iOS, deprecated, renamed: "attribution.setAttributes(_:)") + @available(tvOS, deprecated, renamed: "attribution.setAttributes(_:)") + @available(watchOS, deprecated, renamed: "attribution.setAttributes(_:)") + @available(macOS, deprecated, renamed: "attribution.setAttributes(_:)") + @available(macCatalyst, deprecated, renamed: "attribution.setAttributes(_:)") + @objc func setAttributes(_ attributes: [String: String]) { + self.attribution.setAttributes(attributes) + } + + @available(iOS, deprecated, renamed: "attribution.setEmail(_:)") + @available(tvOS, deprecated, renamed: "attribution.setEmail(_:)") + @available(watchOS, deprecated, renamed: "attribution.setEmail(_:)") + @available(macOS, deprecated, renamed: "attribution.setEmail(_:)") + @available(macCatalyst, deprecated, renamed: "attribution.setEmail(_:)") + @objc func setEmail(_ email: String?) { + self.attribution.setEmail(email) + } + + @available(iOS, deprecated, renamed: "attribution.setPhoneNumber(_:)") + @available(tvOS, deprecated, renamed: "attribution.setPhoneNumber(_:)") + @available(watchOS, deprecated, renamed: "attribution.setPhoneNumber(_:)") + @available(macOS, deprecated, renamed: "attribution.setPhoneNumber(_:)") + @available(macCatalyst, deprecated, renamed: "attribution.setPhoneNumber(_:)") + @objc func setPhoneNumber(_ phoneNumber: String?) { + self.attribution.setPhoneNumber(phoneNumber) + } + + @available(iOS, deprecated, renamed: "attribution.setDisplayName(_:)") + @available(tvOS, deprecated, renamed: "attribution.setDisplayName(_:)") + @available(watchOS, deprecated, renamed: "attribution.setDisplayName(_:)") + @available(macOS, deprecated, renamed: "attribution.setDisplayName(_:)") + @available(macCatalyst, deprecated, renamed: "attribution.setDisplayName(_:)") + @objc func setDisplayName(_ displayName: String?) { + self.attribution.setDisplayName(displayName) + } + + @available(iOS, deprecated, renamed: "attribution.setPushToken(_:)") + @available(tvOS, deprecated, renamed: "attribution.setPushToken(_:)") + @available(watchOS, deprecated, renamed: "attribution.setPushToken(_:)") + @available(macOS, deprecated, renamed: "attribution.setPushToken(_:)") + @available(macCatalyst, deprecated, renamed: "attribution.setPushToken(_:)") + @objc func setPushToken(_ pushToken: Data?) { + self.attribution.setPushToken(pushToken) + } + + @available(iOS, deprecated, renamed: "attribution.setPushTokenString(_:)") + @available(tvOS, deprecated, renamed: "attribution.setPushTokenString(_:)") + @available(watchOS, deprecated, renamed: "attribution.setPushTokenString(_:)") + @available(macOS, deprecated, renamed: "attribution.setPushTokenString(_:)") + @available(macCatalyst, deprecated, renamed: "attribution.setPushTokenString(_:)") + @objc func setPushTokenString(_ pushToken: String?) { + self.attribution.setPushTokenString(pushToken) + } + + @available(iOS, deprecated, renamed: "attribution.setAdjustID(_:)") + @available(tvOS, deprecated, renamed: "attribution.setAdjustID(_:)") + @available(watchOS, deprecated, renamed: "attribution.setAdjustID(_:)") + @available(macOS, deprecated, renamed: "attribution.setAdjustID(_:)") + @available(macCatalyst, deprecated, renamed: "attribution.setAdjustID(_:)") + @objc func setAdjustID(_ adjustID: String?) { + self.attribution.setAdjustID(adjustID) + } + + @available(iOS, deprecated, renamed: "attribution.setAppsflyerID(_:)") + @available(tvOS, deprecated, renamed: "attribution.setAppsflyerID(_:)") + @available(watchOS, deprecated, renamed: "attribution.setAppsflyerID(_:)") + @available(macOS, deprecated, renamed: "attribution.setAppsflyerID(_:)") + @available(macCatalyst, deprecated, renamed: "attribution.setAppsflyerID(_:)") + @objc func setAppsflyerID(_ appsflyerID: String?) { + self.attribution.setAppsflyerID(appsflyerID) + } + + @available(iOS, deprecated, renamed: "attribution.setFBAnonymousID(_:)") + @available(tvOS, deprecated, renamed: "attribution.setFBAnonymousID(_:)") + @available(watchOS, deprecated, renamed: "attribution.setFBAnonymousID(_:)") + @available(macOS, deprecated, renamed: "attribution.setFBAnonymousID(_:)") + @available(macCatalyst, deprecated, renamed: "attribution.setFBAnonymousID(_:)") + @objc func setFBAnonymousID(_ fbAnonymousID: String?) { + self.attribution.setFBAnonymousID(fbAnonymousID) + } + + @available(iOS, deprecated, renamed: "attribution.setMparticleID(_:)") + @available(tvOS, deprecated, renamed: "attribution.setMparticleID(_:)") + @available(watchOS, deprecated, renamed: "attribution.setMparticleID(_:)") + @available(macOS, deprecated, renamed: "attribution.setMparticleID(_:)") + @available(macCatalyst, deprecated, renamed: "attribution.setMparticleID(_:)") + @objc func setMparticleID(_ mparticleID: String?) { + self.attribution.setMparticleID(mparticleID) + } + + @available(iOS, deprecated, renamed: "attribution.setOnesignalID(_:)") + @available(tvOS, deprecated, renamed: "attribution.setOnesignalID(_:)") + @available(watchOS, deprecated, renamed: "attribution.setOnesignalID(_:)") + @available(macOS, deprecated, renamed: "attribution.setOnesignalID(_:)") + @available(macCatalyst, deprecated, renamed: "attribution.setOnesignalID(_:)") + @objc func setOnesignalID(_ onesignalID: String?) { + self.attribution.setOnesignalID(onesignalID) + } + + @available(iOS, deprecated, renamed: "attribution.setAirshipChannelID(_:)") + @available(tvOS, deprecated, renamed: "attribution.setAirshipChannelID(_:)") + @available(watchOS, deprecated, renamed: "attribution.setAirshipChannelID(_:)") + @available(macOS, deprecated, renamed: "attribution.setAirshipChannelID(_:)") + @available(macCatalyst, deprecated, renamed: "attribution.setAirshipChannelID(_:)") + @objc func setAirshipChannelID(_ airshipChannelID: String?) { + self.attribution.setAirshipChannelID(airshipChannelID) + } + + @available(iOS, deprecated, renamed: "attribution.setCleverTapID(_:)") + @available(tvOS, deprecated, renamed: "attribution.setCleverTapID(_:)") + @available(watchOS, deprecated, renamed: "attribution.setCleverTapID(_:)") + @available(macOS, deprecated, renamed: "attribution.setCleverTapID(_:)") + @available(macCatalyst, deprecated, renamed: "attribution.setCleverTapID(_:)") + @objc func setCleverTapID(_ cleverTapID: String?) { + self.attribution.setCleverTapID(cleverTapID) + } + + @available(iOS, deprecated, renamed: "attribution.setMixpanelDistinctID(_:)") + @available(tvOS, deprecated, renamed: "attribution.setMixpanelDistinctID(_:)") + @available(watchOS, deprecated, renamed: "attribution.setMixpanelDistinctID(_:)") + @available(macOS, deprecated, renamed: "attribution.setMixpanelDistinctID(_:)") + @available(macCatalyst, deprecated, renamed: "attribution.setMixpanelDistinctID(_:)") + @objc func setMixpanelDistinctID(_ mixpanelDistinctID: String?) { + self.attribution.setMixpanelDistinctID(mixpanelDistinctID) + } + + @available(iOS, deprecated, renamed: "attribution.setFirebaseAppInstanceID(_:)") + @available(tvOS, deprecated, renamed: "attribution.setFirebaseAppInstanceID(_:)") + @available(watchOS, deprecated, renamed: "attribution.setFirebaseAppInstanceID(_:)") + @available(macOS, deprecated, renamed: "attribution.setFirebaseAppInstanceID(_:)") + @available(macCatalyst, deprecated, renamed: "attribution.setFirebaseAppInstanceID(_:)") + @objc func setFirebaseAppInstanceID(_ firebaseAppInstanceID: String?) { + self.attribution.setFirebaseAppInstanceID(firebaseAppInstanceID) + } + + @available(iOS, deprecated, renamed: "attribution.setMediaSource(_:)") + @available(tvOS, deprecated, renamed: "attribution.setMediaSource(_:)") + @available(watchOS, deprecated, renamed: "attribution.setMediaSource(_:)") + @available(macOS, deprecated, renamed: "attribution.setMediaSource(_:)") + @available(macCatalyst, deprecated, renamed: "attribution.setMediaSource(_:)") + @objc func setMediaSource(_ mediaSource: String?) { + self.attribution.setMediaSource(mediaSource) + } + + @available(iOS, deprecated, renamed: "attribution.setCampaign(_:)") + @available(tvOS, deprecated, renamed: "attribution.setCampaign(_:)") + @available(watchOS, deprecated, renamed: "attribution.setCampaign(_:)") + @available(macOS, deprecated, renamed: "attribution.setCampaign(_:)") + @available(macCatalyst, deprecated, renamed: "attribution.setCampaign(_:)") + @objc func setCampaign(_ campaign: String?) { + self.attribution.setCampaign(campaign) + } + + @available(iOS, deprecated, renamed: "attribution.setAdGroup(_:)") + @available(tvOS, deprecated, renamed: "attribution.setAdGroup(_:)") + @available(watchOS, deprecated, renamed: "attribution.setAdGroup(_:)") + @available(macOS, deprecated, renamed: "attribution.setAdGroup(_:)") + @available(macCatalyst, deprecated, renamed: "attribution.setAdGroup(_:)") + @objc func setAdGroup(_ adGroup: String?) { + self.attribution.setAdGroup(adGroup) + } + + @available(iOS, deprecated, renamed: "attribution.setAd(_:)") + @available(tvOS, deprecated, renamed: "attribution.setAd(_:)") + @available(watchOS, deprecated, renamed: "attribution.setAd(_:)") + @available(macOS, deprecated, renamed: "attribution.setAd(_:)") + @available(macCatalyst, deprecated, renamed: "attribution.setAd(_:)") + @objc func setAd(_ installAd: String?) { + self.attribution.setAd(installAd) + } + + @available(iOS, deprecated, renamed: "attribution.setKeyword(_:)") + @available(tvOS, deprecated, renamed: "attribution.setKeyword(_:)") + @available(watchOS, deprecated, renamed: "attribution.setKeyword(_:)") + @available(macOS, deprecated, renamed: "attribution.setKeyword(_:)") + @available(macCatalyst, deprecated, renamed: "attribution.setKeyword(_:)") + @objc func setKeyword(_ keyword: String?) { + self.attribution.setKeyword(keyword) + } + + @available(iOS, deprecated, renamed: "attribution.setCreative(_:)") + @available(tvOS, deprecated, renamed: "attribution.setCreative(_:)") + @available(watchOS, deprecated, renamed: "attribution.setCreative(_:)") + @available(macOS, deprecated, renamed: "attribution.setCreative(_:)") + @available(macCatalyst, deprecated, renamed: "attribution.setCreative(_:)") + @objc func setCreative(_ creative: String?) { + self.attribution.setCreative(creative) + } + + @available(iOS, deprecated, renamed: "purchase(_:completion:)") + @available(tvOS, deprecated, renamed: "purchase(_:completion:)") + @available(watchOS, deprecated, renamed: "purchase(_:completion:)") + @available(macOS, deprecated, renamed: "purchase(_:completion:)") + @available(macCatalyst, deprecated, renamed: "purchase(_:completion:)") + @objc(params:withCompletion:) + func purchaseWithParams(_ params: PurchaseParams, completion: @escaping PurchaseCompletedBlock) { + self.purchase(params, completion: completion) + } +} + +public extension StoreProduct { + + @available(iOS, introduced: 13.0, deprecated, renamed: "eligiblePromotionalOffers()") + @available(tvOS, introduced: 13.0, deprecated, renamed: "eligiblePromotionalOffers()") + @available(watchOS, introduced: 6.2, deprecated, renamed: "eligiblePromotionalOffers()") + @available(macOS, introduced: 10.15, deprecated, renamed: "eligiblePromotionalOffers()") + @available(macCatalyst, introduced: 13.0, deprecated, renamed: "eligiblePromotionalOffers()") + func getEligiblePromotionalOffers() async -> [PromotionalOffer] { + return await self.eligiblePromotionalOffers() + } + +} + +#endif + +extension CustomerInfo { + + /// Returns all product IDs of the non-subscription purchases a user has made. + @available(*, deprecated, message: "use nonSubscriptionTransactions") + @objc public var nonConsumablePurchases: Set { + return Set(self.nonSubscriptionTransactions.map { $0.productIdentifier }) + } + + /** + * Returns all the non-subscription purchases a user has made. + * The purchases are ordered by purchase date in ascending order. + */ + @available(*, deprecated, renamed: "nonSubscriptions") + @objc public var nonSubscriptionTransactions: [StoreTransaction] { + return self.nonSubscriptions + .map(BackendParsedTransaction.init) + .map(StoreTransaction.init) + } + + @available(*, deprecated, message: "Use NonSubscriptionTransaction") + private struct BackendParsedTransaction: StoreTransactionType, @unchecked Sendable { + + let productIdentifier: String + let purchaseDate: Date + let transactionIdentifier: String + let quantity: Int + var storefront: Storefront? { return nil } + internal var jwsRepresentation: String? { return nil } + internal var environment: StoreEnvironment? { return nil } + var reason: TransactionReason? { return nil } + + var hasKnownPurchaseDate: Bool { true } + var hasKnownTransactionIdentifier: Bool { return true } + + init(with transaction: NonSubscriptionTransaction) { + self.productIdentifier = transaction.productIdentifier + self.purchaseDate = transaction.purchaseDate + self.transactionIdentifier = transaction.transactionIdentifier + + // Defaulting to `1` since multi-quantity purchases aren't currently supported. + self.quantity = 1 + } + + func finish(_ wrapper: PaymentQueueWrapperType, completion: @escaping @Sendable () -> Void) { + completion() + } + + } + +} + +public extension Configuration.Builder { + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + @available(*, deprecated, message: "Use .with(storeKitVersion:) to enable StoreKit 2") + @objc func with(usesStoreKit2IfAvailable: Bool) -> Configuration.Builder { + return self.with(storeKitVersion: usesStoreKit2IfAvailable ? .storeKit2 : .default) + } + + #endif + +} + +// swiftlint:enable line_length missing_docs file_length diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Either.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Either.swift new file mode 100644 index 00000000..98b8ca96 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Either.swift @@ -0,0 +1,40 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Either.swift +// +// Created by Nacho Soto on 10/7/22. + +import Foundation + +/// A type that may contain one of two possible values. +internal enum Either { + + case left(Left) + case right(Right) + +} + +extension Either { + + var left: Left? { + switch self { + case let .left(left): return left + case .right: return nil + } + } + + var right: Right? { + switch self { + case .left: return nil + case let .right(right): return right + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Locale/PreferredLocalesProvider.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Locale/PreferredLocalesProvider.swift new file mode 100644 index 00000000..b22d0f84 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Locale/PreferredLocalesProvider.swift @@ -0,0 +1,56 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PreferredLocalesProvider.swift +// +// Created by Cesar de la Vega on 1/7/24. + +import Foundation + +final class PreferredLocalesProvider { + + private static let defaultPreferredLocalesGetter = { + return Locale.preferredLanguages + } + + /// Developer-set preferred locale that takes precedence over the system preferred locales. + private(set) var preferredLocaleOverride: String? + + /// Closure to get the user's preferred locales, allowing for dependency injection in tests. + private var systemPreferredLocalesGetter: () -> [String] + + /// Initializes the provider with an optional override for the preferred locale. + /// - Parameters: + /// - preferredLocaleOverride: The preferred locale to override the system's preferred languages, if any. + /// - preferredLocalesGetter: The closure to get the preferred locales, defaults to the system's preferred + /// languages. + init( + preferredLocaleOverride: String?, + systemPreferredLocalesGetter: @escaping () -> [String] = PreferredLocalesProvider.defaultPreferredLocalesGetter + ) { + self.preferredLocaleOverride = preferredLocaleOverride + self.systemPreferredLocalesGetter = systemPreferredLocalesGetter + } + + /// Returns the list of the user's preferred languages, including the preferred locale override as the first + /// locale of the array. + var preferredLocales: [String] { + if let preferredLocaleOverride = self.preferredLocaleOverride { + return [preferredLocaleOverride] + systemPreferredLocalesGetter() + } else { + return systemPreferredLocalesGetter() + } + } + + /// Sets a new preferred locale override that will take precedence over the system's preferred languages. + func overridePreferredLocale(_ locale: String?) { + self.preferredLocaleOverride = locale + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/MacDevice.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/MacDevice.swift new file mode 100644 index 00000000..8debfd6e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/MacDevice.swift @@ -0,0 +1,95 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// MacDevice.swift +// +// Created by Juanpe Catalán on 30/11/21. + +#if os(macOS) || targetEnvironment(macCatalyst) +import Foundation + +#if canImport(IOKit) +import IOKit +#endif + +enum MacDevice { + + // Based on Apple's documentation, the mac address must + // be used to validate receipts. + // https://developer.apple.com/documentation/appstorereceipts/validating_receipts_on_the_device + static var identifierForVendor: UUID? { + networkInterfaceMacAddressData?.uuid + } + + static var networkInterfaceMacAddressData: Data? { + #if canImport(IOKit) + guard let service = getIOService(named: "en0", wantBuiltIn: true) + ?? getIOService(named: "en1", wantBuiltIn: true) + ?? getIOService(named: "en0", wantBuiltIn: false) + else { return nil } + + defer { IOObjectRelease(service) } + + return IORegistryEntrySearchCFProperty( + service, + kIOServicePlane, + "IOMACAddress" as CFString, + kCFAllocatorDefault, + IOOptionBits(kIORegistryIterateRecursively | kIORegistryIterateParents) + ) as? Data + #else + return nil + #endif + } + + #if canImport(IOKit) + static private func getIOService(named name: String, wantBuiltIn: Bool) -> io_service_t? { + // 0 is `kIOMasterPortDefault` / `kIOMainPortDefault`, but the first is deprecated + // And the second isn't available in Catalyst on Xcode 14. + let defaultPort: mach_port_t = 0 + var iterator = io_iterator_t() + defer { + if iterator != IO_OBJECT_NULL { + IOObjectRelease(iterator) + } + } + + guard let matchingDict = IOBSDNameMatching(defaultPort, 0, name), + IOServiceGetMatchingServices(defaultPort, + matchingDict as CFDictionary, + &iterator) == KERN_SUCCESS, + iterator != IO_OBJECT_NULL + else { + return nil + } + + var candidate = IOIteratorNext(iterator) + while candidate != IO_OBJECT_NULL { + if let cftype = IORegistryEntryCreateCFProperty(candidate, + "IOBuiltin" as CFString, + kCFAllocatorDefault, + 0) { + // swiftlint:disable:next force_cast + let isBuiltIn = cftype.takeRetainedValue() as! CFBoolean + if wantBuiltIn == CFBooleanGetValue(isBuiltIn) { + return candidate + } + } + + IOObjectRelease(candidate) + candidate = IOIteratorNext(iterator) + } + + return nil + } + #endif + +} + +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/MapAppStoreDetector.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/MapAppStoreDetector.swift new file mode 100644 index 00000000..04246a1f --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/MapAppStoreDetector.swift @@ -0,0 +1,72 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// MapAppStoreDetector.swift +// +// Created by Nacho Soto on 1/10/24. + +import Foundation + +/// A type that can determine whether the application is running on the MAS. +protocol MacAppStoreDetector: Sendable { + + var isMacAppStore: Bool { get } + +} + +#if os(macOS) || targetEnvironment(macCatalyst) + +final class DefaultMacAppStoreDetector: MacAppStoreDetector { + + /// Returns whether the bundle was signed for Mac App Store distribution by checking + /// the existence of a specific extension (marker OID) on the code signing certificate. + /// + /// This routine is inspired by the source code from ProcInfo, the underlying library + /// of the WhatsYourSign code signature checking tool developed by Objective-See. Initially, + /// it checked the common name but was changed to an extension check to make it more + /// future-proof. + /// + /// For more information, see the following references: + /// - https://github.com/objective-see/ProcInfo/blob/master/procInfo/Signing.m#L184-L247 + /// - https://gist.github.com/lukaskubanek/cbfcab29c0c93e0e9e0a16ab09586996#gistcomment-3993808 + var isMacAppStore: Bool { + var status = noErr + + var code: SecStaticCode? + status = SecStaticCodeCreateWithPath(Bundle.main.bundleURL as CFURL, [], &code) + + guard status == noErr, let code = code else { + Logger.error(Strings.receipt.error_validating_bundle_signature) + return false + } + + var requirement: SecRequirement? + status = SecRequirementCreateWithString( + "anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.9]" as CFString, + [], // default + &requirement + ) + + guard status == noErr, let requirement = requirement else { + Logger.error(Strings.receipt.error_validating_bundle_signature) + return false + } + + status = SecStaticCodeCheckValidity( + code, + [], // default + requirement + ) + + return status == errSecSuccess + } + +} + +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Obsoletions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Obsoletions.swift new file mode 100644 index 00000000..63b2cfc4 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/Obsoletions.swift @@ -0,0 +1,900 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Obsoletions.swift +// +// Created by Nacho Soto on 11/15/21. + +import StoreKit + +// All these methods are `obsoleted`, which means they can't be called by users of the SDK, +// and therefore the `fatalError`s are unreachable. +// See also: Contributing/Deprecations.md + +// swiftlint:disable file_length missing_docs line_length + +public extension Purchases { + + /** + * This method will post all purchases associated with the current App Store account to RevenueCat and become + * associated with the current ``appUserID``. If the receipt is being used by an existing user, the current + * ``appUserID`` will be aliased together with the `appUserID` of the existing user. + * Going forward, either `appUserID` will be able to reference the same user. + * + * You shouldn't use this method if you have your own account system. In that case "restoration" is provided + * by your app passing the same `appUserId` used to purchase originally. + * + * - Note: This may force your users to enter the App Store password so should only be performed on request of + * the user. Typically with a button in settings or near your purchase UI. Use + * ``Purchases/syncPurchases(completion:)`` if you need to restore transactions programmatically. + */ + @available(iOS, obsoleted: 1, renamed: "restorePurchases(completion:)") + @available(tvOS, obsoleted: 1, renamed: "restorePurchases(completion:)") + @available(watchOS, obsoleted: 1, renamed: "restorePurchases(completion:)") + @available(macOS, obsoleted: 1, renamed: "restorePurchases(completion:)") + @objc(restoreTransactionsWithCompletionBlock:) + func restoreTransactions(completion: ((CustomerInfo?, Error?) -> Void)? = nil) { + fatalError() + } + + /** + * This method will post all purchases associated with the current App Store account to RevenueCat and become + * associated with the current ``appUserID``. If the receipt is being used by an existing user, the current + * ``appUserID`` will be aliased together with the `appUserID` of the existing user. + * Going forward, either `appUserID` will be able to reference the same user. + * + * You shouldn't use this method if you have your own account system. In that case "restoration" is provided + * by your app passing the same `appUserId` used to purchase originally. + * + * - Note: This may force your users to enter the App Store password so should only be performed on request of + * the user. Typically with a button in settings or near your purchase UI. Use + * ``Purchases/syncPurchases(completion:)`` if you need to restore transactions programmatically. + */ + @available(iOS, introduced: 13.0, unavailable, renamed: "restorePurchases()") + @available(tvOS, introduced: 13.0, unavailable, renamed: "restorePurchases()") + @available(watchOS, introduced: 6.2, unavailable, renamed: "restorePurchases()") + @available(macOS, introduced: 10.15, unavailable, renamed: "restorePurchases()") + @available(macCatalyst, introduced: 13.0, unavailable, renamed: "restorePurchases()") + func restoreTransactions() throws -> CustomerInfo { + fatalError() + } + + /** + * Get latest available purchaser info. + * + * - Parameter completion: A completion block called when customer info is available and not stale. + * Called immediately if info is cached. Customer info can be nil if an error occurred. + */ + @available(iOS, obsoleted: 1, renamed: "getCustomerInfo(completion:)") + @available(tvOS, obsoleted: 1, renamed: "getCustomerInfo(completion:)") + @available(watchOS, obsoleted: 1, renamed: "getCustomerInfo(completion:)") + @available(macOS, obsoleted: 1, renamed: "getCustomerInfo(completion:)") + @objc func customerInfo(completion: @escaping (CustomerInfo?, Error?) -> Void) { + fatalError() + } + + /** + * Get latest available purchaser info. + * + * - Parameter completion: A completion block called when customer info is available and not stale. + * Called immediately if info is cached. Customer info can be nil if an error occurred. + */ + @available(iOS, obsoleted: 1, renamed: "getCustomerInfo(completion:)") + @available(tvOS, obsoleted: 1, renamed: "getCustomerInfo(completion:)") + @available(watchOS, obsoleted: 1, renamed: "getCustomerInfo(completion:)") + @available(macOS, obsoleted: 1, renamed: "getCustomerInfo(completion:)") + @objc(purchaserInfoWithCompletionBlock:) + func purchaserInfo(completion: @escaping (CustomerInfo?, Error?) -> Void) { + fatalError() + } + + /** + * Get latest available purchaser info. + */ + @available(iOS, introduced: 13.0, unavailable, renamed: "customerInfo()") + @available(tvOS, introduced: 13.0, unavailable, renamed: "customerInfo()") + @available(watchOS, introduced: 6.2, unavailable, renamed: "customerInfo()") + @available(macOS, introduced: 10.15, unavailable, renamed: "customerInfo()") + @available(macCatalyst, introduced: 13.0, unavailable, renamed: "customerInfo()") + func purchaserInfo() throws -> CustomerInfo { + fatalError() + } + + /** + * Fetches the `SKProducts` for your IAPs for given `productIdentifiers`. + * Use this method if you aren't using `-offeringsWithCompletionBlock:`. + * You should use offerings though. + * + * - Note: `completion` may be called without `SKProduct`s that you are expecting. + * This is usually caused by iTunesConnect configuration errors. + * Ensure your IAPs have the "Ready to Submit" status in iTunesConnect. + * Also ensure that you have an active developer program subscription and you have + * signed the latest paid application agreements. + * + * If you're having trouble see: https://www.revenuecat.com/2018/10/11/configuring-in-app-products-is-hard + * + * - Parameter productIdentifiers: A set of product identifiers for in app purchases setup via iTunesConnect. + * This should be either hard coded in your application, from a file, or from + * a custom endpoint if you want to be able to deploy new IAPs without an app update. + * - Parameter completion: An @escaping callback that is called with the loaded products. + * If the fetch fails for any reason it will return an empty array. + */ + @available(iOS, obsoleted: 1, renamed: "getProducts(_:completion:)") + @available(tvOS, obsoleted: 1, renamed: "getProducts(_:completion:)") + @available(watchOS, obsoleted: 1, renamed: "getProducts(_:completion:)") + @available(macOS, obsoleted: 1, renamed: "getProducts(_:completion:)") + @objc(productsWithIdentifiers:completionBlock:) + func products(_ productIdentifiers: [String], completion: @escaping ([SKProduct]) -> Void) { + fatalError() + } + + /** + * Fetch the configured offerings for this users. + * Offerings allows you to configure your in-app products via RevenueCat and greatly simplifies management. + * + * Offerings will be fetched and cached on instantiation so that, by the time they are needed, + * your prices are loaded for your purchase flow. Time is money. + * + * - Parameter completion: A completion block called when offerings are available. + * Called immediately if offerings are cached. Offerings will be nil if an error occurred. + * + * #### Related Articles + * - [Displaying Products](https://docs.revenuecat.com/docs/displaying-products) + */ + @available(iOS, obsoleted: 1, renamed: "getOfferings(completion:)") + @available(tvOS, obsoleted: 1, renamed: "getOfferings(completion:)") + @available(watchOS, obsoleted: 1, renamed: "getOfferings(completion:)") + @available(macOS, obsoleted: 1, renamed: "getOfferings(completion:)") + @objc(offeringsWithCompletionBlock:) + func offerings(completion: @escaping (Offerings?, Error?) -> Void) { + fatalError() + } + + /** + * Purchase the passed `Package`. + * Call this method when a user has decided to purchase a product. Only call this in direct response to user input. + * From here `Purchases` will handle the purchase with `StoreKit` and call the `RCPurchaseCompletedBlock`. + * - Note: You do not need to finish the transaction yourself in the completion callback, + * Purchases will handle this for you. + * - Parameter package: The `Package` the user intends to purchase + * + * - Parameter completion: A completion block that is called when the purchase completes. + * If the purchase was successful there will be a `SKPaymentTransaction` and a `RCPurchaserInfo` + * If the purchase was not successful, there will be an `NSError`. + * If the user cancelled, `userCancelled` will be `YES`. + */ + @available(iOS, obsoleted: 1, renamed: "purchase(package:completion:)") + @available(tvOS, obsoleted: 1, renamed: "purchase(package:completion:)") + @available(watchOS, obsoleted: 1, renamed: "purchase(package:completion:)") + @available(macOS, obsoleted: 1, renamed: "purchase(package:completion:)") + @objc(purchasePackage:withCompletionBlock:) + func purchasePackage(_ package: Package, _ completion: @escaping PurchaseCompletedBlock) { + fatalError() + } + + /** + * Purchase the passed `Package`. + * Call this method when a user has decided to purchase a product. Only call this in direct response to user input. + * From here `Purchases` will handle the purchase with `StoreKit` and call the `RCPurchaseCompletedBlock`. + * - Note: You do not need to finish the transaction yourself in the completion callback, + * Purchases will handle this for you. + * - Parameter package: The `Package` the user intends to purchase + */ + @available(iOS, introduced: 13.0, unavailable, renamed: "purchase(package:)") + @available(tvOS, introduced: 13.0, unavailable, renamed: "purchase(package:)") + @available(watchOS, introduced: 6.2, unavailable, renamed: "purchase(package:)") + @available(macOS, introduced: 10.15, unavailable, renamed: "purchase(package:)") + @available(macCatalyst, introduced: 13.0, unavailable, renamed: "purchase(package:)") + func purchasePackage(_ package: Package) throws -> PurchaseResultData { + fatalError() + } + + /** + * Purchase the passed `Package`. + * Call this method when a user has decided to purchase a product. Only call this in direct response to user input. + * From here `Purchases` will handle the purchase with `StoreKit` and call the `RCPurchaseCompletedBlock`. + * - Note: You do not need to finish the transaction yourself in the completion callback, + * Purchases will handle this for you. + * - Parameter package: The `Package` the user intends to purchase + * + * - Parameter completion: A completion block that is called when the purchase completes. + * If the purchase was successful there will be a `SKPaymentTransaction` and a `RCPurchaserInfo`. + * If the purchase was not successful, there will be an `NSError`. + * If the user cancelled, `userCancelled` will be `YES`. + */ + @available(iOS, introduced: 12.2, unavailable, renamed: "purchase(package:promotionalOffer:completion:)") + @available(tvOS, introduced: 12.2, unavailable, renamed: "purchase(package:promotionalOffer:completion:)") + @available(watchOS, introduced: 6.2, unavailable, renamed: "purchase(package:promotionalOffer:completion:)") + @available(macOS, introduced: 10.14.4, unavailable, renamed: "purchase(package:promotionalOffer:completion:)") + @available(macCatalyst, introduced: 13.0, unavailable, renamed: "purchase(package:promotionalOffer:completion:)") + @objc(purchasePackage:withDiscount:completionBlock:) + func purchasePackage(_ package: Package, + discount: SKPaymentDiscount, + _ completion: @escaping PurchaseCompletedBlock) { + fatalError() + } + + /** + * Purchase the passed `Package`. + * Call this method when a user has decided to purchase a product. Only call this in direct response to user input. + * From here `Purchases` will handle the purchase with `StoreKit` and call the `RCPurchaseCompletedBlock`. + * - Note: You do not need to finish the transaction yourself in the completion callback, + * Purchases will handle this for you. + * - Parameter package: The `Package` the user intends to purchase + */ + @available(iOS, introduced: 13.0, unavailable, renamed: "purchase(package:promotionalOffer:)") + @available(tvOS, introduced: 13.0, unavailable, renamed: "purchase(package:promotionalOffer:)") + @available(watchOS, introduced: 6.2, unavailable, renamed: "purchase(package:promotionalOffer:)") + @available(macOS, introduced: 10.15, unavailable, renamed: "purchase(package:promotionalOffer:)") + @available(macCatalyst, introduced: 13.0, unavailable, renamed: "purchase(package:promotionalOffer:)") + func purchasePackage(_ package: Package, + discount: SKPaymentDiscount) throws -> PurchaseResultData { + fatalError() + } + + /** + * Use this function if you are not using the Offerings system to purchase an `SKProduct`. + * If you are using the Offerings system, use `-[RCPurchases purchasePackage:withCompletionBlock]` instead. + * Call this method when a user has decided to purchase a product. Only call this in direct response to user input. + * From here `Purchases` will handle the purchase with `StoreKit` and call the `RCPurchaseCompletedBlock`. + * + * - Note: You do not need to finish the transaction yourself in the completion callback, + * Purchases will handle this for you. + * - Parameter product: The `SKProduct` the user intends to purchase + * - Parameter completion: A completion block that is called when the purchase completes. + * If the purchase was successful there will be a `SKPaymentTransaction` and a `RCPurchaserInfo`. + * If the purchase was not successful, there will be an `NSError`. + * If the user cancelled, `userCancelled` will be `YES`. + */ + @available(iOS, obsoleted: 1, renamed: "purchase(product:_:)") + @available(tvOS, obsoleted: 1, renamed: "purchase(product:_:)") + @available(watchOS, obsoleted: 1, renamed: "purchase(product:_:)") + @available(macOS, obsoleted: 1, renamed: "purchase(product:_:)") + @objc(purchaseProduct:withCompletionBlock:) + func purchaseProduct(_ product: SKProduct, _ completion: @escaping PurchaseCompletedBlock) { + fatalError() + } + + /** + * Use this function if you are not using the Offerings system to purchase an `SKProduct`. + * If you are using the Offerings system, use `-[RCPurchases purchasePackage:withCompletionBlock]` instead. + * Call this method when a user has decided to purchase a product. Only call this in direct response to user input. + * From here `Purchases` will handle the purchase with `StoreKit` and call the `RCPurchaseCompletedBlock`. + * + * - Note: You do not need to finish the transaction yourself in the completion callback, + * Purchases will handle this for you. + * - Parameter product: The `SKProduct` the user intends to purchase + */ + @available(iOS, introduced: 13.0, unavailable, renamed: "purchase(product:)") + @available(tvOS, introduced: 13.0, unavailable, renamed: "purchase(product:)") + @available(watchOS, introduced: 6.2, unavailable, renamed: "purchase(product:)") + @available(macOS, introduced: 10.15, unavailable, renamed: "purchase(product:)") + @available(macCatalyst, introduced: 13.0, unavailable, renamed: "purchase(product:)") + func purchaseProduct(_ product: SKProduct) throws { + fatalError() + } + + /** + * Use this function if you are not using the Offerings system to purchase an `SKProduct`. + * If you are using the Offerings system, use `-[RCPurchases purchasePackage:withCompletionBlock]` instead. + * Call this method when a user has decided to purchase a product. Only call this in direct response to user input. + * From here `Purchases` will handle the purchase with `StoreKit` and call the `RCPurchaseCompletedBlock`. + * + * - Note: You do not need to finish the transaction yourself in the completion callback, + * Purchases will handle this for you. + * - Parameter product: The `SKProduct` the user intends to purchase + * - Parameter completion: A completion block that is called when the purchase completes. + * If the purchase was successful there will be a `SKPaymentTransaction` and a `RCPurchaserInfo`. + * If the purchase was not successful, there will be an `NSError`. + * If the user cancelled, `userCancelled` will be `YES`. + */ + @available(iOS, introduced: 12.2, unavailable, renamed: "purchase(product:promotionalOffer:completion:)") + @available(tvOS, introduced: 12.2, unavailable, renamed: "purchase(product:promotionalOffer:completion:)") + @available(watchOS, introduced: 6.2, unavailable, renamed: "purchase(product:promotionalOffer:completion:)") + @available(macOS, introduced: 10.14.4, unavailable, renamed: "purchase(product:promotionalOffer:completion:)") + @available(macCatalyst, introduced: 13.0, unavailable, renamed: "purchase(product:promotionalOffer:completion:)") + @objc(purchaseProduct:withDiscount:completionBlock:) + func purchaseProduct(_ product: SKProduct, + discount: SKPaymentDiscount, + _ completion: @escaping PurchaseCompletedBlock) { + fatalError() + } + + /** + * Use this function if you are not using the Offerings system to purchase an `SKProduct`. + * If you are using the Offerings system, use `-[RCPurchases purchasePackage:withCompletionBlock]` instead. + * Call this method when a user has decided to purchase a product. Only call this in direct response to user input. + * From here `Purchases` will handle the purchase with `StoreKit` and call the `RCPurchaseCompletedBlock`. + * + * - Note: You do not need to finish the transaction yourself in the completion callback, + * Purchases will handle this for you. + * - Parameter product: The `SKProduct` the user intends to purchase + */ + @available(iOS, introduced: 13.0, unavailable, renamed: "purchase(product:promotionalOffer:)") + @available(tvOS, introduced: 13.0, unavailable, renamed: "purchase(product:promotionalOffer:)") + @available(watchOS, introduced: 6.2, unavailable, renamed: "purchase(product:promotionalOffer:)") + @available(macOS, introduced: 10.15, unavailable, renamed: "purchase(product:promotionalOffer:)") + @available(macCatalyst, introduced: 13.0, unavailable, renamed: "purchase(product:promotionalOffer:)") + func purchaseProduct(_ product: SKProduct, discount: SKPaymentDiscount) throws { + fatalError() + } + + @available(iOS, introduced: 13.0, unavailable, renamed: "purchase(package:promotionalOffer:)") + @available(tvOS, introduced: 13.0, unavailable, renamed: "purchase(package:promotionalOffer:)") + @available(watchOS, introduced: 6.2, unavailable, renamed: "purchase(package:promotionalOffer:)") + @available(macOS, introduced: 10.15, unavailable, renamed: "purchase(package:promotionalOffer:)") + @available(macCatalyst, introduced: 13.0, unavailable, renamed: "purchase(package:promotionalOffer:)") + func purchase(package: Package, discount: StoreProductDiscount) throws -> PurchaseResultData { + fatalError() + } + + @available(iOS, introduced: 12.2, unavailable, renamed: "purchase(package:promotionalOffer:completion:)") + @available(tvOS, introduced: 12.2, unavailable, renamed: "purchase(package:promotionalOffer:completion:)") + @available(watchOS, introduced: 6.2, unavailable, renamed: "purchase(package:promotionalOffer:completion:)") + @available(macOS, introduced: 10.14.4, unavailable, renamed: "purchase(package:promotionalOffer:completion:)") + @available(macCatalyst, introduced: 12.2, unavailable, renamed: "purchase(package:promotionalOffer:completion:)") + func purchase(package: Package, discount: StoreProductDiscount, completion: @escaping PurchaseCompletedBlock) { + fatalError() + } + + @available(iOS, introduced: 13.0, unavailable, renamed: "purchase(package:promotionalOffer:)") + @available(tvOS, introduced: 13.0, unavailable, renamed: "purchase(package:promotionalOffer:)") + @available(watchOS, introduced: 6.2, unavailable, renamed: "purchase(package:promotionalOffer:)") + @available(macOS, introduced: 10.15, unavailable, renamed: "purchase(package:promotionalOffer:)") + @available(macCatalyst, introduced: 13.0, unavailable, renamed: "purchase(package:promotionalOffer:)") + func purchase(product: StoreProduct, discount: StoreProductDiscount) throws -> PurchaseResultData { + fatalError() + } + + @available(iOS, introduced: 12.2, unavailable, renamed: "purchase(package:promotionalOffer:completion:)") + @available(tvOS, introduced: 12.2, unavailable, renamed: "purchase(package:promotionalOffer:completion:)") + @available(watchOS, introduced: 6.2, unavailable, renamed: "purchase(package:promotionalOffer:completion:)") + @available(macOS, introduced: 10.14.4, unavailable, renamed: "purchase(package:promotionalOffer:completion:)") + @available(macCatalyst, introduced: 12.2, unavailable, renamed: "purchase(package:promotionalOffer:completion:)") + func purchase(product: StoreProduct, discount: StoreProductDiscount, completion: @escaping PurchaseCompletedBlock) { + fatalError() + } + + @available(iOS, introduced: 13.0, unavailable, renamed: "getPromotionalOffer(forProductDiscount:product:)") + @available(tvOS, introduced: 13.0, unavailable, renamed: "getPromotionalOffer(forProductDiscount:product:)") + @available(watchOS, introduced: 6.2, unavailable, renamed: "getPromotionalOffer(forProductDiscount:product:)") + @available(macOS, introduced: 10.15, unavailable, renamed: "getPromotionalOffer(forProductDiscount:product:)") + @available(macCatalyst, introduced: 13.0, unavailable, renamed: "getPromotionalOffer(forProductDiscount:product:)") + func checkPromotionalDiscountEligibility(forProductDiscount: StoreProductDiscount, product: StoreProduct) { + fatalError() + } + + @available(iOS, introduced: 12.2, unavailable, + renamed: "getPromotionalOffer(forProductDiscount:product:completion:)") + @available(tvOS, introduced: 12.2, unavailable, + renamed: "getPromotionalOffer(forProductDiscount:product:completion:)") + @available(watchOS, introduced: 6.2, unavailable, + renamed: "getPromotionalOffer(forProductDiscount:product:completion:)") + @available(macOS, introduced: 10.14.4, unavailable, + renamed: "getPromotionalOffer(forProductDiscount:product:completion:)") + @available(macCatalyst, introduced: 12.2, unavailable, + renamed: "getPromotionalOffer(forProductDiscount:product:completion:)") + func checkPromotionalDiscountEligibility(forProductDiscount: StoreProductDiscount, + product: StoreProduct, + completion: @escaping (AnyObject, Error?) -> Void) { + fatalError() + } + + @available(iOS, obsoleted: 1, renamed: "invalidateCustomerInfoCache") + @available(tvOS, obsoleted: 1, renamed: "invalidateCustomerInfoCache") + @available(watchOS, obsoleted: 1, renamed: "invalidateCustomerInfoCache") + @available(macOS, obsoleted: 1, renamed: "invalidateCustomerInfoCache") + @available(macCatalyst, obsoleted: 1, renamed: "invalidateCustomerInfoCache") + @objc func invalidatePurchaserInfoCache() { + fatalError() + } + + /** + * Computes whether or not a user is eligible for the introductory pricing period of a given product. + * You should use this method to determine whether or not you show the user the normal product price or + * the introductory price. This also applies to trials (trials are considered a type of introductory pricing). + * [iOS Introductory Offers](https://docs.revenuecat.com/docs/ios-subscription-offers). + * + * - Note: If you're looking to use Promotional Offers use instead, + * use ``Purchases/checkPromotionalDiscountEligibility(forProductDiscount:product:completion:)``. + * + * - Note: Subscription groups are automatically collected for determining eligibility. If RevenueCat can't + * definitively compute the eligibilty, most likely because of missing group information, it will return + * ``IntroEligibilityStatus/unknown``. The best course of action on unknown status is to display the non-intro + * pricing, to not create a misleading situation. To avoid this, make sure you are testing with the latest + * version of iOS so that the subscription group can be collected by the SDK. + * + * - Parameter productIdentifiers: Array of product identifiers for which you want to compute eligibility + * - Parameter completion: A block that receives a dictionary of product_id -> ``IntroEligibility``. + */ + @available(iOS, obsoleted: 1, renamed: "checkTrialOrIntroDiscountEligibility(_:completion:)") + @available(tvOS, obsoleted: 1, renamed: "checkTrialOrIntroDiscountEligibility(_:completion:)") + @available(watchOS, obsoleted: 1, renamed: "checkTrialOrIntroDiscountEligibility(_:completion:)") + @available(macOS, obsoleted: 1, renamed: "checkTrialOrIntroDiscountEligibility(_:completion:)") + @available(macCatalyst, obsoleted: 1, renamed: "checkTrialOrIntroDiscountEligibility(_:completion:)") + @objc(checkTrialOrIntroductoryPriceEligibility:completion:) + func checkTrialOrIntroductoryPriceEligibility(_ productIdentifiers: [String], + completion: @escaping ([String: IntroEligibility]) -> Void) { + fatalError() + } + + /** + * Use this function to retrieve the `SKPaymentDiscount` for a given `SKProduct`. + * + * - Parameter discount: The `SKProductDiscount` to apply to the product. + * - Parameter product: The `SKProduct` the user intends to purchase. + * - Parameter completion: A completion block that is called when the `SKPaymentDiscount` is returned. + * If it was not successful, there will be an `Error`. + */ + @available(iOS, introduced: 12.2, unavailable, + message: "Check eligibility for a discount using getPromotionalOffer:") + @available(tvOS, introduced: 12.2, unavailable, + message: "Check eligibility for a discount using getPromotionalOffer:") + @available(watchOS, introduced: 6.2, unavailable, + message: "Check eligibility for a discount using getPromotionalOffer:") + @available(macOS, introduced: 10.14.4, unavailable, + message: "Check eligibility for a discount using getPromotionalOffer:") + @available(macCatalyst, introduced: 13.0, unavailable, + message: "Check eligibility for a discount using getPromotionalOffer:") + @objc(paymentDiscountForProductDiscount:product:completion:) + func paymentDiscount(for discount: SKProductDiscount, + product: SKProduct, + completion: @escaping (SKPaymentDiscount?, Error?) -> Void) { + fatalError() + } + + /** + * Use this function to retrieve the `SKPaymentDiscount` for a given `SKProduct`. + * + * - Parameter discount: The `SKProductDiscount` to apply to the product. + * - Parameter product: The `SKProduct` the user intends to purchase. + */ + @available(iOS, introduced: 13.0, unavailable, + message: "Check eligibility for a discount using getPromotionalOffer:") + @available(tvOS, introduced: 13.0, unavailable, + message: "Check eligibility for a discount using getPromotionalOffer:") + @available(watchOS, introduced: 6.2, unavailable, + message: "Check eligibility for a discount using getPromotionalOffer:") + @available(macOS, introduced: 10.15, unavailable, + message: "Check eligibility for a discount using getPromotionalOffer:") + @available(macCatalyst, introduced: 13.0, unavailable, + message: "Check eligibility for a discount using getPromotionalOffer:") + func paymentDiscount(for discount: SKProductDiscount, + product: SKProduct) throws -> SKPaymentDiscount { + fatalError() + } + + @available(iOS, obsoleted: 1, message: "This was never meant to be public. Use `PurchasesDelegate.purchases(_:readyForPromotedProduct:purchase:)`") + @available(tvOS, obsoleted: 1, message: "This was never meant to be public. Use `PurchasesDelegate.purchases(_:readyForPromotedProduct:purchase:)`") + @available(watchOS, obsoleted: 1, message: "This was never meant to be public. Use `PurchasesDelegate.purchases(_:readyForPromotedProduct:purchase:)`") + @available(macOS, obsoleted: 1, message: "This was never meant to be public. Use `PurchasesDelegate.purchases(_:readyForPromotedProduct:purchase:)`") + @available(macCatalyst, obsoleted: 1, message: "This was never meant to be public. Use `PurchasesDelegate.purchases(_:readyForPromotedProduct:purchase:)`") + @objc func shouldPurchasePromoProduct(_ product: StoreProduct, + defermentBlock: @escaping StartPurchaseBlock) { + fatalError() + } + + /** + * This function will alias two appUserIDs together. + * + * - Parameter alias: The new appUserID that should be linked to the currently identified appUserID + * - Parameter completion: An optional completion block called when the aliasing has been successful. + * This completion block will receive an error if there's been one. + */ + @available(iOS, obsoleted: 1, renamed: "logIn") + @available(tvOS, obsoleted: 1, renamed: "logIn") + @available(watchOS, obsoleted: 1, renamed: "logIn") + @available(macOS, obsoleted: 1, renamed: "logIn") + @objc(createAlias:completionBlock:) + func createAlias(_ alias: String, _ completion: ((CustomerInfo?, Error?) -> Void)?) { + fatalError() + } + + /** + * This function will identify the current user with an appUserID. Typically this would be used after a + * logout to identify a new user without calling configure. + * + * - Parameter appUserID: The appUserID that should be linked to the current user. + * - Parameter completion: An optional completion block called when the identify call has completed. + * This completion block will receive an error if there's been one. + */ + @available(iOS, obsoleted: 1, renamed: "logIn") + @available(tvOS, obsoleted: 1, renamed: "logIn") + @available(watchOS, obsoleted: 1, renamed: "logIn") + @available(macOS, obsoleted: 1, renamed: "logIn") + @objc(identify:completionBlock:) + func identify(_ appUserID: String, _ completion: ((CustomerInfo?, Error?) -> Void)?) { + fatalError() + } + + /** + * Resets the Purchases client clearing the saved appUserID. + * This will generate a random user id and save it in the cache. + */ + @available(iOS, obsoleted: 1, renamed: "logOut") + @available(tvOS, obsoleted: 1, renamed: "logOut") + @available(watchOS, obsoleted: 1, renamed: "logOut") + @available(macOS, obsoleted: 1, renamed: "logOut") + @objc(resetWithCompletionBlock:) + func reset(completion: ((CustomerInfo?, Error?) -> Void)?) { + fatalError() + } + + /** + * Configures an instance of the Purchases SDK with a custom `UserDefaults`. + * + * Use this constructor if you want to + * sync status across a shared container, such as between a host app and an extension. The instance of the + * Purchases SDK will be set as a singleton. + * You should access the singleton instance using ``Purchases/shared`` + * + * - Parameter apiKey: The API Key generated for your app from https://app.revenuecat.com/ + * + * - Parameter appUserID: The unique app user id for this user. This user id will allow users to share their + * purchases and subscriptions across devices. Pass `nil` or an empty string if you want ``Purchases`` + * to generate this for you. + * + * - Parameter observerMode: Set this to `true` if you have your own IAP implementation and want to use only + * RevenueCat's backend. Default is `false`. + * + * - Returns: An instantiated ``Purchases`` object that has been set as a singleton. + * + * - Warning: This assumes your IAP implementation uses StoreKit 1. + * - Warning: If you're using observer mode with StoreKit 2, configure the SDK with `configure(withAPIKey:appUserID:observerMode:storeKitVersion:)` passing in `.storeKit2` as the `storeKitVersion` and ensure that you call ``Purchases/recordPurchase(_:)`` after making a purchase. + */ + @available(iOS, obsoleted: 1, + message: "Explicitly setting the StoreKit version is now required when setting purchasesAreCompletedBy.", + renamed: "configure(withAPIKey:appUserID:purchasesAreCompletedBy:storeKitVersion:)") + @available(tvOS, obsoleted: 1, + message: "Explicitly setting the StoreKit version is now required when setting purchasesAreCompletedBy.", + renamed: "configure(withAPIKey:appUserID:purchasesAreCompletedBy:storeKitVersion:)") + @available(watchOS, obsoleted: 1, + message: "Explicitly setting the StoreKit version is now required when setting purchasesAreCompletedBy.", + renamed: "configure(withAPIKey:appUserID:purchasesAreCompletedBy:storeKitVersion:)") + @available(macOS, obsoleted: 1, + message: "Explicitly setting the StoreKit version is now required when setting purchasesAreCompletedBy.", + renamed: "configure(withAPIKey:appUserID:purchasesAreCompletedBy:storeKitVersion:)") + @objc(configureWithAPIKey:appUserID:observerMode:) + @_disfavoredOverload + @discardableResult static func configure(withAPIKey apiKey: String, + appUserID: String?, + observerMode: Bool) -> Purchases { + fatalError() + } + + @available(iOS, obsoleted: 1, + message: "Explicitly setting the StoreKit version is now required when setting purchasesAreCompletedBy.", + renamed: "configure(withAPIKey:appUserID:purchasesAreCompletedBy:storeKitVersion:)") + @available(tvOS, obsoleted: 1, + message: "Explicitly setting the StoreKit version is now required when setting purchasesAreCompletedBy.", + renamed: "configure(withAPIKey:appUserID:purchasesAreCompletedBy:storeKitVersion:)") + @available(watchOS, obsoleted: 1, + message: "Explicitly setting the StoreKit version is now required when setting purchasesAreCompletedBy.", + renamed: "configure(withAPIKey:appUserID:purchasesAreCompletedBy:storeKitVersion:)") + @available(macOS, obsoleted: 1, + message: "Explicitly setting the StoreKit version is now required when setting purchasesAreCompletedBy.", + renamed: "configure(withAPIKey:appUserID:purchasesAreCompletedBy:storeKitVersion:)") + @discardableResult static func configure(withAPIKey apiKey: String, + appUserID: StaticString, + observerMode: Bool) -> Purchases { + fatalError() + } + + @available(iOS, obsoleted: 1, + message: """ +Explicitly setting the StoreKit version is now required when setting +purchasesAreCompletedBy. Please use the Configuration.Builder class to configure the SDK with +custom UserDefaults. +""", + renamed: "configure(withAPIKey:appUserID:purchasesAreCompletedBy:storeKitVersion:)") + @available(tvOS, obsoleted: 1, + message: """ +Explicitly setting the StoreKit version is now required when setting +purchasesAreCompletedBy. Please use the Configuration.Builder class to configure the SDK with +custom UserDefaults. +""", + renamed: "configure(withAPIKey:appUserID:purchasesAreCompletedBy:storeKitVersion:)") + @available(watchOS, obsoleted: 1, + message: """ +Explicitly setting the StoreKit version is now required when setting +purchasesAreCompletedBy. Please use the Configuration.Builder class to configure the SDK with +custom UserDefaults. +""", + renamed: "configure(withAPIKey:appUserID:purchasesAreCompletedBy:storeKitVersion:)") + @available(macOS, obsoleted: 1, + message: """ +Explicitly setting the StoreKit version is now required when setting +purchasesAreCompletedBy. Please use the Configuration.Builder class to configure the SDK with +custom UserDefaults. +""", + renamed: "configure(withAPIKey:appUserID:purchasesAreCompletedBy:storeKitVersion:)") + @objc(configureWithAPIKey:appUserID:observerMode:userDefaults:) + @discardableResult static func configure(withAPIKey apiKey: String, + appUserID: String?, + observerMode: Bool, + userDefaults: UserDefaults?) -> Purchases { + fatalError() + + } + + @available(iOS, obsoleted: 1, renamed: "configure(with:)") + @available(tvOS, obsoleted: 1, renamed: "configure(with:)") + @available(watchOS, obsoleted: 1, renamed: "configure(with:)") + @available(macOS, obsoleted: 1, renamed: "configure(with:)") + @available(macCatalyst, obsoleted: 1, renamed: "configure(with:)") + @objc(configureWithAPIKey:appUserID:observerMode:userDefaults:useStoreKit2IfAvailable:) + @discardableResult static func configure(withAPIKey apiKey: String, + appUserID: String?, + observerMode: Bool, + userDefaults: UserDefaults?, + useStoreKit2IfAvailable: Bool) -> Purchases { + fatalError() + } + + @available(iOS, obsoleted: 1, renamed: "configure(with:)") + @available(tvOS, obsoleted: 1, renamed: "configure(with:)") + @available(watchOS, obsoleted: 1, renamed: "configure(with:)") + @available(macOS, obsoleted: 1, renamed: "configure(with:)") + @available(macCatalyst, obsoleted: 1, renamed: "configure(with:)") + @objc(configureWithAPIKey:appUserID:observerMode:userDefaults:useStoreKit2IfAvailable:dangerousSettings:) + // swiftlint:disable:next function_parameter_count + @discardableResult static func configure(withAPIKey apiKey: String, + appUserID: String?, + observerMode: Bool, + userDefaults: UserDefaults?, + useStoreKit2IfAvailable: Bool, + dangerousSettings: DangerousSettings?) -> Purchases { + fatalError() + } + + /** + * Enable automatic collection of Apple Search Ads attribution. Defaults to `false`. + */ + @available(iOS, obsoleted: 1, + message: """ + Use Purchases.shared.attribution.enableAdServicesAttributionTokenCollection() instead. + AdClient doesn't work after February 7, 2023 so this boolean doesn't have any effect. + """, + renamed: "Purchases.shared.attribution.enableAdServicesAttributionTokenCollection()") + @available(tvOS, obsoleted: 1, + message: """ + Use Purchases.shared.attribution.enableAdServicesAttributionTokenCollection() instead. + AdClient doesn't work after February 7, 2023 so this boolean doesn't have any effect. + """, + renamed: "Purchases.shared.attribution.enableAdServicesAttributionTokenCollection()") + @available(watchOS, obsoleted: 1, + message: """ + Use Purchases.shared.attribution.enableAdServicesAttributionTokenCollection() instead. + AdClient doesn't work after February 7, 2023 so this boolean doesn't have any effect. + """, + renamed: "Purchases.shared.attribution.enableAdServicesAttributionTokenCollection()") + @available(macOS, obsoleted: 1, + message: """ + Use Purchases.shared.attribution.enableAdServicesAttributionTokenCollection() instead. + AdClient doesn't work after February 7, 2023 so this boolean doesn't have any effect. + """, + renamed: "Purchases.shared.attribution.enableAdServicesAttributionTokenCollection()") + @objc static var automaticAppleSearchAdsAttributionCollection: Bool { + get { fatalError() } + // swiftlint:disable:next unused_setter_value + set { fatalError() } + } + +} + +@available(iOS, obsoleted: 1, renamed: "StartPurchaseBlock") +@available(tvOS, obsoleted: 1, renamed: "StartPurchaseBlock") +@available(watchOS, obsoleted: 1, renamed: "StartPurchaseBlock") +@available(macOS, obsoleted: 1, renamed: "StartPurchaseBlock") +public typealias DeferredPromotionalPurchaseBlock = StartPurchaseBlock + +@available(iOS, obsoleted: 1, renamed: "CustomerInfo") +@available(tvOS, obsoleted: 1, renamed: "CustomerInfo") +@available(watchOS, obsoleted: 1, renamed: "CustomerInfo") +@available(macOS, obsoleted: 1, renamed: "CustomerInfo") +@objc(RCPurchaserInfo) public class PurchaserInfo: NSObject { } + +@available(iOS, obsoleted: 1, renamed: "StoreTransaction") +@available(tvOS, obsoleted: 1, renamed: "StoreTransaction") +@available(watchOS, obsoleted: 1, renamed: "StoreTransaction") +@available(macOS, obsoleted: 1, renamed: "StoreTransaction") +@objc(RCTransaction) public class Transaction: NSObject { } + +public extension StoreTransaction { + + @available(iOS, obsoleted: 1, renamed: "productIdentifier") + @available(tvOS, obsoleted: 1, renamed: "productIdentifier") + @available(watchOS, obsoleted: 1, renamed: "productIdentifier") + @available(macOS, obsoleted: 1, renamed: "productIdentifier") + @objc var productId: String { fatalError() } + + @available(iOS, obsoleted: 1, renamed: "transactionIdentifier") + @available(tvOS, obsoleted: 1, renamed: "transactionIdentifier") + @available(watchOS, obsoleted: 1, renamed: "transactionIdentifier") + @available(macOS, obsoleted: 1, renamed: "transactionIdentifier") + @objc var revenueCatId: String { fatalError() } + +} + +public extension Package { + /** + `SKProduct` assigned to this package. https://developer.apple.com/documentation/storekit/skproduct + */ + @available(iOS, obsoleted: 1, renamed: "storeProduct", message: "Use StoreProduct instead") + @available(tvOS, obsoleted: 1, renamed: "storeProduct", message: "Use StoreProduct instead") + @available(watchOS, obsoleted: 1, renamed: "storeProduct", message: "Use StoreProduct instead") + @available(macOS, obsoleted: 1, renamed: "storeProduct", message: "Use StoreProduct instead") + @available(macCatalyst, obsoleted: 1, renamed: "storeProduct", message: "Use StoreProduct instead") + @objc var product: SKProduct { fatalError() } +} + +public extension StoreProductDiscount.PaymentMode { + /// No payment mode specified + @available(iOS, obsoleted: 1, message: "This option no longer exists. PaymentMode would be nil instead.") + @available(tvOS, obsoleted: 1, message: "This option no longer exists. PaymentMode would be nil instead.") + @available(watchOS, obsoleted: 1, message: "This option no longer exists. PaymentMode would be nil instead.") + @available(macOS, obsoleted: 1, message: "This option no longer exists. PaymentMode would be nil instead.") + @available(macCatalyst, obsoleted: 1, message: "This option no longer exists. PaymentMode would be nil instead.") + static var none: StoreProductDiscount.PaymentMode { fatalError() } +} + +// Note: `RCPaymentMode` is still available to Objective-C through `StoreProductDiscount.PaymentMode`. +/// The payment mode for a `StoreProductDiscount` +@available(iOS, obsoleted: 1, renamed: "StoreProductDiscount.PaymentMode") +@available(tvOS, obsoleted: 1, renamed: "StoreProductDiscount.PaymentMode") +@available(watchOS, obsoleted: 1, renamed: "StoreProductDiscount.PaymentMode") +@available(macOS, obsoleted: 1, renamed: "StoreProductDiscount.PaymentMode") +@available(macCatalyst, obsoleted: 1, renamed: "StoreProductDiscount.PaymentMode") +public enum RCPaymentMode {} + +@available(iOS, obsoleted: 1, message: "Use PromotionalOffer instead") +@available(tvOS, obsoleted: 1, message: "Use PromotionalOffer instead") +@available(watchOS, obsoleted: 1, message: "Use PromotionalOffer instead") +@available(macOS, obsoleted: 1, message: "Use PromotionalOffer instead") +@available(macCatalyst, obsoleted: 1, message: "Use PromotionalOffer instead") +@objc(RCPromotionalOfferEligibility) +public class PromotionalOfferEligibility: NSObject {} + +/// `NSErrorDomain` for errors occurring within the scope of the Purchases SDK. +@available(iOS, obsoleted: 1, message: "Use ErrorCode instead") +@available(tvOS, obsoleted: 1, message: "Use ErrorCode instead") +@available(watchOS, obsoleted: 1, message: "Use ErrorCode instead") +@available(macOS, obsoleted: 1, message: "Use ErrorCode instead") +@available(macCatalyst, obsoleted: 1, message: "Use ErrorCode instead") +// swiftlint:disable:next identifier_name +public var ErrorDomain: NSErrorDomain { fatalError() } + +@available(iOS, obsoleted: 1, message: "Use ErrorCode instead") +@available(tvOS, obsoleted: 1, message: "Use ErrorCode instead") +@available(watchOS, obsoleted: 1, message: "Use ErrorCode instead") +@available(macOS, obsoleted: 1, message: "Use ErrorCode instead") +@available(macCatalyst, obsoleted: 1, message: "Use ErrorCode instead") +public enum RCBackendErrorCode {} + +@available(iOS, obsoleted: 1) +@available(tvOS, obsoleted: 1) +@available(watchOS, obsoleted: 1) +@available(macOS, obsoleted: 1) +@available(macCatalyst, obsoleted: 1) +public class RCPurchasesErrorUtils: NSObject {} + +public extension Purchases { + + @available(iOS, obsoleted: 1, renamed: "ErrorCode") + @available(tvOS, obsoleted: 1, renamed: "ErrorCode") + @available(watchOS, obsoleted: 1, renamed: "ErrorCode") + @available(macOS, obsoleted: 1, renamed: "ErrorCode") + @available(macCatalyst, obsoleted: 1, renamed: "ErrorCode") + enum Errors {} + + @available(iOS, obsoleted: 1) + @available(tvOS, obsoleted: 1) + @available(watchOS, obsoleted: 1) + @available(macOS, obsoleted: 1) + @available(macCatalyst, obsoleted: 1) + enum FinishableKey {} + + @available(iOS, obsoleted: 1) + @available(tvOS, obsoleted: 1) + @available(watchOS, obsoleted: 1) + @available(macOS, obsoleted: 1) + @available(macCatalyst, obsoleted: 1) + enum ReadableErrorCodeKey {} + + @available(iOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(tvOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(watchOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(macOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(macCatalyst, obsoleted: 1, message: "Remove `Purchases.`") + enum ErrorCode {} + + @available(iOS, obsoleted: 1) + @available(tvOS, obsoleted: 1) + @available(watchOS, obsoleted: 1) + @available(macOS, obsoleted: 1) + @available(macCatalyst, obsoleted: 1) + enum RevenueCatBackendErrorCode {} + + @available(iOS, obsoleted: 1, renamed: "StoreTransaction") + @available(tvOS, obsoleted: 1, renamed: "StoreTransaction") + @available(watchOS, obsoleted: 1, renamed: "StoreTransaction") + @available(macOS, obsoleted: 1, renamed: "StoreTransaction") + @available(macCatalyst, obsoleted: 1, renamed: "StoreTransaction") + enum Transaction {} + + @available(iOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(tvOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(watchOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(macOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(macCatalyst, obsoleted: 1, message: "Remove `Purchases.`") + enum EntitlementInfo {} + + @available(iOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(tvOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(watchOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(macOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(macCatalyst, obsoleted: 1, message: "Remove `Purchases.`") + enum EntitlementInfos {} + + @available(iOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(tvOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(watchOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(macOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(macCatalyst, obsoleted: 1, message: "Remove `Purchases.`") + enum PackageType {} + + @available(iOS, obsoleted: 1, renamed: "CustomerInfo") + @available(tvOS, obsoleted: 1, renamed: "CustomerInfo") + @available(watchOS, obsoleted: 1, renamed: "CustomerInfo") + @available(macOS, obsoleted: 1, renamed: "CustomerInfo") + @available(macCatalyst, obsoleted: 1, renamed: "CustomerInfo") + enum PurchaserInfo {} + + @available(iOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(tvOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(watchOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(macOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(macCatalyst, obsoleted: 1, message: "Remove `Purchases.`") + enum Offering {} + + @available(iOS, obsoleted: 1) + @available(tvOS, obsoleted: 1) + @available(watchOS, obsoleted: 1) + @available(macOS, obsoleted: 1) + @available(macCatalyst, obsoleted: 1) + enum ErrorUtils {} + + @available(iOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(tvOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(watchOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(macOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(macCatalyst, obsoleted: 1, message: "Remove `Purchases.`") + enum Store {} + + @available(iOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(tvOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(watchOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(macOS, obsoleted: 1, message: "Remove `Purchases.`") + @available(macCatalyst, obsoleted: 1, message: "Remove `Purchases.`") + enum PeriodType {} +} + +public extension Configuration.Builder { + @available(iOS, obsoleted: 1, renamed: "with(purchasesAreCompletedBy:storeKitVersion:)", + message: "Observer Mode is now named PurchasesAreCompletedBy.") + @available(tvOS, obsoleted: 1, renamed: "with(purchasesAreCompletedBy:storeKitVersion:)", + message: "Observer Mode is now named PurchasesAreCompletedBy.") + @available(watchOS, obsoleted: 1, renamed: "with(purchasesAreCompletedBy:storeKitVersion:)", + message: "Observer Mode is now named PurchasesAreCompletedBy.") + @available(macOS, obsoleted: 1, renamed: "with(purchasesAreCompletedBy:storeKitVersion:)", + message: "Observer Mode is now named PurchasesAreCompletedBy.") + @objc func with(observerMode: Bool) -> Configuration.Builder { + fatalError() + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/PlatformInfo.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/PlatformInfo.swift new file mode 100644 index 00000000..0048d22b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/PlatformInfo.swift @@ -0,0 +1,32 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PlatformInfo.swift +// +// Created by Josh Holtz on 2/17/22. + +import Foundation + +// swiftlint:disable missing_docs +extension Purchases { + + @objc(RCPlatformInfo) + public final class PlatformInfo: NSObject { + let flavor: String + let version: String + + @objc public init(flavor: String, version: String) { + self.flavor = flavor + self.version = version + } + } + + @objc public static var platformInfo: PlatformInfo? + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/PriceFormatterProvider.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/PriceFormatterProvider.swift new file mode 100644 index 00000000..0ac02e0e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/PriceFormatterProvider.swift @@ -0,0 +1,94 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PriceFormatterProvider.swift +// +// Created by Juanpe Catalán on 10/3/22. + +import Foundation + +/// A `NumberFormatter` provider class for prices. +/// This provider caches the formatter to improve the performance. +final class PriceFormatterProvider: Sendable { + + private let cachedPriceFormatterForSK1: Atomic = nil + + func priceFormatterForSK1(with locale: Locale) -> NumberFormatter { + func makePriceFormatterForSK1(with locale: Locale) -> NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.locale = locale + return formatter + } + + return self.cachedPriceFormatterForSK1.modify { formatter in + guard let formatter = formatter, formatter.locale == locale else { + let newFormatter = makePriceFormatterForSK1(with: locale) + formatter = newFormatter + + return newFormatter + } + + return formatter + } + } + + private let cachedPriceFormatterForSK2: Atomic = nil + + func priceFormatterForSK2( + withCurrencyCode currencyCode: String, + locale: Locale = .autoupdatingCurrent + ) -> NumberFormatter { + func makePriceFormatterForSK2(with currencyCode: String) -> NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.locale = locale + formatter.currencyCode = currencyCode + return formatter + } + + return self.cachedPriceFormatterForSK2.modify { formatter in + guard let formatter = formatter, formatter.currencyCode == currencyCode, formatter.locale == locale else { + let newFormatter = makePriceFormatterForSK2(with: currencyCode) + formatter = newFormatter + + return newFormatter + } + + return formatter + } + } + + private let cachedPriceFormatterForWebProducts: Atomic = nil + + func priceFormatterForWebProducts( + withCurrencyCode currencyCode: String, + locale: Locale = .autoupdatingCurrent + ) -> NumberFormatter { + func makePriceFormatterForWebProducts(with currencyCode: String) -> NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.locale = locale + formatter.currencyCode = currencyCode + return formatter + } + + return self.cachedPriceFormatterForWebProducts.modify { formatter in + guard let formatter = formatter, formatter.currencyCode == currencyCode, formatter.locale == locale else { + let newFormatter = makePriceFormatterForWebProducts(with: currencyCode) + formatter = newFormatter + + return newFormatter + } + + return formatter + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/RateLimiter.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/RateLimiter.swift new file mode 100644 index 00000000..c3a563f9 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/RateLimiter.swift @@ -0,0 +1,49 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// RateLimiter.swift +// +// Created by Josh Holtz on 2/27/24. + +import Foundation + +internal final class RateLimiter: @unchecked Sendable { + private let lock = Lock() + private var timestamps: [Date?] + private var index: Int = 0 + private let maxCallsInclusive: Int + + let maxCalls: Int + let period: TimeInterval + + init(maxCalls: Int, period: TimeInterval) { + self.maxCalls = maxCalls + self.maxCallsInclusive = self.maxCalls + 1 + self.period = period + + self.timestamps = Array(repeating: nil, count: maxCallsInclusive) + } + + func shouldProceed() -> Bool { + return self.lock.perform { + let now = Date() + let oldestIndex = (index + 1) % maxCallsInclusive + let oldestTimestamp = timestamps[oldestIndex] + + // Check if the oldest timestamp is outside the rate limiting period or if it's nil + if let oldestTimestamp = oldestTimestamp, now.timeIntervalSince(oldestTimestamp) <= period { + return false + } else { + timestamps[index] = now + index = oldestIndex + return true + } + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/SandboxEnvironmentDetector.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/SandboxEnvironmentDetector.swift new file mode 100644 index 00000000..bdf101d4 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/SandboxEnvironmentDetector.swift @@ -0,0 +1,101 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SandboxEnvironmentDetector.swift +// +// Created by Nacho Soto on 6/2/22. + +import Foundation + +/// A type that can determine if the current environment is sandbox. +protocol SandboxEnvironmentDetector: Sendable { + + var isSandbox: Bool { get } + +} + +/// ``SandboxEnvironmentDetector`` that uses a `Bundle` to detect the environment +final class BundleSandboxEnvironmentDetector: SandboxEnvironmentDetector { + + private let bundle: Atomic + private let isRunningInSimulator: Bool + private let receiptFetcher: LocalReceiptFetcherType + private let macAppStoreDetector: MacAppStoreDetector? + + init( + bundle: Bundle = .main, + isRunningInSimulator: Bool = SystemInfo.isRunningInSimulator, + receiptFetcher: LocalReceiptFetcherType = LocalReceiptFetcher(), + macAppStoreDetector: MacAppStoreDetector? = nil + ) { + self.bundle = .init(bundle) + self.isRunningInSimulator = isRunningInSimulator + self.receiptFetcher = receiptFetcher + self.macAppStoreDetector = macAppStoreDetector + } + + var isSandbox: Bool { + guard !self.isRunningInSimulator else { + return true + } + + guard let path = self.bundle.value.appStoreReceiptURL?.path else { + return false + } + + #if os(macOS) || targetEnvironment(macCatalyst) + // this relies on an undocumented field in the receipt that provides the Environment. + // if it's not present, we go to a secondary check. + if let isProductionReceipt = self.isProductionReceipt { + return !isProductionReceipt + } else { + return !self.isMacAppStore + } + + #else + return path.contains("sandboxReceipt") + #endif + } + + #if DEBUG + // Mutable in tests so it can be overriden + static var `default`: SandboxEnvironmentDetector = BundleSandboxEnvironmentDetector() + #else + static let `default`: SandboxEnvironmentDetector = BundleSandboxEnvironmentDetector() + #endif + +} + +extension BundleSandboxEnvironmentDetector: Sendable {} + +// MARK: - + +#if os(macOS) || targetEnvironment(macCatalyst) + +private extension BundleSandboxEnvironmentDetector { + + var isProductionReceipt: Bool? { + do { + let receiptEnvironment = try self.receiptFetcher.fetchAndParseLocalReceipt().environment + guard receiptEnvironment != .unknown else { return nil } // don't make assumptions if we're not sure + return receiptEnvironment == .production + } catch { + Logger.error(Strings.receipt.parse_receipt_locally_error(error: error)) + return nil + } + } + + var isMacAppStore: Bool { + let detector = self.macAppStoreDetector ?? DefaultMacAppStoreDetector() + return detector.isMacAppStore + } + +} + +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/StoreKitVersion.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/StoreKitVersion.swift new file mode 100644 index 00000000..c9850827 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/StoreKitVersion.swift @@ -0,0 +1,85 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StoreKitVersion.swift +// +// Created by Mark Villacampa on 4/13/23. + +import Foundation + +/// Defines which version of StoreKit may be used +@objc(RCStoreKitVersion) +public enum StoreKitVersion: Int { + + /// Always use StoreKit 1. + @objc(RCStoreKitVersion1) + case storeKit1 = 1 + + /// Always use StoreKit 2 (StoreKit 1 will be used if StoreKit 2 is not available in the current device.) + /// + /// - Warning: Make sure you have an In-App Purchase Key configured in your app. + /// Please see https://rev.cat/in-app-purchase-key-configuration for more info. + @objc(RCStoreKitVersion2) + case storeKit2 = 2 + +} + +public extension StoreKitVersion { + + /// Let RevenueCat use the most appropiate version of StoreKit + static let `default` = Self.storeKit2 + +} + +extension StoreKitVersion: CustomDebugStringConvertible { + + /// Returns a spurtring representation of the StoreKit version + public var debugDescription: String { + switch self { + case .storeKit1: return "1" + case .storeKit2: return "2" + } + } +} + +extension StoreKitVersion { + + /// - Returns: `true` if SK2 is available in this device. + static var isStoreKit2Available: Bool { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + return true + } else { + return false + } + } + + /// - Returns: `true` if and only if SK2 is enabled and it's available. + var isStoreKit2EnabledAndAvailable: Bool { + switch self { + case .storeKit1: return false + case .storeKit2: return Self.isStoreKit2Available + } + } + + /// Returns the effective version of StoreKit used. + /// This can be different from the configured version if StoreKit 2 is not available on the current device. + var effectiveVersion: StoreKitVersion { + switch self { + case .storeKit1: + return .storeKit1 + case .storeKit2: + if Self.isStoreKit2Available { + return .storeKit2 + } else { + return .storeKit1 + } + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/SystemInfo.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/SystemInfo.swift new file mode 100644 index 00000000..6099612e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Misc/SystemInfo.swift @@ -0,0 +1,439 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SystemInfo.swift +// +// Created by Joshua Liebowitz on 6/29/21. +// + +import Foundation + +#if os(iOS) || os(tvOS) || VISION_OS || targetEnvironment(macCatalyst) +import UIKit +#elseif os(watchOS) +import UIKit +import WatchKit +#elseif os(macOS) +import AppKit +#endif + +// swiftlint:disable file_length +class SystemInfo { + + // swiftlint:disable:next force_unwrapping + static let appleSubscriptionsURL = URL(string: "https://apps.apple.com/account/subscriptions")! + + static var forceUniversalAppStore: Bool { + get { self._forceUniversalAppStore.value } + set { self._forceUniversalAppStore.value = newValue } + } + + let storeKitVersion: StoreKitVersion + private var _apiKeyValidationResult: Configuration.APIKeyValidationResult + var apiKeyValidationResult: Configuration.APIKeyValidationResult { + get { return self._apiKeyValidationResult } + set { self._apiKeyValidationResult = newValue } + } + + /// Whether the API key used to configure the SDK is a Simulated Store API key. + var isSimulatedStoreAPIKey: Bool { + return self.apiKeyValidationResult == .simulatedStore + } + + let operationDispatcher: OperationDispatcher + let platformFlavor: String + let platformFlavorVersion: String? + let responseVerificationMode: Signing.ResponseVerificationMode + let dangerousSettings: DangerousSettings + let clock: ClockType + private let preferredLocalesProvider: PreferredLocalesProvider + + var finishTransactions: Bool { + get { return self._finishTransactions.value } + set { self._finishTransactions.value = newValue } + } + + var isAppBackgroundedState: Bool { + get { self._isAppBackgroundedState.value } + set { self._isAppBackgroundedState.value = newValue } + } + + var bundle: Bundle { return self._bundle.value } + + var observerMode: Bool { return !self.finishTransactions } + + private let sandboxEnvironmentDetector: SandboxEnvironmentDetector + private let storefrontProvider: StorefrontProviderType + private let _finishTransactions: Atomic + private let _isAppBackgroundedState: Atomic + private let _bundle: Atomic + + private static let _forceUniversalAppStore: Atomic = false + private static let _proxyURL: Atomic = nil + + // swiftlint:disable:next force_unwrapping + static let defaultApiBaseURL = URL(string: "https://api.revenuecat.com")! + private static let _apiBaseURL: Atomic = .init(defaultApiBaseURL) + + private lazy var _isSandbox: Bool = { + return self.sandboxEnvironmentDetector.isSandbox + }() + + var isSandbox: Bool { + return self._isSandbox + } + + var isDebugBuild: Bool { +#if DEBUG + return true +#else + return false +#endif + } + + var storefront: StorefrontType? { + return self.storefrontProvider.currentStorefront + } + + static var frameworkVersion: String { + return "5.59.2" + } + + static var systemVersion: String { + return ProcessInfo.processInfo.operatingSystemVersionString + } + + static var appVersion: String { + return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + } + + static var buildVersion: String { + return Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" + } + + static var bundleIdentifier: String { + return Bundle.main.bundleIdentifier ?? "" + } + + static var platformHeader: String { + return Self.forceUniversalAppStore ? "iOS" : self.platformHeaderConstant + } + + static var deviceVersion: String { + var systemInfo = utsname() + uname(&systemInfo) + + let machineMirror = Mirror(reflecting: systemInfo.machine) + let identifier = machineMirror.children.reduce("") { identifier, element in + guard let value = element.value as? Int8, value != 0 else { return identifier } + return identifier + String(UnicodeScalar(UInt8(value))) + } + + return identifier + } + + var identifierForVendor: String? { + // Should match available platforms in + // https://developer.apple.com/documentation/uikit/uidevice?language=swift + // https://developer.apple.com/documentation/watchkit/wkinterfacedevice?language=swift + + #if os(iOS) || os(tvOS) || VISION_OS + // Fix-me: `UIDevice.current` is `@MainActor` so this method + // will need to be marked as such too. + return UIDevice.current.identifierForVendor?.uuidString + #elseif os(watchOS) + return WKInterfaceDevice.current().identifierForVendor?.uuidString + #elseif os(macOS) || targetEnvironment(macCatalyst) + return self.isSandbox ? MacDevice.identifierForVendor?.uuidString : nil + #else + return nil + #endif + } + + static var proxyURL: URL? { + get { return self._proxyURL.value } + set { + self._proxyURL.value = newValue + + if let privateProxyURLString = newValue?.absoluteString { + Logger.info(Strings.configure.configuring_purchases_proxy_url_set(url: privateProxyURLString)) + } + } + } + + /* + Allows for updating the base URL for API calls that use `HTTPRequest.Path`. + Useful for testing in case we want to perform tests against another instance of our backend. + + We've decided not to use the proxy URL for this, because it's behavior is slightly different. + Specifically, when using a proxy URL the fallback logic is not used, because all requests should + be going through the proxy URL instead. + */ + static var apiBaseURL: URL { + get { return self._apiBaseURL.value } + set { + self._apiBaseURL.value = newValue + } + } + + static let appSessionID = UUID() + + init(platformInfo: Purchases.PlatformInfo?, + finishTransactions: Bool, + operationDispatcher: OperationDispatcher = .default, + bundle: Bundle = .main, + sandboxEnvironmentDetector: SandboxEnvironmentDetector = BundleSandboxEnvironmentDetector.default, + storefrontProvider: StorefrontProviderType = DefaultStorefrontProvider(), + storeKitVersion: StoreKitVersion = .default, + apiKeyValidationResult: Configuration.APIKeyValidationResult = .validApplePlatform, + responseVerificationMode: Signing.ResponseVerificationMode = .default, + dangerousSettings: DangerousSettings? = nil, + isAppBackgrounded: Bool? = nil, + clock: ClockType = Clock.default, + preferredLocalesProvider: PreferredLocalesProvider) { + self.platformFlavor = platformInfo?.flavor ?? "native" + self.platformFlavorVersion = platformInfo?.version + self._bundle = .init(bundle) + + self._finishTransactions = .init(finishTransactions) + self._isAppBackgroundedState = .init(isAppBackgrounded ?? false) + self.operationDispatcher = operationDispatcher + self.storeKitVersion = storeKitVersion + self._apiKeyValidationResult = apiKeyValidationResult + self.sandboxEnvironmentDetector = sandboxEnvironmentDetector + self.storefrontProvider = storefrontProvider + self.responseVerificationMode = responseVerificationMode + self.dangerousSettings = dangerousSettings ?? DangerousSettings() + self.clock = clock + self.preferredLocalesProvider = preferredLocalesProvider + + if isAppBackgrounded == nil { + self.isApplicationBackgrounded { isAppBackgrounded in + self.isAppBackgroundedState = isAppBackgrounded + } + } + } + + var supportsOfflineEntitlements: Bool { + !self.observerMode && !self.dangerousSettings.customEntitlementComputation + } + + /// Asynchronous API to check if app is backgrounded at a specific moment. + func isApplicationBackgrounded(completion: @escaping @Sendable (Bool) -> Void) { + self.operationDispatcher.dispatchOnMainActor { + var isApplicationBackgrounded: Bool = false + #if os(iOS) || os(tvOS) || VISION_OS + isApplicationBackgrounded = self.isApplicationBackgroundedIOSAndTVOS + #elseif os(watchOS) + isApplicationBackgrounded = self.isApplicationBackgroundedWatchOS + #endif + completion(isApplicationBackgrounded) + } + } + + #if targetEnvironment(simulator) + static let isRunningInSimulator = true + #else + static let isRunningInSimulator = false + #endif + + func isOperatingSystemAtLeast(_ version: OperatingSystemVersion) -> Bool { + return ProcessInfo.processInfo.isOperatingSystemAtLeast(version) + } + + /// Checks for exposure to https://github.com/RevenueCat/purchases-ios/issues/4954 + func isSubjectToKnownIssue_18_4_sim() -> Bool { + let firstOSVersionWithBug = OperatingSystemVersion(majorVersion: 18, + minorVersion: 4, + patchVersion: 0) + + // Conservative estimate. No Simulator iOS fix version currently known (as at 2025-04-15). + let firstOSVersionWithFix = OperatingSystemVersion(majorVersion: 18, + minorVersion: 5, + patchVersion: 0) + + return SystemInfo.isRunningInSimulator + && self.isOperatingSystemAtLeast(firstOSVersionWithBug) + && !self.isOperatingSystemAtLeast(firstOSVersionWithFix) + } + + #if os(iOS) || os(tvOS) || VISION_OS + var sharedUIApplication: UIApplication? { + return Self.sharedUIApplication + } + + static var sharedUIApplication: UIApplication? { + return UIApplication.value(forKey: "sharedApplication") as? UIApplication + } + + #endif + + static func isAppleSubscription(managementURL: URL) -> Bool { + guard let host = managementURL.host else { return false } + return host.contains("apple.com") + } + + /// Returns the preferred locales, including the locale override if set. + var preferredLocales: [String] { + return self.preferredLocalesProvider.preferredLocales + } + + /// Developer-set preferred locale. + /// + /// `preferredLocales` already includes it if set, so this property is only useful for reading the override value. + var preferredLocaleOverride: String? { + return self.preferredLocalesProvider.preferredLocaleOverride + } + + func overridePreferredLocale(_ locale: String?) { + self.preferredLocalesProvider.overridePreferredLocale(locale) + } +} + +#if os(iOS) || os(tvOS) || VISION_OS +extension SystemInfo { + + @available(iOS 13.0, macCatalyst 13.1, tvOS 13.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(watchOSApplicationExtension, unavailable) + @MainActor + var currentWindowScene: UIWindowScene { + get throws { + let scene = self.sharedUIApplication?.currentWindowScene + + return try scene.orThrow(ErrorUtils.storeProblemError(withMessage: "Failed to get UIWindowScene")) + } + } +} +#endif + +extension SystemInfo: SandboxEnvironmentDetector {} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension SystemInfo: @unchecked Sendable {} + +extension SystemInfo { + + #if targetEnvironment(macCatalyst) + static let platformHeaderConstant = "uikitformac" + #elseif os(iOS) + static let platformHeaderConstant = "iOS" + #elseif os(watchOS) + static let platformHeaderConstant = "watchOS" + #elseif os(tvOS) + static let platformHeaderConstant = "tvOS" + #elseif os(macOS) + static let platformHeaderConstant = "macOS" + #elseif VISION_OS + static let platformHeaderConstant = "visionOS" + #endif + + static var applicationWillEnterForegroundNotification: Notification.Name { + #if os(iOS) || os(tvOS) || VISION_OS + UIApplication.willEnterForegroundNotification + #elseif os(macOS) + NSApplication.willBecomeActiveNotification + #elseif os(watchOS) + Notification.Name.NSExtensionHostWillEnterForeground + #endif + } + + static var applicationWillResignActiveNotification: Notification.Name { + #if os(iOS) || os(tvOS) || VISION_OS + UIApplication.willResignActiveNotification + #elseif os(macOS) + NSApplication.willResignActiveNotification + #elseif os(watchOS) + Notification.Name.NSExtensionHostWillResignActive + #endif + } + + static var applicationDidEnterBackgroundNotification: Notification.Name { + #if os(iOS) || os(tvOS) || VISION_OS + UIApplication.didEnterBackgroundNotification + #elseif os(macOS) + NSApplication.didResignActiveNotification + #elseif os(watchOS) + Notification.Name.NSExtensionHostDidEnterBackground + #endif + } + + /// Returns the appropriate `Notification.Name` for the OS's didBecomeActive notification, + /// indicating that the application did become active. This value is only nil for watchOS + /// versions below 7.0. + static var applicationDidBecomeActiveNotification: Notification.Name? { + #if os(iOS) || os(tvOS) || VISION_OS || targetEnvironment(macCatalyst) + return UIApplication.didBecomeActiveNotification + #elseif os(macOS) + return NSApplication.didBecomeActiveNotification + #elseif os(watchOS) + if #available(watchOS 9, *) { + return WKApplication.didBecomeActiveNotification + } else if #available(watchOS 7, *) { + // Work around for "Symbol not found" dyld crashes on watchOS 7.0..<9.0 + return Notification.Name("WKApplicationDidBecomeActiveNotification") + } else { + // There's no equivalent notification available on watchOS <7. + return nil + } + #endif + } + + var isAppExtension: Bool { + return self.bundle.bundlePath.hasSuffix(".appex") + } +} + +private extension SystemInfo { + + #if os(iOS) || os(tvOS) || VISION_OS + + // iOS/tvOS App extensions can't access UIApplication.sharedApplication, and will fail to compile if any calls to + // it are made. There are no pre-processor macros available to check if the code is running in an app extension, + // so we check if we're running in an app extension at runtime, and if not, we use KVC to call sharedApplication. + @MainActor + var isApplicationBackgroundedIOSAndTVOS: Bool { + if self.isAppExtension { + return true + } + + guard let sharedUIApplication = self.sharedUIApplication else { return false } + return sharedUIApplication.applicationState == .background + } + + #elseif os(watchOS) + + @MainActor + var isApplicationBackgroundedWatchOS: Bool { + var isSingleTargetApplication: Bool { + return Bundle.main.infoDictionary?.keys.contains("WKApplication") == true + } + + if #available(watchOS 7.0, *), self.isOperatingSystemAtLeast(.init(majorVersion: 9, + minorVersion: 0, + patchVersion: 0)) { + // `WKApplication` works on both dual-target and single-target apps + // When running on watchOS 9.0+ + return WKApplication.shared().applicationState == .background + } else { + if isSingleTargetApplication { + // Before watchOS 9.0, single-target apps don't allow using `WKExtension` or `WKApplication` + // (see https://github.com/RevenueCat/purchases-ios/issues/1891) + // So we can't detect if it's running in the background + return false + } else { + return WKExtension.shared().applicationState == .background + } + } + } + + #endif +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Backend.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Backend.swift new file mode 100644 index 00000000..af93ac1c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Backend.swift @@ -0,0 +1,274 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Backend.swift +// +// Created by Joshua Liebowitz on 8/2/21. + +import Foundation + +class Backend { + + let identity: IdentityAPI + let offerings: OfferingsAPI + let webBilling: WebBillingAPI + let offlineEntitlements: OfflineEntitlementsAPI + let customer: CustomerAPI + let internalAPI: InternalAPI + let customerCenterConfig: CustomerCenterConfigAPI + let redeemWebPurchaseAPI: RedeemWebPurchaseAPI + let virtualCurrenciesAPI: VirtualCurrenciesAPI + + private let config: BackendConfiguration + + convenience init( + apiKey: String, + systemInfo: SystemInfo, + httpClientTimeout: TimeInterval = Configuration.networkTimeoutDefault, + eTagManager: ETagManager, + operationDispatcher: OperationDispatcher, + attributionFetcher: AttributionFetcher, + offlineCustomerInfoCreator: OfflineCustomerInfoCreator?, + diagnosticsTracker: DiagnosticsTrackerType?, + dateProvider: DateProvider = DateProvider() + ) { + let httpClient = HTTPClient(apiKey: apiKey, + systemInfo: systemInfo, + eTagManager: eTagManager, + signing: Signing(apiKey: apiKey, clock: systemInfo.clock), + diagnosticsTracker: diagnosticsTracker, + requestTimeout: httpClientTimeout, + operationDispatcher: OperationDispatcher.default) + let config = BackendConfiguration(httpClient: httpClient, + operationDispatcher: operationDispatcher, + operationQueue: QueueProvider.createBackendQueue(), + diagnosticsQueue: QueueProvider.createDiagnosticsQueue(), + systemInfo: systemInfo, + offlineCustomerInfoCreator: offlineCustomerInfoCreator, + dateProvider: dateProvider) + self.init(backendConfig: config, attributionFetcher: attributionFetcher) + } + + convenience init(backendConfig: BackendConfiguration, attributionFetcher: AttributionFetcher) { + let customer = CustomerAPI(backendConfig: backendConfig, attributionFetcher: attributionFetcher) + let identity = IdentityAPI(backendConfig: backendConfig) + let offerings = OfferingsAPI(backendConfig: backendConfig) + let webBilling = WebBillingAPI(backendConfig: backendConfig) + let offlineEntitlements = OfflineEntitlementsAPI(backendConfig: backendConfig) + let internalAPI = InternalAPI(backendConfig: backendConfig) + let customerCenterConfig = CustomerCenterConfigAPI(backendConfig: backendConfig) + let redeemWebPurchaseAPI = RedeemWebPurchaseAPI(backendConfig: backendConfig) + let virtualCurrenciesAPI = VirtualCurrenciesAPI(backendConfig: backendConfig) + + self.init(backendConfig: backendConfig, + customerAPI: customer, + identityAPI: identity, + offeringsAPI: offerings, + webBillingAPI: webBilling, + offlineEntitlements: offlineEntitlements, + internalAPI: internalAPI, + customerCenterConfig: customerCenterConfig, + redeemWebPurchaseAPI: redeemWebPurchaseAPI, + virtualCurrenciesAPI: virtualCurrenciesAPI) + } + + required init(backendConfig: BackendConfiguration, + customerAPI: CustomerAPI, + identityAPI: IdentityAPI, + offeringsAPI: OfferingsAPI, + webBillingAPI: WebBillingAPI, + offlineEntitlements: OfflineEntitlementsAPI, + internalAPI: InternalAPI, + customerCenterConfig: CustomerCenterConfigAPI, + redeemWebPurchaseAPI: RedeemWebPurchaseAPI, + virtualCurrenciesAPI: VirtualCurrenciesAPI) { + self.config = backendConfig + + self.customer = customerAPI + self.identity = identityAPI + self.offerings = offeringsAPI + self.webBilling = webBillingAPI + self.offlineEntitlements = offlineEntitlements + self.internalAPI = internalAPI + self.customerCenterConfig = customerCenterConfig + self.redeemWebPurchaseAPI = redeemWebPurchaseAPI + self.virtualCurrenciesAPI = virtualCurrenciesAPI + } + + func clearHTTPClientCaches() { + self.config.clearCache() + } + + func post(attributionData: [String: Any], + network: AttributionNetwork, + appUserID: String, + completion: CustomerAPI.SimpleResponseHandler?) { + self.customer.post(attributionData: attributionData, + network: network, + appUserID: appUserID, + completion: completion) + } + + func post(adServicesToken: String, + appUserID: String, + completion: CustomerAPI.SimpleResponseHandler?) { + self.customer.post(adServicesToken: adServicesToken, + appUserID: appUserID, + completion: completion) + } + + func isPurchaseAllowedByRestoreBehavior( + appUserID: String, + transactionJWS: String, + isAppBackgrounded: Bool, + completion: @escaping CustomerAPI.IsPurchaseAllowedByRestoreBehaviorResponseHandler + ) { + self.customer.isPurchaseAllowedByRestoreBehavior(appUserID: appUserID, + transactionJWS: transactionJWS, + isAppBackgrounded: isAppBackgrounded, + completion: completion) + } + + func getCustomerInfo(appUserID: String, + isAppBackgrounded: Bool, + allowComputingOffline: Bool = true, + completion: @escaping CustomerAPI.CustomerInfoResponseHandler) { + self.customer.getCustomerInfo(appUserID: appUserID, + isAppBackgrounded: isAppBackgrounded, + allowComputingOffline: allowComputingOffline, + completion: completion) + } + + // swiftlint:disable:next function_parameter_count + func post(receipt: EncodedAppleReceipt, + productData: ProductRequestData?, + transactionData: PurchasedTransactionData, + postReceiptSource: PostReceiptSource, + observerMode: Bool, + // Value at the time of the purchase (which might come from the `LocalTransactionMetadataStore`) + originalPurchaseCompletedBy: PurchasesAreCompletedBy?, + appTransaction: String? = nil, + associatedTransactionId: String? = nil, + sdkOriginated: Bool = false, + appUserID: String, + containsAttributionData: Bool = false, + completion: @escaping CustomerAPI.CustomerInfoResponseHandler) { + self.customer.post(receipt: receipt, + productData: productData, + transactionData: transactionData, + postReceiptSource: postReceiptSource, + observerMode: observerMode, + originalPurchaseCompletedBy: originalPurchaseCompletedBy, + appTransaction: appTransaction, + associatedTransactionId: associatedTransactionId, + sdkOriginated: sdkOriginated, + appUserID: appUserID, + containsAttributionData: containsAttributionData, + completion: completion) + } + + func post(subscriberAttributes: SubscriberAttribute.Dictionary, + appUserID: String, + completion: CustomerAPI.SimpleResponseHandler?) { + self.customer.post(subscriberAttributes: subscriberAttributes, appUserID: appUserID, completion: completion) + } + + #if DEBUG + /// Checks if the SDK should log the status of the health report to the console. + /// - Parameter appUserID: An `appUserID` that allows the Backend to check for health report availability + /// - Returns: Whether the health report should be reported to the console for the given `appUserID`. + func healthReportAvailabilityRequest(appUserID: String) async throws -> HealthReportAvailability { + try await Async.call { (completion: @escaping (Result) -> Void) in + self.internalAPI.healthReportAvailabilityRequest( + appUserID: appUserID, + completion: completion + ) + } + } + + /// Call the `/health_report` endpoint and perform a full validation of the SDK's configuration + /// - Parameter appUserID: An `appUserID` that allows the Backend to fetch offerings + /// - Returns: A report with all validation checks along with their status + func healthReportRequest(appUserID: String) async throws -> HealthReport { + try await Async.call { (completion: @escaping (Result) -> Void) in + self.internalAPI.healthReportRequest(appUserID: appUserID, completion: completion) + } + } + #endif +} + +extension Backend { + + /// - Throws: `NetworkError` + func healthRequest(signatureVerification: Bool) async throws { + try await Async.call { completion in + self.internalAPI.healthRequest(signatureVerification: signatureVerification) { error in + completion(.init(error)) + } + } + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension Backend: @unchecked Sendable {} + +// MARK: - Internal + +extension Backend { + + typealias ResponseHandler = @Sendable (Swift.Result) -> Void + +} + +extension Backend { + + @objc var signatureVerificationEnabled: Bool { + return self.config.httpClient.signatureVerificationEnabled + } + +} + +extension Backend { + + enum QueueProvider { + + static func createBackendQueue() -> OperationQueue { + let operationQueue = OperationQueue() + operationQueue.name = "RC Backend Queue" + operationQueue.maxConcurrentOperationCount = 1 + return operationQueue + } + + static func createDiagnosticsQueue() -> OperationQueue { + let operationQueue = OperationQueue() + operationQueue.name = "RC Diagnostics Queue" + operationQueue.maxConcurrentOperationCount = 1 + operationQueue.qualityOfService = .background + return operationQueue + } + + } + +} + +// MARK: - Testing extensions + +extension Backend { + + var networkTimeout: TimeInterval { + return self.config.httpClient.timeout + } + + var offlineCustomerInfoEnabled: Bool { + return self.config.offlineCustomerInfoCreator != nil + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/BackendConfiguration.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/BackendConfiguration.swift new file mode 100644 index 00000000..6ba237f9 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/BackendConfiguration.swift @@ -0,0 +1,79 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// BackendConfiguration.swift +// +// Created by Joshua Liebowitz on 6/13/22. + +import Foundation + +class BackendConfiguration { + + let httpClient: HTTPClient + + let operationDispatcher: OperationDispatcher + let operationQueue: OperationQueue + let diagnosticsQueue: OperationQueue + let dateProvider: DateProvider + let systemInfo: SystemInfo + let offlineCustomerInfoCreator: OfflineCustomerInfoCreator? + + init(httpClient: HTTPClient, + operationDispatcher: OperationDispatcher, + operationQueue: OperationQueue, + diagnosticsQueue: OperationQueue, + systemInfo: SystemInfo, + offlineCustomerInfoCreator: OfflineCustomerInfoCreator?, + dateProvider: DateProvider = DateProvider()) { + self.httpClient = httpClient + self.operationDispatcher = operationDispatcher + self.operationQueue = operationQueue + self.diagnosticsQueue = diagnosticsQueue + self.offlineCustomerInfoCreator = offlineCustomerInfoCreator + self.dateProvider = dateProvider + self.systemInfo = systemInfo + } + + func clearCache() { + self.httpClient.clearCaches() + } + +} + +extension BackendConfiguration: NetworkConfiguration {} + +extension BackendConfiguration { + + /// Adds the `operation` to the `OperationQueue` (based on `CallbackCacheStatus`) potentially adding a random delay. + func addCacheableOperation( + with factory: CacheableNetworkOperationFactory, + delay: JitterableDelay, + cacheStatus: CallbackCacheStatus + ) { + self.operationDispatcher.dispatchOnWorkerThread(jitterableDelay: delay) { + self.operationQueue.addCacheableOperation(with: factory, cacheStatus: cacheStatus) + } + } + + /// Adds the `operation` to the diagnostics `OperationQueue` potentially adding a random delay. + func addDiagnosticsOperation( + _ operation: NetworkOperation, + delay: JitterableDelay = .long + ) { + self.operationDispatcher.dispatchOnWorkerThread(jitterableDelay: delay) { + self.diagnosticsQueue.addOperation(operation) + } + } + +} + +// @unchecked because: +// - `OperationQueue` is not `Sendable` as of Swift 5.7 +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension BackendConfiguration: @unchecked Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/CacheFetchPolicy.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/CacheFetchPolicy.swift new file mode 100644 index 00000000..2d900c01 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/CacheFetchPolicy.swift @@ -0,0 +1,37 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CacheFetchPolicy.swift +// +// Created by Nacho Soto on 5/24/22. + +/// Specifies the behavior for a caching API. +@objc(RCCacheFetchPolicy) +public enum CacheFetchPolicy: Int { + + /// Returns values from the cache, or throws an error if not available. + case fromCacheOnly + + /// Always fetch the most up-to-date data. + case fetchCurrent + + /// Returns the cached data if available and not stale, or fetches up-to-date data. + /// - Warning: if the cached data is stale, and fetching up-to-date data fails (if offline, for example) + /// an error will be returned instead of the outdated cached data. + case notStaleCachedOrFetched + + /// Default behavior: returns the cached data if available (even if stale), or fetches up-to-date data. + case cachedOrFetched + + /// Default ``CacheFetchPolicy`` behavior. + public static let `default`: Self = .cachedOrFetched + +} + +extension CacheFetchPolicy: Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/CallbackCache.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/CallbackCache.swift new file mode 100644 index 00000000..e3eb9240 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/CallbackCache.swift @@ -0,0 +1,75 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CallbackCache.swift +// +// Created by Joshua Liebowitz on 11/18/21. + +import Foundation + +/** + Generic callback cache whose primary usage is to help ensure API calls in flight are not duplicated. + Users of this class will store a completion block for any Cacheable API call that is running. If the same request is + made while a request is in-flight, the completion block will be added to the list and the API call will not be + performed. Once the first API call has finished, the user is required to call `performOnAllItemsAndRemoveFromCache`. + This way the results from the initial API call will be surfaced to the waiting completion blocks from the duplicate + API calls that were not sent. After being called these blocks are removed from the cache. + */ +final class CallbackCache where T: CacheKeyProviding { + + private let _cachedCallbacksByKey: Atomic<[String: [T]]> = .init([:]) + + var cachedCallbacksByKey: [String: [T]] { return self._cachedCallbacksByKey.value } + + func add(_ callback: T) -> CallbackCacheStatus { + return self._cachedCallbacksByKey.modify { cachedCallbacksByKey in + var values = cachedCallbacksByKey[callback.cacheKey] ?? [] + let cacheStatus: CallbackCacheStatus = !values.isEmpty ? + .addedToExistingInFlightList : + .firstCallbackAddedToList + + values.append(callback) + cachedCallbacksByKey[callback.cacheKey] = values + return cacheStatus + } + } + + func performOnAllItemsAndRemoveFromCache(withCacheable cacheable: CacheKeyProviding, _ block: (T) -> Void) { + // Remove items from cache while holding the lock, then invoke callbacks AFTER releasing it. + // This prevents deadlock when callbacks synchronously add new items to the cache. + let items: [T]? = self._cachedCallbacksByKey.modify { cachedCallbacksByKey in + return cachedCallbacksByKey.removeValue(forKey: cacheable.cacheKey) + } + items?.forEach(block) + } + + deinit { + #if DEBUG + if ProcessInfo.isRunningRevenueCatTests { + precondition( + self.cachedCallbacksByKey.isEmpty, + "\(type(of: self)) was deallocated with callbacks still stored." + ) + } + #endif + } + +} + +extension CallbackCache: Sendable where T: Sendable {} + +/** + For use with `CallbackCache`. We store a list of callback objects in the cache and the key used for the list of + callbacks is provided by an object that conforms to `CacheKeyProviding`. + */ +protocol CacheKeyProviding { + + var cacheKey: String { get } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/CallbackCacheStatus.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/CallbackCacheStatus.swift new file mode 100644 index 00000000..358fe7e1 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/CallbackCacheStatus.swift @@ -0,0 +1,25 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CallbackCacheStatus.swift +// +// Created by Joshua Liebowitz on 11/17/21. + +import Foundation + +enum CallbackCacheStatus { + + /// When an array exists in the cache for a particular path, we add to it and return this value. + case addedToExistingInFlightList + + /// When an array doesn't yet exist in the cache for a particular path, we create one, add to it + /// and return this value. + case firstCallbackAddedToList + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/CustomerCenterConfigCallback.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/CustomerCenterConfigCallback.swift new file mode 100644 index 00000000..9ac24785 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/CustomerCenterConfigCallback.swift @@ -0,0 +1,23 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CustomerCenterConfigCallback.swift +// +// +// Created by Cesar de la Vega on 31/5/24. +// + +import Foundation + +struct CustomerCenterConfigCallback: CacheKeyProviding { + + let cacheKey: String + let completion: (Result) -> Void + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/CustomerInfoCallback.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/CustomerInfoCallback.swift new file mode 100644 index 00000000..3e9c4660 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/CustomerInfoCallback.swift @@ -0,0 +1,65 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CustomerInfoCallback.swift +// +// Created by Joshua Liebowitz on 11/18/21. + +import Foundation + +struct CustomerInfoCallback: CacheKeyProviding { + + typealias Completion = (Result) -> Void + + var cacheKey: String + var source: NetworkOperation.Type + var completion: Completion + + init(cacheKey: String, + source: T.Type, + completion: @escaping Completion) { + self.cacheKey = cacheKey + self.source = T.self + self.completion = completion + } + +} + +// MARK: - CallbackCache helpers + +extension CallbackCache where T == CustomerInfoCallback { + + func addOrAppendToPostReceiptDataOperation(callback: CustomerInfoCallback) -> CallbackCacheStatus { + if let existing = self.callbacks(ofType: PostReceiptDataOperation.self).last { + return self.add(callback.withNewCacheKey(existing.cacheKey)) + } else { + return self.add(callback) + } + } + + private func callbacks(ofType type: NetworkOperation.Type) -> [T] { + return self + .cachedCallbacksByKey + .lazy + .flatMap(\.value) + .filter { $0.source == type } + } + +} + +private extension CustomerInfoCallback { + + func withNewCacheKey(_ newKey: String) -> Self { + var copy = self + copy.cacheKey = newKey + + return copy + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/IsPurchaseAllowedByRestoreBehaviorCallback.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/IsPurchaseAllowedByRestoreBehaviorCallback.swift new file mode 100644 index 00000000..5ed79538 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/IsPurchaseAllowedByRestoreBehaviorCallback.swift @@ -0,0 +1,22 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// IsPurchaseAllowedByRestoreBehaviorCallback.swift +// +// Created by Will Taylor on 2/4/26. + +import Foundation + +// swiftlint:disable:next type_name +struct IsPurchaseAllowedByRestoreBehaviorCallback: CacheKeyProviding { + + let cacheKey: String + let completion: (Result) -> Void + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/LogInCallback.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/LogInCallback.swift new file mode 100644 index 00000000..1f353bcb --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/LogInCallback.swift @@ -0,0 +1,21 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// LogInCallback.swift +// +// Created by Joshua Liebowitz on 11/19/21. + +import Foundation + +struct LogInCallback: CacheKeyProviding { + + let cacheKey: String + let completion: (Result<(info: CustomerInfo, created: Bool), BackendError>) -> Void + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/OfferingsCallback.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/OfferingsCallback.swift new file mode 100644 index 00000000..a5e02e96 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/OfferingsCallback.swift @@ -0,0 +1,21 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// OfferingsCallback.swift +// +// Created by Joshua Liebowitz on 11/19/21. + +import Foundation + +struct OfferingsCallback: CacheKeyProviding { + + let cacheKey: String + let completion: (Result) -> Void + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/ProductEntitlementMappingCallback.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/ProductEntitlementMappingCallback.swift new file mode 100644 index 00000000..92a81f78 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/ProductEntitlementMappingCallback.swift @@ -0,0 +1,21 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ProductEntitlementMappingCallback.swift +// +// Created by Nacho Soto on 3/17/23. + +import Foundation + +struct ProductEntitlementMappingCallback: CacheKeyProviding { + + let cacheKey: String + let completion: (Result) -> Void + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/VirtualCurrenciesCallback.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/VirtualCurrenciesCallback.swift new file mode 100644 index 00000000..3f6d0895 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/VirtualCurrenciesCallback.swift @@ -0,0 +1,21 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// VirtualCurrenciesCallback.swift +// +// Created by Will Taylor on 6/10/25. + +import Foundation + +struct VirtualCurrenciesCallback: CacheKeyProviding { + + let cacheKey: String + let completion: (Result) -> Void + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/WebBillingProductsCallback.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/WebBillingProductsCallback.swift new file mode 100644 index 00000000..ad7d5bd4 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/WebBillingProductsCallback.swift @@ -0,0 +1,21 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// WebBillingProductsCallback.swift +// +// Created by Antonio Pallares on 23/7/25. + +import Foundation + +struct WebBillingProductsCallback: CacheKeyProviding { + + let cacheKey: String + let completion: (Result) -> Void + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/WebOfferingProductsCallback.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/WebOfferingProductsCallback.swift new file mode 100644 index 00000000..dc2d9971 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Caching/WebOfferingProductsCallback.swift @@ -0,0 +1,21 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// WebOfferingProductsCallback.swift +// +// Created by Toni Rico on 5/6/25. + +import Foundation + +struct WebOfferingProductsCallback: CacheKeyProviding { + + let cacheKey: String + let completion: (Result) -> Void + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/ConnectionErrorReason.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/ConnectionErrorReason.swift new file mode 100644 index 00000000..0cd9fbca --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/ConnectionErrorReason.swift @@ -0,0 +1,52 @@ +// +// ConnectionErrorReason.swift +// RevenueCat +// +// Created by Rick van der Linden on 24/11/2025. +// Copyright © 2025 RevenueCat, Inc. All rights reserved. +// + +import Foundation + +enum ConnectionErrorReason: String, Codable { + case timeout = "TIMEOUT" + case noNetwork = "NO_NETWORK" + case other = "OTHER" +} + +extension ConnectionErrorReason { + init(from error: NetworkError) { + switch error { + case let .networkError(networkError, _): + guard let urlError = networkError as? URLError else { + self = .other + return + } + + switch urlError.code { + // Timeout error + case .timedOut: + self = .timeout + + // Network connectivity errors + case .notConnectedToInternet, + .cannotConnectToHost, + .cannotFindHost, + .networkConnectionLost, + .dnsLookupFailed, + .internationalRoamingOff, + .callIsActive, + .dataNotAllowed: + self = .noNetwork + + // Any other URLError.code + default: + self = .other + } + case .dnsError, .unexpectedResponse: + self = .noNetwork + default: + self = .other + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/CustomerAPI.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/CustomerAPI.swift new file mode 100644 index 00000000..334c74f2 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/CustomerAPI.swift @@ -0,0 +1,209 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SubscribersAPI.swift +// +// Created by Joshua Liebowitz on 11/17/21. + +import Foundation + +final class CustomerAPI { + + typealias CustomerInfoResponseHandler = Backend.ResponseHandler + typealias SimpleResponseHandler = (BackendError?) -> Void + + // swiftlint:disable:next type_name + typealias IsPurchaseAllowedByRestoreBehaviorResponseHandler = + Backend.ResponseHandler + + private let backendConfig: BackendConfiguration + private let customerInfoCallbackCache: CallbackCache + private let isPurchaseAllowedByRestoreBehaviorCallbacksCache: + CallbackCache + private let attributionFetcher: AttributionFetcher + + init(backendConfig: BackendConfiguration, attributionFetcher: AttributionFetcher) { + self.backendConfig = backendConfig + self.attributionFetcher = attributionFetcher + self.customerInfoCallbackCache = CallbackCache() + self.isPurchaseAllowedByRestoreBehaviorCallbacksCache = + CallbackCache() + } + + func getCustomerInfo(appUserID: String, + isAppBackgrounded: Bool, + allowComputingOffline: Bool, + completion: @escaping CustomerInfoResponseHandler) { + let config = NetworkOperation.UserSpecificConfiguration(httpClient: self.backendConfig.httpClient, + appUserID: appUserID) + + let factory = GetCustomerInfoOperation.createFactory( + configuration: config, + customerInfoCallbackCache: self.customerInfoCallbackCache, + offlineCreator: allowComputingOffline + ? self.backendConfig.offlineCustomerInfoCreator + : nil + ) + + let callback = CustomerInfoCallback(cacheKey: factory.cacheKey, + source: factory.operationType, + completion: completion) + let cacheStatus = self.customerInfoCallbackCache.addOrAppendToPostReceiptDataOperation(callback: callback) + self.backendConfig.addCacheableOperation(with: factory, + delay: .default(forBackgroundedApp: isAppBackgrounded), + cacheStatus: cacheStatus) + } + + func post(subscriberAttributes: SubscriberAttribute.Dictionary, + appUserID: String, + completion: SimpleResponseHandler?) { + let config = NetworkOperation.UserSpecificConfiguration(httpClient: self.backendConfig.httpClient, + appUserID: appUserID) + let operation = PostSubscriberAttributesOperation(configuration: config, + subscriberAttributes: subscriberAttributes, + completion: completion) + self.backendConfig.operationQueue.addOperation(operation) + } + + func post(attributionData: [String: Any], + network: AttributionNetwork, + appUserID: String, + completion: SimpleResponseHandler?) { + let config = NetworkOperation.UserSpecificConfiguration(httpClient: self.backendConfig.httpClient, + appUserID: appUserID) + let postAttributionDataOperation = PostAttributionDataOperation(configuration: config, + attributionData: attributionData, + network: network, + responseHandler: completion) + self.backendConfig.operationQueue.addOperation(postAttributionDataOperation) + } + + func post(adServicesToken: String, + appUserID: String, + completion: SimpleResponseHandler?) { + let config = NetworkOperation.UserSpecificConfiguration(httpClient: self.backendConfig.httpClient, + appUserID: appUserID) + let postAttributionDataOperation = PostAdServicesTokenOperation(configuration: config, + token: adServicesToken, + responseHandler: completion) + self.backendConfig.operationQueue.addOperation(postAttributionDataOperation) + } + + func isPurchaseAllowedByRestoreBehavior( + appUserID: String, + transactionJWS: String, + isAppBackgrounded: Bool, + completion: @escaping IsPurchaseAllowedByRestoreBehaviorResponseHandler + ) { + let config = NetworkOperation.UserSpecificConfiguration(httpClient: self.backendConfig.httpClient, + appUserID: appUserID) + let postData = PostIsPurchaseAllowedByRestoreBehaviorOperation.PostData( + transactionJWS: transactionJWS + ) + let factory = PostIsPurchaseAllowedByRestoreBehaviorOperation.createFactory( + configuration: config, + postData: postData, + isPurchaseAllowedByRestoreBehaviorCallbackCache: self.isPurchaseAllowedByRestoreBehaviorCallbacksCache + ) + let callback = IsPurchaseAllowedByRestoreBehaviorCallback(cacheKey: factory.cacheKey, completion: completion) + let cacheStatus = self.isPurchaseAllowedByRestoreBehaviorCallbacksCache.add(callback) + + self.backendConfig.addCacheableOperation( + with: factory, + delay: .default(forBackgroundedApp: isAppBackgrounded), + cacheStatus: cacheStatus + ) + } + + // swiftlint:disable function_parameter_count + func post(receipt: EncodedAppleReceipt, + productData: ProductRequestData?, + transactionData: PurchasedTransactionData, + postReceiptSource: PostReceiptSource, + observerMode: Bool, + originalPurchaseCompletedBy: PurchasesAreCompletedBy?, + appTransaction: String?, + associatedTransactionId: String?, + sdkOriginated: Bool = false, + appUserID: String, + containsAttributionData: Bool, + completion: @escaping CustomerAPI.CustomerInfoResponseHandler) { + var subscriberAttributesToPost: SubscriberAttribute.Dictionary? + + if !self.backendConfig.systemInfo.dangerousSettings.customEntitlementComputation { + subscriberAttributesToPost = transactionData.unsyncedAttributes ?? [:] + let attributionStatus = self.attributionFetcher.authorizationStatus + let consentStatus = SubscriberAttribute(attribute: ReservedSubscriberAttribute.consentStatus, + value: attributionStatus.description, + dateProvider: self.backendConfig.dateProvider, + ignoreTimeInCacheIdentity: true) + subscriberAttributesToPost?[consentStatus.key] = consentStatus + } + + let config = NetworkOperation.UserSpecificConfiguration(httpClient: self.backendConfig.httpClient, + appUserID: appUserID) + + let postData = PostReceiptDataOperation.PostData( + transactionData: transactionData.withAttributesToPost(subscriberAttributesToPost), + postReceiptSource: postReceiptSource, + appUserID: appUserID, + productData: productData, + receipt: receipt, + observerMode: observerMode, + purchaseCompletedBy: originalPurchaseCompletedBy, + testReceiptIdentifier: self.backendConfig.systemInfo.testReceiptIdentifier, + appTransaction: appTransaction, + transactionId: associatedTransactionId, + containsAttributionData: containsAttributionData, + sdkOriginated: sdkOriginated + ) + let factory = PostReceiptDataOperation.createFactory( + configuration: config, + postData: postData, + customerInfoCallbackCache: self.customerInfoCallbackCache, + offlineCustomerInfoCreator: self.backendConfig.offlineCustomerInfoCreator + ) + + let callbackObject = CustomerInfoCallback(cacheKey: factory.cacheKey, + source: PostReceiptDataOperation.self, + completion: completion) + + let cacheStatus = customerInfoCallbackCache.add(callbackObject) + + self.backendConfig.operationQueue.addCacheableOperation(with: factory, cacheStatus: cacheStatus) + } + +} + +private extension PurchasedTransactionData { + + func withAttributesToPost(_ newAttributes: SubscriberAttribute.Dictionary?) -> Self { + var copy = self + copy.unsyncedAttributes = newAttributes + + return copy + } + +} + +// MARK: - + +private extension SystemInfo { + + /// This allows the backend to disambiguate between receipts created across + /// separate test invocations when in the sandbox. + var testReceiptIdentifier: String? { + #if DEBUG + return self.dangerousSettings.internalSettings.testReceiptIdentifier + #else + return nil + #endif + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/CustomerCenterConfigAPI.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/CustomerCenterConfigAPI.swift new file mode 100644 index 00000000..d989d35b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/CustomerCenterConfigAPI.swift @@ -0,0 +1,153 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CustomerCenterConfigAPI.swift +// +// Created by Cesar de la Vega on 31/5/24. +// + +import Foundation + +class CustomerCenterConfigAPI { + + typealias CustomerCenterConfigResponseHandler = Backend.ResponseHandler + typealias CreateTicketResponseHandler = (Result) -> Void + + private let customerCenterConfigResponseCallbacksCache: CallbackCache + private let backendConfig: BackendConfiguration + + init(backendConfig: BackendConfiguration) { + self.backendConfig = backendConfig + self.customerCenterConfigResponseCallbacksCache = .init() + } + + func getCustomerCenterConfig(appUserID: String, + isAppBackgrounded: Bool, + completion: @escaping CustomerCenterConfigResponseHandler) { + let config = NetworkOperation.UserSpecificConfiguration(httpClient: self.backendConfig.httpClient, + appUserID: appUserID) + + let factory = GetCustomerCenterConfigOperation.createFactory( + configuration: config, + callbackCache: self.customerCenterConfigResponseCallbacksCache + ) + + let callback = CustomerCenterConfigCallback(cacheKey: factory.cacheKey, completion: completion) + let cacheStatus = self.customerCenterConfigResponseCallbacksCache.add(callback) + + self.backendConfig.addCacheableOperation( + with: factory, + delay: .default(forBackgroundedApp: isAppBackgrounded), + cacheStatus: cacheStatus + ) + } + + func postCreateTicket(appUserID: String, + customerEmail: String, + ticketDescription: String, + completion: @escaping CreateTicketResponseHandler) { + let config = NetworkOperation.UserSpecificConfiguration(httpClient: self.backendConfig.httpClient, + appUserID: appUserID) + + let operation = PostCreateTicketOperation(configuration: config, + customerEmail: customerEmail, + ticketDescription: ticketDescription, + responseHandler: completion) + + self.backendConfig.operationQueue.addOperation(operation) + } + +} + +// MARK: - PostCreateTicketOperation + +private class PostCreateTicketOperation: NetworkOperation { + + private let configuration: UserSpecificConfiguration + private let customerEmail: String + private let ticketDescription: String + private let responseHandler: CreateTicketResponseHandler? + + typealias CreateTicketResponseHandler = (Result) -> Void + + init(configuration: UserSpecificConfiguration, + customerEmail: String, + ticketDescription: String, + responseHandler: CreateTicketResponseHandler?) { + self.customerEmail = customerEmail + self.ticketDescription = ticketDescription + self.configuration = configuration + self.responseHandler = responseHandler + + super.init(configuration: configuration) + } + + override func begin(completion: @escaping () -> Void) { + self.post(completion: completion) + } + + private func post(completion: @escaping () -> Void) { + let appUserID = self.configuration.appUserID + + guard appUserID.isNotEmpty else { + self.responseHandler?(.failure(.missingAppUserID())) + completion() + return + } + + let body = Body( + appUserID: appUserID, + customerEmail: self.customerEmail, + issueDescription: self.ticketDescription + ) + let request = HTTPRequest(method: .post(body), path: .postCreateTicket) + + self.httpClient.perform(request) { (response: VerifiedHTTPResponse.Result) in + defer { + completion() + } + + self.responseHandler?( + response + .map { $0.body } + .mapError(BackendError.networkError) + ) + } + } + +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension PostCreateTicketOperation: @unchecked Sendable {} + +private struct PostCreateTicketBody: HTTPRequestBody, Encodable { + + let appUserID: String + let customerEmail: String + let issueDescription: String + + enum CodingKeys: String, CodingKey { + case appUserID = "app_user_id" + case customerEmail = "customer_email" + case issueDescription = "issue_description" + } + +} + +private extension PostCreateTicketOperation { + + typealias Body = PostCreateTicketBody + +} + +struct CreateTicketResponse: HTTPResponseBody, Decodable { + + let sent: Bool + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/DNSChecker.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/DNSChecker.swift new file mode 100644 index 00000000..fee929ae --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/DNSChecker.swift @@ -0,0 +1,86 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DNSChecker.swift +// +// Created by Joshua Liebowitz on 12/20/21. + +import Foundation + +protocol DNSCheckerType { + + static func isBlockedAPIError(_ error: Error?) -> Bool + static func errorWithBlockedHostFromError(_ error: Error?) -> NetworkError? + static func isBlockedURL(_ url: URL) -> Bool + static func resolvedHost(fromURL url: URL) -> String? + +} + +enum DNSChecker: DNSCheckerType { + + static let invalidHosts = Set(["0.0.0.0", "127.0.0.1"]) + + static func isBlockedAPIError(_ error: Error?) -> Bool { + guard let error = error else { + return false + } + + let nsError = error as NSError + guard nsError.domain == NSURLErrorDomain, + nsError.code == NSURLErrorCannotConnectToHost else { + return false + } + + guard let failedURL = nsError.userInfo[NSURLErrorFailingURLErrorKey] as? URL else { + return false + } + + return isBlockedURL(failedURL) + } + + static func errorWithBlockedHostFromError(_ error: Error?) -> NetworkError? { + guard self.isBlockedAPIError(error), + let nsError = error as NSError?, + let failedURL = nsError.userInfo[NSURLErrorFailingURLErrorKey] as? URL else { + return nil + } + + let host = self.resolvedHost(fromURL: failedURL) + return .dnsError(failedURL: failedURL, resolvedHost: host) + } + + static func isBlockedURL(_ url: URL) -> Bool { + guard let resolvedHostName = self.resolvedHost(fromURL: url) else { + return false + } + + Logger.debug(Strings.network.failing_url_resolved_to_host(url: url, + resolvedHost: resolvedHostName)) + + return self.invalidHosts.contains(resolvedHostName) + } + + static func resolvedHost(fromURL url: URL) -> String? { + guard let name = url.host, + let host = name.withCString({gethostbyname($0)}), + host.pointee.h_length > 0 else { + return nil + } + var addr = in_addr() + memcpy(&addr.s_addr, host.pointee.h_addr_list[0], Int(host.pointee.h_length)) + + guard let remoteIPAsC = inet_ntoa(addr) else { + return nil + } + + let hostIP = String(cString: remoteIPAsC) + return hostIP + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/ETagManager.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/ETagManager.swift new file mode 100644 index 00000000..bc799a4e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/ETagManager.swift @@ -0,0 +1,341 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ETagManager.swift +// +// Created by César de la Vega on 4/16/21. +// + +import Foundation + +class ETagManager { + static let eTagRequestHeader = HTTPClient.RequestHeader.eTag + static let eTagValidationTimeRequestHeader = HTTPClient.RequestHeader.eTagValidationTime + static let eTagResponseHeader = HTTPClient.ResponseHeader.eTag + + private let cache: SynchronizedLargeItemCache + private static let fileManager = FileManager.default + + init() { + self.cache = .init( + cache: Self.fileManager, + basePath: Self.cacheBasePath + ) + + // Perform one-time cleanup if needed + self.deleteOldDirectoryInDocumentsIfNeeded() + } + + #if DEBUG + /// Only used in testing. In any other case the init above should be used + init(largeItemCache: SynchronizedLargeItemCache) { + self.cache = largeItemCache + } + #endif + + /// - Parameter withSignatureVerification: whether requests require a signature. + func eTagHeader( + for urlRequest: URLRequest, + withSignatureVerification: Bool, + refreshETag: Bool = false + ) -> [String: String] { + func eTag() -> (tag: String, date: String?)? { + if refreshETag { return nil } + guard let storedETagAndResponse = self.storedETagAndResponse(for: urlRequest) else { + Logger.verbose(Strings.etag.found_no_etag(urlRequest)) + return nil + } + + if self.shouldUseETag(storedETagAndResponse, + withSignatureVerification: withSignatureVerification) { + Logger.verbose(Strings.etag.using_etag(urlRequest, + storedETagAndResponse.eTag, + storedETagAndResponse.validationTime)) + + return (tag: storedETagAndResponse.eTag, + date: storedETagAndResponse.validationTime?.millisecondsSince1970.description) + } else { + Logger.verbose(Strings.etag.not_using_etag( + urlRequest, + storedETagAndResponse.verificationResult, + needsSignatureVerification: withSignatureVerification + + )) + return nil + } + } + + let (etag, date) = eTag() ?? ("", nil) + + return [ + HTTPClient.RequestHeader.eTag.rawValue: etag, + HTTPClient.RequestHeader.eTagValidationTime.rawValue: date + ] + .compactMapValues { $0 } + } + + /// - Returns: `response` if a cached response couldn't be fetched, + /// or the cached `HTTPResponse`, always including the headers in `response`. + func httpResultFromCacheOrBackend(with response: VerifiedHTTPResponse, + request: URLRequest, + retried: Bool, + isFallbackURLRequest: Bool) -> VerifiedHTTPResponse? { + let statusCode: HTTPStatusCode = response.httpStatusCode + let resultFromBackend = response.asOptionalResponse + + guard let eTagInResponse = response.value(forHeaderField: Self.eTagResponseHeader) else { + return resultFromBackend + } + + if self.shouldUseCachedVersion(responseCode: statusCode) { + if let storedResponse = self.storedETagAndResponse(for: request) { + let newResponse = storedResponse.withUpdatedValidationTime() + + self.storeIfPossible(newResponse, for: request) + return newResponse.asResponse(withRequestDate: response.requestDate, + headers: response.responseHeaders, + responseVerificationResult: response.verificationResult) + } + if retried { + Logger.warn( + Strings.etag.could_not_find_cached_response_in_already_retried( + response: resultFromBackend?.description ?? "" + ) + ) + return resultFromBackend + } + return nil + } + + self.storeStatusCodeAndResponseIfNoError( + for: request, + response: response, + eTag: eTagInResponse, + isLoadShedderResponse: response.response.isLoadShedder, + isFallbackURLRequest: isFallbackURLRequest + ) + return resultFromBackend + } + + func clearCaches() { + Logger.debug(Strings.etag.clearing_cache) + + self.cache.clear() + } + +} + +extension ETagManager { + + // Visible for tests + static func cacheKey(for request: URLRequest) -> String? { + return request.url?.absoluteString.asData.md5String + } + + static var oldDocumentsDirectoryBasePath: String { + let bundleID = Bundle.main.bundleIdentifier ?? "com.revenuecat" + return "\(bundleID).revenuecat.etags" + } + + static var cacheBasePath: String { + return "etags" + } +} + +// MARK: - Private + +private extension ETagManager { + + func shouldUseCachedVersion(responseCode: HTTPStatusCode) -> Bool { + responseCode == .notModified + } + + func shouldUseETag(_ response: Response, withSignatureVerification: Bool) -> Bool { + switch response.verificationResult { + case .verified: return true + case .notRequested: return !withSignatureVerification + // This is theoretically impossible since we won't store these responses anyway. + case .failed, .verifiedOnDevice: return false + } + } + + func storedETagAndResponse(for request: URLRequest) -> Response? { + if let cacheKey = Self.cacheKey(for: request) { + return try? self.cache.value(forKey: cacheKey) + } + return nil + } + + func storeStatusCodeAndResponseIfNoError(for request: URLRequest, + response: VerifiedHTTPResponse, + eTag: String, + isLoadShedderResponse: Bool, + isFallbackURLRequest: Bool) { + if let data = response.body { + if response.shouldStore { + self.storeIfPossible( + Response( + eTag: eTag, + statusCode: response.httpStatusCode, + data: data, + verificationResult: response.verificationResult, + isLoadShedderResponse: isLoadShedderResponse, + isFallbackUrlResponse: isFallbackURLRequest + ), + for: request + ) + } else { + Logger.verbose(Strings.etag.not_storing_etag(response)) + } + } + } + + func storeIfPossible(_ response: Response, for request: URLRequest) { + if let cacheKey = Self.cacheKey(for: request) { + Logger.verbose(Strings.etag.storing_response(request, response)) + + self.cache.set(codable: response, forKey: cacheKey) + } + } + + private func oldETagDirectoryURL() -> URL? { + // swiftlint:disable:next avoid_using_directory_apis_directly + guard let documentsURL = Self.fileManager.urls( + for: .documentDirectory, + in: .userDomainMask + ).first else { + return nil + } + + return documentsURL.appendingPathComponent(Self.oldDocumentsDirectoryBasePath) + } + + /* + We were previously storing these files in the Documents directory + which may end up in the Files app or the user's Documents directory on macOS. + We'll delete it on initialization if it exists. + */ + private func deleteOldDirectoryInDocumentsIfNeeded() { + guard let oldDirectoryURL = self.oldETagDirectoryURL(), + Self.fileManager.fileExists(atPath: oldDirectoryURL.path) else { + return + } + + try? Self.fileManager.removeItem(at: oldDirectoryURL) + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension ETagManager: @unchecked Sendable {} + +// MARK: Response + +extension ETagManager { + + struct Response { + + var eTag: String + var statusCode: HTTPStatusCode + var data: Data + /// Used by the backend for advanced load shedding techniques. + @DefaultValue + var validationTime: Date? + @DefaultValue + var verificationResult: VerificationResult + + @DefaultDecodable.False + var isLoadShedderResponse: Bool + @DefaultDecodable.False + var isFallbackUrlResponse: Bool + + init( + eTag: String, + statusCode: HTTPStatusCode, + data: Data, + validationTime: Date? = nil, + verificationResult: VerificationResult, + isLoadShedderResponse: Bool, + isFallbackUrlResponse: Bool + ) { + self.eTag = eTag + self.statusCode = statusCode + self.data = data + self.validationTime = validationTime + self.verificationResult = verificationResult + self.isLoadShedderResponse = isLoadShedderResponse + self.isFallbackUrlResponse = isFallbackUrlResponse + } + + } + +} + +extension ETagManager.Response: Codable {} + +extension ETagManager.Response { + + func asData() -> Data? { + return try? self.jsonEncodedData + } + + /// - Parameter responseVerificationResult: the result of the 304 response + fileprivate func asResponse( + withRequestDate requestDate: Date?, + headers: HTTPClient.ResponseHeaders, + responseVerificationResult: VerificationResult + ) -> VerifiedHTTPResponse { + return HTTPResponse( + httpStatusCode: self.statusCode, + responseHeaders: headers, + body: self.data, + requestDate: requestDate, + origin: .cache + ) + .verified(with: responseVerificationResult, + isLoadShedderResponse: self.isLoadShedderResponse, + isFallbackUrlResponse: self.isFallbackUrlResponse) + } + + fileprivate func withUpdatedValidationTime() -> Self { + var copy = self + copy.validationTime = Date() + + return copy + } + +} + +// MARK: - + +private extension VerifiedHTTPResponse { + + var shouldStore: Bool { + return ( + self.httpStatusCode != .notModified && + // Note that we do want to store 400 responses to help the server + // If the request was wrong, it will also be wrong the next time. + !self.httpStatusCode.isServerError && + self.verificationResult.shouldStore + ) + } + +} + +private extension VerificationResult { + + var shouldStore: Bool { + switch self { + case .notRequested, .verified: return true + case .verifiedOnDevice, .failed: return false + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/ErrorResponse.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/ErrorResponse.swift new file mode 100644 index 00000000..756b37d0 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/ErrorResponse.swift @@ -0,0 +1,167 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ErrorResponse.swift +// +// Created by Nacho Soto on 6/22/23. + +import Foundation + +/// The response content of a failed request. +struct ErrorResponse: Equatable { + + var code: BackendErrorCode + var originalCode: Int + var message: String? + var attributeErrors: [String: String] = [:] + var purchaseRedemptionErrorInfo: PurchaseRedemptionErrorInfo? + + struct PurchaseRedemptionErrorInfo: Decodable, Equatable { + + let obfuscatedEmail: String + + } +} + +extension ErrorResponse { + + /// Converts this `ErrorResponse` into an `ErrorCode` backed by the corresponding `BackendErrorCode`. + func asBackendError( + with statusCode: HTTPStatusCode, + file: String = #fileID, + function: String = #function, + line: UInt = #line + ) -> PurchasesError { + var userInfo: [NSError.UserInfoKey: Any] = [ + .statusCode: statusCode.rawValue + ] + + if !self.attributeErrors.isEmpty { + userInfo[.attributeErrors] = self.attributeErrors as NSDictionary + } + + if let redemptionErrorInfo = self.purchaseRedemptionErrorInfo { + userInfo[.obfuscatedEmail] = redemptionErrorInfo.obfuscatedEmail + } + + // If the backend didn't provide a message we default to showing the status code. + let errorMessage = self.message ?? Strings.network.api_request_failed_status_code(statusCode).description + + let message: String? = self.code != .unknownBackendError + ? errorMessage + : [ + errorMessage, + // Append original error code if we couldn't map it to a value. + "(\(self.originalCode))" + ] + .compactMap { $0 } + .joined(separator: " ") + + return ErrorUtils.backendError( + withBackendCode: self.code, + originalBackendErrorCode: self.originalCode, + message: self.attributeErrors.isEmpty + ? nil + : self.attributeErrors.description, + backendMessage: message, + extraUserInfo: userInfo, + fileName: file, functionName: function, line: line + ) + } + +} + +extension ErrorResponse: Decodable { + + private enum CodingKeys: String, CodingKey { + + case code + case message + case attributeErrors + case purchaseRedemptionErrorInfo + + } + + private struct AttributeError: Decodable { + + let keyName: String + let message: String + + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let codeAsInteger = try? container.decodeIfPresent(Int.self, forKey: .code) + let codeAsString = try? container.decodeIfPresent(String.self, forKey: .code) + + self.code = BackendErrorCode(code: codeAsInteger ?? codeAsString) + self.originalCode = codeAsInteger ?? BackendErrorCode.unknownBackendError.rawValue + self.message = try container.decodeIfPresent(String.self, forKey: .message) + self.purchaseRedemptionErrorInfo = try container.decodeIfPresent(PurchaseRedemptionErrorInfo.self, + forKey: .purchaseRedemptionErrorInfo) + + let attributeErrors = ( + try? container.decodeIfPresent(Array.self, + forKey: .attributeErrors) + ) ?? [] + + self.attributeErrors = attributeErrors + .dictionaryAllowingDuplicateKeys { $0.keyName } + .mapValues { $0.message } + } + +} + +extension ErrorResponse { + + /// For some endpoints the backend may return `ErrorResponse` inside of this wrapper. + private struct Wrapper: Decodable { + + let attributesErrorResponse: ErrorResponse + + } + + private static func parseWrapper(_ data: Data) -> Wrapper? { + return try? JSONDecoder.default.decode(jsonData: data, logErrors: false) + } + + /// Creates an `ErrorResponse` with the content of an `HTTPResponse`. + /// This method supports extracting error information from the root, or from inside `"attributes_error_response"` + /// - Note: if the error couldn't be decoded, a default error is created. + /// - Warning: this is "deprecated". Ideally in the future all `ErrorResponses` are created from `Data`. + static func from(_ dictionary: [String: Any]) -> Self { + guard let data = try? JSONSerialization.data(withJSONObject: dictionary) else { + return .defaultResponse + } + + return Self.from(data) + } + + /// Creates an `ErrorResponse` with the content of an `HTTPResponse`. + /// This method supports extracting error information from the root, or from inside `"attributes_error_response"` + /// - Note: if the error couldn't be decoded, a default error is created. + static func from(_ data: Data) -> Self { + do { + if let wrapper = Self.parseWrapper(data) { + return wrapper.attributesErrorResponse + } else { + return try JSONDecoder.default.decode(jsonData: data) + } + } catch { + ErrorUtils.logDecodingError(error, type: Self.self, data: data) + return Self.defaultResponse + } + } + + static let defaultResponse: Self = .init(code: .unknownError, + originalCode: BackendErrorCode.unknownError.rawValue, + message: nil) + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPClient.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPClient.swift new file mode 100644 index 00000000..0d318b2c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPClient.swift @@ -0,0 +1,1037 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// HTTPClient.swift +// +// Created by César de la Vega on 7/22/21. + +import Foundation + +// swiftlint:disable file_length + +class HTTPClient { + + typealias RequestHeaders = HTTPRequest.Headers + typealias ResponseHeaders = HTTPResponse.Headers + typealias Completion = (VerifiedHTTPResponse.Result) -> Void + + let systemInfo: SystemInfo + let timeout: TimeInterval + let apiKey: String + let authHeaders: RequestHeaders + + private let session: URLSession + private let state: Atomic = .init(.initial) + private let eTagManager: ETagManager + private let dnsChecker: DNSCheckerType.Type + private let signing: SigningType + private let diagnosticsTracker: DiagnosticsTrackerType? + private let dateProvider: DateProvider + private let retriableStatusCodes: Set + private let operationDispatcher: OperationDispatcher + private let requestTimeoutManager: HTTPRequestTimeoutManagerType + + private let retryBackoffIntervals: [TimeInterval] = [ + TimeInterval(0), + TimeInterval(0.75), + TimeInterval(3) + ] + + init(apiKey: String, + systemInfo: SystemInfo, + eTagManager: ETagManager, + signing: SigningType, + diagnosticsTracker: DiagnosticsTrackerType?, + dnsChecker: DNSCheckerType.Type = DNSChecker.self, + retriableStatusCodes: Set = Set([.tooManyRequests]), + requestTimeout: TimeInterval = Configuration.networkTimeoutDefault, + dateProvider: DateProvider = DateProvider(), + operationDispatcher: OperationDispatcher, + timeoutManager: HTTPRequestTimeoutManagerType? = nil + ) { + let config = URLSessionConfiguration.ephemeral + config.httpMaximumConnectionsPerHost = 1 + config.timeoutIntervalForRequest = requestTimeout + config.timeoutIntervalForResource = requestTimeout + config.urlCache = nil // We implement our own caching with `ETagManager`. + self.session = URLSession(configuration: config, + delegate: RedirectLoggerSessionDelegate(), + delegateQueue: nil) + self.systemInfo = systemInfo + self.eTagManager = eTagManager + self.signing = signing + self.diagnosticsTracker = diagnosticsTracker + self.dnsChecker = dnsChecker + self.retriableStatusCodes = retriableStatusCodes + self.timeout = requestTimeout + self.apiKey = apiKey + self.authHeaders = HTTPClient.authorizationHeader(withAPIKey: apiKey) + self.dateProvider = dateProvider + self.operationDispatcher = operationDispatcher + self.requestTimeoutManager = timeoutManager ?? HTTPRequestTimeoutManager( + defaultTimeout: timeout, + dateProvider: dateProvider + ) + } + + /// - Parameter verificationMode: if `nil`, this will default to `SystemInfo.responseVerificationMode` + func perform( + _ request: HTTPRequest, + with verificationMode: Signing.ResponseVerificationMode? = nil, + completionHandler: Completion? + ) { + self.perform(request: .init(httpRequest: request, + authHeaders: self.authHeaders, + defaultHeaders: self.defaultHeaders, + verificationMode: verificationMode ?? self.systemInfo.responseVerificationMode, + internalSettings: self.systemInfo.dangerousSettings.internalSettings, + completionHandler: completionHandler)) + } + + func clearCaches() { + self.eTagManager.clearCaches() + } + + var signatureVerificationEnabled: Bool { + return self.systemInfo.responseVerificationMode.isEnabled + } + + // Visible for tests + var defaultHeaders: RequestHeaders { + let preferredLocales = self.systemInfo.preferredLocales.prefix(3).map { + $0.replacingOccurrences(of: "-", with: "_") + }.joined(separator: ",") + var headers: RequestHeaders = [ + "content-type": "application/json", + "X-Version": SystemInfo.frameworkVersion, + "X-Platform": SystemInfo.platformHeader, + "X-Platform-Version": SystemInfo.systemVersion, + "X-Platform-Flavor": self.systemInfo.platformFlavor, + "X-Platform-Device": SystemInfo.deviceVersion, + "X-Client-Version": SystemInfo.appVersion, + "X-Client-Build-Version": SystemInfo.buildVersion, + "X-Client-Bundle-ID": SystemInfo.bundleIdentifier, + "X-Preferred-Locales": preferredLocales, + "X-StoreKit2-Enabled": "\(self.systemInfo.storeKitVersion.isStoreKit2EnabledAndAvailable)", + "X-StoreKit-Version": "\(self.systemInfo.storeKitVersion.effectiveVersion)", + "X-Observer-Mode-Enabled": "\(self.systemInfo.observerMode)", + RequestHeader.retryCount.rawValue: "0", + RequestHeader.sandbox.rawValue: "\(self.systemInfo.isSandbox)", + "X-Is-Backgrounded": "\(self.systemInfo.isAppBackgroundedState)", + "X-Is-Debug-Build": "\(self.systemInfo.isDebugBuild)" + ] + + if let storefront = self.systemInfo.storefront { + headers["X-Storefront"] = storefront.countryCode + } + + if let platformFlavorVersion = self.systemInfo.platformFlavorVersion { + headers["X-Platform-Flavor-Version"] = platformFlavorVersion + } + + if let idfv = self.systemInfo.identifierForVendor { + headers["X-Apple-Device-Identifier"] = idfv + } + + if self.systemInfo.dangerousSettings.customEntitlementComputation { + headers["X-Custom-Entitlements-Computation"] = "\(true)" + } + + if self.systemInfo.dangerousSettings.uiPreviewMode { + headers["X-UI-Preview-Mode"] = "\(true)" + } + + return headers + } + +} + +extension HTTPClient { + + static func authorizationHeader(withAPIKey apiKey: String) -> RequestHeaders { + return [RequestHeader.authorization.rawValue: "Bearer \(apiKey)"] + } + + static func nonceHeader(with data: Data) -> RequestHeaders { + return [RequestHeader.nonce.rawValue: data.base64EncodedString()] + } + + static func postParametersHeaderForSigning(with body: HTTPRequestBody) -> RequestHeaders { + if let header = body.postParameterHeader { + return [RequestHeader.postParameters.rawValue: header] + } else { + return [:] + } + } + + static func headerParametersForSignatureHeader( + with headers: RequestHeaders, + path: HTTPRequestPath + ) -> RequestHeaders { + if let header = HTTPRequest.headerParametersForSignatureHeader( + headers: headers, + path: path + ) { + return [RequestHeader.headerParametersForSignature.rawValue: header] + } else { + return [:] + } + } + + enum RequestHeader: String { + + case authorization = "Authorization" + case nonce = "X-Nonce" + case eTag = "X-RevenueCat-ETag" + case eTagValidationTime = "X-RC-Last-Refresh-Time" + case postParameters = "X-Post-Params-Hash" + case headerParametersForSignature = "X-Headers-Hash" + case sandbox = "X-Is-Sandbox" + case retryCount = "X-Retry-Count" + + } + + enum ResponseHeader: String { + + case eTag = "X-RevenueCat-ETag" + case location = "Location" + case signature = "X-Signature" + case requestDate = "X-RevenueCat-Request-Time" + case contentType = "Content-Type" + case isLoadShedder = "X-RevenueCat-Fortress" + case requestID = "X-Request-ID" + case amazonTraceID = "X-Amzn-Trace-ID" + case retryAfter = "Retry-After" + case isRetryable = "Is-Retryable" + + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension HTTPClient: @unchecked Sendable {} + +// MARK: - Private + +internal extension HTTPClient { + + struct State { + var queuedRequests: [Request] + var currentSerialRequest: Request? + + static let initial: Self = .init(queuedRequests: [], currentSerialRequest: nil) + } + + struct Request: CustomStringConvertible { + + var httpRequest: HTTPRequest + var headers: HTTPClient.RequestHeaders + var verificationMode: Signing.ResponseVerificationMode + var completionHandler: HTTPClient.Completion? + private(set) var fallbackUrlIndex: Int? + + /// Whether the request has been retried. + var retried: Bool { + return self.retryCount > 0 + } + + /// The number of times that we have retried the request + var retryCount: UInt = 0 + + /// Whether the request is being made to a fallback URL. + var isFallbackURLRequest: Bool { + return self.fallbackUrlIndex != nil + } + + init(httpRequest: HTTPRequest, + authHeaders: HTTPClient.RequestHeaders, + defaultHeaders: HTTPClient.RequestHeaders, + verificationMode: Signing.ResponseVerificationMode, + internalSettings: InternalDangerousSettingsType, + completionHandler: HTTPClient.Completion?) { + self.httpRequest = httpRequest.requestAddingNonceIfRequired(with: verificationMode) + self.headers = self.httpRequest.headers( + with: authHeaders, + defaultHeaders: defaultHeaders, + verificationMode: verificationMode, + internalSettings: internalSettings + ) + self.verificationMode = verificationMode + + if let completionHandler = completionHandler { + self.completionHandler = { result in + completionHandler(result.parseResponse()) + } + } else { + self.completionHandler = nil + } + } + + var method: HTTPRequest.Method { self.httpRequest.method } + var path: String { self.httpRequest.path.relativePath } + + func getCurrentRequestURL(proxyURL: URL?) -> URL? { + return self.httpRequest.path.url( + proxyURL: proxyURL, + fallbackUrlIndex: self.fallbackUrlIndex + ) + } + + func retriedRequest() -> Self { + var copy = self + copy.retryCount += 1 + copy.headers[RequestHeader.retryCount.rawValue] = "\(copy.retryCount)" + return copy + } + + func requestWithNextFallbackHost(proxyURL: URL?) -> Self? { + guard proxyURL == nil else { + // Don't fallback to next host if proxyURL is set + return nil + } + var copy = self + copy.fallbackUrlIndex = self.fallbackUrlIndex?.advanced(by: 1) ?? 0 + guard copy.getCurrentRequestURL(proxyURL: nil) != nil else { + // No more fallback hosts available + return nil + } + return copy + } + + var description: String { + """ + <\(type(of: self)): httpMethod=\(self.method.httpMethod) + path=\(self.path) + headers=\(self.headers.description) + retried=\(self.retried) + > + """ + } + } +} + +private extension HTTPClient { + + static let serverErrorResponse: ErrorResponse = .init(code: .internalServerError, + originalCode: BackendErrorCode.unknownBackendError.rawValue) + + func perform(request: Request) { + if !request.retried { + let requestEnqueued: Bool = self.state.modify { + if $0.currentSerialRequest != nil { + Logger.debug(Strings.network.serial_request_queued(httpMethod: request.method.httpMethod, + path: request.path, + queuedRequestsCount: $0.queuedRequests.count)) + + $0.queuedRequests.append(request) + return true + } else { + Logger.debug(Strings.network.starting_request(httpMethod: request.method.httpMethod, + path: request.path)) + $0.currentSerialRequest = request + return false + } + } + + guard !requestEnqueued else { return } + } + + self.start(request: request) + } + + /// - Returns: `nil` if the request must be retried + // swiftlint:disable:next function_parameter_count + func parse(urlResponse: URLResponse?, + request: Request, + urlRequest: URLRequest, + data: Data?, + error networkError: Error?, + requestStartTime: Date) -> VerifiedHTTPResponse.Result? { + if let networkError = networkError { + return .failure(NetworkError(networkError, dnsChecker: self.dnsChecker)) + } + + guard let httpURLResponse = urlResponse as? HTTPURLResponse else { + return .failure(.unexpectedResponse(urlResponse)) + } + + let statusCode: HTTPStatusCode = .init(rawValue: httpURLResponse.statusCode) + + // `nil` if status code is 304, since the response will be empty and fetched from the eTag. + let dataIfAvailable = statusCode == .notModified + ? nil + : data + + return self.createVerifiedResponse(request: request, + urlRequest: urlRequest, + data: dataIfAvailable, + response: httpURLResponse, + requestStartTime: requestStartTime) + } + + /// - Returns `Result, NetworkError>?` + // swiftlint:disable:next function_body_length + private func createVerifiedResponse( + request: Request, + urlRequest: URLRequest, + data: Data?, + response httpURLResponse: HTTPURLResponse, + requestStartTime: Date + ) -> VerifiedHTTPResponse.Result? { + #if DEBUG + let requestHeaders: HTTPClient.RequestHeaders + + if self.systemInfo.dangerousSettings.internalSettings.disableHeaderSignatureVerification { + Logger.warn(Strings.network.api_request_disabling_header_parameter_signature_verification( + request.httpRequest + )) + requestHeaders = [:] + } else { + requestHeaders = request.headers + } + #else + let requestHeaders = request.headers + #endif + + let result = Result + .success(data) + .mapToResponse(response: httpURLResponse, request: request.httpRequest) + // Verify response + .map { cachedResponse -> VerifiedHTTPResponse in + let isLoadShedderResponse = httpURLResponse.isLoadShedder + let isFallbackUrlResponse = request.isFallbackURLRequest + #if DEBUG + if isFallbackUrlResponse && isLoadShedderResponse { + Logger.warn( + Strings.network.api_request_response_both_fallback_and_load_shedder(request.httpRequest) + ) + } + #endif + return cachedResponse.verify( + signing: self.signing(for: request.httpRequest), + request: request.httpRequest, + requestHeaders: requestHeaders, + publicKey: request.verificationMode.publicKey, + isLoadShedderResponse: isLoadShedderResponse, + isFallbackUrlResponse: isFallbackUrlResponse + ) + } + // Fetch from ETagManager if available + .map { (response) -> VerifiedHTTPResponse? in + return self.eTagManager.httpResultFromCacheOrBackend( + with: response, + request: urlRequest, + retried: request.retried, + isFallbackURLRequest: request.isFallbackURLRequest + ) + } + // Upgrade to error in enforced mode + .flatMap { response -> Result?, NetworkError> in + if let response = response, response.verificationResult == .failed { + if case .enforced = request.verificationMode { + return .failure(.signatureVerificationFailed(path: request.httpRequest.path, + code: response.httpStatusCode)) + } else { + // Any other mode gets forwarded as a success, but we log the error + Logger.error(Strings.signing.request_failed_verification(request.httpRequest)) + return .success(response) + } + } else { + return .success(response) + } + } + .asOptionalResult? + .convertUnsuccessfulResponseToError() + + return result + } + + // swiftlint:disable:next function_parameter_count function_body_length + func handle(urlResponse: URLResponse?, + request: Request, + urlRequest: URLRequest, + data: Data?, + error networkError: Error?, + requestStartTime: Date) { + RCTestAssertNotMainThread() + + let response = self.parse(urlResponse: urlResponse, + request: request, + urlRequest: urlRequest, + data: data, + error: networkError, + requestStartTime: requestStartTime) + + var requestTimeoutResult: HTTPRequestTimeoutManager.RequestResult = .other + + if let response = response { + let httpURLResponse = urlResponse as? HTTPURLResponse + var retryScheduled = false + + switch response { + case let .success(response): + Logger.debug(Strings.network.api_request_completed( + request.httpRequest, + // Getting status code from the original response to detect 304s + // If that can't be extracted, get status code from the parsed response. + httpCode: httpURLResponse?.httpStatusCode ?? response.httpStatusCode, + metadata: Logger.verboseLogsEnabled ? response.metadata : nil + )) + + if response.isLoadShedder { + Logger.debug(Strings.network.request_handled_by_load_shedder(request.httpRequest.path)) + } + + // Record successful response from the main backend + if !request.isFallbackURLRequest { + requestTimeoutResult = .successOnMainBackend + } + + case let .failure(error): + let httpURLResponse = urlResponse as? HTTPURLResponse + + Logger.debug(Strings.network.api_request_failed(request.httpRequest, + httpCode: httpURLResponse?.httpStatusCode, + error: error, + metadata: httpURLResponse?.metadata)) + + if httpURLResponse?.isLoadShedder == true { + Logger.debug(Strings.network.request_handled_by_load_shedder(request.httpRequest.path)) + } + + // A timeout on a main backend URL for a request that has a fallback URL + if let error = networkError as? URLError, case .timedOut = error.code, + !request.isFallbackURLRequest, + request.httpRequest.path.supportsFallbackURLs { + requestTimeoutResult = .timeoutOnMainBackendForFallbackSupportedEndpoint + } + + retryScheduled = self.retryRequestWithNextFallbackHostIfNeeded(request: request, + error: error) + + if !retryScheduled { + retryScheduled = self.retryRequestIfNeeded(request: request, + httpURLResponse: httpURLResponse) + } + } + + if !retryScheduled { + request.completionHandler?(response) + } + } else { + Logger.debug(Strings.network.retrying_request(httpMethod: request.method.httpMethod, path: request.path)) + + self.state.modify { + $0.queuedRequests.insert(request.retriedRequest(), at: 0) + } + } + + self.requestTimeoutManager.recordRequestResult(requestTimeoutResult) + + self.trackHttpRequestPerformedIfNeeded(request: request, + host: urlRequest.url?.host, + requestStartTime: requestStartTime, + result: response) + + self.beginNextRequest() + } + + func beginNextRequest() { + let nextRequest: Request? = self.state.modify { + Logger.debug(Strings.network.serial_request_done(httpMethod: $0.currentSerialRequest?.method.httpMethod, + path: $0.currentSerialRequest?.path, + queuedRequestsCount: $0.queuedRequests.count)) + $0.currentSerialRequest = $0.queuedRequests.popFirst() + + return $0.currentSerialRequest + } + + if let nextRequest = nextRequest { + Logger.debug(Strings.network.starting_next_request(request: nextRequest.description)) + self.start(request: nextRequest) + } + } + + func start(request: Request) { + let urlRequest = self.convert(request: request) + + guard let urlRequest = urlRequest else { + let error: NetworkError = .unableToCreateRequest(request.httpRequest.path) + + Logger.error(error.description) + request.completionHandler?(.failure(error)) + return + } + + Logger.debug(Strings.network.api_request_started(request.httpRequest)) + + var finalURLRequest = urlRequest + + let requestStartTime = self.dateProvider.now() + + #if DEBUG + // Meant only for testing error handling behavior of the SDK. + if let forceErrorStrategy = self.systemInfo.dangerousSettings.internalSettings.forceServerErrorStrategy { + + if let (fakeResponse, fakeData) = forceErrorStrategy.fakeResponseWithoutPerformingRequest(request) { + + // `FB13133387`: when computing offline CustomerInfo, `StoreKit.Transaction.unfinished` + // might be empty if called immediately after `Product.purchase()`. + // This introduces a delay to simulate a real API request, and avoid that race condition. + + Logger.warn(Strings.network.api_request_faking_error_response(request.httpRequest)) + DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(300)) { + self.handle(urlResponse: fakeResponse, + request: request, + urlRequest: urlRequest, + data: fakeData, + error: nil, + requestStartTime: requestStartTime) + } + return + } + + if forceErrorStrategy.shouldForceServerError(request) { + Logger.warn(Strings.network.api_request_forcing_server_error(request.httpRequest)) + finalURLRequest = URLRequest(url: forceErrorStrategy.serverErrorURL) + } + } + #endif + + finalURLRequest.timeoutInterval = requestTimeoutManager.timeout( + for: request.httpRequest.path, + isFallback: request.isFallbackURLRequest + ) + + // swiftlint:disable:next redundant_void_return + let task = self.session.dataTask(with: finalURLRequest) { (data, urlResponse, error) -> Void in + self.handle(urlResponse: urlResponse, + request: request, + urlRequest: urlRequest, + data: data, + error: error, + requestStartTime: requestStartTime) + } + task.resume() + } + + func convert(request: Request) -> URLRequest? { + guard let requestURL = request.getCurrentRequestURL(proxyURL: SystemInfo.proxyURL) else { + return nil + } + var urlRequest = URLRequest(url: requestURL) + urlRequest.httpMethod = request.method.httpMethod + urlRequest.allHTTPHeaderFields = self.headers(for: request, urlRequest: urlRequest) + + do { + urlRequest.httpBody = try request.httpRequest.requestBody?.jsonEncodedData + } catch { + Logger.error(Strings.network.creating_json_error(error: error.localizedDescription)) + return nil + } + + return urlRequest + } + + private func headers(for request: Request, urlRequest: URLRequest) -> HTTPClient.RequestHeaders { + if request.httpRequest.path.shouldSendEtag { + let eTagHeader = self.eTagManager.eTagHeader( + for: urlRequest, + withSignatureVerification: request.verificationMode.isEnabled, + refreshETag: request.retried + ) + return request.headers.merging(eTagHeader) + } else { + return request.headers + } + } + + private func signing(for request: HTTPRequest) -> SigningType { + #if DEBUG + if self.systemInfo.dangerousSettings.internalSettings.forceSignatureFailures { + Logger.warn(Strings.network.api_request_forcing_signature_failure(request)) + return FakeSigning.default + } + #endif + + return self.signing + } + + private func trackHttpRequestPerformedIfNeeded(request: Request, + host: String?, + requestStartTime: Date, + result: Result, NetworkError>?) { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) { + guard let diagnosticsTracker = self.diagnosticsTracker, let result else { return } + let responseTime = self.dateProvider.now().timeIntervalSince(requestStartTime) + let requestPathName = request.httpRequest.path.name + switch result { + case let .success(response): + let httpStatusCode = response.httpStatusCode.rawValue + let verificationResult = response.verificationResult + diagnosticsTracker.trackHttpRequestPerformed(endpointName: requestPathName, + host: host, + responseTime: responseTime, + wasSuccessful: true, + responseCode: httpStatusCode, + backendErrorCode: nil, + resultOrigin: response.origin, + verificationResult: verificationResult, + isRetry: request.retried, + connectionErrorReason: nil) + case let .failure(error): + var responseCode = -1 + var backendErrorCode: Int? + if case let .errorResponse(errorResponse, code, _) = error { + responseCode = code.rawValue + backendErrorCode = errorResponse.code.rawValue + } + diagnosticsTracker.trackHttpRequestPerformed(endpointName: requestPathName, + host: host, + responseTime: responseTime, + wasSuccessful: false, + responseCode: responseCode, + backendErrorCode: backendErrorCode, + resultOrigin: nil, + verificationResult: .notRequested, + isRetry: request.retried, + connectionErrorReason: .init(from: error)) + } + } + } +} + +// MARK: - Request Retry Logic +extension HTTPClient { + + /// Evaluates whether a request should be retried with the next host in the list of fallback hosts. + /// + /// This function checks the HTTP response status code to determine if the request should be retried + /// with the next fallback hosts. If the retry conditions are met, it schedules the request immediately and + /// returns `true` to indicate that the request was retried. + /// + /// - Parameters: + /// - request: The original `HTTPClient.Request` that may need to be retried. + /// - error: The `HTTPClient.NetworkError` that was received. + /// - Returns: A Boolean value indicating whether the request was retried. + internal func retryRequestWithNextFallbackHostIfNeeded( + request: HTTPClient.Request, + error: NetworkError + ) -> Bool { + + // The request must be able to be retried with a fallback host + guard error.isAllowedToRetryWithFallbackHost, + let nextRequest = request.requestWithNextFallbackHost(proxyURL: SystemInfo.proxyURL) else { + return false + } + + Logger.debug(Strings.network.retrying_request_with_fallback_path( + httpMethod: nextRequest.method.httpMethod, + path: nextRequest.path + )) + self.state.modify { + $0.queuedRequests.insert(nextRequest, at: 0) + } + return true + } + + /// Evaluates whether a request should be retried and schedules a retry if necessary. + /// + /// This function checks the HTTP response status code to determine if the request should be retried. + /// If the retry conditions are met, it schedules the request to be retried after a backoff interval. + /// + /// - Parameters: + /// - request: The original `HTTPClient.Request` that may need to be retried. + /// - httpURLResponse: An optional `HTTPURLResponse` that contains the status code of the response. + /// - Returns: A Boolean value indicating whether the request was scheduled for a retry. + internal func retryRequestIfNeeded( + request: HTTPClient.Request, + httpURLResponse: HTTPURLResponse? + ) -> Bool { + + guard request.httpRequest.isRetryable, + let httpURLResponse = httpURLResponse, + isResponseRetryable(httpURLResponse) else { return false } + + // At this point, retryCount hasn't been incremented yet, so we'll need to do it early here + // to determine if another retry is appropriate. + let nextRetryCount = request.retryCount + 1 + + guard nextRetryCount <= self.retryBackoffIntervals.count else { + Logger.error( + NetworkStrings.api_request_failed_all_retries( + httpMethod: request.method.httpMethod, + path: request.path, + retryCount: request.retryCount + ) + ) + return false + } + + let retryBackoffInterval: TimeInterval = calculateRetryBackoffTime( + forResponse: httpURLResponse, + retryCount: nextRetryCount + ) + + Logger.debug( + NetworkStrings.api_request_queued_for_retry( + httpMethod: request.method.httpMethod, + retryNumber: nextRetryCount, + path: request.path, + backoffInterval: retryBackoffInterval + ) + ) + self.operationDispatcher.dispatchOnWorkerThread(after: retryBackoffInterval) { + let retriedRequest = request.retriedRequest() + self.state.modify { + $0.queuedRequests.insert(retriedRequest, at: 0) + } + self.beginNextRequest() + } + return true + } + + internal func isResponseRetryable(_ urlResponse: HTTPURLResponse) -> Bool { + let isStatusCodeRetryable = self.retriableStatusCodes.contains(urlResponse.httpStatusCode) + let doesServerAllowRetryString = urlResponse.value(forHTTPHeaderField: ResponseHeader.isRetryable.rawValue) + let doesServerAllowRetry: Bool + if let doesServerAllowRetryString = doesServerAllowRetryString { + doesServerAllowRetry = Bool(doesServerAllowRetryString.lowercased()) ?? true + } else { + doesServerAllowRetry = true + } + + return isStatusCodeRetryable && doesServerAllowRetry + } + + internal func calculateRetryBackoffTime( + forResponse httpURLResponse: HTTPURLResponse, + retryCount: UInt + ) -> TimeInterval { + // Use the retry after value from the backend if present + if let retryAfterHeaderValue = httpURLResponse.allHeaderFields[ResponseHeader.retryAfter.rawValue] as? String, + let retryAfterSeconds = Double(retryAfterHeaderValue) { + + // Ensure that the retry interval is not negative or greater than 1 hour + let nonNegativeRetryAfterSeconds = max(0, retryAfterSeconds) + let cappedRetryInterval = min( + nonNegativeRetryAfterSeconds, + 3_600 // 1 hour in seconds + ) + + return TimeInterval(cappedRetryInterval) + } + + // Otherwise, use a default value + let backoffIntervalIndex = Int(max(retryCount - 1, 0)) + let backoffIntervalIndexIsWithinBounds = backoffIntervalIndex < self.retryBackoffIntervals.count + return backoffIntervalIndexIsWithinBounds ? self.retryBackoffIntervals[backoffIntervalIndex] : 0 + } +} + +// MARK: - Extensions + +fileprivate extension NetworkError { + var isAllowedToRetryWithFallbackHost: Bool { + switch self { + case .decoding, .unableToCreateRequest, .signatureVerificationFailed: + return false + case .dnsError, .networkError, .unexpectedResponse: + return true + case let .errorResponse(_, statusCode, _): + return HTTPStatusCode(rawValue: statusCode.rawValue).isServerError + } + } +} + +extension HTTPClient { + + /// Information from a response to help identify a request. + struct ResponseMetadata { + var requestID: String? + var amazonTraceID: String? + } + +} + +extension HTTPRequest { + + func requestAddingNonceIfRequired( + with verificationMode: Signing.ResponseVerificationMode + ) -> HTTPRequest { + var result = self + + if result.nonce == nil, + result.path.needsNonceForSigning, + verificationMode.isEnabled { + result.addRandomNonce() + } + + return result + } + + func headers( + with authHeaders: HTTPClient.RequestHeaders, + defaultHeaders: HTTPClient.RequestHeaders, + verificationMode: Signing.ResponseVerificationMode, + internalSettings: InternalDangerousSettingsType + ) -> HTTPClient.RequestHeaders { + var result: HTTPClient.RequestHeaders = defaultHeaders + + if self.path.authenticated { + result += authHeaders + } + + if let nonce = self.nonce { + result += HTTPClient.nonceHeader(with: nonce) + } + + if verificationMode.isEnabled, + self.path.supportsSignatureVerification { + let headerParametersSignature = HTTPClient.headerParametersForSignatureHeader( + with: defaultHeaders, + path: self.path + ) + + #if DEBUG + if !internalSettings.disableHeaderSignatureVerification { + result += headerParametersSignature + } + #else + result += headerParametersSignature + #endif + + if let body = self.requestBody { + result += HTTPClient.postParametersHeaderForSigning(with: body) + } + } + + return result + } + + /// Add a nonce to the request + private mutating func addRandomNonce() { + self.nonce = Data.randomNonce() + } + +} + +private extension NetworkError { + + /// Creates a `NetworkError` from any request `Error`. + init(_ error: Error, dnsChecker: DNSCheckerType.Type) { + if let blockedError = dnsChecker.errorWithBlockedHostFromError(error) { + Logger.error(blockedError.description) + self = blockedError + } else { + self = .networkError(error as NSError) + } + } + +} + +extension Result where Success == Data?, Failure == NetworkError { + + /// Converts a `Result` into `Result, NetworkError>` + func mapToResponse( + response: HTTPURLResponse, + request: HTTPRequest + ) -> Result, Failure> { + return self.flatMap { body in + return .success( + .init( + httpStatusCode: response.httpStatusCode, + responseHeaders: response.allHeaderFields, + body: body + ) + ) + } + } + +} + +extension Result where Success == VerifiedHTTPResponse, Failure == NetworkError { + + // Parses a `Result>` to `Result>` + func parseResponse() -> VerifiedHTTPResponse.Result { + return self.flatMap { response in // Convert the `Result` type + Result, Error> { // Create a new `Result` + try response.mapBody { data in // Convert the from `Data` -> `Value` + try Value.create(with: data) // Decode `Data` into `Value` + } + .copyWithNewRequestDate() // Update request date for 304 responses + } + // Convert decoding errors into `NetworkError.decoding` + .mapError { NetworkError.decoding($0, response.response.body) } + } + } + +} + +extension Result where Success == VerifiedHTTPResponse, Failure == NetworkError { + + // Converts an unsuccessful response into a `Result.failure` + fileprivate func convertUnsuccessfulResponseToError() -> Self { + return self.flatMap { + $0.response.httpStatusCode.isSuccessfulResponse + ? .success($0) + : .failure($0.response.parseUnsuccessfulResponse()) + } + } + +} + +private extension VerifiedHTTPResponse { + + func copyWithNewRequestDate() -> Self { + // Update request time from server unless it failed verification. + guard self.verificationResult != .failed, let requestDate = self.requestDate else { return self } + + return self.mapBody { + return $0.copy(with: requestDate) + } + } + +} + +extension HTTPResponseType { + + var isLoadShedder: Bool { + return self.value(forHeaderField: HTTPClient.ResponseHeader.isLoadShedder) == "true" + } + +} + +private extension HTTPResponseType { + + var metadata: HTTPClient.ResponseMetadata { + return .init( + requestID: self.value(forHeaderField: HTTPClient.ResponseHeader.requestID), + amazonTraceID: self.value(forHeaderField: HTTPClient.ResponseHeader.amazonTraceID) + ) + } + +} + +private extension HTTPResponse where Body == Data { + + func parseUnsuccessfulResponse() -> NetworkError { + let contentType = self.value(forHeaderField: HTTPClient.ResponseHeader.contentType) ?? "" + let isJSON = contentType.starts(with: "application/json") + + return .errorResponse( + isJSON + ? .from(self.body) + : .defaultResponse, + self.httpStatusCode + ) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPRequest.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPRequest.swift new file mode 100644 index 00000000..5d57ca05 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPRequest.swift @@ -0,0 +1,123 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// HTTPRequest.swift +// +// Created by Nacho Soto on 2/27/22. + +import Foundation + +/// A request to be made by ``HTTPClient`` +struct HTTPRequest { + + typealias Headers = [String: String] + + var method: Method + var path: HTTPRequestPath + /// If present, this will be used by the server to compute a checksum of the response signed with a private key. + var nonce: Data? + /// Whether or not this request should be retried by the HTTPClient for certain status codes. + var isRetryable: Bool + + init( + method: Method, + path: HTTPRequest.Path, + nonce: Data? = nil, + isRetryable: Bool = false + ) { + self.init(method: method, requestPath: path, nonce: nonce, isRetryable: isRetryable) + } + + init( + method: Method, + path: HTTPRequest.FeatureEventsPath, + nonce: Data? = nil, + isRetryable: Bool = false + ) { + self.init(method: method, requestPath: path, nonce: nonce, isRetryable: isRetryable) + } + + init( + method: Method, + path: HTTPRequest.DiagnosticsPath, + nonce: Data? = nil, + isRetryable: Bool = false + ) { + self.init(method: method, requestPath: path, nonce: nonce, isRetryable: isRetryable) + } + + init( + method: Method, + path: HTTPRequest.WebBillingPath, + nonce: Data? = nil, + isRetryable: Bool = false + ) { + self.init(method: method, requestPath: path, nonce: nonce, isRetryable: isRetryable) + } + + init( + method: Method, + path: HTTPRequest.AdPath, + nonce: Data? = nil, + isRetryable: Bool = false + ) { + self.init(method: method, requestPath: path, nonce: nonce, isRetryable: isRetryable) + } + + internal init( + method: Method, + requestPath: HTTPRequestPath, + nonce: Data? = nil, + isRetryable: Bool = false + ) { + assert(nonce == nil || nonce?.count == Data.nonceLength, + "Invalid nonce: \(nonce?.description ?? "")") + + self.method = method + self.path = requestPath + self.nonce = nonce + self.isRetryable = isRetryable + } + +} + +// MARK: - Method + +extension HTTPRequest { + + enum Method { + + case get + case post(HTTPRequestBody) + + } + +} + +extension HTTPRequest { + + var requestBody: HTTPRequestBody? { + switch self.method { + case let .post(body): return body + case .get: return nil + } + } + +} + +extension HTTPRequest.Method { + + var httpMethod: String { + switch self { + case .get: return "GET" + case .post: return "POST" + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPRequestBody.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPRequestBody.swift new file mode 100644 index 00000000..c864b4cb --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPRequestBody.swift @@ -0,0 +1,32 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// HTTPRequestBody.swift +// +// Created by Nacho Soto on 7/5/23. + +import Foundation + +/// The content of an `HTTPRequest` for `HTTPRequest.Method.post` +protocol HTTPRequestBody: Encodable { + + /// The keys and values that will be included in the signature. + /// - Note: this is not `[String: String]` because we need to preserve ordering. + var contentForSignature: [(key: String, value: String?)] { get } + +} + +extension HTTPRequestBody { + + // Default implementation for endpoints which don't support signing. + var contentForSignature: [(key: String, value: String?)] { + return [] + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPRequestPath.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPRequestPath.swift new file mode 100644 index 00000000..fb6108eb --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPRequestPath.swift @@ -0,0 +1,397 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// HTTPRequestPath.swift +// +// Created by Nacho Soto on 8/8/23. + +import Foundation + +protocol HTTPRequestPath { + + /// The base URL for requests to this path. + static var serverHostURL: URL { get } + + /// The fallback URLs to use when the main server is down. + /// + /// Not all endpoints have a fallback URL, but some do. + var fallbackUrls: [URL] { get } + + /// Whether requests to this path are authenticated. + var authenticated: Bool { get } + + /// Whether requests to this path can be cached using `ETagManager`. + var shouldSendEtag: Bool { get } + + /// Whether the endpoint will perform signature verification. + var supportsSignatureVerification: Bool { get } + + /// Whether endpoint requires a nonce for signature verification. + var needsNonceForSigning: Bool { get } + + /// The name of the endpoint. + var name: String { get } + + /// The full relative path for this endpoint. + var relativePath: String { get } + + /// The fallback relative path for this endpoint, if any. + var fallbackRelativePath: String? { get } +} + +extension HTTPRequestPath { + + var fallbackUrls: [URL] { + return [] + } + + var supportsFallbackURLs: Bool { + !fallbackUrls.isEmpty + } + + var fallbackRelativePath: String? { + return nil + } + + var url: URL? { return self.url(proxyURL: nil) } + + func url(proxyURL: URL? = nil, fallbackUrlIndex: Int? = nil) -> URL? { + let baseURL: URL + if let proxyURL { + // When a Proxy URL is set, we don't support fallback URLs + guard fallbackUrlIndex == nil else { + // This is to safe guard against a potential infinite loop if the caller mistakenly + // passes both a proxyURL and a fallbackUrlIndex. + return nil + } + baseURL = proxyURL + } else if let fallbackUrlIndex { + return self.fallbackUrls[safe: fallbackUrlIndex] + } else { + baseURL = Self.serverHostURL + } + return URL(string: self.relativePath, relativeTo: baseURL) + } +} + +// MARK: - Main paths + +extension HTTPRequest { + + enum Path: Hashable { + + case getCustomerInfo(appUserID: String) + case getOfferings(appUserID: String) + case getIntroEligibility(appUserID: String) + case logIn + case postAttributionData(appUserID: String) + case postOfferForSigning + case postReceiptData + case postSubscriberAttributes(appUserID: String) + case postAdServicesToken(appUserID: String) + case health + case appHealthReport(appUserID: String) + case appHealthReportAvailability(appUserID: String) + case getProductEntitlementMapping + case getCustomerCenterConfig(appUserID: String) + case getVirtualCurrencies(appUserID: String) + case postRedeemWebPurchase + case postCreateTicket + case isPurchaseAllowedByRestoreBehavior(appUserID: String) + + } + + enum FeatureEventsPath: Hashable { + + case postEvents + + } + + enum DiagnosticsPath: Hashable { + + case postDiagnostics + + } + + enum WebBillingPath: Hashable { + + case getWebOfferingProducts(appUserID: String) + case getWebBillingProducts(userId: String, productIds: Set) + + } + + enum AdPath: Hashable { + + case postEvents + + } + +} + +extension HTTPRequest.Path: HTTPRequestPath { + + static var serverHostURL: URL { + SystemInfo.apiBaseURL + } + + private static let fallbackServerHostURLs = [ + URL(string: "https://api-production.8-lives-cat.io") + ] + + var fallbackRelativePath: String? { + switch self { + case .getOfferings: + return "/v1/offerings" + case .getProductEntitlementMapping: + return "/v1/product_entitlement_mapping" + default: + return nil + } + } + + var fallbackUrls: [URL] { + guard let fallbackRelativePath = self.fallbackRelativePath else { + return [] + } + + return Self.fallbackServerHostURLs.compactMap { baseURL in + guard let baseURL = baseURL, + let fallbackUrl = URL(string: fallbackRelativePath, relativeTo: baseURL) else { + let errorMessage = "Invalid fallback URL configuration for path: \(self.name)" + assertionFailure(errorMessage) + Logger.error(errorMessage) + return nil + } + return fallbackUrl + } + } + + var authenticated: Bool { + switch self { + case .getCustomerInfo, + .getOfferings, + .getIntroEligibility, + .logIn, + .postAttributionData, + .postOfferForSigning, + .postReceiptData, + .postSubscriberAttributes, + .postAdServicesToken, + .postRedeemWebPurchase, + .getProductEntitlementMapping, + .getCustomerCenterConfig, + .getVirtualCurrencies, + .appHealthReport, + .postCreateTicket, + .isPurchaseAllowedByRestoreBehavior: + return true + + case .health, + .appHealthReportAvailability: + return false + } + } + + var shouldSendEtag: Bool { + switch self { + case .getCustomerInfo, + .getOfferings, + .getIntroEligibility, + .logIn, + .postAttributionData, + .postOfferForSigning, + .postReceiptData, + .postSubscriberAttributes, + .postAdServicesToken, + .postRedeemWebPurchase, + .getProductEntitlementMapping, + .getCustomerCenterConfig, + .getVirtualCurrencies, + .appHealthReport, + .postCreateTicket, + .isPurchaseAllowedByRestoreBehavior: + return true + case .health, + .appHealthReportAvailability: + return false + } + } + + var supportsSignatureVerification: Bool { + switch self { + case .getCustomerInfo, + .logIn, + .postReceiptData, + .health, + .getOfferings, + .getProductEntitlementMapping, + .getVirtualCurrencies, + .appHealthReport, + .appHealthReportAvailability, + .isPurchaseAllowedByRestoreBehavior: + return true + case .getIntroEligibility, + .postSubscriberAttributes, + .postAttributionData, + .postAdServicesToken, + .postOfferForSigning, + .postRedeemWebPurchase, + .getCustomerCenterConfig, + .postCreateTicket: + return false + } + } + + var needsNonceForSigning: Bool { + switch self { + case .getCustomerInfo, + .logIn, + .postReceiptData, + .getVirtualCurrencies, + .health, + .appHealthReportAvailability, + .isPurchaseAllowedByRestoreBehavior: + return true + case .getOfferings, + .getIntroEligibility, + .postSubscriberAttributes, + .postAttributionData, + .postAdServicesToken, + .postOfferForSigning, + .postRedeemWebPurchase, + .getProductEntitlementMapping, + .getCustomerCenterConfig, + .appHealthReport, + .postCreateTicket: + return false + } + } + + var relativePath: String { + return "/v1/\(self.pathComponent)" + } + + var pathComponent: String { + switch self { + case let .getCustomerInfo(appUserID): + return "subscribers/\(Self.escape(appUserID))" + + case let .getOfferings(appUserID): + return "subscribers/\(Self.escape(appUserID))/offerings" + + case let .getIntroEligibility(appUserID): + return "subscribers/\(Self.escape(appUserID))/intro_eligibility" + + case let .appHealthReport(appUserID): + return "subscribers/\(Self.escape(appUserID))/health_report" + + case let .appHealthReportAvailability(appUserID): + return "subscribers/\(Self.escape(appUserID))/health_report_availability" + + case .logIn: + return "subscribers/identify" + + case let .postAttributionData(appUserID): + return "subscribers/\(Self.escape(appUserID))/attribution" + + case let .postAdServicesToken(appUserID): + return "subscribers/\(Self.escape(appUserID))/adservices_attribution" + + case .postOfferForSigning: + return "offers" + + case .postReceiptData: + return "receipts" + + case let .postSubscriberAttributes(appUserID): + return "subscribers/\(Self.escape(appUserID))/attributes" + + case .health: + return "health" + + case .getProductEntitlementMapping: + return "product_entitlement_mapping" + + case let .getCustomerCenterConfig(appUserID): + return "customercenter/\(Self.escape(appUserID))" + + case .postRedeemWebPurchase: + return "subscribers/redeem_purchase" + + case let .getVirtualCurrencies(appUserID): + return "subscribers/\(Self.escape(appUserID))/virtual_currencies" + + case .postCreateTicket: + return "customercenter/support/create-ticket" + case let .isPurchaseAllowedByRestoreBehavior(appUserID): + return "subscribers/\(Self.escape(appUserID))/restore/eligibility" + } + } + + var name: String { + switch self { + case .getCustomerInfo: + return "get_customer" + + case .getOfferings: + return "get_offerings" + + case .getIntroEligibility: + return "get_intro_eligibility" + + case .logIn: + return "log_in" + + case .postAttributionData: + return "post_attribution" + + case .postAdServicesToken: + return "post_adservices_token" + + case .postOfferForSigning: + return "post_offer_for_signing" + + case .postReceiptData: + return "post_receipt" + + case .postSubscriberAttributes: + return "post_attributes" + + case .health: + return "post_health" + + case .getProductEntitlementMapping: + return "get_product_entitlement_mapping" + + case .getCustomerCenterConfig: + return "customer_center" + + case .postRedeemWebPurchase: + return "post_redeem_web_purchase" + + case .appHealthReport: + return "get_app_health_report" + + case .getVirtualCurrencies: + return "get_virtual_currencies" + + case .appHealthReportAvailability: + return "get_app_health_report_availability" + + case .postCreateTicket: + return "post_create_ticket" + case .isPurchaseAllowedByRestoreBehavior: + return "post_restore_eligibility" + } + } + + private static func escape(_ appUserID: String) -> String { + return appUserID.trimmedAndEscaped + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPRequestTimeoutManager.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPRequestTimeoutManager.swift new file mode 100644 index 00000000..7698fbbd --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPRequestTimeoutManager.swift @@ -0,0 +1,113 @@ +// +// HTTPRequestTimeoutManager.swift +// RevenueCat +// +// Created by Rick van der Linden on 06/11/2025. +// Copyright © 2025 RevenueCat, Inc. All rights reserved. +// + +import Foundation + +protocol HTTPRequestTimeoutManagerType { + + /// Determines the timeout to be used by the HTTP Request for the given path. + /// + /// - Parameters: + /// - path: The HTTP request path for which to determine the timeout + /// - isFallback: Whether this is a fallback request + /// - Returns: The timeout interval in seconds + func timeout(for path: HTTPRequestPath, isFallback: Bool) -> TimeInterval + + /// Updates the internal state in response to the result received from the backend. + /// + /// - Parameter result: The result of the HTTP request + func recordRequestResult(_ result: HTTPRequestTimeoutManager.RequestResult) +} + +class HTTPRequestTimeoutManager: HTTPRequestTimeoutManagerType { + + enum RequestResult { + + /// Request succeeded on the main backend + case successOnMainBackend + + /// Request timed out on the main backend endpoint and supports fallback URLs + case timeoutOnMainBackendForFallbackSupportedEndpoint + + /// Any other result (non-main backend, non-timeout errors, etc.) + case other + } + + enum Timeout: TimeInterval { + + /// The default timeout for backend requests that support a fallback + case mainBackendRequestSupportingFallback = 5 + + /// The reduced timeout for requests with fallback support after timeout + case reduced = 2 + } + + // The amount of time after which the 'last timeout request received' state can be reset + private static let timeoutResetInterval: TimeInterval = 600 // 10 minutes + + // The last time at which a timeout was received from the main backend + private var lastTimeoutRequestTime: Date? + + // The default timeout to use + private let defaultTimeout: TimeInterval + + private let dateProvider: DateProvider + + init( + defaultTimeout: TimeInterval, + dateProvider: DateProvider = .init() + ) { + self.defaultTimeout = defaultTimeout + self.dateProvider = dateProvider + } + + func timeout(for path: HTTPRequestPath, isFallback: Bool) -> TimeInterval { + if shouldResetTimeout { + resetLastTimeoutRequestTime() + } + + let timeout: TimeInterval + + // A fallback request or a request that doesn't support a fallback + if isFallback || !path.supportsFallbackURLs { + timeout = self.defaultTimeout + } + // Main backend request that supports fallback when a timeout was previously received from the main backend + else if lastTimeoutRequestTime != nil { + timeout = Timeout.reduced.rawValue + } + // Main backend request that supports fallback, no timeout received recently + else { + timeout = Timeout.mainBackendRequestSupportingFallback.rawValue + } + + return timeout + } + + func recordRequestResult(_ result: RequestResult) { + switch result { + case .successOnMainBackend: + resetLastTimeoutRequestTime() + case .timeoutOnMainBackendForFallbackSupportedEndpoint: + lastTimeoutRequestTime = dateProvider.now() + case .other: + break + } + } + + private func resetLastTimeoutRequestTime() { + lastTimeoutRequestTime = nil + } + + private var shouldResetTimeout: Bool { + guard let lastTimeoutRequestTime else { return false } + + let timeElapsed = dateProvider.now().timeIntervalSince(lastTimeoutRequestTime) + return timeElapsed >= Self.timeoutResetInterval + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPResponse.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPResponse.swift new file mode 100644 index 00000000..6b871500 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPResponse.swift @@ -0,0 +1,286 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// HTTPResponse.swift +// +// Created by César de la Vega on 4/19/21. +// + +import Foundation + +/// A type that represents an HTTP response +protocol HTTPResponseType { + + associatedtype Body: HTTPResponseBody + + typealias Result = Swift.Result + typealias Headers = [AnyHashable: Any] + + var httpStatusCode: HTTPStatusCode { get } + /// Because this property is a standard Swift dictionary, its keys are case-sensitive. + /// To perform a case-insensitive header lookup, use the `value(forHeaderField:)` method instead. + var responseHeaders: HTTPClient.ResponseHeaders { get } + var body: Body { get } + var requestDate: Date? { get } + var origin: HTTPResponseOrigin { get } + +} + +struct HTTPResponse: HTTPResponseType { + + var httpStatusCode: HTTPStatusCode + var responseHeaders: HTTPClient.ResponseHeaders + var body: Body + var requestDate: Date? + var origin: HTTPResponseOrigin = .backend + +} + +extension HTTPResponse: CustomStringConvertible { + + fileprivate var bodyDescription: String { + if let bodyDescription = (self.body as? CustomStringConvertible)?.description { + return bodyDescription + } else { + return "\(type(of: self.body))" + } + } + + var description: String { + return """ + HTTPResponse( + statusCode: \(self.httpStatusCode.rawValue), + body: \(self.bodyDescription), + requestDate: \(self.requestDate?.description ?? "<>") + ) + """ + } + +} + +// MARK: - VerifiedHTTPResponse + +struct VerifiedHTTPResponse: HTTPResponseType { + + var response: HTTPResponse + var verificationResult: VerificationResult + var originalSource: HTTPResponseOriginalSource + + init(response: HTTPResponse, + verificationResult: VerificationResult, + originalSource: HTTPResponseOriginalSource + ) { + self.response = response + self.verificationResult = verificationResult + self.originalSource = originalSource + } + + init(response: HTTPResponse, + verificationResult: VerificationResult, + isLoadShedderResponse: Bool, + isFallbackUrlResponse: Bool + ) { + self.init(response: response, + verificationResult: verificationResult, + originalSource: HTTPResponseOriginalSource(isFallbackUrlResponse: isFallbackUrlResponse, + isLoadShedderResponse: isLoadShedderResponse)) + } + + init( + httpStatusCode: HTTPStatusCode, + responseHeaders: HTTPClient.ResponseHeaders, + body: Body, + requestDate: Date? = nil, + verificationResult: VerificationResult, + isLoadShedderResponse: Bool, + isFallbackUrlResponse: Bool + ) { + self.init( + response: .init( + httpStatusCode: httpStatusCode, + responseHeaders: responseHeaders, + body: body, + requestDate: requestDate + ), + verificationResult: verificationResult, + isLoadShedderResponse: isLoadShedderResponse, + isFallbackUrlResponse: isFallbackUrlResponse + ) + } + +} + +extension VerifiedHTTPResponse: CustomStringConvertible { + + var description: String { + return """ + VerifiedHTTPResponse( + statusCode: \(self.httpStatusCode.rawValue), + body: \(self.response.bodyDescription), + requestDate: \(self.requestDate?.description ?? "<>") + verification: \(self.verificationResult) + ) + """ + } + +} + +// MARK: - URLResponse + +/// `HTTPURLResponse` conformance to `HTTPResponseType` +extension HTTPURLResponse: HTTPResponseType { + + typealias Body = Data? + + var httpStatusCode: HTTPStatusCode { .init(rawValue: self.statusCode) } + + var responseHeaders: HTTPClient.ResponseHeaders { return self.allHeaderFields } + + var body: Data? { return nil } + + var requestDate: Date? { HTTPResponse.parseRequestDate(headers: self.responseHeaders) } + + var origin: HTTPResponseOrigin { .backend } + +} + +// MARK: - Extensions + +extension HTTPResponseType { + + /// Equivalent to `HTTPURLResponse.value(forHTTPHeaderField:)` + /// In keeping with the HTTP RFC, HTTP header field names are case-insensitive. + func value(forHeaderField field: HTTPClient.ResponseHeader) -> String? { + return Self.value(forCaseInsensitiveHeaderField: field, in: self.responseHeaders) + } + + static func value( + forCaseInsensitiveHeaderField field: HTTPClient.ResponseHeader, + in headers: Headers + ) -> String? { + return Self.value(forCaseInsensitiveHeaderField: field.rawValue, in: headers) + } + + static func value(forCaseInsensitiveHeaderField field: String, in headers: Headers) -> String? { + let header = headers + .first { (key, _) in + (key as? String)?.caseInsensitiveCompare(field) == .orderedSame + } + + return header?.value as? String + } + +} + +extension VerifiedHTTPResponse where Body: OptionalType, Body.Wrapped: HTTPResponseBody { + + /// Converts a `VerifiedHTTPResponse` into a `VerifiedHTTPResponse?` + var asOptionalResponse: VerifiedHTTPResponse? { + guard let body = self.body.asOptional else { + return nil + } + + return self.mapBody { _ in body } + } + +} + +extension HTTPResponse { + + func mapBody(_ mapping: (Body) throws -> NewBody) rethrows -> HTTPResponse { + return .init(httpStatusCode: self.httpStatusCode, + responseHeaders: self.responseHeaders, + body: try mapping(self.body), + requestDate: self.requestDate) + } + + func verified( + with verificationResult: VerificationResult, + isLoadShedderResponse: Bool, + isFallbackUrlResponse: Bool + ) -> VerifiedHTTPResponse { + return .init( + response: self, + verificationResult: verificationResult, + isLoadShedderResponse: isLoadShedderResponse, + isFallbackUrlResponse: isFallbackUrlResponse + ) + } + +} + +extension HTTPResponse { + + /// Creates an `HTTPResponse` extracting the `requestDate` from its headers + init( + httpStatusCode: HTTPStatusCode, + responseHeaders: HTTPClient.ResponseHeaders, + body: Body + ) { + self.httpStatusCode = httpStatusCode + self.responseHeaders = responseHeaders + self.body = body + self.requestDate = Self.parseRequestDate(headers: responseHeaders) + } + + fileprivate static func parseRequestDate(headers: HTTPResponseType.Headers) -> Date? { + guard let stringValue = Self.value( + forCaseInsensitiveHeaderField: HTTPClient.ResponseHeader.requestDate.rawValue, + in: headers + ), + let intValue = UInt64(stringValue) else { return nil } + + return .init(millisecondsSince1970: intValue) + } + +} + +extension VerifiedHTTPResponse { + + var httpStatusCode: HTTPStatusCode { self.response.httpStatusCode } + var responseHeaders: HTTPClient.ResponseHeaders { self.response.responseHeaders } + var body: Body { self.response.body } + var requestDate: Date? { self.response.requestDate } + var origin: HTTPResponseOrigin { self.response.origin } + + func mapBody(_ mapping: (Body) throws -> NewBody) rethrows -> VerifiedHTTPResponse { + return .init( + response: try self.response.mapBody(mapping), + verificationResult: self.verificationResult, + originalSource: self.originalSource + ) + } + +} + +enum HTTPResponseOrigin { + + case backend + case cache + +} + +/// The server from which the HTTP response was received. +enum HTTPResponseOriginalSource { + + case mainServer + case loadShedder + case fallbackUrl + + init(isFallbackUrlResponse: Bool, isLoadShedderResponse: Bool) { + if isFallbackUrlResponse { + self = .fallbackUrl + } else if isLoadShedderResponse { + self = .loadShedder + } else { + self = .mainServer + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPResponseBody.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPResponseBody.swift new file mode 100644 index 00000000..c8a7686c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPResponseBody.swift @@ -0,0 +1,73 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// HTTPResponseBody.swift +// +// Created by Nacho Soto on 3/30/22. + +import Foundation + +/// The content of an `HTTPResponse` +protocol HTTPResponseBody { + + static func create(with data: Data) throws -> Self + + /// Returns a copy of this response body updating only the request date + /// This is useful for types that include a response date (like `CustomerInfo`), that need to + /// get the most up-to-date time coming from the response header. + /// + /// - Note: The default implementation is a no-op. + func copy(with newRequestDate: Date) -> Self + +} + +extension HTTPResponseBody { + + func copy(with newRequestDate: Date) -> Self { return self } + +} + +/// An empty `HTTPResponseBody` for responses with no content. +/// This can be used to obtain an `HTTPResponse` where the content of the response does not matter. +struct HTTPEmptyResponseBody: HTTPResponseBody { + + static func create(with data: Data) throws -> HTTPEmptyResponseBody { + return .init() + } + +} + +// MARK: - Implementations + +/// Default implementation of `HTTPResponseBody` for `Data` +extension Data: HTTPResponseBody { + + static func create(with data: Data) throws -> Data { + return data + } + +} + +/// Default implementation of `HTTPResponseBody` for any `Decodable` +extension Decodable { + + static func create(with data: Data) throws -> Self { + return try JSONDecoder.default.decode(jsonData: data) + } + +} + +/// Default implementation of `HTTPResponseBody` for an `Optional` +extension Optional: HTTPResponseBody where Wrapped: HTTPResponseBody { + + static func create(with data: Data) throws -> Wrapped? { + return try Wrapped.create(with: data) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPStatusCode.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPStatusCode.swift new file mode 100644 index 00000000..25523035 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/HTTPStatusCode.swift @@ -0,0 +1,108 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// HTTPStatusCode.swift +// +// Created by César de la Vega on 4/19/21. +// + +import Foundation + +enum HTTPStatusCode { + + case success + case createdSuccess + case redirect + case notModified + case temporaryRedirect + case invalidRequest + case unauthorized + case forbidden + case notFoundError + case tooManyRequests + case internalServerError + case networkConnectTimeoutError + + case other(Int) + + private static let knownStatus: Set = [ + .success, + .createdSuccess, + .redirect, + .notModified, + .temporaryRedirect, + .invalidRequest, + .unauthorized, + .forbidden, + .notFoundError, + .internalServerError, + .networkConnectTimeoutError + ] + private static let statusByCode: [Int: HTTPStatusCode] = Self.knownStatus.dictionaryWithKeys { $0.rawValue } +} + +extension HTTPStatusCode: RawRepresentable { + + init(rawValue: Int) { + self = Self.statusByCode[rawValue] ?? .other(rawValue) + } + + var rawValue: Int { + switch self { + case .success: return 200 + case .createdSuccess: return 201 + case .redirect: return 300 + case .notModified: return 304 + case .temporaryRedirect: return 307 + case .invalidRequest: return 400 + case .unauthorized: return 401 + case .forbidden: return 403 + case .notFoundError: return 404 + case .tooManyRequests: return 429 + case .internalServerError: return 500 + case .networkConnectTimeoutError: return 599 + + case let .other(code): return code + } + } + +} + +extension HTTPStatusCode: ExpressibleByIntegerLiteral { + + init(integerLiteral value: IntegerLiteralType) { + self.init(rawValue: value) + } + +} + +extension HTTPStatusCode: Hashable {} + +extension HTTPStatusCode: Codable {} + +extension HTTPStatusCode { + + var isSuccessfulResponse: Bool { + return 200...399 ~= self.rawValue + } + + var isServerError: Bool { + return 500...599 ~= self.rawValue + } + + /// Used to determine if we can consider subscriber attributes as synced. + /// - Note: whether to finish transactions is determined based on `isServerError` instead. + var isSuccessfullySynced: Bool { + // Note: this means that all 4xx (except 404) are considered as successfully synced. + // The reason is because it's likely due to a client error, so continuing to retry + // won't yield any different results and instead kill pandas. + return !(self.isServerError || self == .notFoundError) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/NetworkError.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/NetworkError.swift new file mode 100644 index 00000000..a98c5192 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/NetworkError.swift @@ -0,0 +1,267 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// NetworkError.swift +// +// Created by Nacho Soto on 3/31/22. + +// swiftlint:disable multiline_parameters + +import Foundation + +/// Represents an error created by `HTTPClient`. +enum NetworkError: Swift.Error, Equatable { + + case decoding(NSError, Source) + case networkError(NSError, Source) + case dnsError(failedURL: URL, resolvedHost: String?, Source) + case unableToCreateRequest(path: String, Source) + case unexpectedResponse(URLResponse?, Source) + case errorResponse(ErrorResponse, HTTPStatusCode, Source) + case signatureVerificationFailed(path: String, HTTPStatusCode, Source) + +} + +extension NetworkError { + + static func decoding( + _ error: Error, + _ data: Data, + file: String = #fileID, function: String = #function, line: UInt = #line + ) -> Self { + // Explicitly logging errors since it might help debugging issues. + Logger.error(Strings.network.parsing_json_error(error: error)) + Logger.error(Strings.network.json_data_received( + dataString: String(data: data, encoding: .utf8) ?? "" + )) + + return .decoding(error as NSError, .init(file: file, function: function, line: line)) + } + + static func networkError( + _ error: Error, + file: String = #fileID, function: String = #function, line: UInt = #line + ) -> Self { + return .networkError(error as NSError, .init(file: file, function: function, line: line)) + } + + static func dnsError( + failedURL: URL, resolvedHost: String?, + file: String = #fileID, function: String = #function, line: UInt = #line + ) -> Self { + return .dnsError(failedURL: failedURL, resolvedHost: resolvedHost, + .init(file: file, function: function, line: line)) + } + + static func unableToCreateRequest( + _ path: HTTPRequestPath, + file: String = #fileID, function: String = #function, line: UInt = #line + ) -> Self { + return .unableToCreateRequest(path: path.relativePath, .init(file: file, function: function, line: line)) + } + + static func unexpectedResponse( + _ response: URLResponse?, + file: String = #fileID, function: String = #function, line: UInt = #line + ) -> Self { + return .unexpectedResponse(response, .init(file: file, function: function, line: line)) + } + + static func errorResponse( + _ response: ErrorResponse, _ statusCode: HTTPStatusCode, + file: String = #fileID, function: String = #function, line: UInt = #line + ) -> Self { + return .errorResponse(response, statusCode, .init(file: file, function: function, line: line)) + } + + static func signatureVerificationFailed( + path: HTTPRequestPath, + code: HTTPStatusCode, + file: String = #fileID, function: String = #function, line: UInt = #line + ) -> Self { + return .signatureVerificationFailed( + path: path.relativePath, + code, + .init(file: file, function: function, line: line) + ) + } + +} + +extension NetworkError: PurchasesErrorConvertible { + + var asPurchasesError: PurchasesError { + switch self { + case let .decoding(error, source): + return ErrorUtils.unexpectedBackendResponse( + withSubError: error, + fileName: source.file, + functionName: source.function, + line: source.line + ) + + case let .networkError(error, source) + where error.domain == NSURLErrorDomain && error.code == NSURLErrorNotConnectedToInternet: + return ErrorUtils.offlineConnectionError( + fileName: source.file, + functionName: source.function, + line: source.line + ) + + case let .networkError(error, source): + return ErrorUtils.networkError( + withUnderlyingError: error, + fileName: source.file, + functionName: source.function, + line: source.line + ) + + case let .dnsError(failedURL, resolvedHost, source): + return ErrorUtils.networkError( + message: NetworkStrings.blocked_network(url: failedURL, newHost: resolvedHost).description, + withUnderlyingError: self, + fileName: source.file, + functionName: source.function, + line: source.line + ) + + case let .unableToCreateRequest(path, source): + return ErrorUtils.networkError( + extraUserInfo: [ + "request_path": path + ], + fileName: source.file, + functionName: source.function, + line: source.line + ) + + case let .unexpectedResponse(response, source): + return ErrorUtils.unexpectedBackendResponseError( + extraUserInfo: [ + "response": response?.description ?? "" + ], + fileName: source.file, + functionName: source.function, + line: source.line + ) + + case let .errorResponse(response, statusCode, source): + return response.asBackendError(with: statusCode, + file: source.file, + function: source.function, + line: source.line) + + case let .signatureVerificationFailed(path, code, source): + return ErrorUtils.signatureVerificationFailedError( + path: path, + code: code, + fileName: source.file, + functionName: source.function, + line: source.line + ) + } + } + +} + +extension NetworkError: DescribableError { + + var description: String { + switch self { + case let .decoding(error, _): + return error.localizedDescription + + case let .networkError(error, _) + where error.domain == NSURLErrorDomain && error.code == NSURLErrorNotConnectedToInternet: + return ErrorCode.offlineConnectionError.description + + case let .networkError(error, _): + return error.localizedDescription + + case let .dnsError(failedURL, resolvedHost, _): + return NetworkStrings.blocked_network(url: failedURL, newHost: resolvedHost).description + + case let .unableToCreateRequest(path, _): + return "Could not create request to \(path)" + + case let .unexpectedResponse(response, _): + return "Unexpected response type: \(response.debugDescription)" + + case .errorResponse: + return self.asPurchasesError.localizedDescription + + case .signatureVerificationFailed: + return self.asPurchasesError.localizedDescription + } + } + +} + +extension NetworkError: CustomNSError { + + var errorUserInfo: [String: Any] { + return [ + NSLocalizedDescriptionKey: self.description + ] + } + +} + +extension NetworkError { + + /// Whether the network request producing this error actually synced the data. + var successfullySynced: Bool { + return self.errorStatusCode?.isSuccessfullySynced ?? false + } + + /// Whether the network request producing this error can be completed. + /// If `false`, the response was a server error. + var finishable: Bool { + if let statusCode = self.errorStatusCode { + return !statusCode.isServerError + } else { + return false + } + } + + private var errorStatusCode: HTTPStatusCode? { + switch self { + case let .errorResponse(_, statusCode, _): + return statusCode + + case .decoding, + .networkError, + .dnsError, + .unableToCreateRequest, + .unexpectedResponse, + .signatureVerificationFailed: + return nil + } + } + + var isServerDown: Bool { + return self.errorStatusCode?.isServerError == true + } + + /// Whether to fall back to cached offerings in case of this error when fetching offerings. + var shouldFallBackToCachedOfferings: Bool { + if let errorStatusCode { + return errorStatusCode.isServerError + } else { + return true + } + } + +} + +extension NetworkError { + + typealias Source = ErrorSource + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/RedirectLoggerTaskDelegate.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/RedirectLoggerTaskDelegate.swift new file mode 100644 index 00000000..3b8e8d13 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/RedirectLoggerTaskDelegate.swift @@ -0,0 +1,34 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// RedirectLoggerSessionDelegate.swift +// +// Created by Nacho Soto on 3/23/23. + +import Foundation + +/// Implementation of `URLSessionTaskDelegate` that logs when the task will perform a redirection. +final class RedirectLoggerSessionDelegate: NSObject, URLSessionTaskDelegate { + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + if let responseURL = response.url, let requestURL = request.url { + Logger.debug(Strings.network.api_request_redirect(from: responseURL, + to: requestURL)) + } + + completionHandler(request) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/SimpleNetworkServiceType.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/SimpleNetworkServiceType.swift new file mode 100644 index 00000000..b841f254 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/HTTPClient/SimpleNetworkServiceType.swift @@ -0,0 +1,48 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SimpleNetworkServiceType.swift +// +// Created by Jacob Zivan Rakidzich on 8/12/25. + +import Foundation + +/// A protocol representing a simple network service +@available(iOS 15.0, macOS 12.0, tvOS 15.0, visionOS 1.0, watchOS 8.0, *) +protocol SimpleNetworkServiceType { + + /// Fetch data from the network + /// - Parameter url: The URL to fetch data from + /// - Returns: Bytes upon success + func bytes(from url: URL) async throws -> AsyncThrowingStream +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, visionOS 1.0, watchOS 8.0, *) +extension URLSession: SimpleNetworkServiceType { + + func bytes(from url: URL) async throws -> AsyncThrowingStream { + let (bytes, res) = try await bytes(for: .init(url: url), delegate: nil) + if let httpURLResponse = res as? HTTPURLResponse, !(200..<300).contains(httpURLResponse.statusCode) { + throw URLError(.badServerResponse) + } + + return AsyncThrowingStream { continuation in + Task { + do { + for try await byte in bytes { + continuation.yield(byte) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/IdentityAPI.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/IdentityAPI.swift new file mode 100644 index 00000000..fc800837 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/IdentityAPI.swift @@ -0,0 +1,48 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// IdentityAPI.swift +// +// Created by Joshua Liebowitz on 6/15/22. + +import Foundation + +class IdentityAPI { + + typealias LogInResponse = Result<(info: CustomerInfo, created: Bool), BackendError> + typealias LogInResponseHandler = (LogInResponse) -> Void + + private let logInCallbacksCache: CallbackCache + private let backendConfig: BackendConfiguration + + init(backendConfig: BackendConfiguration) { + self.backendConfig = backendConfig + self.logInCallbacksCache = CallbackCache() + } + + func logIn(currentAppUserID: String, + newAppUserID: String, + completion: @escaping LogInResponseHandler) { + let config = NetworkOperation.UserSpecificConfiguration(httpClient: self.backendConfig.httpClient, + appUserID: currentAppUserID) + let factory = LogInOperation.createFactory(configuration: config, + newAppUserID: newAppUserID, + loginCallbackCache: self.logInCallbacksCache) + + let loginCallback = LogInCallback(cacheKey: factory.cacheKey, completion: completion) + let cacheStatus = self.logInCallbacksCache.add(loginCallback) + + self.backendConfig.operationQueue.addCacheableOperation(with: factory, cacheStatus: cacheStatus) + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension IdentityAPI: @unchecked Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/InternalAPI.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/InternalAPI.swift new file mode 100644 index 00000000..3cf203d8 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/InternalAPI.swift @@ -0,0 +1,173 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// InternalAPI.swift +// +// Created by Nacho Soto on 10/5/22. + +import Foundation + +class InternalAPI { + + typealias ResponseHandler = (BackendError?) -> Void + #if DEBUG + typealias HealthReportResponseHandler = (Result) -> Void + typealias HealthReportAvailabilityResponseHandler = (Result) -> Void + + private let healthReportCallbackCache: CallbackCache + private let healthReportAvailabilityCallbackCache: CallbackCache + #endif + + private let backendConfig: BackendConfiguration + private let healthCallbackCache: CallbackCache + + init(backendConfig: BackendConfiguration) { + self.backendConfig = backendConfig + self.healthCallbackCache = .init() + #if DEBUG + self.healthReportCallbackCache = .init() + self.healthReportAvailabilityCallbackCache = .init() + #endif + } + + func healthRequest(signatureVerification: Bool, completion: @escaping ResponseHandler) { + let factory = HealthOperation.createFactory(httpClient: self.backendConfig.httpClient, + callbackCache: self.healthCallbackCache, + signatureVerification: signatureVerification) + + let callback = HealthOperation.Callback(cacheKey: factory.cacheKey, completion: completion) + let cacheStatus = self.healthCallbackCache.add(callback) + + self.backendConfig.addCacheableOperation(with: factory, + delay: .none, + cacheStatus: cacheStatus) + } + + #if DEBUG + func healthReportRequest(appUserID: String, completion: @escaping HealthReportResponseHandler) { + let config = NetworkOperation.UserSpecificConfiguration(httpClient: self.backendConfig.httpClient, + appUserID: appUserID) + let factory = HealthReportOperation.createFactory(configuration: config, + callbackCache: self.healthReportCallbackCache) + let callback = HealthReportOperation.Callback(cacheKey: factory.cacheKey, completion: completion) + let cacheStatus = self.healthReportCallbackCache.add(callback) + + self.backendConfig.addCacheableOperation(with: factory, + delay: .none, + cacheStatus: cacheStatus) + } + + func healthReportAvailabilityRequest( + appUserID: String, + completion: @escaping HealthReportAvailabilityResponseHandler + ) { + let config = NetworkOperation.UserSpecificConfiguration( + httpClient: self.backendConfig.httpClient, + appUserID: appUserID + ) + let factory = HealthReportAvailabilityOperation.createFactory( + configuration: config, + callbackCache: self.healthReportAvailabilityCallbackCache + ) + let callback = HealthReportAvailabilityOperation.Callback(cacheKey: factory.cacheKey, completion: completion) + let cacheStatus = self.healthReportAvailabilityCallbackCache.add(callback) + + self.backendConfig.addCacheableOperation(with: factory, + delay: .none, + cacheStatus: cacheStatus) + } + #endif + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + func postFeatureEvents(events: [StoredFeatureEvent], completion: @escaping ResponseHandler) { + guard !events.isEmpty else { + completion(nil) + return + } + + let request = FeatureEventsRequest(events: events) + let operation = PostFeatureEventsOperation( + configuration: .init(httpClient: self.backendConfig.httpClient), + request: request, + path: HTTPRequest.FeatureEventsPath.postEvents, + responseHandler: completion + ) + + self.backendConfig.operationQueue.addOperation(operation) + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + func postDiagnosticsEvents(events: [DiagnosticsEvent], completion: @escaping ResponseHandler) { + guard !events.isEmpty else { + completion(nil) + return + } + + let operation = DiagnosticsPostOperation(configuration: .init(httpClient: self.backendConfig.httpClient), + request: .init(events: events), + responseHandler: completion) + + self.backendConfig.addDiagnosticsOperation(operation, delay: .long) + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + func postAdEvents(events: [StoredAdEvent], completion: @escaping ResponseHandler) { + guard !events.isEmpty else { + completion(nil) + return + } + + let request = AdEventsRequest(events: events) + let operation = PostAdEventsOperation( + configuration: .init(httpClient: self.backendConfig.httpClient), + request: request, + path: HTTPRequest.AdPath.postEvents, + responseHandler: completion + ) + + self.backendConfig.operationQueue.addOperation(operation) + } + +} + +extension InternalAPI { + + /// - Throws: `BackendError` + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + func postFeatureEvents(events: [StoredFeatureEvent]) async throws { + let error = await Async.call { completion in + self.postFeatureEvents(events: events, completion: completion) + } + + if let error { throw error } + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + func postDiagnosticsEvents(events: [DiagnosticsEvent]) async throws { + let error = await Async.call { completion in + self.postDiagnosticsEvents(events: events, completion: completion) + } + + if let error { throw error } + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + func postAdEvents(events: [StoredAdEvent]) async throws { + let error = await Async.call { completion in + self.postAdEvents(events: events, completion: completion) + } + + if let error { throw error } + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension InternalAPI: @unchecked Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/OfferingsAPI.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/OfferingsAPI.swift new file mode 100644 index 00000000..934c3c34 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/OfferingsAPI.swift @@ -0,0 +1,114 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// OfferingsAPI.swift +// +// Created by Joshua Liebowitz on 6/15/22. + +import Foundation + +class OfferingsAPI { + + typealias IntroEligibilityResponseHandler = ([String: IntroEligibility], BackendError?) -> Void + typealias OfferSigningResponseHandler = Backend.ResponseHandler + typealias OfferingsResponseHandler = Backend.ResponseHandler + typealias WebOfferingProductsResponseHandler = Backend.ResponseHandler + + private let offeringsCallbacksCache: CallbackCache + private let webOfferingProductsCallbacksCache: CallbackCache + private let backendConfig: BackendConfiguration + + init(backendConfig: BackendConfiguration) { + self.backendConfig = backendConfig + self.offeringsCallbacksCache = .init() + self.webOfferingProductsCallbacksCache = .init() + } + + func getOfferings(appUserID: String, + isAppBackgrounded: Bool, + completion: @escaping OfferingsResponseHandler) { + let config = NetworkOperation.UserSpecificConfiguration(httpClient: self.backendConfig.httpClient, + appUserID: appUserID) + let factory = GetOfferingsOperation.createFactory( + configuration: config, + offeringsCallbackCache: self.offeringsCallbacksCache + ) + + let offeringsCallback = OfferingsCallback(cacheKey: factory.cacheKey, completion: completion) + let cacheStatus = self.offeringsCallbacksCache.add(offeringsCallback) + + if cacheStatus == .firstCallbackAddedToList { + Logger.debug(isAppBackgrounded + ? Strings.offering.offerings_stale_updating_in_background + : Strings.offering.offerings_stale_updating_in_foreground) + } + + self.backendConfig.addCacheableOperation( + with: factory, + delay: .default(forBackgroundedApp: isAppBackgrounded), + cacheStatus: cacheStatus + ) + } + + func getWebOfferingProducts(appUserID: String, completion: @escaping WebOfferingProductsResponseHandler) { + let config = NetworkOperation.UserSpecificConfiguration(httpClient: self.backendConfig.httpClient, + appUserID: appUserID) + let factory = GetWebOfferingProductsOperation.createFactory( + configuration: config, + webOfferingProductsCallbackCache: self.webOfferingProductsCallbacksCache + ) + + let webProductsCallback = WebOfferingProductsCallback(cacheKey: factory.cacheKey, completion: completion) + let cacheStatus = self.webOfferingProductsCallbacksCache.add(webProductsCallback) + + self.backendConfig.addCacheableOperation( + with: factory, + delay: .none, + cacheStatus: cacheStatus + ) + } + + func getIntroEligibility(appUserID: String, + receiptData: Data, + productIdentifiers: Set, + completion: @escaping IntroEligibilityResponseHandler) { + let config = NetworkOperation.UserSpecificConfiguration(httpClient: self.backendConfig.httpClient, + appUserID: appUserID) + let getIntroEligibilityOperation = GetIntroEligibilityOperation(configuration: config, + receiptData: receiptData, + productIdentifiers: productIdentifiers, + responseHandler: completion) + self.backendConfig.operationQueue.addOperation(getIntroEligibilityOperation) + } + + // swiftlint:disable:next function_parameter_count + func post(offerIdForSigning offerIdentifier: String, + productIdentifier: String, + subscriptionGroup: String, + receipt: EncodedAppleReceipt, + appUserID: String, + completion: @escaping OfferSigningResponseHandler) { + let config = NetworkOperation.UserSpecificConfiguration(httpClient: self.backendConfig.httpClient, + appUserID: appUserID) + + let postOfferData = PostOfferForSigningOperation.PostOfferForSigningData(offerIdentifier: offerIdentifier, + productIdentifier: productIdentifier, + subscriptionGroup: subscriptionGroup, + receipt: receipt) + let postOfferForSigningOperation = PostOfferForSigningOperation(configuration: config, + postOfferForSigningData: postOfferData, + responseHandler: completion) + self.backendConfig.operationQueue.addOperation(postOfferForSigningOperation) + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension OfferingsAPI: @unchecked Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/OfflineEntitlementsAPI.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/OfflineEntitlementsAPI.swift new file mode 100644 index 00000000..902200e6 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/OfflineEntitlementsAPI.swift @@ -0,0 +1,45 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// OfflineEntitlementsAPI.swift +// +// Created by Nacho Soto on 3/22/23. + +import Foundation + +class OfflineEntitlementsAPI { + + typealias ProductEntitlementMappingResponseHandler = Backend.ResponseHandler + + private let productEntitlementMappingCallbacksCache: CallbackCache + private let backendConfig: BackendConfiguration + + init(backendConfig: BackendConfiguration) { + self.backendConfig = backendConfig + self.productEntitlementMappingCallbacksCache = .init() + } + + func getProductEntitlementMapping(isAppBackgrounded: Bool, + completion: @escaping ProductEntitlementMappingResponseHandler) { + let factory = GetProductEntitlementMappingOperation.createFactory( + configuration: self.backendConfig, + callbackCache: self.productEntitlementMappingCallbacksCache + ) + + let callback = ProductEntitlementMappingCallback(cacheKey: factory.cacheKey, completion: completion) + let cacheStatus = self.productEntitlementMappingCallbacksCache.add(callback) + + self.backendConfig.addCacheableOperation( + with: factory, + delay: .default(forBackgroundedApp: isAppBackgrounded), + cacheStatus: cacheStatus + ) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetCustomerCenterConfigOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetCustomerCenterConfigOperation.swift new file mode 100644 index 00000000..0b568783 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetCustomerCenterConfigOperation.swift @@ -0,0 +1,90 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// GetCustomerCenterConfigOperation.swift +// +// +// Created by Cesar de la Vega on 31/5/24. +// + +import Foundation + +final class GetCustomerCenterConfigOperation: CacheableNetworkOperation { + + private let customerCenterConfigCallbackCache: CallbackCache + private let configuration: AppUserConfiguration + + static func createFactory( + configuration: UserSpecificConfiguration, + callbackCache: CallbackCache + ) -> CacheableNetworkOperationFactory { + return .init({ cacheKey in + .init( + configuration: configuration, + customerCenterConfigCallbackCache: callbackCache, + cacheKey: cacheKey + ) + }, + individualizedCacheKeyPart: configuration.appUserID) + } + + private init(configuration: UserSpecificConfiguration, + customerCenterConfigCallbackCache: CallbackCache, + cacheKey: String) { + self.configuration = configuration + self.customerCenterConfigCallbackCache = customerCenterConfigCallbackCache + + super.init(configuration: configuration, cacheKey: cacheKey) + } + + override func begin(completion: @escaping () -> Void) { + self.getCustomerCenterConfig(completion: completion) + } + +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension GetCustomerCenterConfigOperation: @unchecked Sendable {} + +private extension GetCustomerCenterConfigOperation { + + func getCustomerCenterConfig(completion: @escaping () -> Void) { + let appUserID = self.configuration.appUserID + + guard appUserID.isNotEmpty else { + self.customerCenterConfigCallbackCache.performOnAllItemsAndRemoveFromCache( + withCacheable: self + ) { callback in + callback.completion(.failure(.missingAppUserID())) + } + completion() + + return + } + + let request = HTTPRequest(method: .get, path: .getCustomerCenterConfig(appUserID: appUserID)) + + httpClient.perform(request) { (response: VerifiedHTTPResponse.Result) in + defer { + completion() + } + + self.customerCenterConfigCallbackCache.performOnAllItemsAndRemoveFromCache( + withCacheable: self + ) { callback in + callback.completion( + response + .map { $0.body } + .mapError(BackendError.networkError) + ) + } + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetCustomerInfoOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetCustomerInfoOperation.swift new file mode 100644 index 00000000..ed391640 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetCustomerInfoOperation.swift @@ -0,0 +1,104 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// GetCustomerInfoOperation.swift +// +// Created by Joshua Liebowitz on 11/18/21. + +import Foundation + +final class GetCustomerInfoOperation: CacheableNetworkOperation { + + private let customerInfoResponseHandler: CustomerInfoResponseHandler + private let customerInfoCallbackCache: CallbackCache + private let configuration: UserSpecificConfiguration + + static func createFactory( + configuration: UserSpecificConfiguration, + customerInfoCallbackCache: CallbackCache, + offlineCreator: OfflineCustomerInfoCreator? + ) -> CacheableNetworkOperationFactory { + return Self.createFactory( + configuration: configuration, + customerInfoResponseHandler: .init( + offlineCreator: offlineCreator, + userID: configuration.appUserID, + failIfInvalidSubscriptionKeyDetectedInDebug: false + ), + customerInfoCallbackCache: customerInfoCallbackCache) + } + + static func createFactory( + configuration: UserSpecificConfiguration, + customerInfoResponseHandler: CustomerInfoResponseHandler, + customerInfoCallbackCache: CallbackCache + ) -> CacheableNetworkOperationFactory { + return .init({ + .init(configuration: configuration, + customerInfoResponseHandler: customerInfoResponseHandler, + customerInfoCallbackCache: customerInfoCallbackCache, + cacheKey: $0) }, + individualizedCacheKeyPart: configuration.appUserID + ) + } + + private init( + configuration: UserSpecificConfiguration, + customerInfoResponseHandler: CustomerInfoResponseHandler, + customerInfoCallbackCache: CallbackCache, + cacheKey: String + ) { + self.configuration = configuration + self.customerInfoResponseHandler = customerInfoResponseHandler + self.customerInfoCallbackCache = customerInfoCallbackCache + + super.init(configuration: configuration, + cacheKey: cacheKey) + } + + override func begin(completion: @escaping () -> Void) { + self.getCustomerInfo(completion: completion) + } + +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension GetCustomerInfoOperation: @unchecked Sendable {} + +private extension GetCustomerInfoOperation { + + func getCustomerInfo(completion: @escaping () -> Void) { + let appUserID = self.configuration.appUserID + + guard appUserID.isNotEmpty else { + self.customerInfoCallbackCache.performOnAllItemsAndRemoveFromCache(withCacheable: self) { callback in + callback.completion(.failure(.missingAppUserID())) + } + completion() + + return + } + + let request = HTTPRequest(method: .get, + path: .getCustomerInfo(appUserID: appUserID)) + + self.httpClient.perform( + request + ) { (response: VerifiedHTTPResponse.Result) in + self.customerInfoResponseHandler.handle(customerInfoResponse: response) { result in + self.customerInfoCallbackCache.performOnAllItemsAndRemoveFromCache(withCacheable: self) { callback in + callback.completion(result) + } + } + + completion() + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetIntroEligibilityOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetIntroEligibilityOperation.swift new file mode 100644 index 00000000..0a9732c6 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetIntroEligibilityOperation.swift @@ -0,0 +1,133 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// GetIntroEligibilityOperation.swift +// +// Created by Joshua Liebowitz on 11/19/21. + +import Foundation + +class GetIntroEligibilityOperation: NetworkOperation { + + private let configuration: UserSpecificConfiguration + private let receiptData: Data + private let productIdentifiers: Set + private let responseHandler: OfferingsAPI.IntroEligibilityResponseHandler + + init(configuration: UserSpecificConfiguration, + receiptData: Data, + productIdentifiers: Set, + responseHandler: @escaping OfferingsAPI.IntroEligibilityResponseHandler) { + self.configuration = configuration + self.receiptData = receiptData + self.productIdentifiers = productIdentifiers + self.responseHandler = responseHandler + + super.init(configuration: configuration) + } + + override func begin(completion: @escaping () -> Void) { + self.getIntroEligibility(completion: completion) + } + +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension GetIntroEligibilityOperation: @unchecked Sendable {} + +private extension GetIntroEligibilityOperation { + + func getIntroEligibility(completion: @escaping () -> Void) { + guard self.productIdentifiers.count > 0 else { + self.responseHandler([:], nil) + completion() + + return + } + + // Requested products with unknown eligibilities + let unknownEligibilities: [String: IntroEligibility] = self.productIdentifiers + .dictionaryWithValues { _ in IntroEligibility(eligibilityStatus: .unknown) } + + guard !self.receiptData.isEmpty else { + if self.httpClient.systemInfo.isSandbox { + Logger.appleWarning(Strings.receipt.no_sandbox_receipt_intro_eligibility) + } + + self.responseHandler(unknownEligibilities, nil) + completion() + + return + } + + let appUserID = self.configuration.appUserID + + guard appUserID.isNotEmpty else { + self.responseHandler(unknownEligibilities, .missingAppUserID()) + completion() + + return + } + + let request = HTTPRequest(method: .post(Body(productIdentifiers: self.productIdentifiers, + fetchToken: self.receiptData.asFetchToken)), + path: .getIntroEligibility(appUserID: appUserID)) + + httpClient.perform( + request + ) { (response: VerifiedHTTPResponse.Result) in + self.handleIntroEligibility(result: response, + productIdentifiers: self.productIdentifiers, + completion: self.responseHandler) + completion() + } + } + + func handleIntroEligibility( + result: VerifiedHTTPResponse.Result, + productIdentifiers: Set, + completion: OfferingsAPI.IntroEligibilityResponseHandler + ) { + let eligibilities = result.value?.body.eligibilityByProductIdentifier + + let result: [String: IntroEligibility] = productIdentifiers + .dictionaryWithValues { productID in eligibilities?[productID] ?? .unknown } + .mapValues(IntroEligibility.init) + + completion(result, nil) + } + +} + +private extension GetIntroEligibilityOperation { + + struct Body: HTTPRequestBody { + + let productIdentifiers: [String] + let fetchToken: String + + init(productIdentifiers: Set, fetchToken: String) { + let identifiers: [String] + + #if DEBUG + identifiers = ProcessInfo.isRunningUnitTests + // Sort for snapshot tests + ? Array(productIdentifiers.sorted()) + : Array(productIdentifiers) + #else + identifiers = Array(productIdentifiers) + #endif + + self.productIdentifiers = identifiers + self.fetchToken = fetchToken + } + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetOfferingsOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetOfferingsOperation.swift new file mode 100644 index 00000000..9447a2b3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetOfferingsOperation.swift @@ -0,0 +1,86 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// GetOfferingsOperation.swift +// +// Created by Joshua Liebowitz on 11/19/21. + +import Foundation + +final class GetOfferingsOperation: CacheableNetworkOperation { + + private let offeringsCallbackCache: CallbackCache + private let configuration: AppUserConfiguration + + static func createFactory( + configuration: UserSpecificConfiguration, + offeringsCallbackCache: CallbackCache + ) -> CacheableNetworkOperationFactory { + return .init({ cacheKey in + .init( + configuration: configuration, + offeringsCallbackCache: offeringsCallbackCache, + cacheKey: cacheKey + ) + }, + individualizedCacheKeyPart: configuration.appUserID) + } + + private init(configuration: UserSpecificConfiguration, + offeringsCallbackCache: CallbackCache, + cacheKey: String) { + self.configuration = configuration + self.offeringsCallbackCache = offeringsCallbackCache + + super.init(configuration: configuration, cacheKey: cacheKey) + } + + override func begin(completion: @escaping () -> Void) { + self.getOfferings(completion: completion) + } + +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension GetOfferingsOperation: @unchecked Sendable {} + +private extension GetOfferingsOperation { + + func getOfferings(completion: @escaping () -> Void) { + let appUserID = self.configuration.appUserID + + guard appUserID.isNotEmpty else { + self.offeringsCallbackCache.performOnAllItemsAndRemoveFromCache(withCacheable: self) { callback in + callback.completion(.failure(.missingAppUserID())) + } + completion() + + return + } + + let request = HTTPRequest(method: .get, path: .getOfferings(appUserID: appUserID)) + + httpClient.perform(request) { (response: VerifiedHTTPResponse.Result) in + defer { + completion() + } + + self.offeringsCallbackCache.performOnAllItemsAndRemoveFromCache(withCacheable: self) { callbackObject in + callbackObject.completion(response + .map { + Offerings.Contents(response: $0.body, + httpResponseOriginalSource: $0.originalSource) + } + .mapError(BackendError.networkError) + ) + } + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetProductEntitlementMappingOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetProductEntitlementMappingOperation.swift new file mode 100644 index 00000000..f9a7d6d3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetProductEntitlementMappingOperation.swift @@ -0,0 +1,69 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// GetProductEntitlementMappingOperation.swift +// +// Created by Nacho Soto on 3/17/23. + +import Foundation + +final class GetProductEntitlementMappingOperation: CacheableNetworkOperation { + + private let callbackCache: CallbackCache + + static func createFactory( + configuration: NetworkConfiguration, + callbackCache: CallbackCache + ) -> CacheableNetworkOperationFactory { + return .init({ cacheKey in + .init( + configuration: configuration, + callbackCache: callbackCache, + cacheKey: cacheKey + ) + }, + individualizedCacheKeyPart: "") + } + + private init(configuration: NetworkConfiguration, + callbackCache: CallbackCache, + cacheKey: String) { + self.callbackCache = callbackCache + super.init(configuration: configuration, cacheKey: cacheKey) + } + + override func begin(completion: @escaping () -> Void) { + self.getResponse(completion: completion) + } + +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension GetProductEntitlementMappingOperation: @unchecked Sendable {} + +private extension GetProductEntitlementMappingOperation { + + func getResponse(completion: @escaping () -> Void) { + let request = HTTPRequest(method: .get, path: .getProductEntitlementMapping) + + self.httpClient.perform(request) { (response: VerifiedHTTPResponse.Result) in + defer { + completion() + } + + self.callbackCache.performOnAllItemsAndRemoveFromCache(withCacheable: self) { callbackObject in + callbackObject.completion(response + .map { $0.body } + .mapError(BackendError.networkError) + ) + } + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetVirtualCurrenciesOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetVirtualCurrenciesOperation.swift new file mode 100644 index 00000000..b2e9ff65 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetVirtualCurrenciesOperation.swift @@ -0,0 +1,88 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// GetVirtualCurrenciesOperation.swift +// +// Created by Will Taylor on 6/9/25. + +import Foundation + +final class GetVirtualCurrenciesOperation: CacheableNetworkOperation { + + private let virtualCurrenciesCallbackCache: CallbackCache + private let configuration: AppUserConfiguration + + static func createFactory( + configuration: UserSpecificConfiguration, + callbackCache: CallbackCache + ) -> CacheableNetworkOperationFactory { + return .init({ cacheKey in + .init( + configuration: configuration, + virtualCurrenciesCallbackCache: callbackCache, + cacheKey: cacheKey + ) + }, + individualizedCacheKeyPart: configuration.appUserID) + } + + private init( + configuration: UserSpecificConfiguration, + virtualCurrenciesCallbackCache: CallbackCache, + cacheKey: String + ) { + self.configuration = configuration + self.virtualCurrenciesCallbackCache = virtualCurrenciesCallbackCache + + super.init(configuration: configuration, cacheKey: cacheKey) + } + + override func begin(completion: @escaping () -> Void) { + self.getVirtualCurrencies(completion: completion) + } +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension GetVirtualCurrenciesOperation: @unchecked Sendable {} + +private extension GetVirtualCurrenciesOperation { + + func getVirtualCurrencies(completion: @escaping () -> Void) { + let appUserID = self.configuration.appUserID + + guard appUserID.isNotEmpty else { + self.virtualCurrenciesCallbackCache.performOnAllItemsAndRemoveFromCache( + withCacheable: self + ) { callback in + callback.completion(.failure(.missingAppUserID())) + } + completion() + + return + } + + let request = HTTPRequest(method: .get, path: .getVirtualCurrencies(appUserID: appUserID)) + + httpClient.perform(request) { (response: VerifiedHTTPResponse.Result) in + defer { + completion() + } + + self.virtualCurrenciesCallbackCache.performOnAllItemsAndRemoveFromCache( + withCacheable: self + ) { callback in + callback.completion( + response + .map { $0.body } + .mapError(BackendError.networkError) + ) + } + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetWebBillingProductsOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetWebBillingProductsOperation.swift new file mode 100644 index 00000000..39862fe9 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetWebBillingProductsOperation.swift @@ -0,0 +1,91 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// GetWebBillingProductsOperation.swift +// +// Created by Antonio Pallares on 23/7/25. + +import Foundation + +final class GetWebBillingProductsOperation: CacheableNetworkOperation { + + private let webBillingProductsCallbackCache: CallbackCache + private let configuration: AppUserConfiguration + private let productIds: Set + + static func createFactory( + configuration: UserSpecificConfiguration, + webBillingProductsCallbackCache: CallbackCache, + productIds: Set + ) -> CacheableNetworkOperationFactory { + return .init({ cacheKey in + .init( + configuration: configuration, + webBillingProductsCallbackCache: webBillingProductsCallbackCache, + productIds: productIds, + cacheKey: cacheKey + ) + }, + individualizedCacheKeyPart: configuration.appUserID + "\n" + productIds.sorted().joined(separator: "\n")) + } + + private init(configuration: UserSpecificConfiguration, + webBillingProductsCallbackCache: CallbackCache, + productIds: Set, + cacheKey: String) { + self.configuration = configuration + self.webBillingProductsCallbackCache = webBillingProductsCallbackCache + self.productIds = productIds + + super.init(configuration: configuration, cacheKey: cacheKey) + } + + override func begin(completion: @escaping () -> Void) { + self.getWebProducts(completion: completion) + } + +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension GetWebBillingProductsOperation: @unchecked Sendable {} + +private extension GetWebBillingProductsOperation { + + func getWebProducts(completion: @escaping () -> Void) { + let appUserID = self.configuration.appUserID + + guard appUserID.isNotEmpty else { + self.webBillingProductsCallbackCache.performOnAllItemsAndRemoveFromCache(withCacheable: self) { callback in + callback.completion(.failure(.missingAppUserID())) + } + completion() + + return + } + + let request = HTTPRequest(method: .get, + path: .getWebBillingProducts(userId: appUserID, productIds: self.productIds)) + + httpClient.perform(request) { (response: VerifiedHTTPResponse.Result) in + defer { + completion() + } + + self.webBillingProductsCallbackCache.performOnAllItemsAndRemoveFromCache( + withCacheable: self + ) { callbackObject in + callbackObject.completion(response + .map { $0.body } + .mapError(BackendError.networkError) + ) + } + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetWebOfferingProductsOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetWebOfferingProductsOperation.swift new file mode 100644 index 00000000..c9d04f6a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/GetWebOfferingProductsOperation.swift @@ -0,0 +1,85 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// GetWebOfferingProductsOperation.swift +// +// Created by Toni Rico on 5/6/25. + +import Foundation + +final class GetWebOfferingProductsOperation: CacheableNetworkOperation { + + private let webOfferingProductsCallbackCache: CallbackCache + private let configuration: AppUserConfiguration + + static func createFactory( + configuration: UserSpecificConfiguration, + webOfferingProductsCallbackCache: CallbackCache + ) -> CacheableNetworkOperationFactory { + return .init({ cacheKey in + .init( + configuration: configuration, + webOfferingProductsCallbackCache: webOfferingProductsCallbackCache, + cacheKey: cacheKey + ) + }, + individualizedCacheKeyPart: configuration.appUserID) + } + + private init(configuration: UserSpecificConfiguration, + webOfferingProductsCallbackCache: CallbackCache, + cacheKey: String) { + self.configuration = configuration + self.webOfferingProductsCallbackCache = webOfferingProductsCallbackCache + + super.init(configuration: configuration, cacheKey: cacheKey) + } + + override func begin(completion: @escaping () -> Void) { + self.getWebOfferingProducts(completion: completion) + } + +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension GetWebOfferingProductsOperation: @unchecked Sendable {} + +private extension GetWebOfferingProductsOperation { + + func getWebOfferingProducts(completion: @escaping () -> Void) { + let appUserID = self.configuration.appUserID + + guard appUserID.isNotEmpty else { + self.webOfferingProductsCallbackCache.performOnAllItemsAndRemoveFromCache(withCacheable: self) { callback in + callback.completion(.failure(.missingAppUserID())) + } + completion() + + return + } + + let request = HTTPRequest(method: .get, path: .getWebOfferingProducts(appUserID: appUserID)) + + httpClient.perform(request) { (response: VerifiedHTTPResponse.Result) in + defer { + completion() + } + + self.webOfferingProductsCallbackCache.performOnAllItemsAndRemoveFromCache( + withCacheable: self + ) { callbackObject in + callbackObject.completion(response + .map { $0.body } + .mapError(BackendError.networkError) + ) + } + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/Handling/CustomerInfoResponseHandler.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/Handling/CustomerInfoResponseHandler.swift new file mode 100644 index 00000000..4c7c7fc3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/Handling/CustomerInfoResponseHandler.swift @@ -0,0 +1,122 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CustomerInfoResponseHandler.swift +// +// Created by Joshua Liebowitz on 11/18/21. + +import Foundation + +class CustomerInfoResponseHandler { + + private let offlineCreator: OfflineCustomerInfoCreator? + private let userID: String + private let failIfInvalidSubscriptionKeyDetectedInDebug: Bool + private let isDebug: Bool + + /// - Parameter offlineCreator: can be `nil` if offline ``CustomerInfo`` shouldn't or can't be computed. + init( + offlineCreator: OfflineCustomerInfoCreator?, + userID: String, + failIfInvalidSubscriptionKeyDetectedInDebug: Bool, + isDebug: Bool = { + #if DEBUG + return true + #endif + return false + }() + ) { + self.offlineCreator = offlineCreator + self.userID = userID + self.failIfInvalidSubscriptionKeyDetectedInDebug = failIfInvalidSubscriptionKeyDetectedInDebug + self.isDebug = isDebug + } + + func handle(customerInfoResponse response: VerifiedHTTPResponse.Result, + completion: @escaping CustomerAPI.CustomerInfoResponseHandler) { + let result: Result = response + .map { response in + // If the response was successful we always want to return the `CustomerInfo`. + if !response.body.errorResponse.attributeErrors.isEmpty { + // If there are any, log attribute errors. + // Creating the error implicitly logs it. + _ = response.body.errorResponse.asBackendError(with: response.httpStatusCode) + } + + return response.body.customerInfo.copy(with: response.verificationResult, + httpResponseOriginalSource: response.originalSource) + } + .mapError(BackendError.networkError) + + self.handle(result: result, completion: completion) + } + + private func handle( + result: Result, + completion: @escaping CustomerAPI.CustomerInfoResponseHandler + ) { + guard let offlineCreator = self.offlineCreator, + result.error?.isServerDown == true, + #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) else { + completion(result) + return + } + + _ = Task { + do { + switch result { + case .success: + completion(.success(try await offlineCreator.create(for: self.userID))) + case .failure(let failure): + let failIfInvalidSubscriptionKeyDetectedInDebug = self.failIfInvalidSubscriptionKeyDetectedInDebug + + if isDebug && failIfInvalidSubscriptionKeyDetectedInDebug, + case let .networkError(networkError) = failure, + case let .errorResponse(errorResponse, _, _) = networkError, + errorResponse.code == .invalidAppleSubscriptionKey { + + Logger.warn(Strings.configure.sk2_invalid_inapp_purchase_key) + + completion(.failure(failure)) + } else { + let customerInfo = try await offlineCreator.create(for: self.userID) + completion(.success(customerInfo)) + } + } + } catch { + Logger.error(Strings.offlineEntitlements.computing_offline_customer_info_failed(error)) + completion(result) + } + } + } + +} + +extension CustomerInfoResponseHandler { + + struct Response: HTTPResponseBody { + + var customerInfo: CustomerInfo + var errorResponse: ErrorResponse + + static func create(with data: Data) throws -> Self { + return .init(customerInfo: try CustomerInfo.create(with: data), + errorResponse: ErrorResponse.from(data)) + } + + func copy(with newRequestDate: Date) -> Self { + var copy = self + copy.customerInfo = copy.customerInfo.copy(with: newRequestDate) + + return copy + } + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/HealthOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/HealthOperation.swift new file mode 100644 index 00000000..94bc28be --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/HealthOperation.swift @@ -0,0 +1,91 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// HealthOperation.swift +// +// Created by Nacho Soto on 9/6/23. + +import Foundation + +final class HealthOperation: CacheableNetworkOperation { + + struct Callback: CacheKeyProviding { + + let cacheKey: String + let completion: InternalAPI.ResponseHandler + + } + + struct Configuration: NetworkConfiguration { + + let httpClient: HTTPClient + + } + + private let callbackCache: CallbackCache + private let signatureVerification: Bool + + static func createFactory( + httpClient: HTTPClient, + callbackCache: CallbackCache, + signatureVerification: Bool + ) -> CacheableNetworkOperationFactory { + return .init({ .init(httpClient: httpClient, + callbackCache: callbackCache, + cacheKey: $0, + signatureVerification: signatureVerification) }, + individualizedCacheKeyPart: "") + } + + private init(httpClient: HTTPClient, + callbackCache: CallbackCache, + cacheKey: String, + signatureVerification: Bool) { + self.callbackCache = callbackCache + self.signatureVerification = signatureVerification + + super.init(configuration: Configuration(httpClient: httpClient), cacheKey: cacheKey) + } + + override func begin(completion: @escaping () -> Void) { + let request: HTTPRequest = .init(method: .get, path: .health) + + self.httpClient.perform( + request, + with: self.verificationMode + ) { (response: VerifiedHTTPResponse.Result) in + self.finish(with: response, completion: completion) + } + } + + private func finish(with response: VerifiedHTTPResponse.Result, + completion: () -> Void) { + self.callbackCache.performOnAllItemsAndRemoveFromCache(withCacheable: self) { callback in + callback.completion( + response + .mapError(BackendError.networkError) + .error + ) + } + + completion() + } + + private var verificationMode: Signing.ResponseVerificationMode { + if self.signatureVerification { + return Signing.enforcedVerificationMode() + } else { + return .disabled + } + } + +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension HealthOperation: @unchecked Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/HealthReportAvailabilityOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/HealthReportAvailabilityOperation.swift new file mode 100644 index 00000000..ae61bc62 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/HealthReportAvailabilityOperation.swift @@ -0,0 +1,75 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// HealthReportAvailabilityOperation.swift +// +// Created by Pol Piella Abadia on 27/06/2025. + +#if DEBUG +import Foundation + +final class HealthReportAvailabilityOperation: CacheableNetworkOperation { + + struct Callback: CacheKeyProviding { + + let cacheKey: String + let completion: InternalAPI.HealthReportAvailabilityResponseHandler + + } + + private let configuration: AppUserConfiguration + private let callbackCache: CallbackCache + + static func createFactory( + configuration: UserSpecificConfiguration, + callbackCache: CallbackCache + ) -> CacheableNetworkOperationFactory { + return .init({ .init(configuration: configuration, + callbackCache: callbackCache, + cacheKey: $0) }, + individualizedCacheKeyPart: "") + } + + private init(configuration: UserSpecificConfiguration, + callbackCache: CallbackCache, + cacheKey: String) { + self.configuration = configuration + self.callbackCache = callbackCache + + super.init(configuration: configuration, cacheKey: cacheKey) + } + + override func begin(completion: @escaping () -> Void) { + let request: HTTPRequest = .init(method: .get, + path: .appHealthReportAvailability(appUserID: configuration.appUserID)) + + self.httpClient.perform( + request + ) { (response: VerifiedHTTPResponse.Result) in + self.finish(with: response, completion: completion) + } + } + + private func finish(with response: VerifiedHTTPResponse.Result, + completion: () -> Void) { + self.callbackCache.performOnAllItemsAndRemoveFromCache(withCacheable: self) { callback in + callback.completion( + response + .mapError(BackendError.networkError) + .map { $0.body } + ) + } + + completion() + } +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension HealthReportAvailabilityOperation: @unchecked Sendable {} +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/HealthReportOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/HealthReportOperation.swift new file mode 100644 index 00000000..a4922a60 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/HealthReportOperation.swift @@ -0,0 +1,75 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// HealthReportOperation.swift +// +// Created by Pol Piella on 4/8/25. + +#if DEBUG +import Foundation + +final class HealthReportOperation: CacheableNetworkOperation { + + struct Callback: CacheKeyProviding { + + let cacheKey: String + let completion: InternalAPI.HealthReportResponseHandler + + } + + private let configuration: AppUserConfiguration + private let callbackCache: CallbackCache + + static func createFactory( + configuration: UserSpecificConfiguration, + callbackCache: CallbackCache + ) -> CacheableNetworkOperationFactory { + return .init({ .init(configuration: configuration, + callbackCache: callbackCache, + cacheKey: $0) }, + individualizedCacheKeyPart: "") + } + + private init(configuration: UserSpecificConfiguration, + callbackCache: CallbackCache, + cacheKey: String) { + self.configuration = configuration + self.callbackCache = callbackCache + + super.init(configuration: configuration, cacheKey: cacheKey) + } + + override func begin(completion: @escaping () -> Void) { + let request: HTTPRequest = .init(method: .get, + path: .appHealthReport(appUserID: configuration.appUserID)) + + self.httpClient.perform( + request + ) { (response: VerifiedHTTPResponse.Result) in + self.finish(with: response, completion: completion) + } + } + + private func finish(with response: VerifiedHTTPResponse.Result, + completion: () -> Void) { + self.callbackCache.performOnAllItemsAndRemoveFromCache(withCacheable: self) { callback in + callback.completion( + response + .mapError(BackendError.networkError) + .map { $0.body } + ) + } + + completion() + } +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension HealthReportOperation: @unchecked Sendable {} +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/LogInOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/LogInOperation.swift new file mode 100644 index 00000000..2efe0fff --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/LogInOperation.swift @@ -0,0 +1,132 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// LogInOperation.swift +// +// Created by Joshua Liebowitz on 11/19/21. + +import Foundation + +final class LogInOperation: CacheableNetworkOperation { + + private let loginCallbackCache: CallbackCache + private let configuration: UserSpecificConfiguration + private let newAppUserID: String + + static func createFactory( + configuration: UserSpecificConfiguration, + newAppUserID: String, + loginCallbackCache: CallbackCache + ) -> CacheableNetworkOperationFactory { + return .init({ + .init( + configuration: configuration, + newAppUserID: newAppUserID, + loginCallbackCache: loginCallbackCache, + cacheKey: $0 + ) }, + individualizedCacheKeyPart: configuration.appUserID + newAppUserID) + } + + private init( + configuration: UserSpecificConfiguration, + newAppUserID: String, + loginCallbackCache: CallbackCache, + cacheKey: String + ) { + self.configuration = configuration + self.newAppUserID = newAppUserID + self.loginCallbackCache = loginCallbackCache + + super.init(configuration: configuration, cacheKey: cacheKey) + } + + override func begin(completion: @escaping () -> Void) { + self.logIn(completion: completion) + } + +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension LogInOperation: @unchecked Sendable {} + +private extension LogInOperation { + + func logIn(completion: @escaping () -> Void) { + guard self.newAppUserID.isNotEmpty else { + self.loginCallbackCache.performOnAllItemsAndRemoveFromCache(withCacheable: self) { callback in + callback.completion(.failure(.missingAppUserID())) + } + completion() + + return + } + + let request = HTTPRequest(method: .post(Body(appUserID: self.configuration.appUserID, + newAppUserID: self.newAppUserID)), + path: .logIn) + + self.httpClient.perform(request) { (response: VerifiedHTTPResponse.Result) in + self.loginCallbackCache.performOnAllItemsAndRemoveFromCache(withCacheable: self) { callbackObject in + self.handleLogin(response, completion: callbackObject.completion) + } + + completion() + } + } + + func handleLogin(_ result: VerifiedHTTPResponse.Result, + completion: IdentityAPI.LogInResponseHandler) { + let result: Result<(info: CustomerInfo, created: Bool), BackendError> = result + .map { response in + ( + response.body.copy(with: response.verificationResult, + httpResponseOriginalSource: response.originalSource), + created: response.httpStatusCode == .createdSuccess + ) + } + .mapError(BackendError.networkError) + + if case .success = result { + Logger.user(Strings.identity.login_success) + } + + completion(result) + } +} + +extension LogInOperation { + + struct Body: Encodable { + + // Note: These keys need to be explicitly declared using snake_case + // because the CodingKeys are also used for request signing via `contentForSignature`. + // swiftlint:disable:next nesting + fileprivate enum CodingKeys: String, CodingKey { + case appUserID = "app_user_id" + case newAppUserID = "new_app_user_id" + } + + let appUserID: String + let newAppUserID: String + + } + +} + +extension LogInOperation.Body: HTTPRequestBody { + + var contentForSignature: [(key: String, value: String?)] { + return [ + (Self.CodingKeys.appUserID.stringValue, self.appUserID), + (Self.CodingKeys.newAppUserID.stringValue, self.newAppUserID) + ] + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/NetworkOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/NetworkOperation.swift new file mode 100644 index 00000000..b678660a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/NetworkOperation.swift @@ -0,0 +1,208 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// NetworkOperation.swift +// +// Created by Joshua Liebowitz on 11/18/21. + +import Foundation + +/// A type that can construct a `CacheableNetworkOperation` and pre-compute a cache key. +final class CacheableNetworkOperationFactory { + + let creator: (_ cacheKey: String) -> T + let cacheKey: String + var operationType: T.Type { T.self } + + /** + - Parameter individualizedCacheKeyPart: The part of the cacheKey that makes it unique from other operations of the + same type. Example: If you posted receipts two times in a row you'd have 2 operations. The cache key would be + PostOperation + individualizedCacheKeyPart where individualizedCacheKeyPart is whatever you determine to be unique. + */ + init(_ creator: @escaping (_ cacheKey: String) -> T, individualizedCacheKeyPart: String) { + self.creator = creator + self.cacheKey = T.cacheKey(with: individualizedCacheKeyPart) + } + + func create() -> T { + return self.creator(self.cacheKey) + } + +} + +class CacheableNetworkOperation: NetworkOperation, CacheKeyProviding { + + let cacheKey: String + + init(configuration: NetworkConfiguration, cacheKey: String) { + self.cacheKey = cacheKey + + super.init(configuration: configuration) + } + + fileprivate static func cacheKey(with individualizedCacheKeyPart: String) -> String { + return "\(Self.self) \(individualizedCacheKeyPart)" + } + +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension CacheableNetworkOperation: @unchecked Sendable {} + +class NetworkOperation: Operation { + + let httpClient: HTTPClient + + private let _didStart: Atomic = false + private var didStart: Bool { return self._didStart.value } + + // Note: implementing asynchronousy `Operations` needs KVO. + // We're not using Swift's `KeyPath` verison (`willChangeValue(for:)`) + // due to it crashing on iOS 12. See https://github.com/RevenueCat/purchases-ios/pull/2008. + + private let _isExecuting: Atomic = false + private(set) override final var isExecuting: Bool { + get { + return self._isExecuting.value + } + set { + self.willChangeValue(forKey: #keyPath(NetworkOperation.isExecuting)) + self._isExecuting.value = newValue + self.didChangeValue(forKey: #keyPath(NetworkOperation.isExecuting)) + } + } + + private let _isFinished: Atomic = false + private(set) override final var isFinished: Bool { + get { + return self._isFinished.value + } + set { + self.willChangeValue(forKey: #keyPath(NetworkOperation.isFinished)) + self._isFinished.value = newValue + self.didChangeValue(forKey: #keyPath(NetworkOperation.isFinished)) + } + } + + private let _isCancelled: Atomic = false + private(set) override final var isCancelled: Bool { + get { + return self._isCancelled.value + } + set { + self.willChangeValue(forKey: #keyPath(NetworkOperation.isCancelled)) + self._isCancelled.value = newValue + self.didChangeValue(forKey: #keyPath(NetworkOperation.isCancelled)) + + } + } + + init(configuration: NetworkConfiguration) { + self.httpClient = configuration.httpClient + + super.init() + } + + deinit { + RCTestAssert( + self.didStart, + "\(type(of: self)) was deallocated but it never started. Did it need to be created?" + ) + RCTestAssert( + self.isFinished, + "\(type(of: self)) started but never finished. " + + "Did the operation not call `completion` in its `begin` implementation?" + ) + } + + override final func main() { + self._didStart.value = true + + if self.isCancelled { + self.isFinished = true + return + } + + self.isExecuting = true + + self.log("Started") + + self.begin { + self.finish() + } + } + + override final func cancel() { + self.isCancelled = true + self.isExecuting = false + self.isFinished = true + + self.log("Cancelled") + } + + /// Overriden by subclasses to define the body of the operation + /// - Important: this method may be called from any thread so it must be thread-safe. + /// - Parameter completion: must be called when the operation has finished. + func begin(completion: @escaping () -> Void) { + fatalError("Subclasses must override this method") + } + + private final func finish() { + assert(!self.isFinished, "Operation \(type(of: self)) (\(self)) was already finished") + + self.log("Finished") + + self.isExecuting = false + self.isFinished = true + } + + // MARK: - + + final override var isAsynchronous: Bool { + return true + } + + // MARK: - + + internal func log(_ message: CustomStringConvertible) { + Logger.debug(Strings.network.operation_state(type(of: self), + state: message.description)) + } + + // MARK: - + + struct Configuration: NetworkConfiguration { + + let httpClient: HTTPClient + + } + + struct UserSpecificConfiguration: AppUserConfiguration, NetworkConfiguration { + + let httpClient: HTTPClient + let appUserID: String + + } + +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension NetworkOperation: @unchecked Sendable {} + +protocol AppUserConfiguration { + + var appUserID: String { get } + +} + +protocol NetworkConfiguration { + + var httpClient: HTTPClient { get } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/PostAdServicesTokenOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/PostAdServicesTokenOperation.swift new file mode 100644 index 00000000..c2c7c5fe --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/PostAdServicesTokenOperation.swift @@ -0,0 +1,74 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PostAdServicesTokenOperation.swift +// +// Created by Madeline Beyl on 4/20/22. + +import Foundation + +class PostAdServicesTokenOperation: NetworkOperation { + + private let configuration: UserSpecificConfiguration + private let token: String + private let responseHandler: CustomerAPI.SimpleResponseHandler? + + init(configuration: UserSpecificConfiguration, + token: String, + responseHandler: CustomerAPI.SimpleResponseHandler?) { + self.token = token + self.configuration = configuration + self.responseHandler = responseHandler + + super.init(configuration: configuration) + } + + override func begin(completion: @escaping () -> Void) { + self.post(completion: completion) + } + + private func post(completion: @escaping () -> Void) { + let appUserID = self.configuration.appUserID + + guard appUserID.isNotEmpty else { + self.responseHandler?(.missingAppUserID()) + completion() + return + } + + let request = HTTPRequest(method: .post(Body(aadAttributionToken: self.token)), + path: .postAdServicesToken(appUserID: appUserID)) + + self.httpClient.perform(request) { (response: VerifiedHTTPResponse.Result) in + defer { + completion() + } + + self.responseHandler?(response.error.map(BackendError.networkError)) + } + } + +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension PostAdServicesTokenOperation: @unchecked Sendable {} + +private extension PostAdServicesTokenOperation { + + struct Body: HTTPRequestBody { + + let aadAttributionToken: String + + init(aadAttributionToken: String) { + self.aadAttributionToken = aadAttributionToken + } + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/PostAttributionDataOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/PostAttributionDataOperation.swift new file mode 100644 index 00000000..8571b42c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/PostAttributionDataOperation.swift @@ -0,0 +1,80 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PostAttributionDataOperation.swift +// +// Created by Joshua Liebowitz on 11/19/21. + +import Foundation + +class PostAttributionDataOperation: NetworkOperation { + + private let configuration: UserSpecificConfiguration + private let attributionData: [String: Any] + private let network: AttributionNetwork + private let responseHandler: CustomerAPI.SimpleResponseHandler? + + init(configuration: UserSpecificConfiguration, + attributionData: [String: Any], + network: AttributionNetwork, + responseHandler: CustomerAPI.SimpleResponseHandler?) { + self.attributionData = attributionData + self.network = network + self.configuration = configuration + self.responseHandler = responseHandler + + super.init(configuration: configuration) + } + + override func begin(completion: @escaping () -> Void) { + self.post(completion: completion) + } + + private func post(completion: @escaping () -> Void) { + let appUserID = self.configuration.appUserID + + guard appUserID.isNotEmpty else { + self.responseHandler?(.missingAppUserID()) + completion() + + return + } + + let request = HTTPRequest(method: .post(Body(network: self.network, attributionData: self.attributionData)), + path: .postAttributionData(appUserID: appUserID)) + + self.httpClient.perform(request) { (response: VerifiedHTTPResponse.Result) in + defer { + completion() + } + + self.responseHandler?(response.error.map(BackendError.networkError)) + } + } + +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension PostAttributionDataOperation: @unchecked Sendable {} + +private extension PostAttributionDataOperation { + + struct Body: HTTPRequestBody { + + let network: AttributionNetwork + let data: AnyEncodable + + init(network: AttributionNetwork, attributionData: [String: Any]) { + self.network = network + self.data = AnyEncodable(attributionData) + } + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/PostIsPurchaseAllowedByRestoreBehaviorOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/PostIsPurchaseAllowedByRestoreBehaviorOperation.swift new file mode 100644 index 00000000..dd9d6bdb --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/PostIsPurchaseAllowedByRestoreBehaviorOperation.swift @@ -0,0 +1,140 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PostIsPurchaseAllowedByRestoreBehaviorOperation.swift +// +// Created by Will Taylor on 02/03/2026. + +import Foundation + +// swiftlint:disable:next type_name +final class PostIsPurchaseAllowedByRestoreBehaviorOperation: CacheableNetworkOperation { + + private let configuration: AppUserConfiguration + private let postData: PostData + private let isPurchaseAllowedByRestoreBehaviorCallbackCache: + CallbackCache + + static func createFactory( + configuration: UserSpecificConfiguration, + postData: PostData, + isPurchaseAllowedByRestoreBehaviorCallbackCache: CallbackCache + ) -> CacheableNetworkOperationFactory { + let cacheKey = "\(configuration.appUserID)-\(postData.transactionJWS)" + + return CacheableNetworkOperationFactory({ cacheKey in + PostIsPurchaseAllowedByRestoreBehaviorOperation( + configuration: configuration, + postData: postData, + // swiftlint:disable:next line_length + isPurchaseAllowedByRestoreBehaviorCallbackCache: isPurchaseAllowedByRestoreBehaviorCallbackCache, + cacheKey: cacheKey + ) + }, + individualizedCacheKeyPart: cacheKey + ) + } + + init( + configuration: UserSpecificConfiguration, + postData: PostData, + isPurchaseAllowedByRestoreBehaviorCallbackCache: CallbackCache, + cacheKey: String + ) { + self.configuration = configuration + self.postData = postData + self.isPurchaseAllowedByRestoreBehaviorCallbackCache = isPurchaseAllowedByRestoreBehaviorCallbackCache + + super.init(configuration: configuration, cacheKey: cacheKey) + } + + override func begin(completion: @escaping () -> Void) { + self.post(completion: completion) + } + + private func post(completion: @escaping () -> Void) { + guard self.configuration.appUserID.isNotEmpty else { + self.handleResult(.failure(.missingAppUserID())) + completion() + return + } + + guard self.postData.transactionJWS.isNotEmpty else { + self.handleResult(.failure(.missingTransactionJWS())) + completion() + return + } + + let request = HTTPRequest( + method: .post(self.postData), + path: .isPurchaseAllowedByRestoreBehavior(appUserID: self.configuration.appUserID) + ) + + // swiftlint:disable:next line_length + self.httpClient.perform(request) { (response: VerifiedHTTPResponse.Result) in + let result = response + .map { $0.body } + .mapError(BackendError.networkError) + + self.handleResult(result) + completion() + } + } + +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension PostIsPurchaseAllowedByRestoreBehaviorOperation: @unchecked Sendable {} + +private extension PostIsPurchaseAllowedByRestoreBehaviorOperation { + + func handleResult(_ result: Result) { + self.isPurchaseAllowedByRestoreBehaviorCallbackCache.performOnAllItemsAndRemoveFromCache( + withCacheable: self + ) { callback in + callback.completion(result) + } + } + +} + +extension PostIsPurchaseAllowedByRestoreBehaviorOperation { + + struct PostData { + let transactionJWS: String + } + +} + +// MARK: - Codable + +extension PostIsPurchaseAllowedByRestoreBehaviorOperation.PostData: Encodable { + + private enum CodingKeys: String, CodingKey { + case transactionJWS = "fetch_token" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.transactionJWS, forKey: .transactionJWS) + } + +} + +// MARK: - HTTPRequestBody + +extension PostIsPurchaseAllowedByRestoreBehaviorOperation.PostData: HTTPRequestBody { + + var contentForSignature: [(key: String, value: String?)] { + return [ + (CodingKeys.transactionJWS.stringValue, self.transactionJWS) + ] + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/PostOfferForSigningOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/PostOfferForSigningOperation.swift new file mode 100644 index 00000000..84236288 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/PostOfferForSigningOperation.swift @@ -0,0 +1,145 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PostOfferForSigningOperation.swift +// +// Created by Joshua Liebowitz on 11/19/21. + +import Foundation + +class PostOfferForSigningOperation: NetworkOperation { + + typealias SigningData = (signature: String, keyIdentifier: String, nonce: UUID, timestamp: Int) + + struct PostOfferForSigningData { + + let offerIdentifier: String + let productIdentifier: String + let subscriptionGroup: String + let receipt: EncodedAppleReceipt + + } + + private let configuration: UserSpecificConfiguration + private let postOfferData: PostOfferForSigningData + private let responseHandler: OfferingsAPI.OfferSigningResponseHandler + + init(configuration: UserSpecificConfiguration, + postOfferForSigningData: PostOfferForSigningData, + responseHandler: @escaping OfferingsAPI.OfferSigningResponseHandler) { + self.configuration = configuration + self.postOfferData = postOfferForSigningData + self.responseHandler = responseHandler + + super.init(configuration: configuration) + } + + override func begin(completion: @escaping () -> Void) { + self.post(completion: completion) + } + + private func post(completion: @escaping () -> Void) { + let request = HTTPRequest( + method: .post(Body(appUserID: self.configuration.appUserID, data: self.postOfferData)), + path: .postOfferForSigning + ) + + self.httpClient.perform(request) { (response: VerifiedHTTPResponse.Result) in + let result: Result = response + .mapError { error -> BackendError in + if case .decoding = error { + return .unexpectedBackendResponse(.postOfferIdSignature, + extraContext: error.localizedDescription) + } else { + return .networkError(error) + } + } + .flatMap { response in + let (statusCode, response) = (response.httpStatusCode, response.body) + + let offers = response.offers + + guard let firstOffer = offers.first else { + Logger.debug(Strings.backendError.offerings_response_no_offerings) + + return .failure(.unexpectedBackendResponse(.postOfferIdMissingOffersInResponse)) + } + + return Self.handleOffer(firstOffer, statusCode: statusCode) + } + + self.responseHandler(result) + completion() + } + } + + private static func handleOffer( + _ offer: PostOfferResponse.Offer, + statusCode: HTTPStatusCode + ) -> Result { + if let signatureError = offer.signatureError { + return .failure( + .networkError(.errorResponse(signatureError, statusCode)) + ) + } else if let signingData = offer.asSigningData { + return .success(signingData) + } else { + return .failure( + .unexpectedBackendResponse(.postOfferIdSignature, extraContext: "\(offer)") + ) + } + } + +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension PostOfferForSigningOperation: @unchecked Sendable {} + +private extension PostOfferResponse.Offer { + + var asSigningData: PostOfferForSigningOperation.SigningData? { + guard let data = self.signatureData else { return nil } + + return (data.signature, self.keyIdentifier, data.nonce, data.timestamp) + } + +} + +private extension PostOfferForSigningOperation { + + struct Body: HTTPRequestBody { + + // swiftlint:disable:next nesting + struct Offer: Encodable { + + let offerID: String + let productID: String + let subscriptionGroup: String + + } + + let appUserID: String + let fetchToken: String? + let generateOffers: [Offer] + + init(appUserID: String, data: PostOfferForSigningData) { + self.appUserID = appUserID + self.fetchToken = data.receipt.serialized() + self.generateOffers = [ + .init( + offerID: data.offerIdentifier, + productID: data.productIdentifier, + subscriptionGroup: data.subscriptionGroup + ) + ] + } + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/PostReceiptDataOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/PostReceiptDataOperation.swift new file mode 100644 index 00000000..6a97298a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/PostReceiptDataOperation.swift @@ -0,0 +1,442 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PostReceiptDataOperation.swift +// +// Created by Joshua Liebowitz on 11/18/21. + +import Foundation + +// swiftlint:disable file_length + +final class PostReceiptDataOperation: CacheableNetworkOperation { + + private let postData: PostData + private let configuration: AppUserConfiguration + private let customerInfoResponseHandler: CustomerInfoResponseHandler + private let customerInfoCallbackCache: CallbackCache + + static func createFactory( + configuration: UserSpecificConfiguration, + postData: PostData, + customerInfoCallbackCache: CallbackCache, + offlineCustomerInfoCreator: OfflineCustomerInfoCreator? + ) -> CacheableNetworkOperationFactory { + return Self.createFactory( + configuration: configuration, + postData: postData, + customerInfoResponseHandler: .init( + offlineCreator: offlineCustomerInfoCreator, + userID: configuration.appUserID, + failIfInvalidSubscriptionKeyDetectedInDebug: true + ), + customerInfoCallbackCache: customerInfoCallbackCache + ) + } + + static func createFactory( + configuration: UserSpecificConfiguration, + postData: PostData, + customerInfoResponseHandler: CustomerInfoResponseHandler, + customerInfoCallbackCache: CallbackCache + ) -> CacheableNetworkOperationFactory { + /// Cache key comprises of the following: + /// - `appUserID` + /// - `isRestore` + /// - Receipt (`hashString` instead of `fetchToken` to avoid big receipts leading to a huge cache key) + /// - `ProductRequestData.cacheKey` + /// - `presentedOfferingIdentifier` + /// - `observerMode` + /// - `subscriberAttributesByKey` + /// - `sdkOriginated` + /// - `transactionId` (only if there is attribution data, to always post receipts with attribution data) + let cacheKey = + """ + \(configuration.appUserID)-\(postData.isRestore)-\(postData.receipt.hash) + -\(postData.productData?.cacheKey ?? "") + -\(postData.presentedOfferingIdentifier ?? "")-\(postData.observerMode) + -\(postData.subscriberAttributesByKey?.individualizedCacheKeyPart ?? "") + -\(postData.sdkOriginated) + -\(postData.containsAttributionData ? (postData.transactionId ?? "") : "") + """ + + return .init({ cacheKey in + .init( + configuration: configuration, + postData: postData, + customerInfoResponseHandler: customerInfoResponseHandler, + customerInfoCallbackCache: customerInfoCallbackCache, + cacheKey: cacheKey + ) + }, + individualizedCacheKeyPart: cacheKey + ) + } + + private init( + configuration: UserSpecificConfiguration, + postData: PostData, + customerInfoResponseHandler: CustomerInfoResponseHandler, + customerInfoCallbackCache: CallbackCache, + cacheKey: String + ) { + self.customerInfoResponseHandler = customerInfoResponseHandler + self.customerInfoCallbackCache = customerInfoCallbackCache + self.postData = postData + self.configuration = configuration + + super.init(configuration: configuration, cacheKey: cacheKey) + } + + override func begin(completion: @escaping () -> Void) { + if Logger.logLevel <= .debug { + self.printReceiptData() + } + + self.post(completion: completion) + } + + private func post(completion: @escaping () -> Void) { + let request = HTTPRequest(method: .post(self.postData), path: .postReceiptData, isRetryable: true) + + self.httpClient.perform( + request + ) { (response: VerifiedHTTPResponse.Result) in + self.customerInfoResponseHandler.handle(customerInfoResponse: response) { result in + self.customerInfoCallbackCache.performOnAllItemsAndRemoveFromCache( + withCacheable: self + ) { callbackObject in + callbackObject.completion(result) + } + } + + completion() + } + } +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension PostReceiptDataOperation: @unchecked Sendable {} + +extension PostReceiptDataOperation { + + struct PostData { + + /// Version of the payload format sent to the backend. + /// - Important: Keep in sync with purchases-android. + static let payloadVersion: Int = 1 + + let appUserID: String + let receipt: EncodedAppleReceipt + let isRestore: Bool + let productData: ProductRequestData? + let presentedOfferingIdentifier: String? + let presentedPlacementIdentifier: String? + let appliedTargetingRule: AppliedTargetingRule? + let paywall: Paywall? + + /// The value of observer mode at the time of the request. + let observerMode: Bool + + /// The value of purchaseCompletedBy at purchase time. + let purchaseCompletedBy: PurchasesAreCompletedBy? + let initiationSource: PostReceiptSource.InitiationSource + let subscriberAttributesByKey: SubscriberAttribute.Dictionary? + let aadAttributionToken: String? + /// - Note: this is only used for the backend to disambiguate receipts created in `SKTestSession`s. + let testReceiptIdentifier: String? + + /// The [AppTransaction](https://developer.apple.com/documentation/storekit/apptransaction) JWS token + /// retrieved from StoreKit 2. + let appTransaction: String? + let transactionId: String? + + /// Indicates whether this purchase was initiated via the SDK's `purchase()` methods. + let sdkOriginated: Bool + let metadata: [String: String]? + let containsAttributionData: Bool + } + + struct Paywall { + + var paywallID: String? + var sessionID: String + var revision: Int + var displayMode: PaywallViewMode + var darkMode: Bool + var localeIdentifier: String + + } + + struct AppliedTargetingRule { + + var revision: Int + var ruleId: String + + } +} + +extension PostReceiptDataOperation.PostData { + + init( + transactionData data: PurchasedTransactionData, + postReceiptSource: PostReceiptSource, + appUserID: String, + productData: ProductRequestData?, + receipt: EncodedAppleReceipt, + observerMode: Bool, + purchaseCompletedBy: PurchasesAreCompletedBy?, + testReceiptIdentifier: String?, + appTransaction: String?, + transactionId: String?, + /// Whether it contains attribution data for `transactionId`. This field is not included in the request + containsAttributionData: Bool, + sdkOriginated: Bool = false + ) { + self.init( + appUserID: appUserID, + receipt: receipt, + isRestore: postReceiptSource.isRestore, + productData: productData, + presentedOfferingIdentifier: data.presentedOfferingContext?.offeringIdentifier, + presentedPlacementIdentifier: data.presentedOfferingContext?.placementIdentifier, + appliedTargetingRule: data.presentedOfferingContext?.targetingContext.flatMap { + .init(revision: $0.revision, ruleId: $0.ruleId) + }, + paywall: data.paywall, + observerMode: observerMode, + purchaseCompletedBy: purchaseCompletedBy, + initiationSource: postReceiptSource.initiationSource, + subscriberAttributesByKey: data.unsyncedAttributes, + aadAttributionToken: data.aadAttributionToken, + testReceiptIdentifier: testReceiptIdentifier, + appTransaction: appTransaction, + transactionId: transactionId, + sdkOriginated: sdkOriginated, + metadata: data.metadata, + containsAttributionData: containsAttributionData + ) + } +} + +private extension PurchasedTransactionData { + + var paywall: PostReceiptDataOperation.Paywall? { + guard let paywall = self.presentedPaywall else { return nil } + + return .init(paywallID: paywall.data.paywallIdentifier, + sessionID: paywall.data.sessionIdentifier.uuidString, + revision: paywall.data.paywallRevision, + displayMode: paywall.data.displayMode, + darkMode: paywall.data.darkMode, + localeIdentifier: paywall.data.localeIdentifier) + } +} + +// MARK: - Private + +private extension PostReceiptDataOperation { + + func printReceiptData() { + guard self.postData.receipt != .empty else { return } + + switch self.postData.receipt { + case .jws(let content): + self.log(Strings.receipt.posting_jws( + content, + initiationSource: self.postData.initiationSource.rawValue + )) + case .sk2receipt(let receipt): + self.log(Strings.receipt.posting_sk2_receipt( + (try? receipt.prettyPrintedJSON) ?? "", + initiationSource: self.postData.initiationSource.rawValue + )) + case .receipt(let data): + do { + let receipt = try PurchasesReceiptParser.default.parse(from: data) + self.log(Strings.receipt.posting_receipt( + receipt, + initiationSource: self.postData.initiationSource.rawValue + )) + + for purchase in receipt.inAppPurchases where purchase.purchaseDateEqualsExpiration { + Logger.appleError(Strings.receipt.receipt_subscription_purchase_equals_expiration( + productIdentifier: purchase.productId, + purchase: purchase.purchaseDate, + expiration: purchase.expiresDate + )) + } + + } catch { + Logger.appleError(Strings.receipt.parse_receipt_locally_error(error: error)) + } + case .empty: + return + } + } +} + +// MARK: - Codable + +extension PostReceiptDataOperation.PostData: Encodable { + + private enum CodingKeys: String, CodingKey { + + case payloadVersion = "payload_version" + case fetchToken = "fetch_token" + case appUserID = "app_user_id" + case isRestore + case observerMode + case purchaseCompletedBy = "purchase_completed_by" + case initiationSource + case attributes + case aadAttributionToken + case presentedOfferingIdentifier + case presentedPlacementIdentifier + case appliedTargetingRule + case paywall + case testReceiptIdentifier = "test_receipt_identifier" + case appTransaction = "app_transaction" + case transactionId = "transaction_id" + case sdkOriginated = "sdk_originated" + case metadata + + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(Self.payloadVersion, forKey: .payloadVersion) + try container.encode(self.appUserID, forKey: .appUserID) + try container.encode(self.isRestore, forKey: .isRestore) + try container.encode(self.observerMode, forKey: .observerMode) + try container.encode(self.initiationSource, forKey: .initiationSource) + + if let productData = self.productData { + try productData.encode(to: encoder) + } + + try container.encodeIfPresent(self.fetchToken, forKey: .fetchToken) + try container.encodeIfPresent(self.appTransaction, forKey: .appTransaction) + try container.encodeIfPresent(self.transactionId, forKey: .transactionId) + try container.encode(self.sdkOriginated, forKey: .sdkOriginated) + try container.encodeIfPresent(self.metadata, forKey: .metadata) + try container.encodeIfPresent(self.presentedOfferingIdentifier, forKey: .presentedOfferingIdentifier) + try container.encodeIfPresent(self.presentedPlacementIdentifier, forKey: .presentedPlacementIdentifier) + try container.encodeIfPresent(self.appliedTargetingRule, forKey: .appliedTargetingRule) + try container.encodeIfPresent(self.paywall, forKey: .paywall) + try container.encodeIfPresent(self.purchaseCompletedBy?.name, forKey: .purchaseCompletedBy) + + try container.encodeIfPresent( + self.subscriberAttributesByKey + .map(SubscriberAttribute.map) + .map(AnyEncodable.init), + forKey: .attributes + ) + + try container.encodeIfPresent(self.aadAttributionToken, forKey: .aadAttributionToken) + try container.encodeIfPresent(self.testReceiptIdentifier, forKey: .testReceiptIdentifier) + } + + var fetchToken: String? { return self.receipt.serialized() } +} + +extension PostReceiptDataOperation.Paywall: Codable { + + private enum CodingKeys: String, CodingKey { + + case paywallID = "paywallId" + case sessionID = "sessionId" + case revision + case displayMode + case darkMode + case localeIdentifier = "locale" + + } +} + +extension PostReceiptDataOperation.AppliedTargetingRule: Codable { + + private enum CodingKeys: String, CodingKey { + + case revision + case ruleId + + } +} + +// MARK: - HTTPRequestBody + +extension PostReceiptDataOperation.PostData: HTTPRequestBody { + + var contentForSignature: [(key: String, value: String?)] { + return [ + (Self.CodingKeys.appUserID.stringValue, self.appUserID), + (Self.CodingKeys.fetchToken.stringValue, self.fetchToken), + (Self.CodingKeys.appTransaction.stringValue, self.appTransaction) + ] + } +} + +// MARK: - InitiationSource + +extension PostReceiptSource.InitiationSource: Codable, RawRepresentable { + + var rawValue: String { + switch self { + case .restore: return "restore" + case .purchase: return "purchase" + case .queue: return "queue" + } + } + + init?(rawValue: String) { + guard let value = Self.codes[rawValue] else { return nil } + + self = value + } + + private static let codes: [String: PostReceiptSource.InitiationSource] = Self + .allCases + .dictionaryWithKeys { $0.rawValue } +} + +// MARK: - EncodedAppleReceipt + +private extension EncodedAppleReceipt { + + var hash: String { + switch self { + case let .jws(content): + return content.asData.hashString + case let .receipt(data): + return data.hashString + case let .sk2receipt(receipt): + do { + return try receipt.prettyPrintedData.hashString + } catch { + Logger.warn(Strings.storeKit.sk2_error_encoding_receipt(error)) + return "" + } + case .empty: + return "empty" + } + } +} + +private extension PurchasesAreCompletedBy { + + var name: String { + switch self { + case .revenueCat: return "revenuecat" + case .myApp: return "my_app" + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/PostRedeemWebPurchaseOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/PostRedeemWebPurchaseOperation.swift new file mode 100644 index 00000000..215d7af9 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/PostRedeemWebPurchaseOperation.swift @@ -0,0 +1,146 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PostRedeeemWebPurchaseOperation.swift +// +// Created by Antonio Rico Diez on 2024-10-17. + +import Foundation + +final class PostRedeemWebPurchaseOperation: CacheableNetworkOperation { + + private let postData: PostData + private let configuration: AppUserConfiguration + private let customerInfoResponseHandler: CustomerInfoResponseHandler + private let customerInfoCallbackCache: CallbackCache + + static func createFactory( + configuration: UserSpecificConfiguration, + postData: PostData, + customerInfoCallbackCache: CallbackCache + ) -> CacheableNetworkOperationFactory { + return Self.createFactory( + configuration: configuration, + postData: postData, + customerInfoResponseHandler: .init( + offlineCreator: nil, + userID: configuration.appUserID, + failIfInvalidSubscriptionKeyDetectedInDebug: true + ), + customerInfoCallbackCache: customerInfoCallbackCache + ) + } + + static func createFactory( + configuration: UserSpecificConfiguration, + postData: PostData, + customerInfoResponseHandler: CustomerInfoResponseHandler, + customerInfoCallbackCache: CallbackCache + ) -> CacheableNetworkOperationFactory { + /// Cache key comprises of the following: + /// - `appUserID` + /// - `redemptionToken` + let cacheKey = "\(configuration.appUserID)-\(postData.redemptionToken)" + + return CacheableNetworkOperationFactory({ cacheKey in + PostRedeemWebPurchaseOperation( + configuration: configuration, + postData: postData, + customerInfoResponseHandler: customerInfoResponseHandler, + customerInfoCallbackCache: customerInfoCallbackCache, + cacheKey: cacheKey + ) + }, + individualizedCacheKeyPart: cacheKey + ) + } + + private init( + configuration: UserSpecificConfiguration, + postData: PostData, + customerInfoResponseHandler: CustomerInfoResponseHandler, + customerInfoCallbackCache: CallbackCache, + cacheKey: String + ) { + self.customerInfoResponseHandler = customerInfoResponseHandler + self.customerInfoCallbackCache = customerInfoCallbackCache + self.postData = postData + self.configuration = configuration + + super.init(configuration: configuration, cacheKey: cacheKey) + } + + override func begin(completion: @escaping () -> Void) { + let request = HTTPRequest(method: .post(self.postData), + path: .postRedeemWebPurchase, + isRetryable: true) + + self.httpClient.perform( + request + ) { (response: VerifiedHTTPResponse.Result) in + self.customerInfoResponseHandler.handle(customerInfoResponse: response) { result in + self.customerInfoCallbackCache.performOnAllItemsAndRemoveFromCache( + withCacheable: self + ) { callbackObject in + callbackObject.completion(result) + } + } + + completion() + } + } + +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension PostRedeemWebPurchaseOperation: @unchecked Sendable {} + +extension PostRedeemWebPurchaseOperation { + + struct PostData { + + let appUserID: String + let redemptionToken: String + } + +} + +// MARK: - Private +// MARK: - Codable + +extension PostRedeemWebPurchaseOperation.PostData: Encodable { + + private enum CodingKeys: String, CodingKey { + + case appUserID = "app_user_id" + case redemptionToken = "redemption_token" + + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(self.appUserID, forKey: .appUserID) + try container.encode(self.redemptionToken, forKey: .redemptionToken) + } + +} + +// MARK: - HTTPRequestBody + +extension PostRedeemWebPurchaseOperation.PostData: HTTPRequestBody { + + var contentForSignature: [(key: String, value: String?)] { + return [ + (Self.CodingKeys.appUserID.stringValue, self.appUserID), + (Self.CodingKeys.redemptionToken.stringValue, self.redemptionToken) + ] + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/PostSubscriberAttributesOperation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/PostSubscriberAttributesOperation.swift new file mode 100644 index 00000000..e3e2a3ba --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Operations/PostSubscriberAttributesOperation.swift @@ -0,0 +1,84 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PostSubscriberAttributesOperation.swift +// +// Created by Joshua Liebowitz on 11/18/21. + +import Foundation + +class PostSubscriberAttributesOperation: NetworkOperation { + + private let configuration: UserSpecificConfiguration + private let subscriberAttributes: SubscriberAttribute.Dictionary + private let responseHandler: CustomerAPI.SimpleResponseHandler? + + init(configuration: UserSpecificConfiguration, + subscriberAttributes: SubscriberAttribute.Dictionary, + completion: CustomerAPI.SimpleResponseHandler?) { + self.configuration = configuration + self.subscriberAttributes = subscriberAttributes + self.responseHandler = completion + + super.init(configuration: configuration) + } + + override func begin(completion: @escaping () -> Void) { + post(completion: completion) + } + + private func post(completion: @escaping () -> Void) { + guard self.subscriberAttributes.count > 0 else { + Logger.warn(Strings.attribution.empty_subscriber_attributes) + self.responseHandler?(.emptySubscriberAttributes()) + + return + } + + let appUserID = self.configuration.appUserID + + guard appUserID.isNotEmpty else { + self.responseHandler?(.missingAppUserID()) + completion() + + return + } + + let request = HTTPRequest(method: .post(Body(self.subscriberAttributes)), + path: .postSubscriberAttributes(appUserID: appUserID)) + + self.httpClient.perform(request) { (response: VerifiedHTTPResponse.Result) in + defer { + completion() + } + + self.responseHandler?(response.error.map(BackendError.networkError)) + } + } + +} + +// Restating inherited @unchecked Sendable from Foundation's Operation +extension PostSubscriberAttributesOperation: @unchecked Sendable {} + +private extension PostSubscriberAttributesOperation { + + struct Body: HTTPRequestBody { + + let attributes: AnyEncodable + + init(_ attributes: SubscriberAttribute.Dictionary) { + self.attributes = AnyEncodable( + SubscriberAttribute.map(subscriberAttributes: attributes) + ) + } + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/RedeemWebPurchaseAPI.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/RedeemWebPurchaseAPI.swift new file mode 100644 index 00000000..300f25ff --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/RedeemWebPurchaseAPI.swift @@ -0,0 +1,52 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// RedeemWebPurchaseAPI.swift +// +// Created by Antonio Rico Diez on 2024-10-17. + +import Foundation + +class RedeemWebPurchaseAPI { + + typealias RedeemWebPurchaseResponseHandler = Backend.ResponseHandler + + private let redeemWebPurchaseResponseCallbacksCache: CallbackCache + private let backendConfig: BackendConfiguration + + init(backendConfig: BackendConfiguration) { + self.backendConfig = backendConfig + self.redeemWebPurchaseResponseCallbacksCache = .init() + } + + func postRedeemWebPurchase(appUserID: String, + redemptionToken: String, + completion: @escaping RedeemWebPurchaseResponseHandler) { + let config = NetworkOperation.UserSpecificConfiguration(httpClient: self.backendConfig.httpClient, + appUserID: appUserID) + + let factory = PostRedeemWebPurchaseOperation.createFactory( + configuration: config, + postData: .init(appUserID: appUserID, redemptionToken: redemptionToken), + customerInfoCallbackCache: self.redeemWebPurchaseResponseCallbacksCache + ) + + let callback = CustomerInfoCallback(cacheKey: factory.cacheKey, + source: PostRedeemWebPurchaseOperation.self, + completion: completion) + let cacheStatus = self.redeemWebPurchaseResponseCallbacksCache.add(callback) + + self.backendConfig.addCacheableOperation( + with: factory, + delay: .none, + cacheStatus: cacheStatus + ) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/CustomerCenterConfigResponse.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/CustomerCenterConfigResponse.swift new file mode 100644 index 00000000..1afb9acd --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/CustomerCenterConfigResponse.swift @@ -0,0 +1,268 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CustomerCenterConfigResponse.swift +// +// +// Created by Cesar de la Vega on 31/5/24. +// + +import Foundation + +// swiftlint:disable nesting + +struct CustomerCenterConfigResponse { + + let customerCenter: CustomerCenter + let lastPublishedAppVersion: String? + let itunesTrackId: UInt? + + struct CustomerCenter { + + let appearance: Appearance + let screens: [String: Screen] + let localization: Localization + let support: Support + let changePlans: [ChangePlan] + + } + + struct Localization { + + let locale: String + let localizedStrings: [String: String] + + } + + struct HelpPath { + + let id: String + let title: String + let type: PathType + let url: String? + let openMethod: OpenMethod? + let promotionalOffer: PromotionalOffer? + let feedbackSurvey: FeedbackSurvey? + let refundWindow: String? + let actionIdentifier: String? + + enum PathType: String { + + case missingPurchase = "MISSING_PURCHASE" + case refundRequest = "REFUND_REQUEST" + case changePlans = "CHANGE_PLANS" + case cancel = "CANCEL" + case customUrl = "CUSTOM_URL" + case customAction = "CUSTOM_ACTION" + case unknown + + } + + enum OpenMethod: String { + + case inApp = "IN_APP" + case external = "EXTERNAL" + case unknown + + } + + struct PromotionalOffer { + + let iosOfferId: String + let eligible: Bool + let title: String + let subtitle: String + let productMapping: [String: String] + let crossProductPromotions: [String: CrossProductPromotion]? + + struct CrossProductPromotion { + let storeOfferIdentifier: String + let targetProductId: String + } + + } + + struct FeedbackSurvey { + + let title: String + let options: [Option] + + struct Option { + + let id: String + let title: String + let promotionalOffer: PromotionalOffer? + + } + + } + + } + + struct Appearance { + + let light: AppearanceCustomColors + let dark: AppearanceCustomColors + + struct AppearanceCustomColors { + + let accentColor: String? + let textColor: String? + let backgroundColor: String? + let buttonTextColor: String? + let buttonBackgroundColor: String? + + } + + } + + struct Screen { + + let title: String + let type: ScreenType + let subtitle: String? + let paths: [HelpPath] + let offering: ScreenOffering? + + enum ScreenType: String { + + case management = "MANAGEMENT" + case noActive = "NO_ACTIVE" + case unknown + + } + + } + + struct ScreenOffering { + let type: String + let offeringId: String? + let buttonText: String? + } + + struct Support { + + let email: String + let shouldWarnCustomerToUpdate: Bool? + let displayPurchaseHistoryLink: Bool? + let displayUserDetailsSection: Bool? + let displayVirtualCurrencies: Bool? + let shouldWarnCustomersAboutMultipleSubscriptions: Bool? + let supportTickets: SupportTickets? + + struct SupportTickets { + let allowCreation: Bool + let customerType: String + let customerDetails: CustomerDetails? + + struct CustomerDetails { + let activeEntitlements: Bool? + let appUserId: Bool? + let attConsent: Bool? + let country: Bool? + let deviceVersion: Bool? + let email: Bool? + let facebookAnonId: Bool? + let idfa: Bool? + let idfv: Bool? + let ipAddress: Bool? + let lastOpened: Bool? + let lastSeenAppVersion: Bool? + let totalSpent: Bool? + let userSince: Bool? + + enum CodingKeys: String, CodingKey { + case activeEntitlements + case appUserId + case attConsent + case country + case deviceVersion + case email + case facebookAnonId + case idfa + case idfv + case ipAddress = "ip" + case lastOpened + case lastSeenAppVersion + case totalSpent + case userSince + } + } + } + } + + struct ChangePlan { + let groupId: String + let groupName: String + let products: [ChangePlanProduct] + } + + struct ChangePlanProduct { + let productId: String + let selected: Bool + } + +} + +extension CustomerCenterConfigResponse: Codable, Equatable {} +extension CustomerCenterConfigResponse.CustomerCenter: Codable, Equatable {} +extension CustomerCenterConfigResponse.Localization: Codable, Equatable {} +extension CustomerCenterConfigResponse.HelpPath: Codable, Equatable {} +extension CustomerCenterConfigResponse.HelpPath.PathType: Equatable {} +extension CustomerCenterConfigResponse.HelpPath.OpenMethod: Equatable {} +extension CustomerCenterConfigResponse.HelpPath.PromotionalOffer: Codable, Equatable {} +extension CustomerCenterConfigResponse.HelpPath.PromotionalOffer.CrossProductPromotion: Codable, Equatable {} +extension CustomerCenterConfigResponse.HelpPath.FeedbackSurvey: Codable, Equatable {} +extension CustomerCenterConfigResponse.HelpPath.FeedbackSurvey.Option: Codable, Equatable {} +extension CustomerCenterConfigResponse.Appearance: Codable, Equatable {} +extension CustomerCenterConfigResponse.Appearance.AppearanceCustomColors: Codable, Equatable {} +extension CustomerCenterConfigResponse.Screen: Codable, Equatable {} +extension CustomerCenterConfigResponse.ScreenOffering: Codable, Equatable {} +extension CustomerCenterConfigResponse.Screen.ScreenType: Equatable {} +extension CustomerCenterConfigResponse.Support: Codable, Equatable {} +extension CustomerCenterConfigResponse.Support.SupportTickets: Codable, Equatable {} +extension CustomerCenterConfigResponse.Support.SupportTickets.CustomerDetails: Codable, Equatable {} +extension CustomerCenterConfigResponse.ChangePlan: Codable, Equatable {} +extension CustomerCenterConfigResponse.ChangePlanProduct: Codable, Equatable {} + +protocol CodableEnumWithUnknownCase: Codable { + + static var unknownCase: Self { get } + +} + +extension CodableEnumWithUnknownCase where Self: RawRepresentable, Self.RawValue == String { + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + self = Self(rawValue: value) ?? Self.unknownCase + } + +} + +extension CustomerCenterConfigResponse.Screen.ScreenType: CodableEnumWithUnknownCase { + + static var unknownCase: Self { .unknown } + +} + +extension CustomerCenterConfigResponse.HelpPath.PathType: CodableEnumWithUnknownCase { + + static var unknownCase: Self { .unknown } + +} + +extension CustomerCenterConfigResponse.HelpPath.OpenMethod: CodableEnumWithUnknownCase { + + static var unknownCase: Self { .unknown } + +} + +extension CustomerCenterConfigResponse: HTTPResponseBody {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/CustomerInfoResponse.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/CustomerInfoResponse.swift new file mode 100644 index 00000000..ddc05bdd --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/CustomerInfoResponse.swift @@ -0,0 +1,273 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CustomerInfoResponse.swift +// +// Created by Nacho Soto on 4/12/22. + +import Foundation + +/// The representation of ``CustomerInfo`` as sent by the backend. +/// Thanks to `@IgnoreHashable`, only `subscriber` is used for equality / hash. +struct CustomerInfoResponse { + + var subscriber: Subscriber + + @IgnoreHashable + var requestDate: Date + @IgnoreEncodable @IgnoreHashable + var rawData: [String: Any] + +} + +extension CustomerInfoResponse { + + struct Subscriber { + + var originalAppUserId: String + @IgnoreDecodeErrors + var managementUrl: URL? + var originalApplicationVersion: String? + var originalPurchaseDate: Date? + var firstSeen: Date + @DefaultDecodable.EmptyDictionary + var subscriptions: [String: Subscription] + @DefaultDecodable.EmptyDictionary + var nonSubscriptions: [String: [Transaction]] + @DefaultDecodable.EmptyDictionary + var entitlements: [String: Entitlement] + + } + + struct Subscription { + + @IgnoreDecodeErrors + var periodType: PeriodType + var purchaseDate: Date + var originalPurchaseDate: Date? + var expiresDate: Date? + @IgnoreDecodeErrors + var store: Store + @DefaultDecodable.False + var isSandbox: Bool + var unsubscribeDetectedAt: Date? + var billingIssuesDetectedAt: Date? + @IgnoreDecodeErrors + var ownershipType: PurchaseOwnershipType + var productPlanIdentifier: String? + var metadata: [String: String]? + var gracePeriodExpiresDate: Date? + var refundedAt: Date? + var storeTransactionId: String? + + var displayName: String? + + /// Price paid for the subscription + var price: PurchasePaidPrice? + + /// Management URL for the purchase + var managementUrl: URL? + } + + struct PurchasePaidPrice { + let currency: String + let amount: Double + } + + struct Transaction { + + var purchaseDate: Date + var originalPurchaseDate: Date? + var transactionIdentifier: String? + var storeTransactionIdentifier: String? + @IgnoreDecodeErrors + var store: Store + var isSandbox: Bool + var displayName: String? + /// Price paid for the subscription + var price: PurchasePaidPrice? + } + + struct Entitlement { + + var expiresDate: Date? + var productIdentifier: String + var purchaseDate: Date? + + @IgnoreEncodable @IgnoreHashable + var rawData: [String: Any] + + } + +} + +// MARK: - Codable + +extension CustomerInfoResponse.Subscriber: Codable, Hashable {} +extension CustomerInfoResponse.Subscription: Codable, Hashable {} +extension CustomerInfoResponse.PurchasePaidPrice: Codable, Hashable {} + +extension CustomerInfoResponse.Entitlement: Hashable {} +extension CustomerInfoResponse.Entitlement: Encodable {} +extension CustomerInfoResponse.Entitlement: Decodable { + + // Note: this must be manually implemented because of the custom call to `decodeRawData` + // which can't be abstracted as a property wrapper. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.expiresDate = try container.decodeIfPresent(Date.self, forKey: .expiresDate) + self.productIdentifier = try container.decode(String.self, forKey: .productIdentifier) + self.purchaseDate = try container.decodeIfPresent(Date.self, forKey: .purchaseDate) + + self.rawData = decoder.decodeRawData() + } + +} + +extension CustomerInfoResponse.Transaction: Codable, Hashable { + + private enum CodingKeys: String, CodingKey { + + case purchaseDate + case originalPurchaseDate + case transactionIdentifier = "id" + case storeTransactionIdentifier = "storeTransactionId" + case store + case isSandbox + case displayName + case price + } + +} + +extension CustomerInfoResponse: Codable { + + // Note: this must be manually implemented because of the custom call to `decodeRawData` + // which can't be abstracted as a property wrapper. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.requestDate = try container.decode(Date.self, forKey: .requestDate) + self.subscriber = try container.decode(Subscriber.self, forKey: .subscriber) + + self.rawData = decoder.decodeRawData() + } + +} + +extension CustomerInfoResponse: Equatable, Hashable {} + +// MARK: - Extensions + +extension CustomerInfoResponse.Subscriber { + + init( + originalAppUserId: String, + managementUrl: URL? = nil, + originalApplicationVersion: String? = nil, + originalPurchaseDate: Date? = nil, + firstSeen: Date, + subscriptions: [String: CustomerInfoResponse.Subscription], + nonSubscriptions: [String: [CustomerInfoResponse.Transaction]], + entitlements: [String: CustomerInfoResponse.Entitlement] + ) { + self.originalAppUserId = originalAppUserId + self.managementUrl = managementUrl + self.originalApplicationVersion = originalApplicationVersion + self.originalPurchaseDate = originalPurchaseDate + self.firstSeen = firstSeen + self.subscriptions = subscriptions + self.nonSubscriptions = nonSubscriptions + self.entitlements = entitlements + } + +} + +extension CustomerInfoResponse.Transaction { + + init( + purchaseDate: Date, + originalPurchaseDate: Date?, + transactionIdentifier: String?, + storeTransactionIdentifier: String?, + store: Store, + isSandbox: Bool + ) { + self.purchaseDate = purchaseDate + self.originalPurchaseDate = originalPurchaseDate + self.transactionIdentifier = transactionIdentifier + self.storeTransactionIdentifier = storeTransactionIdentifier + self.store = store + self.isSandbox = isSandbox + } + + var asSubscription: CustomerInfoResponse.Subscription { + return .init(purchaseDate: self.purchaseDate, + originalPurchaseDate: self.originalPurchaseDate, + store: self.store, + isSandbox: self.isSandbox) + } + +} + +extension CustomerInfoResponse.Subscription { + + init( + periodType: PeriodType = .defaultValue, + purchaseDate: Date, + originalPurchaseDate: Date? = nil, + expiresDate: Date? = nil, + store: Store = .defaultValue, + isSandbox: Bool, + unsubscribeDetectedAt: Date? = nil, + billingIssuesDetectedAt: Date? = nil, + ownershipType: PurchaseOwnershipType = .defaultValue, + storeTransactionId: String? = nil + ) { + self.periodType = periodType + self.purchaseDate = purchaseDate + self.originalPurchaseDate = originalPurchaseDate + self.expiresDate = expiresDate + self.store = store + self.isSandbox = isSandbox + self.unsubscribeDetectedAt = unsubscribeDetectedAt + self.billingIssuesDetectedAt = billingIssuesDetectedAt + self.ownershipType = ownershipType + self.storeTransactionId = storeTransactionId + } + + var asTransaction: CustomerInfoResponse.Transaction { + return .init(purchaseDate: self.purchaseDate, + originalPurchaseDate: self.originalPurchaseDate, + transactionIdentifier: nil, + storeTransactionIdentifier: nil, + store: self.store, + isSandbox: self.isSandbox) + } + +} + +extension CustomerInfoResponse.Subscriber { + + var allTransactionsByProductId: [String: CustomerInfoResponse.Transaction] { + return self.allPurchasesByProductId.mapValues { $0.asTransaction } + } + + // This returns objects of type `Subscription` but also includes non-subscriptions + var allPurchasesByProductId: [String: CustomerInfoResponse.Subscription] { + let subscriptions = self.subscriptions + let latestNonSubscriptionTransactionsByProductId = self.nonSubscriptions + .compactMapValues { $0.last } + .mapValues { $0.asSubscription } + + return subscriptions + latestNonSubscriptionTransactionsByProductId + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/GetIntroEligibilityResponse.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/GetIntroEligibilityResponse.swift new file mode 100644 index 00000000..d3efbc8e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/GetIntroEligibilityResponse.swift @@ -0,0 +1,39 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// GetIntroEligibilityResponse.swift +// +// Created by Nacho Soto on 5/12/22. + +import Foundation + +struct GetIntroEligibilityResponse { + + var eligibilityByProductIdentifier: [String: IntroEligibilityStatus] + +} + +extension GetIntroEligibilityResponse: HTTPResponseBody { + + static func create(with data: Data) throws -> Self { + let response = try [String: Bool?].create(with: data) + + return .init( + eligibilityByProductIdentifier: response + .mapValues { + if let status = $0 { + return status ? .eligible : .ineligible + } else { + return .unknown + } + } + ) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/HealthReportAvailabilityResponse.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/HealthReportAvailabilityResponse.swift new file mode 100644 index 00000000..b48c43ad --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/HealthReportAvailabilityResponse.swift @@ -0,0 +1,21 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// HealthReportAvailabilityResponse.swift +// +// Created by Pol Piella Abadia on 27/06/2025. + +import Foundation + +struct HealthReportAvailability { + let reportLogs: Bool +} + +extension HealthReportAvailability: HTTPResponseBody {} +extension HealthReportAvailability: Codable, Equatable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/HealthReportResponse.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/HealthReportResponse.swift new file mode 100644 index 00000000..115d646b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/HealthReportResponse.swift @@ -0,0 +1,142 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// HealthReportResponse.swift +// +// Created by Pol Piella on 4/8/25. + +#if DEBUG +import Foundation + +enum HealthCheckStatus: String { + case passed + case failed + case warning + case unknown +} + +enum HealthCheckType: String { + case apiKey = "api_key" + case bundleId = "bundle_id" + case products = "products" + case offerings = "offerings" + case offeringsProducts = "offerings_products" +} + +enum ProductStatus: String { + case valid = "ok" + case couldNotCheck = "could_not_check" + case notFound = "not_found" + case actionInProgress = "action_in_progress" + case needsAction = "needs_action" + case unknown +} + +struct PackageHealthReport { + let identifier: String + let title: String? + let status: ProductStatus + let description: String + let productIdentifier: String + let productTitle: String? +} + +struct OfferingHealthReport { + let identifier: String + let packages: [PackageHealthReport] + let status: HealthCheckStatus +} + +struct OfferingsCheckDetails { + let offerings: [OfferingHealthReport] +} + +struct BundleIdCheckDetails { + let sdkBundleId: String + let appBundleId: String +} + +enum HealthCheckDetails { + case offeringsProducts(OfferingsCheckDetails) + case bundleId(BundleIdCheckDetails) + case products(ProductsCheckDetails) +} + +struct ProductsCheckDetails { + let products: [ProductHealthReport] +} + +struct ProductHealthReport { + let identifier: String + let title: String? + let status: ProductStatus + let description: String +} + +struct HealthCheck { + let name: HealthCheckType + let status: HealthCheckStatus + let details: HealthCheckDetails? + + enum CodingKeys: String, CodingKey { + case name + case status + case details + } + + init(name: HealthCheckType, status: HealthCheckStatus, details: HealthCheckDetails? = nil) { + self.name = name + self.status = status + self.details = details + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(HealthCheckType.self, forKey: .name) + status = try container.decode(HealthCheckStatus.self, forKey: .status) + + switch name { + case .offeringsProducts: + details = (try container.decodeIfPresent(OfferingsCheckDetails.self, forKey: .details)) + .map({ .offeringsProducts($0) }) + case .bundleId: + details = (try container.decodeIfPresent(BundleIdCheckDetails.self, forKey: .details)) + .map({ .bundleId($0) }) + + case .products: + details = (try container.decodeIfPresent(ProductsCheckDetails.self, forKey: .details)) + .map({ .products($0) }) + + default: + details = nil + } + } +} + +struct HealthReport { + let status: HealthCheckStatus + let projectId: String? + let appId: String? + let checks: [HealthCheck] +} + +extension HealthReport: HTTPResponseBody {} +extension HealthReport: Codable, Equatable {} +extension HealthCheck: Codable, Equatable {} +extension HealthCheckDetails: Codable, Equatable {} +extension HealthCheckType: Codable, Equatable {} +extension HealthCheckStatus: Codable, Equatable {} +extension OfferingsCheckDetails: Codable, Equatable {} +extension BundleIdCheckDetails: Codable, Equatable {} +extension ProductsCheckDetails: Codable, Equatable {} +extension ProductStatus: Codable, Equatable {} +extension ProductHealthReport: Codable, Equatable {} +extension OfferingHealthReport: Codable, Equatable {} +extension PackageHealthReport: Codable, Equatable {} +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/IsPurchaseAllowedByRestoreBehaviorResponse.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/IsPurchaseAllowedByRestoreBehaviorResponse.swift new file mode 100644 index 00000000..d7186f96 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/IsPurchaseAllowedByRestoreBehaviorResponse.swift @@ -0,0 +1,23 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// IsPurchaseAllowedByRestoreBehaviorResponse.swift +// +// Created by Will Taylor on 2/4/26. + +import Foundation + +// swiftlint:disable:next type_name +struct IsPurchaseAllowedByRestoreBehaviorResponse: Decodable { + + let isPurchaseAllowedByRestoreBehavior: Bool + +} + +extension IsPurchaseAllowedByRestoreBehaviorResponse: HTTPResponseBody {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/OfferingsResponse.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/OfferingsResponse.swift new file mode 100644 index 00000000..acfed0f7 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/OfferingsResponse.swift @@ -0,0 +1,88 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// OfferingsResponse.swift +// +// Created by Nacho Soto on 3/31/22. + +import Foundation + +struct OfferingsResponse { + + struct Offering { + + // swiftlint:disable:next nesting + struct Package { + + let identifier: String + let platformProductIdentifier: String + let webCheckoutUrl: URL? + + } + + let identifier: String + let description: String + let packages: [Package] + @IgnoreDecodeErrors + var paywall: PaywallData? + @DefaultDecodable.EmptyDictionary + var metadata: [String: AnyDecodable] + var paywallComponents: PaywallComponentsData? + var draftPaywallComponents: PaywallComponentsData? + let webCheckoutUrl: URL? + } + + struct Placements { + let fallbackOfferingId: String? + @DefaultDecodable.EmptyDictionary + var offeringIdsByPlacement: [String: String?] + } + + struct Targeting { + let revision: Int + let ruleId: String + } + + let currentOfferingId: String? + let offerings: [Offering] + let placements: Placements? + let targeting: Targeting? + let uiConfig: UIConfig? + +} + +extension OfferingsResponse { + + var productIdentifiers: Set { + return Set( + self.offerings + .lazy + .flatMap { $0.packages } + .map { $0.platformProductIdentifier } + ) + } + + var hasAnyWebCheckoutUrl: Bool { + return self.offerings + .lazy + .contains { $0.webCheckoutUrl != nil } + } + + var packages: [Offering.Package] { + return self.offerings.flatMap { $0.packages } + } +} + +extension OfferingsResponse.Offering.Package: Codable, Equatable {} +extension OfferingsResponse.Offering: Codable, Equatable {} +extension OfferingsResponse.Placements: Codable, Equatable {} +extension OfferingsResponse.Targeting: Codable, Equatable {} +extension OfferingsResponse: Codable, Equatable {} + +extension OfferingsResponse: HTTPResponseBody {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/PostOfferResponse.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/PostOfferResponse.swift new file mode 100644 index 00000000..f52d1f55 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/PostOfferResponse.swift @@ -0,0 +1,57 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PostOfferResponse.swift +// +// Created by Nacho Soto on 5/12/22. + +import Foundation + +// swiftlint:disable nesting + +struct PostOfferResponse { + + struct Offer { + + struct SignatureData { + + let nonce: UUID + let signature: String + let timestamp: Int + + } + + let keyIdentifier: String + let offerIdentifier: String + let productIdentifier: String + let signatureData: SignatureData? + let signatureError: ErrorResponse? + + } + + let offers: [Offer] +} + +extension PostOfferResponse.Offer.SignatureData: Decodable {} +extension PostOfferResponse.Offer: Decodable { + + enum CodingKeys: String, CodingKey { + + case keyIdentifier = "keyId" + case offerIdentifier = "offerId" + case productIdentifier = "productId" + case signatureError + case signatureData + + } + +} + +extension PostOfferResponse: Decodable {} +extension PostOfferResponse: HTTPResponseBody {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/ProductEntitlementMappingResponse.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/ProductEntitlementMappingResponse.swift new file mode 100644 index 00000000..8cbf7cf1 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/ProductEntitlementMappingResponse.swift @@ -0,0 +1,57 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ProductEntitlementMappingResponse.swift +// +// Created by Nacho Soto on 3/17/23. + +import Foundation + +/// Response from product entitlement mapping endpoint +/// - Seealso: `ProductEntitlementMapping` +struct ProductEntitlementMappingResponse: Equatable { + + var products: [String: Product] + +} + +extension ProductEntitlementMappingResponse { + + struct Product: Equatable { + + var identifier: String + var entitlements: [String] + + } + +} + +// MARK: - Codable + +extension ProductEntitlementMappingResponse.Product: Codable { + + private enum CodingKeys: String, CodingKey { + + case identifier = "productIdentifier" + case entitlements + + } + +} + +extension ProductEntitlementMappingResponse: Codable { + + private enum CodingKeys: String, CodingKey { + + case products = "productEntitlementMapping" + + } + +} +extension ProductEntitlementMappingResponse: HTTPResponseBody {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/RevenueCatUI/PaywallComponentsData.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/RevenueCatUI/PaywallComponentsData.swift new file mode 100644 index 00000000..7c8617c3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/RevenueCatUI/PaywallComponentsData.swift @@ -0,0 +1,250 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallComponentsData.swift +// +// Created by Josh Holtz on 11/11/24. +// swiftlint:disable identifier_name missing_docs + +import Foundation + +public struct PaywallComponentsData: Codable, Equatable, Sendable { + + public struct ComponentsConfig: Codable, Equatable, Sendable { + + public var base: PaywallComponentsConfig + + public init(base: PaywallComponentsConfig) { + self.base = base + } + + } + + public struct PaywallComponentsConfig: Codable, Equatable, Sendable { + + public var stack: PaywallComponent.StackComponent + public let stickyFooter: PaywallComponent.StickyFooterComponent? + public var background: PaywallComponent.Background + + public init( + stack: PaywallComponent.StackComponent, + stickyFooter: PaywallComponent.StickyFooterComponent?, + background: PaywallComponent.Background + ) { + self.stack = stack + self.stickyFooter = stickyFooter + self.background = background + } + + } + + public enum LocalizationData: Codable, Equatable, Sendable { + case string(String), image(PaywallComponent.ThemeImageUrls) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let stringValue = try? container.decode(String.self) { + self = .string(stringValue) + } else if let imageValue = try? container.decode(PaywallComponent.ThemeImageUrls.self) { + self = .image(imageValue) + } else { + throw DecodingError.typeMismatch( + LocalizationData.self, + DecodingError.Context(codingPath: decoder.codingPath, + debugDescription: "Wrong type for LocalizationData") + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let stringValue): + try container.encode(stringValue) + case .image(let imageValue): + try container.encode(imageValue) + } + } + } + + /// The unique identifier for this paywall. + public var id: String? + + public var templateName: String + + /// The base remote URL where assets for this paywall are stored. + public var assetBaseURL: URL + + /// The revision identifier for this paywall. + public var revision: Int { + get { return self._revision } + set { self._revision = newValue } + } + + /// The storefront country codes that should display whole number prices without decimal places. + /// For example, in these countries "$60.00" would be displayed as "$60". + public private(set) var zeroDecimalPlaceCountries: [String] = [] + + public var componentsConfig: ComponentsConfig + public var componentsLocalizations: [PaywallComponent.LocaleID: PaywallComponent.LocalizationDictionary] + public var defaultLocale: String + + /// Exit offers configuration for this paywall. + public var exitOffers: ExitOffers? + + @DefaultDecodable.Zero + internal private(set) var _revision: Int = 0 + + public var errorInfo: [String: EquatableError]? + + private enum CodingKeys: String, CodingKey { + case id + case templateName + case componentsConfig + case componentsLocalizations + case defaultLocale + case assetBaseURL = "assetBaseUrl" + case _revision = "revision" + case zeroDecimalPlaceCountries + case exitOffers + } + + public init(id: String? = nil, + templateName: String, + assetBaseURL: URL, + componentsConfig: ComponentsConfig, + componentsLocalizations: [PaywallComponent.LocaleID: PaywallComponent.LocalizationDictionary], + revision: Int, + defaultLocaleIdentifier: String, + zeroDecimalPlaceCountries: [String] = [], + exitOffers: ExitOffers? = nil) { + self.id = id + self.templateName = templateName + self.assetBaseURL = assetBaseURL + self.componentsConfig = componentsConfig + self.componentsLocalizations = componentsLocalizations + self._revision = revision + self.defaultLocale = defaultLocaleIdentifier + self.zeroDecimalPlaceCountries = zeroDecimalPlaceCountries + self.exitOffers = exitOffers + } + +} + +extension PaywallComponentsData { + + // swiftlint:disable:next function_body_length + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + var errors: [String: EquatableError] = [:] + + id = try container.decodeIfPresent(String.self, forKey: .id) + + do { + templateName = try container.decode(String.self, forKey: .templateName) + } catch { + errors["templateName"] = .init(error) + templateName = "" + } + + do { + assetBaseURL = try container.decode(URL.self, forKey: .assetBaseURL) + } catch { + errors["assetBaseURL"] = .init(error) + // swiftlint:disable:next force_unwrapping + assetBaseURL = URL(string: "https://example.com")! + } + + do { + componentsConfig = try container.decode(ComponentsConfig.self, forKey: .componentsConfig) + } catch { + errors["componentsConfig"] = .init(error) + componentsConfig = ComponentsConfig(base: PaywallComponentsConfig( + stack: .init(components: []), + stickyFooter: nil, + background: .color(.init(light: .hex("#ffffff"))) + )) + } + + do { + componentsLocalizations = try container.decode( + [PaywallComponent.LocaleID: PaywallComponent.LocalizationDictionary].self, + forKey: .componentsLocalizations + ) + } catch { + errors["componentsLocalizations"] = .init(error) + componentsLocalizations = [:] + } + + do { + defaultLocale = try container.decode(String.self, forKey: .defaultLocale) + } catch { + errors["defaultLocale"] = .init(error) + defaultLocale = "en" + } + + do { + _revision = try container.decode(Int.self, forKey: ._revision) + } catch { + errors["_revision"] = .init(error) + _revision = 0 + } + + exitOffers = try container.decodeIfPresent(ExitOffers.self, forKey: .exitOffers) + + // Decode zeroDecimalPlaceCountries from the nested structure { "apple": [...] } + if let zeroDecimalData = try container.decodeIfPresent( + PaywallData.ZeroDecimalPlaceCountries.self, + forKey: .zeroDecimalPlaceCountries + ) { + zeroDecimalPlaceCountries = zeroDecimalData.apple + } else { + zeroDecimalPlaceCountries = [] + } + + if !errors.isEmpty { + errorInfo = errors + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(id, forKey: .id) + try container.encode(templateName, forKey: .templateName) + try container.encode(assetBaseURL, forKey: .assetBaseURL) + try container.encode(componentsConfig, forKey: .componentsConfig) + try container.encode(componentsLocalizations, forKey: .componentsLocalizations) + try container.encode(defaultLocale, forKey: .defaultLocale) + try container.encode(_revision, forKey: ._revision) + // Encode zeroDecimalPlaceCountries in the nested structure { "apple": [...] } + try container.encode( + PaywallData.ZeroDecimalPlaceCountries(apple: zeroDecimalPlaceCountries), + forKey: .zeroDecimalPlaceCountries + ) + try container.encodeIfPresent(exitOffers, forKey: .exitOffers) + } + +} + +extension PaywallComponentsData { + + public struct EquatableError: Equatable, Sendable { + let description: String + + init(_ error: Error) { + self.description = String(describing: error) + } + + public static func == (lhs: EquatableError, rhs: EquatableError) -> Bool { + return lhs.description == rhs.description + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/RevenueCatUI/UIConfig.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/RevenueCatUI/UIConfig.swift new file mode 100644 index 00000000..d8e1bdc4 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/RevenueCatUI/UIConfig.swift @@ -0,0 +1,204 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// UIConfig.swift +// +// Created by Josh Holtz on 12/31/24. +// swiftlint:disable missing_docs + +import Foundation + +#if !os(tvOS) // For Paywalls V2 + +public struct UIConfig: Codable, Equatable, Sendable { + + public struct AppConfig: Codable, Equatable, Sendable { + + public var colors: [String: PaywallComponent.ColorScheme] + public var fonts: [String: FontsConfig] + + public init(colors: [String: PaywallComponent.ColorScheme], + fonts: [String: FontsConfig]) { + self.colors = colors + self.fonts = fonts + } + + } + + public struct FontsConfig: Codable, Equatable, Sendable { + @_spi(Internal) public let ios: FontInfo + + @_spi(Internal) public init(ios: FontInfo) { + self.ios = ios + } + } + + @_spi(Internal) public struct FontInfo: Codable, Sendable, Hashable { + @_spi(Internal) public let type: FontInfoType + @_spi(Internal) public let value: String + let webFontInfo: WebFontInfo? + + @_spi(Internal) public init(name: String, webFontInfo: WebFontInfo? = nil) { + self.type = .name + self.value = name + self.webFontInfo = webFontInfo + } + + // swiftlint:disable:next nesting + enum CodingKeys: String, CodingKey { + case type + case value + } + + @_spi(Internal) public init(from decoder: Decoder) throws { + self.webFontInfo = try? WebFontInfo(from: decoder) + let container = try decoder.container(keyedBy: CodingKeys.self) + self.type = try container.decode(FontInfoType.self, forKey: .type) + self.value = try container.decode(String.self, forKey: .value) + } + + @_spi(Internal) public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: UIConfig.FontInfo.CodingKeys.self) + try container.encode(self.type, forKey: UIConfig.FontInfo.CodingKeys.type) + try container.encode(self.value, forKey: UIConfig.FontInfo.CodingKeys.value) + } + + // swiftlint:disable:next nesting + @_spi(Internal) public enum FontInfoType: String, Codable, Sendable { + case name + case googleFonts = "google_fonts" + } + } + + @_spi(Internal) public struct WebFontInfo: Codable, Sendable, Hashable { + + /// The font family name. + internal let family: String? + + /// The remote URL to the font resource file. + internal let url: String + + /// MD5 hash of the font file. + /// + /// Should never be `nil`, but it is optional to prevent potential decoding errors if, for some reason, + /// the hash is not provided from the server. + internal let hash: String + + @_spi(Internal) public init(url: String, hash: String) { + self.family = nil + self.url = url + self.hash = hash + } + } + + public struct VariableConfig: Codable, Equatable, Sendable { + + public var variableCompatibilityMap: [String: String] + public var functionCompatibilityMap: [String: String] + + public init( + variableCompatibilityMap: [String: String], + functionCompatibilityMap: [String: String] + ) { + self.variableCompatibilityMap = variableCompatibilityMap + self.functionCompatibilityMap = functionCompatibilityMap + } + + } + + /// Definition of a custom variable as configured in the RevenueCat dashboard. + public struct CustomVariableDefinition: Codable, Equatable, Sendable { + + /// The type of the variable: "string", "boolean", or "number". + public let type: String + + /// The default value for this variable (always stored as a string). + public let defaultValue: String + + public init(type: String, defaultValue: String) { + self.type = type + self.defaultValue = defaultValue + } + + // Note: Using camelCase rawValues because JSONDecoder.default uses .convertFromSnakeCase + // JSON "default_value" → converted to "defaultValue" → matches CodingKey .defaultValue + // swiftlint:disable:next nesting + private enum CodingKeys: String, CodingKey { + case type + case defaultValue + } + + } + + public var app: AppConfig + public var localizations: [String: [String: String]] + public var variableConfig: VariableConfig + + /// Custom variables defined in the RevenueCat dashboard. + /// Keys are variable names, values contain type and default value. + public var customVariables: [String: CustomVariableDefinition] + + // Note: CodingKeys use camelCase rawValues (the default) because JSONDecoder.default + // uses .convertFromSnakeCase which converts JSON keys before matching against CodingKeys. + // JSON "custom_variables" → converted to "customVariables" → matches CodingKey .customVariables + private enum CodingKeys: String, CodingKey { + case app + case localizations + case variableConfig + case customVariables + } + + public init(app: AppConfig, + localizations: [String: [String: String]], + variableConfig: VariableConfig, + customVariables: [String: CustomVariableDefinition] = [:]) { + self.app = app + self.localizations = localizations + self.variableConfig = variableConfig + self.customVariables = customVariables + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.app = try container.decode(AppConfig.self, forKey: .app) + self.localizations = try container.decode([String: [String: String]].self, forKey: .localizations) + self.variableConfig = try container.decodeIfPresent( + VariableConfig.self, + forKey: .variableConfig + ) ?? VariableConfig(variableCompatibilityMap: [:], functionCompatibilityMap: [:]) + + // Try to decode custom_variables with detailed error logging + do { + self.customVariables = try container.decodeIfPresent( + [String: CustomVariableDefinition].self, + forKey: .customVariables + ) ?? [:] + } catch { + Logger.error(Strings.offering.ui_config_custom_variables_decode_error(error: error)) + self.customVariables = [:] + } + + // Debug logging for custom variables + let hasCustomVariablesKey = container.contains(.customVariables) + Logger.debug(Strings.offering.ui_config_custom_variables_status( + keyPresent: hasCustomVariablesKey, + count: self.customVariables.count, + keys: Array(self.customVariables.keys) + )) + } + +} + +#else + +public struct UIConfig: Codable, Equatable, Sendable { + +} + +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/VirtualCurrenciesResponse.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/VirtualCurrenciesResponse.swift new file mode 100644 index 00000000..48932c9b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/VirtualCurrenciesResponse.swift @@ -0,0 +1,35 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// VirtualCurrenciesResponse.swift +// +// Created by Will Taylor on 6/10/25. + +import Foundation + +struct VirtualCurrenciesResponse { + + let virtualCurrencies: [String: VirtualCurrencyResponse] + + struct VirtualCurrencyResponse { + let balance: Int + let name: String + let code: String + let description: String? + } +} + +extension VirtualCurrenciesResponse.VirtualCurrencyResponse: Codable, Equatable {} +extension VirtualCurrenciesResponse: Codable, Equatable {} + +extension VirtualCurrenciesResponse: HTTPResponseBody { + static func create(with data: Data) throws -> Self { + return try JSONDecoder.default.decode(Self.self, from: data) + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/WebBillingProductsResponse.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/WebBillingProductsResponse.swift new file mode 100644 index 00000000..98cb147b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/WebBillingProductsResponse.swift @@ -0,0 +1,80 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// WebBillingProductsResponse.swift +// +// Created by Antonio Pallares on 23/7/25. + +import Foundation + +struct WebBillingProductsResponse { + + struct Price { + let amountMicros: Int64 + // This will be a 3-letter currency code + let currency: String + } + + struct PricingPhase { + let periodDuration: String? + let price: Price? + let cycleCount: Int + } + + enum ProductType: String { + case subscription + case consumable + case nonConsumable = "non_consumable" + case unknown + } + + struct PurchaseOption { + // Only for non-subscriptions + @IgnoreDecodeErrors + var basePrice: Price? + + // Only for subscriptions + @IgnoreDecodeErrors + var base: PricingPhase? + @IgnoreDecodeErrors + var trial: PricingPhase? + @IgnoreDecodeErrors + var introPrice: PricingPhase? + } + + struct Product { + let identifier: String + let productType: ProductType + let title: String + let description: String? + let defaultPurchaseOptionId: String? + let purchaseOptions: [String: PurchaseOption] + } + + let productDetails: [Product] + +} + +extension WebBillingProductsResponse.Product: Codable, Equatable {} +extension WebBillingProductsResponse.PurchaseOption: Codable, Equatable {} +extension WebBillingProductsResponse.PricingPhase: Codable, Equatable {} +extension WebBillingProductsResponse.Price: Codable, Equatable {} + +extension WebBillingProductsResponse: Codable, Equatable {} + +extension WebBillingProductsResponse: HTTPResponseBody {} + +extension WebBillingProductsResponse.ProductType: Codable, Equatable { + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = WebBillingProductsResponse.ProductType(rawValue: rawValue) ?? .unknown + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/WebOfferingProductsResponse.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/WebOfferingProductsResponse.swift new file mode 100644 index 00000000..1210a42f --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/Responses/WebOfferingProductsResponse.swift @@ -0,0 +1,39 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// WebOfferingProductsResponse.swift +// +// Created by Toni Rico on 5/6/25. + +import Foundation + +struct WebOfferingProductsResponse { + + struct Package { + let identifier: String + let webCheckoutUrl: String + let productDetails: WebBillingProductsResponse.Product + } + + struct Offering { + let identifier: String + let description: String? + let packages: [String: Package] + } + + let offerings: [String: Offering] + +} + +extension WebOfferingProductsResponse.Offering: Codable, Equatable {} +extension WebOfferingProductsResponse.Package: Codable, Equatable {} + +extension WebOfferingProductsResponse: Codable, Equatable {} + +extension WebOfferingProductsResponse: HTTPResponseBody {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/VirtualCurrenciesAPI.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/VirtualCurrenciesAPI.swift new file mode 100644 index 00000000..e14a8fb3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/VirtualCurrenciesAPI.swift @@ -0,0 +1,52 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// VirtualCurrenciesAPI.swift +// +// Created by Will Taylor on 6/10/25. + +import Foundation + +class VirtualCurrenciesAPI { + + typealias VirtualCurrenciesResponseHandler = Backend.ResponseHandler + + private let virtualCurrenciesResponseCallbacksCache: CallbackCache + private let backendConfig: BackendConfiguration + + init(backendConfig: BackendConfiguration) { + self.backendConfig = backendConfig + self.virtualCurrenciesResponseCallbacksCache = .init() + } + + func getVirtualCurrencies( + appUserID: String, + isAppBackgrounded: Bool, + completion: @escaping VirtualCurrenciesResponseHandler + ) { + let config = NetworkOperation.UserSpecificConfiguration( + httpClient: self.backendConfig.httpClient, + appUserID: appUserID + ) + + let factory = GetVirtualCurrenciesOperation.createFactory( + configuration: config, + callbackCache: self.virtualCurrenciesResponseCallbacksCache + ) + + let callback = VirtualCurrenciesCallback(cacheKey: factory.cacheKey, completion: completion) + let cacheStatus = self.virtualCurrenciesResponseCallbacksCache.add(callback) + + self.backendConfig.addCacheableOperation( + with: factory, + delay: .default(forBackgroundedApp: isAppBackgrounded), + cacheStatus: cacheStatus + ) + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/WebBillingAPI.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/WebBillingAPI.swift new file mode 100644 index 00000000..d8fa04b5 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/WebBillingAPI.swift @@ -0,0 +1,53 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// WebBillingAPI.swift +// +// Created by Antonio Pallares on 7/29/25. + +import Foundation + +class WebBillingAPI { + + typealias WebBillingProductsResponseHandler = Backend.ResponseHandler + + private let webBillingProductsCallbackCache: CallbackCache + private let backendConfig: BackendConfiguration + + init(backendConfig: BackendConfiguration) { + self.backendConfig = backendConfig + self.webBillingProductsCallbackCache = .init() + } + + func getWebBillingProducts( + appUserID: String, productIds: Set, completion: @escaping WebBillingProductsResponseHandler + ) { + let config = NetworkOperation.UserSpecificConfiguration(httpClient: self.backendConfig.httpClient, + appUserID: appUserID) + let factory = GetWebBillingProductsOperation.createFactory( + configuration: config, + webBillingProductsCallbackCache: self.webBillingProductsCallbackCache, + productIds: productIds + ) + + let webProductsCallback = WebBillingProductsCallback(cacheKey: factory.cacheKey, completion: completion) + let cacheStatus = self.webBillingProductsCallbackCache.add(webProductsCallback) + + self.backendConfig.addCacheableOperation( + with: factory, + delay: .none, + cacheStatus: cacheStatus + ) + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension WebBillingAPI: @unchecked Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/WebBillingHTTPRequestPath.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/WebBillingHTTPRequestPath.swift new file mode 100644 index 00000000..3d8b3812 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Networking/WebBillingHTTPRequestPath.swift @@ -0,0 +1,75 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// WebBillingHTTPRequestPath.swift +// +// Created by Toni Rico on 5/6/25. + +import Foundation + +extension HTTPRequest.WebBillingPath: HTTPRequestPath { + + // swiftlint:disable:next force_unwrapping + static let serverHostURL = URL(string: "https://api.revenuecat.com")! + + var authenticated: Bool { + switch self { + case .getWebOfferingProducts, + .getWebBillingProducts: + return true + } + } + + var shouldSendEtag: Bool { + switch self { + case .getWebOfferingProducts, + .getWebBillingProducts: + return true + } + } + + var supportsSignatureVerification: Bool { + switch self { + case .getWebOfferingProducts, + .getWebBillingProducts: + return false + } + } + + var needsNonceForSigning: Bool { + switch self { + case .getWebOfferingProducts, + .getWebBillingProducts: + return false + } + } + + var relativePath: String { + switch self { + case let .getWebOfferingProducts(appUserID): + return "/rcbilling/v1/subscribers/\(appUserID.trimmedAndEscaped)/offering_products" + case let .getWebBillingProducts(userId, productIds): + let encodedUserId = userId.trimmedAndEscaped + let encodedProductIds = productIds.map { productId in + "id=\(productId.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? productId)" + }.joined(separator: "&") + return "/rcbilling/v1/subscribers/\(encodedUserId)/products?\(encodedProductIds)" + } + } + + var name: String { + switch self { + case .getWebOfferingProducts: + return "get_web_offering_products" + case .getWebBillingProducts: + return "get_web_products" + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/OfflineEntitlements/CustomerInfo+OfflineEntitlements.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/OfflineEntitlements/CustomerInfo+OfflineEntitlements.swift new file mode 100644 index 00000000..8fe75033 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/OfflineEntitlements/CustomerInfo+OfflineEntitlements.swift @@ -0,0 +1,107 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CustomerInfo+OfflineEntitlements.swift +// +// Created by Nacho Soto on 3/21/23. + +import Foundation + +extension CustomerInfo { + + typealias OfflineCreator = ([PurchasedSK2Product], + ProductEntitlementMapping, + String) -> CustomerInfo + + convenience init( + from purchasedSK2Products: [PurchasedSK2Product], + mapping: ProductEntitlementMapping, + userID: String, + sandboxEnvironmentDetector: SandboxEnvironmentDetector = BundleSandboxEnvironmentDetector.default + ) { + let subscriber = CustomerInfoResponse.Subscriber( + originalAppUserId: userID, + managementUrl: SystemInfo.appleSubscriptionsURL, + originalApplicationVersion: SystemInfo.buildVersion, + originalPurchaseDate: Date(), + firstSeen: Date(), + subscriptions: purchasedSK2Products + .dictionaryAllowingDuplicateKeys { $0.productIdentifier } + .mapValues { $0.subscription }, + nonSubscriptions: [:], + entitlements: Self.createEntitlements(with: purchasedSK2Products, mapping: mapping) + ) + + let content: CustomerInfoResponse = .init( + subscriber: subscriber, + requestDate: Date(), + rawData: (try? subscriber.asJSONDictionary()) ?? [:] + ) + + self.init( + response: content, + entitlementVerification: Self.verification, + sandboxEnvironmentDetector: sandboxEnvironmentDetector, + httpResponseOriginalSource: nil + ) + } + +} + +// MARK: - Private + +private extension CustomerInfo { + + static func createEntitlements( + with products: [PurchasedSK2Product], + mapping: ProductEntitlementMapping + ) -> [String: CustomerInfoResponse.Entitlement] { + func shouldOverride(prior: CustomerInfoResponse.Entitlement, + new: CustomerInfoResponse.Entitlement) -> Bool { + guard let priorExpiration = prior.expiresDate else { + // Prior entitlement is lifetime + return false + } + + guard let newExpiration = new.expiresDate else { + // New entitlement is lifetime + return true + } + + return newExpiration > priorExpiration + } + + var result: [String: CustomerInfoResponse.Entitlement] = .init(minimumCapacity: products.count) + + for product in products { + for entitlement in mapping.entitlements(for: product.productIdentifier) { + if let priorEntitlement = result[entitlement], + !shouldOverride(prior: priorEntitlement, new: product.entitlement) { + continue + } + + result[entitlement] = product.entitlement + } + } + + return result + } + + /// Purchases are verified with StoreKit 2. + private static let verification: VerificationResult = .verifiedOnDevice + +} + +internal extension CustomerInfo { + + var isComputedOffline: Bool { + return self.entitlements.verification == .verifiedOnDevice + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/OfflineEntitlements/OfflineCustomerInfoCreator.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/OfflineEntitlements/OfflineCustomerInfoCreator.swift new file mode 100644 index 00000000..e1a90d85 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/OfflineEntitlements/OfflineCustomerInfoCreator.swift @@ -0,0 +1,162 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// OfflineCustomerInfoCreator.swift +// +// Created by Nacho Soto on 5/18/23. + +import Foundation + +/// Holds the necessary dependencies to create a `CustomerInfo` while offline. +class OfflineCustomerInfoCreator { + + typealias Creator = @Sendable ([PurchasedSK2Product], + ProductEntitlementMapping, + String) -> CustomerInfo + + private let purchasedProductsFetcher: PurchasedProductsFetcherType + private let productEntitlementMappingFetcher: ProductEntitlementMappingFetcher + private let tracker: DiagnosticsTrackerType? + private let creator: Creator + + static func createPurchasedProductsFetcherIfAvailable( + diagnosticsTracker: DiagnosticsTrackerType? + ) -> PurchasedProductsFetcherType? { + if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { + return PurchasedProductsFetcher( + storeKit2TransactionFetcher: StoreKit2TransactionFetcher(diagnosticsTracker: diagnosticsTracker) + ) + } else { + return nil + } + } + + static func createIfAvailable( + with purchasedProductsFetcher: PurchasedProductsFetcherType?, + productEntitlementMappingFetcher: ProductEntitlementMappingFetcher, + tracker: DiagnosticsTrackerType?, + observerMode: Bool + ) -> OfflineCustomerInfoCreator? { + guard let fetcher = purchasedProductsFetcher, !observerMode else { + Logger.debug(Strings.offlineEntitlements.offline_entitlements_not_available) + return nil + } + + return .init(purchasedProductsFetcher: fetcher, + productEntitlementMappingFetcher: productEntitlementMappingFetcher, + tracker: tracker) + } + + convenience init(purchasedProductsFetcher: PurchasedProductsFetcherType, + productEntitlementMappingFetcher: ProductEntitlementMappingFetcher, + tracker: DiagnosticsTrackerType?) { + self.init( + purchasedProductsFetcher: purchasedProductsFetcher, + productEntitlementMappingFetcher: productEntitlementMappingFetcher, + tracker: tracker, + creator: { products, mapping, userID in + CustomerInfo(from: products, mapping: mapping, userID: userID) + } + ) + } + + init( + purchasedProductsFetcher: PurchasedProductsFetcherType, + productEntitlementMappingFetcher: ProductEntitlementMappingFetcher, + tracker: DiagnosticsTrackerType?, + creator: @escaping Creator + ) { + self.purchasedProductsFetcher = purchasedProductsFetcher + self.productEntitlementMappingFetcher = productEntitlementMappingFetcher + self.tracker = tracker + self.creator = creator + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func create(for userID: String) async throws -> CustomerInfo { + do { + Logger.info(Strings.offlineEntitlements.computing_offline_customer_info) + + guard let mapping = self.productEntitlementMappingFetcher.productEntitlementMapping else { + Logger.warn(Strings.offlineEntitlements.computing_offline_customer_info_with_no_entitlement_mapping) + throw Error.noEntitlementMappingAvailable + } + + let products = try await self.purchasedProductsFetcher.fetchPurchasedProducts() + + let offlineCustomerInfo = creator(products, mapping, userID) + + self.tracker?.trackEnteredOfflineEntitlementsMode() + + Logger.info(Strings.offlineEntitlements.computed_offline_customer_info( + products, offlineCustomerInfo.entitlements + )) + Logger.debug(Strings.offlineEntitlements.computed_offline_customer_info_details( + products, offlineCustomerInfo.entitlements + )) + + return offlineCustomerInfo + } catch { + let reason: DiagnosticsEvent.OfflineEntitlementsModeErrorReason + let errorMessage: String + switch error { + case let productsFetcherError as PurchasedProductsFetcher.Error: + switch productsFetcherError { + case .foundConsumablePurchase: + reason = .oneTimePurchaseFound + errorMessage = productsFetcherError.errorUserInfo[NSLocalizedDescriptionKey] as? String ?? "" + } + case let offlineCustomerInfoCreatorError as OfflineCustomerInfoCreator.Error: + switch offlineCustomerInfoCreatorError { + case .noEntitlementMappingAvailable: + reason = .noEntitlementMappingAvailable + errorMessage = offlineCustomerInfoCreatorError.description + } + default: + reason = .unknown + errorMessage = error.localizedDescription + } + + self.tracker?.trackErrorEnteringOfflineEntitlementsMode(reason: reason, errorMessage: errorMessage) + throw error + } + } + +} + +// MARK: - Errors + +private extension OfflineCustomerInfoCreator { + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + enum Error: Swift.Error { + + case noEntitlementMappingAvailable + + } + +} + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +extension OfflineCustomerInfoCreator.Error: DescribableError, CustomNSError { + + var description: String { + switch self { + case .noEntitlementMappingAvailable: + return Strings.offlineEntitlements.computing_offline_customer_info_with_no_entitlement_mapping.description + } + } + + var errorUserInfo: [String: Any] { + return [ + NSLocalizedDescriptionKey: self.description + ] + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/OfflineEntitlements/OfflineEntitlementsManager.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/OfflineEntitlements/OfflineEntitlementsManager.swift new file mode 100644 index 00000000..3a8ef89d --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/OfflineEntitlements/OfflineEntitlementsManager.swift @@ -0,0 +1,122 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// OfflineEntitlementsManager.swift +// +// Created by Nacho Soto on 3/22/23. + +import Foundation + +class OfflineEntitlementsManager { + + private let deviceCache: DeviceCache + private let operationDispatcher: OperationDispatcher + private let api: OfflineEntitlementsAPI + private let systemInfo: SystemInfo + + init(deviceCache: DeviceCache, + operationDispatcher: OperationDispatcher, + api: OfflineEntitlementsAPI, + systemInfo: SystemInfo) { + self.deviceCache = deviceCache + self.operationDispatcher = operationDispatcher + self.api = api + self.systemInfo = systemInfo + } + + func updateProductsEntitlementsCacheIfStale( + isAppBackgrounded: Bool, + completion: (@MainActor @Sendable (Result<(), Error>) -> Void)? + ) { + guard #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *), + self.systemInfo.supportsOfflineEntitlements else { + Logger.debug(Strings.offlineEntitlements.product_entitlement_mapping_unavailable) + + self.dispatchCompletionOnMainThreadIfPossible(completion, result: .failure(.notAvailable)) + return + } + + guard self.deviceCache.isProductEntitlementMappingCacheStale else { + self.dispatchCompletionOnMainThreadIfPossible(completion, result: .success(())) + return + } + + Logger.debug(Strings.offlineEntitlements.product_entitlement_mapping_stale_updating) + + self.api.getProductEntitlementMapping(isAppBackgrounded: isAppBackgrounded) { result in + switch result { + case let .success(response): + self.handleProductEntitlementMappingBackendResult(with: response) + + case let .failure(error): + self.handleProductsEntitlementsUpdateError(error) + } + + self.dispatchCompletionOnMainThreadIfPossible( + completion, + result: result + .map { _ in () } + .mapError(Error.backend) + ) + } + } + + func shouldComputeOfflineCustomerInfo(appUserID: String) -> Bool { + return self.isOfflineEntitlementsEnabled() && + self.deviceCache.cachedCustomerInfoData(appUserID: appUserID) == nil + } + + // We diable offline entitlements for the Test Store since there's no store where to store the client's purchases + private func isOfflineEntitlementsEnabled() -> Bool { + return !self.systemInfo.isSimulatedStoreAPIKey + } + +} + +extension OfflineEntitlementsManager { + + enum Error: Swift.Error { + + case backend(BackendError) + /// Offline entitlements require iOS 15+, and not available for custom entitlements computation + case notAvailable + + } + +} + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +private extension OfflineEntitlementsManager { + + func handleProductEntitlementMappingBackendResult(with response: ProductEntitlementMappingResponse) { + Logger.debug(Strings.offlineEntitlements.product_entitlement_mapping_updated_from_network) + + self.deviceCache.store(productEntitlementMapping: response.toMapping()) + } + + func handleProductsEntitlementsUpdateError(_ error: BackendError) { + Logger.error(Strings.offlineEntitlements.product_entitlement_mapping_fetching_error(error)) + } + +} + +private extension OfflineEntitlementsManager { + + func dispatchCompletionOnMainThreadIfPossible( + _ completion: (@MainActor @Sendable (Result) -> Void)?, + result: Result + ) { + if let completion = completion { + self.operationDispatcher.dispatchOnMainActor { + completion(result) + } + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/OfflineEntitlements/ProductEntitlementMapping.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/OfflineEntitlements/ProductEntitlementMapping.swift new file mode 100644 index 00000000..9bfcee55 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/OfflineEntitlements/ProductEntitlementMapping.swift @@ -0,0 +1,49 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ProductEntitlementMapping.swift +// +// Created by Nacho Soto on 3/22/23. + +import Foundation + +/// A mapping between products and entitlements. +struct ProductEntitlementMapping { + + var entitlementsByProduct: [String: [String]] + +} + +extension ProductEntitlementMapping { + + /// - Returns: entitlement identifiers associated to the given product identifier + func entitlements(for productIdentifier: String) -> [String] { + return self.entitlementsByProduct[productIdentifier] ?? [] + } + +} + +extension ProductEntitlementMapping { + + static let empty: Self = .init(entitlementsByProduct: [:]) + +} + +extension ProductEntitlementMappingResponse { + + func toMapping() -> ProductEntitlementMapping { + return .init(entitlementsByProduct: self.products.mapValues { $0.entitlements }) + } + +} + +// MARK: - + +extension ProductEntitlementMapping: Equatable {} +extension ProductEntitlementMapping: Codable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/OfflineEntitlements/ProductEntitlementMappingFetcher.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/OfflineEntitlements/ProductEntitlementMappingFetcher.swift new file mode 100644 index 00000000..16b95d89 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/OfflineEntitlements/ProductEntitlementMappingFetcher.swift @@ -0,0 +1,21 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ProductEntitlementMappingFetcher.swift +// +// Created by Nacho Soto on 3/23/23. + +import Foundation + +/// A type that can synchronously fetch `ProductEntitlementMapping`. +protocol ProductEntitlementMappingFetcher { + + var productEntitlementMapping: ProductEntitlementMapping? { get } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/OfflineEntitlements/PurchasedProductsFetcher.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/OfflineEntitlements/PurchasedProductsFetcher.swift new file mode 100644 index 00000000..b004e7c7 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/OfflineEntitlements/PurchasedProductsFetcher.swift @@ -0,0 +1,127 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PurchasedProductsFetcher.swift +// +// Created by Andrés Boedo on 3/17/23. + +import Foundation +import StoreKit + +protocol PurchasedProductsFetcherType { + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func fetchPurchasedProducts() async throws -> [PurchasedSK2Product] + + func clearCache() + +} + +/// A type that can fetch purchased products from StoreKit 2. +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +final class PurchasedProductsFetcher: PurchasedProductsFetcherType { + + private typealias Transactions = [StoreKit.VerificationResult] + + private let transactionFetcher: StoreKit2TransactionFetcherType + private let sandboxDetector: SandboxEnvironmentDetector + private let cache: InMemoryCachedObject + + init( + storeKit2TransactionFetcher: StoreKit2TransactionFetcherType, + sandboxDetector: SandboxEnvironmentDetector = BundleSandboxEnvironmentDetector() + ) { + self.sandboxDetector = sandboxDetector + self.transactionFetcher = storeKit2TransactionFetcher + self.cache = .init() + } + + func fetchPurchasedProducts() async throws -> [PurchasedSK2Product] { + var result: [PurchasedSK2Product] = [] + + for transaction in try await self.transactions { + switch transaction { + case let .unverified(transaction, verificationError): + Logger.appleWarning( + Strings.offlineEntitlements.found_unverified_transactions_in_sk2(transactionID: transaction.id, + verificationError) + ) + case let .verified(verifiedTransaction): + result.append(.init(from: verifiedTransaction, + sandboxEnvironmentDetector: self.sandboxDetector)) + } + } + + return result + } + + func clearCache() { + Logger.debug(Strings.offlineEntitlements.purchased_products_invalidating_cache) + + self.cache.clearCache() + } + + private static let cacheDuration: DispatchTimeInterval = .minutes(5) + + private var transactions: Transactions { + get async throws { + if !self.cache.isCacheStale(durationInSeconds: Self.cacheDuration.seconds), + let cache = self.cache.cachedInstance, !cache.isEmpty { + Logger.debug(Strings.offlineEntitlements.purchased_products_returning_cache(count: cache.count)) + return cache + } + + let result = try await TimingUtil.measureAndLogIfTooSlow( + threshold: .purchasedProducts, + message: Strings.offlineEntitlements.purchased_products_fetching_too_slow + ) { + return try await self.fetchTransactions() + } + + self.cache.cache(instance: result) + return result + } + } + + private func fetchTransactions() async throws -> Transactions { + guard await !self.transactionFetcher.hasPendingConsumablePurchase else { + throw Error.foundConsumablePurchase + } + + Logger.debug(Strings.offlineEntitlements.purchased_products_fetching) + let result = await StoreKit.Transaction.currentEntitlements.extractValues() + Logger.debug(Strings.offlineEntitlements.purchased_products_fetched(count: result.count)) + + return result + } + +} + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +extension PurchasedProductsFetcher: Sendable {} + +// MARK: - Error + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +extension PurchasedProductsFetcher { + + enum Error: Swift.Error, CustomNSError { + + case foundConsumablePurchase + + var errorUserInfo: [String: Any] { + return [ + NSLocalizedDescriptionKey: Strings.offlineEntitlements + .computing_offline_customer_info_for_consumable_product.description + ] + } + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/OfflineEntitlements/PurchasedSK2Product.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/OfflineEntitlements/PurchasedSK2Product.swift new file mode 100644 index 00000000..e9a01a00 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/OfflineEntitlements/PurchasedSK2Product.swift @@ -0,0 +1,89 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PurchasedSK2Product.swift +// +// Created by Nacho Soto on 3/21/23. + +import Foundation +import StoreKit + +/// Contains all information from a StoreKit 2 transaction necessary to create an ``EntitlementInfo``. +struct PurchasedSK2Product { + + let productIdentifier: String + let subscription: CustomerInfoResponse.Subscription + let entitlement: CustomerInfoResponse.Entitlement + +} + +extension PurchasedSK2Product: Equatable {} + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +extension PurchasedSK2Product { + + init( + from transaction: StoreKit.Transaction, + sandboxEnvironmentDetector: SandboxEnvironmentDetector = BundleSandboxEnvironmentDetector.default + ) { + let expiration = transaction.expirationDate + + self.productIdentifier = transaction.productID + self.subscription = .init( + periodType: transaction.offerType?.periodType ?? .normal, + purchaseDate: transaction.purchaseDate, + originalPurchaseDate: transaction.purchaseDate, + expiresDate: transaction.expirationDate, + store: .appStore, + isSandbox: sandboxEnvironmentDetector.isSandbox, + ownershipType: transaction.ownershipType.type + ) + self.entitlement = .init( + expiresDate: expiration, + productIdentifier: transaction.productID, + purchaseDate: transaction.purchaseDate, + rawData: (try? transaction.jsonRepresentation.asJSONDictionary()) ?? [:] + ) + } + +} + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +private extension StoreKit.Transaction.OfferType { + + var periodType: PeriodType { + switch self { + case .code, .promotional: + return .intro + case .introductory: + // note: this isn't entirely accurate, but there's no field in SK2 to + // tell us whether this is a free trial after all, so it's a best guess. + // since free trials are much more common than intro pricing, we're going with + // trial + return .trial + default: + return .normal + } + } + +} + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +private extension StoreKit.Transaction.OwnershipType { + + var type: PurchaseOwnershipType { + switch self { + case .familyShared: + return .familyShared + default: + return .purchased + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Common/Background.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Common/Background.swift new file mode 100644 index 00000000..8bd63eff --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Common/Background.swift @@ -0,0 +1,101 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Background.swift +// +// Created by Josh Holtz on 11/20/24. +// swiftlint:disable missing_docs + +import Foundation + +public extension PaywallComponent { + + enum Background: Codable, Sendable, Hashable { + + case color(ColorScheme) + case image(ThemeImageUrls, FitMode, ColorScheme?) + case video(ThemeVideoUrls, ThemeImageUrls, Loop, MuteAudio, FitMode, ColorScheme?) + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .color(let colorScheme): + try container.encode(BackgroundType.color.rawValue, forKey: .type) + try container.encode(colorScheme, forKey: .value) + case .image(let imageInfo, let fitMode, let colorScheme): + try container.encode(BackgroundType.image.rawValue, forKey: .type) + try container.encode(imageInfo, forKey: .value) + try container.encode(fitMode, forKey: .fitMode) + try container.encodeIfPresent(colorScheme, forKey: .colorOverlay) + case let .video(videoInfo, imageInfo, loop, mute, fitMode, colorScheme): + try container.encode(BackgroundType.video.rawValue, forKey: .type) + try container.encode(videoInfo, forKey: .value) + try container.encode(imageInfo, forKey: .fallbackImage) + try container.encode(loop, forKey: .loop) + try container.encode(mute, forKey: .muteAudio) + try container.encode(fitMode, forKey: .fitMode) + try container.encodeIfPresent(colorScheme, forKey: .colorOverlay) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(BackgroundType.self, forKey: .type) + + switch type { + case .color: + let value = try container.decode(ColorScheme.self, forKey: .value) + self = .color(value) + case .image: + let value = try container.decode(ThemeImageUrls.self, forKey: .value) + let fitMode = try container.decode(FitMode.self, forKey: .fitMode) + let colorScheme = try container.decodeIfPresent(ColorScheme.self, forKey: .colorOverlay) + self = .image(value, fitMode, colorScheme) + case .video: + let value = try container.decode(ThemeVideoUrls.self, forKey: .value) + let image = try container.decode(ThemeImageUrls.self, forKey: .fallbackImage) + let fitMode = try container.decode(FitMode.self, forKey: .fitMode) + let loop = try container.decode(Loop.self, forKey: .loop) + let mute = try container.decode(MuteAudio.self, forKey: .muteAudio) + let colorScheme = try container.decodeIfPresent(ColorScheme.self, forKey: .colorOverlay) + self = .video(value, image, loop, mute, fitMode, colorScheme) + } + } + + // swiftlint:disable:next nesting + private enum CodingKeys: String, CodingKey { + + case type + case value + case fallbackImage + case muteAudio + case loop + case fitMode + case colorOverlay + + } + + // swiftlint:disable:next nesting + private enum BackgroundType: String, Decodable { + + case color + case image + case video + + } + + } + +} + +public extension PaywallComponent.Background { + typealias Loop = Bool + typealias MuteAudio = Bool +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Common/Border.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Common/Border.swift new file mode 100644 index 00000000..50eb7c5a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Common/Border.swift @@ -0,0 +1,31 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Border.swift +// +// Created by Josh Holtz on 9/30/24. +// swiftlint:disable missing_docs + +import Foundation + +public extension PaywallComponent { + + struct Border: Codable, Sendable, Hashable { + + public let color: ColorScheme + public let width: Double + + public init(color: PaywallComponent.ColorScheme, width: Double) { + self.color = color + self.width = width + } + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Common/ComponentOverrides.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Common/ComponentOverrides.swift new file mode 100644 index 00000000..e909f97d --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Common/ComponentOverrides.swift @@ -0,0 +1,115 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ComponentOverrides.swift +// +// Created by Josh Holtz on 10/26/24. +// +// swiftlint:disable missing_docs + +import Foundation + +public protocol PaywallPartialComponent: PaywallComponentBase {} + +public extension PaywallComponent { + + typealias ComponentOverrides = [ComponentOverride] + + struct ComponentOverride: Codable, Sendable, Hashable, Equatable { + + public let conditions: [Condition] + public let properties: T + + public init(conditions: [Condition], properties: T) { + self.conditions = conditions + self.properties = properties + } + + } + + enum Condition: String, Codable, Sendable, Hashable, Equatable { + + case compact + case medium + case expanded + case introOffer = "intro_offer" + case promoOffer = "promo_offer" + case selected + + // For unknown cases + case unsupported + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .compact: + try container.encodeIfPresent(ConditionType.compact.rawValue, forKey: .type) + case .medium: + try container.encode(ConditionType.medium.rawValue, forKey: .type) + case .expanded: + try container.encode(ConditionType.expanded.rawValue, forKey: .type) + case .introOffer: + try container.encode(ConditionType.introOffer.rawValue, forKey: .type) + case .promoOffer: + try container.encode(ConditionType.promoOffer.rawValue, forKey: .type) + case .selected: + try container.encode(ConditionType.selected.rawValue, forKey: .type) + case .unsupported: + // Encode a default value for unsupported + try container.encode(Self.unsupported.rawValue, forKey: .type) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let rawValue = try container.decode(String.self, forKey: .type) + + if let conditionType = ConditionType(rawValue: rawValue) { + switch conditionType { + case .compact: + self = .compact + case .medium: + self = .medium + case .expanded: + self = .expanded + case .introOffer: + self = .introOffer + case .promoOffer: + self = .promoOffer + case .selected: + self = .selected + } + } else { + self = .unsupported + } + } + + // swiftlint:disable:next nesting + private enum CodingKeys: String, CodingKey { + + case type + + } + + // swiftlint:disable:next nesting + private enum ConditionType: String, Decodable { + + case compact + case medium + case expanded + case introOffer = "intro_offer" + case promoOffer = "promo_offer" + case selected + + } + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Common/Dimension.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Common/Dimension.swift new file mode 100644 index 00000000..a864c1b9 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Common/Dimension.swift @@ -0,0 +1,90 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Dimension.swift +// +// Created by Josh Holtz on 9/27/24. +// swiftlint:disable missing_docs + +import Foundation + +public extension PaywallComponent { + + enum Dimension: Codable, Sendable, Hashable { + + case vertical(HorizontalAlignment, FlexDistribution) + case horizontal(VerticalAlignment, FlexDistribution) + case zlayer(TwoDimensionAlignment) + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .vertical(let alignment, let distribution): + try container.encode(DimensionType.vertical.rawValue, forKey: .type) + try container.encode(alignment, forKey: .alignment) + try container.encode(distribution, forKey: .distribution) + case .horizontal(let alignment, let distribution): + try container.encode(DimensionType.horizontal.rawValue, forKey: .type) + try container.encode(alignment, forKey: .alignment) + try container.encode(distribution, forKey: .distribution) + case .zlayer(let alignment): + try container.encode(DimensionType.zlayer.rawValue, forKey: .type) + try container.encode(alignment.rawValue, forKey: .alignment) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(DimensionType.self, forKey: .type) + + switch type { + case .vertical: + let alignment = try container.decode(HorizontalAlignment.self, forKey: .alignment) + let distribution = try container.decode(FlexDistribution.self, forKey: .distribution) + self = .vertical(alignment, distribution) + case .horizontal: + let alignment = try container.decode(VerticalAlignment.self, forKey: .alignment) + let distribution = try container.decode(FlexDistribution.self, forKey: .distribution) + self = .horizontal(alignment, distribution) + case .zlayer: + let alignment = try container.decode(TwoDimensionAlignment.self, forKey: .alignment) + self = .zlayer(alignment) + } + } + + public static func horizontal() -> Dimension { + return .horizontal(.center, .start) + } + + public static func vertical() -> Dimension { + return .vertical(.center, .start) + } + + // swiftlint:disable:next nesting + private enum CodingKeys: String, CodingKey { + + case type + case alignment + case distribution + + } + + // swiftlint:disable:next nesting + private enum DimensionType: String, Decodable { + + case vertical + case horizontal + case zlayer + + } + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Common/PaywallComponentBase.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Common/PaywallComponentBase.swift new file mode 100644 index 00000000..02c404d5 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Common/PaywallComponentBase.swift @@ -0,0 +1,217 @@ +// +// File.swift +// +// +// Created by Josh Holtz on 6/11/24. +// +// swiftlint:disable missing_docs +import Foundation + +public protocol PaywallComponentBase: Codable, Sendable, Hashable, Equatable {} + +public enum PaywallComponent: Codable, Sendable, Hashable, Equatable { + + case text(TextComponent) + case image(ImageComponent) + case icon(IconComponent) + case stack(StackComponent) + case button(ButtonComponent) + case package(PackageComponent) + case purchaseButton(PurchaseButtonComponent) + case stickyFooter(StickyFooterComponent) + case timeline(TimelineComponent) + + case tabs(TabsComponent) + case tabControl(TabControlComponent) + case tabControlButton(TabControlButtonComponent) + case tabControlToggle(TabControlToggleComponent) + + case carousel(CarouselComponent) + + case video(VideoComponent) + + case countdown(CountdownComponent) + + public enum ComponentType: String, Codable, Sendable { + + case text + case image + case icon + case stack + case button + case package + case purchaseButton = "purchase_button" + case stickyFooter = "sticky_footer" + case timeline + + case tabs + case tabControl = "tab_control" + case tabControlButton = "tab_control_button" + case tabControlToggle = "tab_control_toggle" + + case carousel + case video + case countdown + + } + +} + +public extension PaywallComponent { + typealias LocaleID = String + typealias LocalizationDictionary = [String: PaywallComponentsData.LocalizationData] + typealias LocalizationKey = String + typealias ColorHex = String +} + +extension PaywallComponent { + + enum CodingKeys: String, CodingKey { + + case type + case fallback + + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .text(let component): + try container.encode(ComponentType.text, forKey: .type) + try component.encode(to: encoder) + case .image(let component): + try container.encode(ComponentType.image, forKey: .type) + try component.encode(to: encoder) + case .icon(let component): + try container.encode(ComponentType.icon, forKey: .type) + try component.encode(to: encoder) + case .stack(let component): + try container.encode(ComponentType.stack, forKey: .type) + try component.encode(to: encoder) + case .button(let component): + try container.encode(ComponentType.button, forKey: .type) + try component.encode(to: encoder) + case .package(let component): + try container.encode(ComponentType.package, forKey: .type) + try component.encode(to: encoder) + case .purchaseButton(let component): + try container.encode(ComponentType.purchaseButton, forKey: .type) + try component.encode(to: encoder) + case .stickyFooter(let component): + try container.encode(ComponentType.stickyFooter, forKey: .type) + try component.encode(to: encoder) + case .timeline(let component): + try container.encode(ComponentType.timeline, forKey: .type) + try component.encode(to: encoder) + case .tabs(let component): + try container.encode(ComponentType.tabs, forKey: .type) + try component.encode(to: encoder) + case .tabControl(let component): + try container.encode(ComponentType.tabControl, forKey: .type) + try component.encode(to: encoder) + case .tabControlButton(let component): + try container.encode(ComponentType.tabControlButton, forKey: .type) + try component.encode(to: encoder) + case .tabControlToggle(let component): + try container.encode(ComponentType.tabControlToggle, forKey: .type) + try component.encode(to: encoder) + case .carousel(let component): + try container.encode(ComponentType.carousel, forKey: .type) + try component.encode(to: encoder) + case .video(let component): + try container.encode(ComponentType.video, forKey: .type) + try component.encode(to: encoder) + case .countdown(let component): + try container.encode(ComponentType.countdown, forKey: .type) + try component.encode(to: encoder) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Decode the raw string for the `type` field + let typeString = try container.decode(String.self, forKey: .type) + + // Attempt to convert raw string into our `ComponentType` enum + if let type = ComponentType(rawValue: typeString) { + self = try Self.decodeType(from: decoder, type: type) + } else { + if !container.contains(.fallback) { + let context = DecodingError.Context( + codingPath: container.codingPath, + debugDescription: + """ + Failed to decode unknown type "\(typeString)" without a fallback. + """ + ) + throw DecodingError.dataCorrupted(context) + } + + do { + // If `typeString` is unknown, try to decode the fallback + self = try container.decode(PaywallComponent.self, forKey: .fallback) + } catch DecodingError.valueNotFound { + let context = DecodingError.Context( + codingPath: container.codingPath, + debugDescription: + """ + Failed to decode unknown type "\(typeString)" without a fallback. + """ + ) + throw DecodingError.dataCorrupted(context) + } catch { + let context = DecodingError.Context( + codingPath: container.codingPath, + debugDescription: + """ + Failed to decode fallback for unknown type "\(typeString)". + """, + underlyingError: error + ) + throw DecodingError.dataCorrupted(context) + } + } + } + + // swiftlint:disable:next cyclomatic_complexity + private static func decodeType(from decoder: Decoder, type: ComponentType) throws -> PaywallComponent { + switch type { + case .text: + return .text(try TextComponent(from: decoder)) + case .image: + return .image(try ImageComponent(from: decoder)) + case .icon: + return .icon(try IconComponent(from: decoder)) + case .stack: + return .stack(try StackComponent(from: decoder)) + case .button: + return .button(try ButtonComponent(from: decoder)) + case .package: + return .package(try PackageComponent(from: decoder)) + case .purchaseButton: + return .purchaseButton(try PurchaseButtonComponent(from: decoder)) + case .stickyFooter: + return .stickyFooter(try StickyFooterComponent(from: decoder)) + case .timeline: + return .timeline(try TimelineComponent(from: decoder)) + case .tabs: + return .tabs(try TabsComponent(from: decoder)) + case .tabControl: + return .tabControl(try TabControlComponent(from: decoder)) + case .tabControlButton: + return .tabControlButton(try TabControlButtonComponent(from: decoder)) + case .tabControlToggle: + return .tabControlToggle(try TabControlToggleComponent(from: decoder)) + case .carousel: + return .carousel(try CarouselComponent(from: decoder)) + case .video: + return .video(try VideoComponent(from: decoder)) + case .countdown: + return .countdown(try CountdownComponent(from: decoder)) + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Common/PaywallComponentLocalization.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Common/PaywallComponentLocalization.swift new file mode 100644 index 00000000..005992e6 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Common/PaywallComponentLocalization.swift @@ -0,0 +1,54 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallComponentLocalization.swift +// +// Created by James Borthwick on 2024-09-03. +// swiftlint:disable missing_docs + +import Foundation + +extension PaywallComponent.LocalizationDictionary { + + public func string(key: String) throws -> String { + guard case let .string(value) = self[key] else { + throw LocalizationValidationError.missingLocalization( + "Missing string localization for property with id: \"\(key)\"" + ) + } + return value + } + + public func image(key: String) throws -> PaywallComponent.ThemeImageUrls { + guard case let .image(value) = self[key] else { + throw LocalizationValidationError.missingLocalization( + "Missing image localization for property with id: \"\(key)\"" + ) + } + return value + } + + @_spi(Internal) public func url(key: String) throws -> URL { + let string = try self.string(key: key) + guard let url = URL(string: string) else { + throw LocalizationValidationError.invalidUrl( + "Invalid URL localization for property with id: \"\(key)\": \"\(string)\"" + ) + } + return url + } + +} + +enum LocalizationValidationError: Error { + + case missingLocalization(String) + case invalidUrl(String) + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Common/PaywallComponentPropertyTypes.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Common/PaywallComponentPropertyTypes.swift new file mode 100644 index 00000000..1231bdaa --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Common/PaywallComponentPropertyTypes.swift @@ -0,0 +1,658 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallComponentPropertyTypes.swift +// +// Created by James Borthwick on 2024-08-29. +// swiftlint:disable missing_docs file_length + +import Foundation + +public extension PaywallComponent { + + struct ThemeImageUrls: Codable, Sendable, Hashable, Equatable { + + public init(light: ImageUrls, dark: ImageUrls? = nil) { + self.light = light + self.dark = dark + } + + public let light: ImageUrls + public let dark: ImageUrls? + + } + + struct ImageUrls: Codable, Sendable, Hashable, Equatable { + + public init(width: Int, height: Int, original: URL, heic: URL, heicLowRes: URL) { + self.width = width + self.height = height + self.original = original + self.heic = heic + self.heicLowRes = heicLowRes + } + + public let width: Int + public let height: Int + public let original: URL + public let heic: URL + public let heicLowRes: URL + } + + struct ThemeVideoUrls: Codable, Sendable, Hashable, Equatable { + + public init(light: VideoUrls, dark: VideoUrls? = nil) { + self.light = light + self.dark = dark + } + + public let light: VideoUrls + public let dark: VideoUrls? + + } + + struct VideoUrls: Codable, Sendable, Hashable, Equatable { + + public let width: Int + public let height: Int + public let url: URL + public let checksum: Checksum? + public let urlLowRes: URL? + public let checksumLowRes: Checksum? + + public init( + width: Int, + height: Int, + url: URL, + checksum: Checksum?, + urlLowRes: URL?, + checksumLowRes: Checksum? + ) { + self.width = width + self.height = height + self.url = url + self.checksum = checksum + self.urlLowRes = urlLowRes + self.checksumLowRes = checksumLowRes + } + + } + + struct GradientPoint: Codable, Sendable, Hashable, Equatable { + + public let color: ColorHex + public let percent: Int + + public init(color: ColorHex, percent: Int) { + self.color = color + self.percent = percent + } + + } + + struct ColorScheme: Codable, Sendable, Hashable, Equatable { + + public init(light: ColorInfo, dark: ColorInfo? = nil) { + self.light = light + self.dark = dark + } + + public let light: ColorInfo + public let dark: ColorInfo? + + } + + enum ColorInfo: Codable, Sendable, Hashable { + + case hex(ColorHex) + case alias(String) + case linear(Int, [GradientPoint]) + case radial([GradientPoint]) + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .hex(let hex): + try container.encode(ColorInfoTypes.hex.rawValue, forKey: .type) + try container.encode(hex, forKey: .value) + case .alias(let alias): + try container.encode(ColorInfoTypes.alias.rawValue, forKey: .type) + try container.encode(alias, forKey: .value) + case .linear(let degrees, let points): + try container.encode(ColorInfoTypes.linear.rawValue, forKey: .type) + try container.encode(degrees, forKey: .degrees) + try container.encode(points, forKey: .points) + case .radial(let points): + try container.encode(ColorInfoTypes.radial.rawValue, forKey: .type) + try container.encode(points, forKey: .points) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(ColorInfoTypes.self, forKey: .type) + + switch type { + case .hex: + let value = try container.decode(ColorHex.self, forKey: .value) + self = .hex(value) + case .alias: + let value = try container.decode(String.self, forKey: .value) + self = .alias(value) + case .linear: + let points = try container.decode([GradientPoint].self, forKey: .points) + let degrees = try container.decode(Int.self, forKey: .degrees) + self = .linear(degrees, points) + case .radial: + let points = try container.decode([GradientPoint].self, forKey: .points) + self = .radial(points) + } + } + + // swiftlint:disable:next nesting + private enum CodingKeys: String, CodingKey { + + case type + case value + case degrees + case points + + } + + // swiftlint:disable:next nesting + private enum ColorInfoTypes: String, Decodable { + + case hex + case alias + case linear + case radial + + } + + } + + enum Shape: Codable, Sendable, Hashable, Equatable { + + case rectangle(CornerRadiuses?) + case pill + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .rectangle(let corners): + try container.encode(ShapeType.rectangle.rawValue, forKey: .type) + try container.encodeIfPresent(corners, forKey: .corners) + case .pill: + try container.encode(ShapeType.pill.rawValue, forKey: .type) + } + } + + public init(from decoder: Decoder) throws { + do { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(ShapeType.self, forKey: .type) + + switch type { + case .rectangle: + let value: CornerRadiuses? = try container.decodeIfPresent(CornerRadiuses.self, forKey: .corners) + self = .rectangle(value) + case .pill: + self = .pill + } + } catch { + self = .rectangle(nil) + } + } + + // swiftlint:disable:next nesting + private enum CodingKeys: String, CodingKey { + + case type + case corners + + } + + // swiftlint:disable:next nesting + private enum ShapeType: String, Decodable { + + case rectangle + case pill + + } + + } + + enum IconBackgroundShape: Codable, Sendable, Hashable, Equatable { + + case rectangle(CornerRadiuses?) + case circle + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .rectangle(let corners): + try container.encode(ShapeType.rectangle.rawValue, forKey: .type) + try container.encodeIfPresent(corners, forKey: .corners) + case .circle: + try container.encode(ShapeType.circle.rawValue, forKey: .type) + } + } + + public init(from decoder: Decoder) throws { + do { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(ShapeType.self, forKey: .type) + + switch type { + case .rectangle: + let value: CornerRadiuses? = try container.decodeIfPresent(CornerRadiuses.self, forKey: .corners) + self = .rectangle(value) + case .circle: + self = .circle + } + } catch { + self = .rectangle(nil) + } + } + + // swiftlint:disable:next nesting + private enum CodingKeys: String, CodingKey { + + case type + case corners + + } + + // swiftlint:disable:next nesting + private enum ShapeType: String, Decodable { + + case rectangle + case circle + + } + + } + + enum MaskShape: Codable, Sendable, Hashable, Equatable { + + case rectangle(CornerRadiuses?) + case circle + case concave + case convex + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .rectangle(let corners): + try container.encodeIfPresent(MaskShapeType.rectangle.rawValue, forKey: .type) + try container.encode(corners, forKey: .corners) + case .circle: + try container.encode(MaskShapeType.circle.rawValue, forKey: .type) + case .concave: + try container.encode(MaskShapeType.concave.rawValue, forKey: .type) + case .convex: + try container.encode(MaskShapeType.convex.rawValue, forKey: .type) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + do { + let type = try container.decode(MaskShapeType.self, forKey: .type) + + switch type { + case .rectangle: + let value: CornerRadiuses? = try container.decodeIfPresent(CornerRadiuses.self, forKey: .corners) + self = .rectangle(value) + case .circle: + self = .circle + case .concave: + self = .concave + case .convex: + self = .convex + } + } catch { + self = .rectangle(nil) + } + } + + // swiftlint:disable:next nesting + private enum CodingKeys: String, CodingKey { + + case type + case corners + + } + + // swiftlint:disable:next nesting + private enum MaskShapeType: String, Decodable { + + case rectangle + case circle + case concave + case convex + + } + + } + + struct Padding: Codable, Sendable, Hashable, Equatable { + + public init(top: Double?, + bottom: Double?, + leading: Double?, + trailing: Double?) { + self.top = top + self.bottom = bottom + self.leading = leading + self.trailing = trailing + } + + public let top: Double? + public let bottom: Double? + public let leading: Double? + public let trailing: Double? + + public static let `default` = Padding(top: 10, bottom: 10, leading: 20, trailing: 20) + public static let zero = Padding(top: 0, bottom: 0, leading: 0, trailing: 0) + + } + + struct CornerRadiuses: Codable, Sendable, Hashable, Equatable { + + public init(topLeading: Double?, + topTrailing: Double?, + bottomLeading: Double?, + bottomTrailing: Double?) { + self.topLeading = topLeading + self.topTrailing = topTrailing + self.bottomLeading = bottomLeading + self.bottomTrailing = bottomTrailing + } + + public let topLeading: Double? + public let topTrailing: Double? + public let bottomLeading: Double? + public let bottomTrailing: Double? + + public static let `default` = CornerRadiuses(topLeading: 0, + topTrailing: 0, + bottomLeading: 0, + bottomTrailing: 0) + public static let zero = CornerRadiuses(topLeading: 0, + topTrailing: 0, + bottomLeading: 0, + bottomTrailing: 0) + + } + + struct Size: Codable, Sendable, Hashable, Equatable { + + public let width: SizeConstraint + public let height: SizeConstraint + + public init(width: PaywallComponent.SizeConstraint, height: PaywallComponent.SizeConstraint) { + self.width = width + self.height = height + } + + } + + enum SizeConstraint: Codable, Sendable, Hashable { + + case fit + case fill + case fixed(UInt) + + // Only used for button sheet for now + case relative(Double) + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .fit: + try container.encode(SizeConstraintType.fit.rawValue, forKey: .type) + case .fill: + try container.encode(SizeConstraintType.fill.rawValue, forKey: .type) + case .fixed(let value): + try container.encode(SizeConstraintType.fixed.rawValue, forKey: .type) + try container.encode(value, forKey: .value) + case .relative(let value): + try container.encode(SizeConstraintType.relative.rawValue, forKey: .type) + try container.encode(value, forKey: .value) + } + } + + public init(from decoder: Decoder) throws { + do { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(SizeConstraintType.self, forKey: .type) + + switch type { + case .fit: + self = .fit + case .fill: + self = .fill + case .fixed: + let value = try container.decode(UInt.self, forKey: .value) + self = .fixed(value) + case .relative: + let value = try container.decode(Double.self, forKey: .value) + self = .relative(value) + } + } catch { + self = .fit + } + } + + // swiftlint:disable:next nesting + private enum CodingKeys: String, CodingKey { + + case type + case value + + } + + // swiftlint:disable:next nesting + private enum SizeConstraintType: String, Decodable { + + case fit + case fill + case fixed + case relative + + } + + } + + enum FlexDistribution: String, Codable, Sendable, Hashable, Equatable { + + case start + case center + case end + case spaceBetween = "space_between" + case spaceAround = "space_around" + case spaceEvenly = "space_evenly" + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = FlexDistribution(rawValue: rawValue) ?? .start + } + + } + + enum HorizontalAlignment: String, Codable, Sendable, Hashable, Equatable { + + case leading + case center + case trailing + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = HorizontalAlignment(rawValue: rawValue) ?? .leading + } + + } + + enum VerticalAlignment: String, Codable, Sendable, Hashable, Equatable { + + case top + case center + case bottom + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = VerticalAlignment(rawValue: rawValue) ?? .top + } + + } + + enum TwoDimensionAlignment: String, Codable, Sendable, Hashable, Equatable { + + case center + case leading + case trailing + case top + case bottom + case topLeading = "top_leading" + case topTrailing = "top_trailing" + case bottomLeading = "bottom_leading" + case bottomTrailing = "bottom_trailing" + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = TwoDimensionAlignment(rawValue: rawValue) ?? .top + } + + } + + enum FontWeight: String, Codable, Sendable, Hashable, Equatable { + + case extraLight = "extra_light" + case thin + case light + case regular + case medium + case semibold + case bold + case extraBold = "extra_bold" + case black + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = FontWeight(rawValue: rawValue) ?? .regular + } + + } + + enum FontSize: String, Codable, Sendable, Hashable, Equatable { + + case headingXXL = "heading_xxl" + case headingXL = "heading_xl" + case headingL = "heading_l" + case headingM = "heading_m" + case headingS = "heading_s" + case headingXS = "heading_xs" + case bodyXL = "body_xl" + case bodyL = "body_l" + case bodyM = "body_m" + case bodyS = "body_s" + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = FontSize(rawValue: rawValue) ?? .bodyM + } + + } + + enum FitMode: String, Codable, Sendable, Hashable, Equatable { + + case fit + case fill + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = FitMode(rawValue: rawValue) ?? .fit + } + + } + + struct Shadow: Codable, Sendable, Hashable, Equatable { + + public let color: ColorScheme + public let radius: CGFloat + // swiftlint:disable:next identifier_name + public let x: CGFloat + // swiftlint:disable:next identifier_name + public let y: CGFloat + + // swiftlint:disable:next identifier_name + public init(color: ColorScheme, radius: CGFloat, x: CGFloat, y: CGFloat) { + self.color = color + self.radius = radius + self.x = x + self.y = y + } + + } + + enum BadgeStyle: String, Codable, Sendable, Hashable, Equatable { + + case edgeToEdge = "edge_to_edge" + case overlaid = "overlay" + case nested = "nested" + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = BadgeStyle(rawValue: rawValue) ?? .overlaid + } + + } + + final class Badge: Codable, Sendable, Hashable, Equatable { + + public let style: BadgeStyle + public let alignment: TwoDimensionAlignment + public let stack: StackComponent + + public init(style: BadgeStyle, alignment: TwoDimensionAlignment, stack: StackComponent) { + self.style = style + self.alignment = alignment + self.stack = stack + } + + public static func == (lhs: Badge, rhs: Badge) -> Bool { + return lhs.style == rhs.style && + lhs.alignment == rhs.alignment && + lhs.stack == rhs.stack + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(style) + hasher.combine(alignment) + hasher.combine(stack) + } + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallButtonComponent.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallButtonComponent.swift new file mode 100644 index 00000000..a2903ae9 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallButtonComponent.swift @@ -0,0 +1,241 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallButtonComponent.swift +// +// Created by Jay Shortway on 02/10/2024. +// +// swiftlint:disable missing_docs nesting + +import Foundation + +public extension PaywallComponent { + + final class ButtonComponent: PaywallComponentBase { + + let type: ComponentType + public let action: Action + public let stack: PaywallComponent.StackComponent + public let transition: PaywallComponent.Transition? + + public init( + action: Action, + stack: PaywallComponent.StackComponent, + transition: PaywallComponent.Transition? = nil + ) { + self.type = .button + self.action = action + self.stack = stack + self.transition = transition + } + + private enum CodingKeys: String, CodingKey { + case type + case action + case stack + case transition + } + + required public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.type = try container.decode(ComponentType.self, forKey: .type) + self.action = try container.decode(Action.self, forKey: .action) + self.stack = try container.decode(PaywallComponent.StackComponent.self, forKey: .stack) + self.transition = try container.decodeIfPresent(PaywallComponent.Transition.self, forKey: .transition) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + try container.encode(action, forKey: .action) + try container.encode(stack, forKey: .stack) + try container.encode(transition, forKey: .transition) + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(action) + hasher.combine(stack) + hasher.combine(transition) + } + + public static func == (lhs: ButtonComponent, rhs: ButtonComponent) -> Bool { + return lhs.type == rhs.type && + lhs.action == rhs.action && + lhs.stack == rhs.stack && + lhs.transition == rhs.transition + + } + + public enum Action: Codable, Sendable, Hashable, Equatable { + case restorePurchases + case navigateBack + case navigateTo(destination: Destination) + + case unknown + + private enum CodingKeys: String, CodingKey { + case type + case destination + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .restorePurchases: + try container.encode("restore_purchases", forKey: .type) + case .navigateBack: + try container.encode("navigate_back", forKey: .type) + case .navigateTo(let destination): + try container.encode("navigate_to", forKey: .type) + try destination.encode(to: encoder) + case .unknown: + try container.encode("unknown", forKey: .type) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + + switch type { + case "restore_purchases": + self = .restorePurchases + case "navigate_back": + self = .navigateBack + case "navigate_to": + let destination = try Destination(from: decoder) + self = .navigateTo(destination: destination) + case "unknown": + self = .unknown + default: + self = .unknown + } + } + } + + public enum Destination: Codable, Sendable, Hashable, Equatable { + case customerCenter + case offerCode + case privacyPolicy(urlLid: String, method: URLMethod) + case sheet(sheet: Sheet) + case terms(urlLid: String, method: URLMethod) + case webPaywallLink(urlLid: String, method: URLMethod) + case url(urlLid: String, method: URLMethod) + + case unknown + + private enum CodingKeys: String, CodingKey { + case destination + case url + case sheet + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .customerCenter: + try container.encode("customer_center", forKey: .destination) + case .offerCode: + try container.encode("offer_code", forKey: .destination) + case .terms(let urlLid, let method): + try container.encode("terms", forKey: .destination) + try container.encode(URLPayload(urlLid: urlLid, method: method), forKey: .url) + case .privacyPolicy(let urlLid, let method): + try container.encode("privacy_policy", forKey: .destination) + try container.encode(URLPayload(urlLid: urlLid, method: method), forKey: .url) + case .webPaywallLink(let urlLid, let method): + try container.encode("web_paywall_link", forKey: .destination) + try container.encode(URLPayload(urlLid: urlLid, method: method), forKey: .url) + case .url(let urlLid, let method): + try container.encode("url", forKey: .destination) + try container.encode(URLPayload(urlLid: urlLid, method: method), forKey: .url) + case .sheet: + try container.encode("sheet", forKey: .destination) + case .unknown: + try container.encode("unknown", forKey: .destination) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let destination = try container.decode(String.self, forKey: .destination) + + switch destination { + case "customer_center": + self = .customerCenter + case "offer_code": + self = .offerCode + case "sheet": + let sheet = try container.decode(Sheet.self, forKey: .sheet) + self = .sheet(sheet: sheet) + case "terms": + let urlPayload = try container.decode(URLPayload.self, forKey: .url) + self = .terms(urlLid: urlPayload.urlLid, method: urlPayload.method) + case "privacy_policy": + let urlPayload = try container.decode(URLPayload.self, forKey: .url) + self = .privacyPolicy(urlLid: urlPayload.urlLid, method: urlPayload.method) + case "url": + let urlPayload = try container.decode(URLPayload.self, forKey: .url) + self = .url(urlLid: urlPayload.urlLid, method: urlPayload.method) + case "web_paywall_link": + let urlPayload = try container.decode(URLPayload.self, forKey: .url) + self = .webPaywallLink(urlLid: urlPayload.urlLid, method: urlPayload.method) + case "unknown": + self = .unknown + default: + self = .unknown + } + } + } + + public enum URLMethod: String, Codable, Sendable, Hashable, Equatable { + case inAppBrowser = "in_app_browser" + case externalBrowser = "external_browser" + case deepLink = "deep_link" + + case unknown = "unknown" + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = URLMethod(rawValue: rawValue) ?? .unknown + } + } + + private struct URLPayload: Codable, Hashable, Sendable { + let urlLid: String + let method: URLMethod + } + + public struct Sheet: Codable, Hashable, Sendable { + public let id: String + public let name: String? + public let stack: StackComponent + public let backgroundBlur: Bool + public let size: Size? + + public init( + id: String, + name: String?, + stack: StackComponent, + backgroundBlur: Bool, + size: Size? + ) { + self.id = id + self.name = name + self.stack = stack + self.backgroundBlur = backgroundBlur + self.size = size + } + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallCarouselComponent.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallCarouselComponent.swift new file mode 100644 index 00000000..85992e6f --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallCarouselComponent.swift @@ -0,0 +1,316 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallCarouselComponent.swift +// +// Created by Josh Holtz on 1/26/25. +// swiftlint:disable missing_docs nesting + +import Foundation + +public extension PaywallComponent { + + final class CarouselComponent: PaywallComponentBase { + + public struct AutoAdvanceSlides: PaywallComponentBase { + + public let msTimePerPage: Int + public let msTransitionTime: Int + public let transitionType: AutoAdvanceTransitionType? + + public init(msTimePerPage: Int, msTransitionTime: Int, transitionType: AutoAdvanceTransitionType?) { + self.msTimePerPage = msTimePerPage + self.msTransitionTime = msTransitionTime + self.transitionType = transitionType + } + + } + + public enum AutoAdvanceTransitionType: String, PaywallComponentBase { + case fade + case slide + } + + public struct PageControl: PaywallComponentBase { + + public enum Position: String, Codable, Sendable, Hashable, Equatable { + case top + case bottom + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = Position(rawValue: rawValue) ?? .bottom + } + } + + public let position: Position + public let padding: Padding? + public let margin: Padding? + public let backgroundColor: ColorScheme? + public let shape: Shape? + public let border: Border? + public let shadow: Shadow? + + public let spacing: Int + public let `default`: PageControlIndicator + public let active: PageControlIndicator + + public init( + position: Position, + padding: Padding?, + margin: Padding?, + backgroundColor: ColorScheme?, + shape: Shape?, + border: Border?, + shadow: Shadow?, + spacing: Int, + default: PageControlIndicator, + active: PageControlIndicator + ) { + self.position = position + self.padding = padding + self.margin = margin + self.backgroundColor = backgroundColor + self.shape = shape + self.border = border + self.shadow = shadow + self.spacing = spacing + self.default = `default` + self.active = active + } + + } + + public struct PageControlIndicator: PaywallComponentBase { + + public let width: Int + public let height: Int + public let color: ColorScheme + public let strokeColor: ColorScheme? + public let strokeWidth: CGFloat? + + public init( + width: Int, + height: Int, + color: ColorScheme, + strokeColor: ColorScheme? = nil, + strokeWidth: CGFloat? = nil + ) { + self.width = width + self.height = height + self.color = color + self.strokeColor = strokeColor + self.strokeWidth = strokeWidth + } + + } + + let type: ComponentType + + public let visible: Bool? + public let size: Size? + public let padding: Padding? + public let margin: Padding? + public let background: Background? + public let shape: Shape? + public let border: Border? + public let shadow: Shadow? + + public let pages: [StackComponent] + public let pageAlignment: VerticalAlignment + public let pageSpacing: Int + public let pagePeek: Int + public let initialPageIndex: Int + public let loop: Bool + public let autoAdvance: AutoAdvanceSlides? + + public let pageControl: PageControl? + + public let overrides: ComponentOverrides? + + public init( + visible: Bool? = nil, + size: PaywallComponent.Size? = nil, + padding: PaywallComponent.Padding? = .zero, + margin: PaywallComponent.Padding? = .zero, + background: PaywallComponent.Background? = nil, + shape: PaywallComponent.Shape? = nil, + border: PaywallComponent.Border? = nil, + shadow: PaywallComponent.Shadow? = nil, + pages: [PaywallComponent.StackComponent], + pageAlignment: PaywallComponent.VerticalAlignment = .center, + pageSpacing: Int = 0, + pagePeek: Int = 20, + initialPageIndex: Int = 0, + loop: Bool = false, + autoAdvance: PaywallComponent.CarouselComponent.AutoAdvanceSlides? = nil, + pageControl: PageControl? = nil, + overrides: ComponentOverrides? = nil + ) { + self.type = .carousel + + self.visible = visible + self.size = size + self.padding = padding + self.margin = margin + self.background = background + self.shape = shape + self.border = border + self.shadow = shadow + self.pages = pages + self.pageAlignment = pageAlignment + self.pageSpacing = pageSpacing + self.pagePeek = pagePeek + self.initialPageIndex = initialPageIndex + self.loop = loop + self.autoAdvance = autoAdvance + self.pageControl = pageControl + self.overrides = overrides + } + + public static func == (lhs: CarouselComponent, rhs: CarouselComponent) -> Bool { + return lhs.type == rhs.type && + lhs.visible == rhs.visible && + lhs.size == rhs.size && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin && + lhs.background == rhs.background && + lhs.shape == rhs.shape && + lhs.border == rhs.border && + lhs.shadow == rhs.shadow && + lhs.pages == rhs.pages && + lhs.pageAlignment == rhs.pageAlignment && + lhs.pageSpacing == rhs.pageSpacing && + lhs.pagePeek == rhs.pagePeek && + lhs.initialPageIndex == rhs.initialPageIndex && + lhs.loop == rhs.loop && + lhs.autoAdvance == rhs.autoAdvance && + lhs.pageControl == rhs.pageControl && + lhs.overrides == rhs.overrides + } + + // MARK: - Hashable + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(visible) + hasher.combine(size) + hasher.combine(padding) + hasher.combine(margin) + hasher.combine(background) + hasher.combine(shape) + hasher.combine(border) + hasher.combine(shadow) + hasher.combine(pages) + hasher.combine(pageAlignment) + hasher.combine(pageSpacing) + hasher.combine(pagePeek) + hasher.combine(initialPageIndex) + hasher.combine(loop) + hasher.combine(autoAdvance) + hasher.combine(pageControl) + hasher.combine(overrides) + } + + } + + final class PartialCarouselComponent: PaywallPartialComponent { + + public let visible: Bool? + public let size: Size? + public let padding: Padding? + public let margin: Padding? + public let background: Background? + public let shape: Shape? + public let border: Border? + public let shadow: Shadow? + + public let pageAlignment: VerticalAlignment? + public let pageSpacing: Int? + public let pagePeek: Int? + public let initialPageIndex: Int? + public let loop: Bool? + public let autoAdvance: PaywallComponent.CarouselComponent.AutoAdvanceSlides? + + public let pageControl: PaywallComponent.CarouselComponent.PageControl? + + public init( + visible: Bool? = true, + size: PaywallComponent.Size? = nil, + padding: PaywallComponent.Padding? = nil, + margin: PaywallComponent.Padding? = nil, + background: PaywallComponent.Background? = nil, + shape: PaywallComponent.Shape? = nil, + border: PaywallComponent.Border? = nil, + shadow: PaywallComponent.Shadow? = nil, + pageAlignment: PaywallComponent.VerticalAlignment? = nil, + pageSpacing: Int? = nil, + pagePeek: Int? = nil, + initialPageIndex: Int? = nil, + loop: Bool? = nil, + autoAdvance: PaywallComponent.CarouselComponent.AutoAdvanceSlides? = nil, + pageControl: PaywallComponent.CarouselComponent.PageControl? = nil + ) { + self.visible = visible + self.size = size + self.padding = padding + self.margin = margin + self.background = background + self.shape = shape + self.border = border + self.shadow = shadow + self.pageAlignment = pageAlignment + self.pageSpacing = pageSpacing + self.pagePeek = pagePeek + self.initialPageIndex = initialPageIndex + self.loop = loop + self.autoAdvance = autoAdvance + self.pageControl = pageControl + } + + public static func == (lhs: PartialCarouselComponent, rhs: PartialCarouselComponent) -> Bool { + return lhs.visible == rhs.visible && + lhs.size == rhs.size && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin && + lhs.background == rhs.background && + lhs.shape == rhs.shape && + lhs.border == rhs.border && + lhs.shadow == rhs.shadow && + lhs.pageAlignment == rhs.pageAlignment && + lhs.pageSpacing == rhs.pageSpacing && + lhs.pagePeek == rhs.pagePeek && + lhs.initialPageIndex == rhs.initialPageIndex && + lhs.loop == rhs.loop && + lhs.autoAdvance == rhs.autoAdvance && + lhs.pageControl == rhs.pageControl + } + + // MARK: - Hashable + public func hash(into hasher: inout Hasher) { + hasher.combine(visible) + hasher.combine(size) + hasher.combine(padding) + hasher.combine(margin) + hasher.combine(background) + hasher.combine(shape) + hasher.combine(border) + hasher.combine(shadow) + hasher.combine(pageAlignment) + hasher.combine(pageSpacing) + hasher.combine(pagePeek) + hasher.combine(initialPageIndex) + hasher.combine(loop) + hasher.combine(autoAdvance) + hasher.combine(pageControl) + } + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallCountdownComponent.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallCountdownComponent.swift new file mode 100644 index 00000000..745be8b3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallCountdownComponent.swift @@ -0,0 +1,185 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallCountdownComponent.swift +// +// Created by Josh Holtz on 1/14/25. +// +// swiftlint:disable missing_docs nesting + +import Foundation + +public extension PaywallComponent { + + final class CountdownComponent: PaywallComponentBase { + + let type: ComponentType + public let name: String? + public let style: CountdownStyle + public let countFrom: CountFrom + public let countdownStack: PaywallComponent.StackComponent + public let endStack: PaywallComponent.StackComponent? + public let fallback: PaywallComponent.StackComponent? + public let overrides: ComponentOverrides? + + public init( + id: String? = nil, + name: String? = nil, + style: CountdownStyle, + countFrom: CountFrom, + countdownStack: PaywallComponent.StackComponent, + endStack: PaywallComponent.StackComponent? = nil, + fallback: PaywallComponent.StackComponent? = nil, + overrides: ComponentOverrides? = nil + ) { + self.type = .countdown + self.name = name + self.style = style + self.countFrom = countFrom + self.countdownStack = countdownStack + self.endStack = endStack + self.fallback = fallback + self.overrides = overrides + } + + private enum CodingKeys: String, CodingKey { + case type + case name + case style + case countFrom + case countdownStack + case endStack + case fallback + case overrides + } + + required public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.type = try container.decode(ComponentType.self, forKey: .type) + self.name = try container.decodeIfPresent(String.self, forKey: .name) + self.style = try container.decode(CountdownStyle.self, forKey: .style) + self.countFrom = try container.decode(CountFrom.self, forKey: .countFrom) + self.countdownStack = try container.decode(PaywallComponent.StackComponent.self, forKey: .countdownStack) + self.endStack = try container.decodeIfPresent(PaywallComponent.StackComponent.self, forKey: .endStack) + self.fallback = try container.decodeIfPresent(PaywallComponent.StackComponent.self, forKey: .fallback) + self.overrides = try container.decodeIfPresent( + ComponentOverrides.self, + forKey: .overrides + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + try container.encodeIfPresent(name, forKey: .name) + try container.encode(style, forKey: .style) + try container.encode(countFrom, forKey: .countFrom) + try container.encode(countdownStack, forKey: .countdownStack) + try container.encode(endStack, forKey: .endStack) + try container.encode(fallback, forKey: .fallback) + try container.encode(overrides, forKey: .overrides) + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(name) + hasher.combine(style) + hasher.combine(countFrom) + hasher.combine(countdownStack) + hasher.combine(endStack) + hasher.combine(fallback) + hasher.combine(overrides) + } + + public static func == (lhs: CountdownComponent, rhs: CountdownComponent) -> Bool { + return lhs.type == rhs.type && + lhs.name == rhs.name && + lhs.style == rhs.style && + lhs.countFrom == rhs.countFrom && + lhs.countdownStack == rhs.countdownStack && + lhs.endStack == rhs.endStack && + lhs.fallback == rhs.fallback && + lhs.overrides == rhs.overrides + } + + public enum CountdownStyle: Codable, Sendable, Hashable, Equatable { + + case date(Date) + + public var date: Date { + switch self { + case .date(let value): + return value + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .date(let date): + try container.encode(CountdownStyleType.date.rawValue, forKey: .type) + try container.encode(date, forKey: .date) + } + } + + public init(from decoder: Decoder) throws { + do { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(CountdownStyleType.self, forKey: .type) + + switch type { + case .date: + let dateValue = try container.decode(Date.self, forKey: .date) + self = .date(dateValue) + } + } catch { + // Default to date with epoch if decoding fails + self = .date(Date(timeIntervalSince1970: 0)) + } + } + + // swiftlint:disable:next nesting + private enum CodingKeys: String, CodingKey { + case type + case date + } + + // swiftlint:disable:next nesting + private enum CountdownStyleType: String, Decodable { + case date + } + + } + + // swiftlint:disable:next nesting + public enum CountFrom: String, Codable, Sendable, Hashable, Equatable { + case days + case hours + case minutes + } + } + + final class PartialCountdownComponent: PaywallPartialComponent { + public let style: CountdownComponent.CountdownStyle? + + public init(style: CountdownComponent.CountdownStyle? = nil) { + self.style = style + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(style) + } + + public static func == (lhs: PartialCountdownComponent, rhs: PartialCountdownComponent) -> Bool { + return lhs.style == rhs.style + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallIconComponent.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallIconComponent.swift new file mode 100644 index 00000000..a1cd1ff1 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallIconComponent.swift @@ -0,0 +1,205 @@ +// +// PaywallIconComponent.swift +// +// +// Created by Josh Holtz on 1/12/24. +// +// swiftlint:disable missing_docs nesting + +import Foundation + +public extension PaywallComponent { + + final class IconComponent: PaywallComponentBase { + + final public class Formats: Codable, Sendable, Hashable, Equatable { + + public let svg: String + public let png: String + public let heic: String + public let webp: String + + public init(svg: String, + png: String, + heic: String, + webp: String) { + self.svg = svg + self.png = png + self.heic = heic + self.webp = webp + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(svg) + hasher.combine(png) + hasher.combine(heic) + hasher.combine(webp) + } + + public static func == (lhs: Formats, rhs: Formats) -> Bool { + return lhs.svg == rhs.svg && + lhs.png == rhs.png && + lhs.heic == rhs.heic && + lhs.webp == rhs.webp + } + } + + final public class IconBackground: Codable, Sendable, Hashable, Equatable { + + public let color: ColorScheme + public let shape: IconBackgroundShape + public let border: Border? + public let shadow: Shadow? + + public init(color: PaywallComponent.ColorScheme, + shape: IconBackgroundShape, + border: PaywallComponent.Border? = nil, + shadow: PaywallComponent.Shadow? = nil) { + self.color = color + self.shape = shape + self.border = border + self.shadow = shadow + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(color) + hasher.combine(shape) + hasher.combine(border) + hasher.combine(shadow) + } + + public static func == (lhs: IconBackground, rhs: IconBackground) -> Bool { + return lhs.color == rhs.color && + lhs.shape == rhs.shape && + lhs.border == rhs.border && + lhs.shadow == rhs.shadow + } + } + + let type: ComponentType + public let visible: Bool? + public let baseUrl: String + public let iconName: String + public let formats: Formats + public let size: Size + public let padding: Padding? + public let margin: Padding? + public let color: ColorScheme + public let iconBackground: IconBackground? + + public let overrides: ComponentOverrides? + + public init( + visible: Bool? = nil, + baseUrl: String, + iconName: String, + formats: Formats, + size: Size, + padding: Padding?, + margin: Padding?, + color: ColorScheme, + iconBackground: IconBackground?, + overrides: ComponentOverrides? = nil + ) { + self.type = .image + self.visible = visible + self.baseUrl = baseUrl + self.iconName = iconName + self.formats = formats + self.size = size + self.padding = padding + self.margin = margin + self.color = color + self.iconBackground = iconBackground + self.overrides = overrides + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(visible) + hasher.combine(baseUrl) + hasher.combine(iconName) + hasher.combine(formats) + hasher.combine(size) + hasher.combine(padding) + hasher.combine(margin) + hasher.combine(color) + hasher.combine(iconBackground) + hasher.combine(overrides) + } + + public static func == (lhs: IconComponent, rhs: IconComponent) -> Bool { + return lhs.type == rhs.type && + lhs.visible == rhs.visible && + lhs.baseUrl == rhs.baseUrl && + lhs.iconName == rhs.iconName && + lhs.formats == rhs.formats && + lhs.size == rhs.size && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin && + lhs.color == rhs.color && + lhs.iconBackground == rhs.iconBackground && + lhs.overrides == rhs.overrides + } + } + + final class PartialIconComponent: PaywallPartialComponent { + + public let visible: Bool? + public let baseUrl: String? + public let iconName: String? + public let formats: IconComponent.Formats? + public let size: Size? + public let padding: Padding? + public let margin: Padding? + public let color: ColorScheme? + public let iconBackground: IconComponent.IconBackground? + + public init( + visible: Bool? = true, + baseUrl: String? = nil, + iconName: String? = nil, + formats: IconComponent.Formats? = nil, + size: Size? = nil, + padding: Padding? = nil, + margin: Padding? = nil, + color: ColorScheme? = nil, + iconBackground: IconComponent.IconBackground? = nil + ) { + self.visible = visible + self.baseUrl = baseUrl + self.iconName = iconName + self.formats = formats + self.size = size + self.padding = padding + self.margin = margin + self.color = color + self.iconBackground = iconBackground + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(visible) + hasher.combine(baseUrl) + hasher.combine(iconName) + hasher.combine(formats) + hasher.combine(size) + hasher.combine(padding) + hasher.combine(margin) + hasher.combine(color) + hasher.combine(iconBackground) + } + + public static func == (lhs: PartialIconComponent, rhs: PartialIconComponent) -> Bool { + return lhs.visible == rhs.visible && + lhs.baseUrl == rhs.baseUrl && + lhs.iconName == rhs.iconName && + lhs.formats == rhs.formats && + lhs.size == rhs.size && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin && + lhs.color == rhs.color && + lhs.iconBackground == rhs.iconBackground + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallImageComponent.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallImageComponent.swift new file mode 100644 index 00000000..158e71d2 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallImageComponent.swift @@ -0,0 +1,161 @@ +// +// PaywallImageComponent.swift +// +// +// Created by Josh Holtz on 6/12/24. +// +// swiftlint:disable missing_docs + +import Foundation + +public extension PaywallComponent { + + final class ImageComponent: PaywallComponentBase { + + let type: ComponentType + public let visible: Bool? + public let source: ThemeImageUrls + public let size: Size + public let overrideSourceLid: LocalizationKey? + public let fitMode: FitMode + public let maskShape: MaskShape? + public let colorOverlay: ColorScheme? + public let padding: Padding? + public let margin: Padding? + public let border: Border? + public let shadow: Shadow? + + public let overrides: ComponentOverrides? + + public init( + visible: Bool? = nil, + source: ThemeImageUrls, + size: Size = .init(width: .fill, height: .fit), + overrideSourceLid: LocalizationKey? = nil, + fitMode: FitMode = .fit, + maskShape: MaskShape? = nil, + colorOverlay: ColorScheme? = nil, + padding: Padding? = nil, + margin: Padding? = nil, + border: Border? = nil, + shadow: Shadow? = nil, + overrides: ComponentOverrides? = nil + ) { + self.type = .image + self.visible = visible + self.source = source + self.size = size + self.overrideSourceLid = overrideSourceLid + self.fitMode = fitMode + self.maskShape = maskShape + self.colorOverlay = colorOverlay + self.padding = padding + self.margin = margin + self.border = border + self.shadow = shadow + self.overrides = overrides + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(visible) + hasher.combine(source) + hasher.combine(size) + hasher.combine(overrideSourceLid) + hasher.combine(fitMode) + hasher.combine(maskShape) + hasher.combine(colorOverlay) + hasher.combine(padding) + hasher.combine(margin) + hasher.combine(border) + hasher.combine(shadow) + hasher.combine(overrides) + } + + public static func == (lhs: ImageComponent, rhs: ImageComponent) -> Bool { + return lhs.type == rhs.type && + lhs.visible == rhs.visible && + lhs.source == rhs.source && + lhs.size == rhs.size && + lhs.overrideSourceLid == rhs.overrideSourceLid && + lhs.fitMode == rhs.fitMode && + lhs.maskShape == rhs.maskShape && + lhs.colorOverlay == rhs.colorOverlay && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin && + lhs.border == rhs.border && + lhs.shadow == rhs.shadow && + lhs.overrides == rhs.overrides + } + } + + final class PartialImageComponent: PaywallPartialComponent { + + public let visible: Bool? + public let source: ThemeImageUrls? + public let size: Size? + public let overrideSourceLid: LocalizationKey? + public let maskShape: MaskShape? + public let fitMode: FitMode? + public let colorOverlay: ColorScheme? + public let padding: Padding? + public let margin: Padding? + public let border: Border? + public let shadow: Shadow? + + public init( + visible: Bool? = true, + source: ThemeImageUrls? = nil, + size: Size? = nil, + overrideSourceLid: LocalizationKey? = nil, + fitMode: FitMode? = nil, + maskShape: MaskShape? = nil, + colorOverlay: ColorScheme? = nil, + padding: Padding? = nil, + margin: Padding? = nil, + border: Border? = nil, + shadow: Shadow? = nil + ) { + self.visible = visible + self.source = source + self.size = size + self.overrideSourceLid = overrideSourceLid + self.fitMode = fitMode + self.maskShape = maskShape + self.colorOverlay = colorOverlay + self.padding = padding + self.margin = margin + self.border = border + self.shadow = shadow + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(visible) + hasher.combine(source) + hasher.combine(size) + hasher.combine(overrideSourceLid) + hasher.combine(fitMode) + hasher.combine(maskShape) + hasher.combine(colorOverlay) + hasher.combine(padding) + hasher.combine(margin) + hasher.combine(border) + hasher.combine(shadow) + } + + public static func == (lhs: PartialImageComponent, rhs: PartialImageComponent) -> Bool { + return lhs.visible == rhs.visible && + lhs.source == rhs.source && + lhs.size == rhs.size && + lhs.overrideSourceLid == rhs.overrideSourceLid && + lhs.fitMode == rhs.fitMode && + lhs.maskShape == rhs.maskShape && + lhs.colorOverlay == rhs.colorOverlay && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin && + lhs.border == rhs.border && + lhs.shadow == rhs.shadow + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallPackageComponent.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallPackageComponent.swift new file mode 100644 index 00000000..00500b4b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallPackageComponent.swift @@ -0,0 +1,70 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallPackageComponent.swift +// +// Created by Josh Holtz on 9/27/24. + +import Foundation + +// swiftlint:disable missing_docs + +public extension PaywallComponent { + + final class PackageComponent: PaywallComponentBase { + + let type: ComponentType + public let packageID: String + public let isSelectedByDefault: Bool + @_spi(Internal) public let applePromoOfferProductCode: String? + public let stack: PaywallComponent.StackComponent + + public init( + packageID: String, + isSelectedByDefault: Bool, + applePromoOfferProductCode: String?, + stack: PaywallComponent.StackComponent + ) { + self.type = .package + self.packageID = packageID + self.isSelectedByDefault = isSelectedByDefault + self.applePromoOfferProductCode = applePromoOfferProductCode + self.stack = stack + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(packageID) + hasher.combine(isSelectedByDefault) + hasher.combine(applePromoOfferProductCode) + hasher.combine(stack) + } + + public static func == (lhs: PackageComponent, rhs: PackageComponent) -> Bool { + return lhs.type == rhs.type && + lhs.packageID == rhs.packageID && + lhs.isSelectedByDefault == rhs.isSelectedByDefault && + lhs.applePromoOfferProductCode == rhs.applePromoOfferProductCode && + lhs.stack == rhs.stack + } + } + +} + +extension PaywallComponent.PackageComponent { + + enum CodingKeys: String, CodingKey { + case type + case packageID = "packageId" + case isSelectedByDefault + case applePromoOfferProductCode + case stack + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallPurchaseButtonComponent.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallPurchaseButtonComponent.swift new file mode 100644 index 00000000..cac55270 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallPurchaseButtonComponent.swift @@ -0,0 +1,159 @@ +// +// File.swift +// +// +// Created by Josh Holtz on 6/12/24. +// +// swiftlint:disable missing_docs + +import Foundation + +public extension PaywallComponent { + + final class PurchaseButtonComponent: PaywallComponentBase { + + let type: ComponentType + public let stack: PaywallComponent.StackComponent + + public let action: Action? + public let method: Method? + + // swiftlint:disable nesting + public enum Action: String, Codable, Sendable, Hashable, Equatable { + case inAppCheckout = "in_app_checkout" + case webCheckout = "web_checkout" + case webProductSelection = "web_product_selection" + } + + public enum Method: Codable, Sendable, Hashable, Equatable { + case inAppCheckout + case webCheckout(WebCheckout) + case webProductSelection(WebCheckout) + case customWebCheckout(CustomWebCheckout) + + case unknown + + private enum CodingKeys: String, CodingKey { + case type + + case webCheckout + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .inAppCheckout: + try container.encode("in_app_checkout", forKey: .type) + case .webCheckout(let webCheckout): + try container.encode("web_checkout", forKey: .type) + try webCheckout.encode(to: encoder) + case .webProductSelection(let webCheckout): + try container.encode("web_product_selection", forKey: .type) + try webCheckout.encode(to: encoder) + case .customWebCheckout(let customWebCheckout): + try container.encode("custom_web_checkout", forKey: .type) + try customWebCheckout.encode(to: encoder) + case .unknown: + try container.encode("unknown", forKey: .type) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + + switch type { + case "in_app_checkout": + self = .inAppCheckout + case "web_checkout": + let webCheckout = try WebCheckout(from: decoder) + self = .webCheckout(webCheckout) + case "web_product_selection": + let webCheckout = try WebCheckout(from: decoder) + self = .webProductSelection(webCheckout) + case "custom_web_checkout": + let customCheckout = try CustomWebCheckout(from: decoder) + self = .customWebCheckout(customCheckout) + case "unknown": + self = .unknown + default: + self = .unknown + } + } + } + + public struct WebCheckout: Codable, Sendable, Hashable, Equatable { + + public let autoDismiss: Bool? + public let openMethod: ButtonComponent.URLMethod? + + public init(autoDismiss: Bool? = nil, openMethod: PaywallComponent.ButtonComponent.URLMethod? = nil) { + self.autoDismiss = autoDismiss + self.openMethod = openMethod + } + + } + + public struct CustomWebCheckout: Codable, Sendable, Hashable, Equatable { + + public struct CustomURL: Codable, Sendable, Hashable, Equatable { + + public let url: LocalizationKey + public let packageParam: String? + + public init(url: PaywallComponent.LocalizationKey, packageParam: String? = nil) { + self.url = url + self.packageParam = packageParam + } + + private enum CodingKeys: String, CodingKey { + case url = "urlLid" + case packageParam + } + + } + + public init( + customUrl: PaywallComponent.PurchaseButtonComponent.CustomWebCheckout.CustomURL, + autoDismiss: Bool? = nil, + openMethod: PaywallComponent.ButtonComponent.URLMethod? = nil + ) { + self.customUrl = customUrl + self.autoDismiss = autoDismiss + self.openMethod = openMethod + } + + public let customUrl: CustomURL + public let autoDismiss: Bool? + public let openMethod: ButtonComponent.URLMethod? + + } + + public init( + stack: PaywallComponent.StackComponent, + action: Action?, + method: Method? + ) { + self.type = .purchaseButton + self.stack = stack + self.action = action + self.method = method + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(stack) + hasher.combine(action) + hasher.combine(method) + } + + public static func == (lhs: PurchaseButtonComponent, rhs: PurchaseButtonComponent) -> Bool { + return lhs.type == rhs.type && + lhs.stack == rhs.stack && + lhs.action == rhs.action && + lhs.method == rhs.method + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallStackComponent.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallStackComponent.swift new file mode 100644 index 00000000..548e15e6 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallStackComponent.swift @@ -0,0 +1,202 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StackComponent.swift +// +// Created by James Borthwick on 2024-08-20. +// swiftlint:disable missing_docs nesting + +import Foundation + +public extension PaywallComponent { + + final class StackComponent: PaywallComponentBase { + + public enum Overflow: String, PaywallComponentBase { + case `default` + case scroll + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try? container.decode(String.self) + self = Overflow(rawValue: rawValue ?? "") ?? .default + } + } + + let type: ComponentType + public let visible: Bool? + public let components: [PaywallComponent] + public let size: Size + public let spacing: CGFloat? + public let backgroundColor: ColorScheme? + public let background: Background? + public let dimension: Dimension + public let padding: Padding + public let margin: Padding + public let shape: Shape? + public let border: Border? + public let shadow: Shadow? + public let badge: Badge? + public let overflow: Overflow? + + public let overrides: ComponentOverrides? + + public init( + visible: Bool? = nil, + components: [PaywallComponent], + dimension: Dimension = .vertical(.center, .start), + size: Size = .init(width: .fill, height: .fit), + spacing: CGFloat? = nil, + backgroundColor: ColorScheme? = nil, + background: Background? = nil, + padding: Padding = .zero, + margin: Padding = .zero, + shape: Shape? = nil, + border: Border? = nil, + shadow: Shadow? = nil, + badge: Badge? = nil, + overflow: Overflow? = nil, + overrides: ComponentOverrides? = nil + ) { + self.visible = visible + self.components = components + self.size = size + self.spacing = spacing + self.backgroundColor = backgroundColor + self.background = background + self.type = .stack + self.dimension = dimension + self.padding = padding + self.margin = margin + self.shape = shape + self.border = border + self.shadow = shadow + self.badge = badge + self.overflow = overflow + self.overrides = overrides + } + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(visible) + hasher.combine(components) + hasher.combine(size) + hasher.combine(spacing) + hasher.combine(backgroundColor) + hasher.combine(background) + hasher.combine(dimension) + hasher.combine(padding) + hasher.combine(margin) + hasher.combine(shape) + hasher.combine(border) + hasher.combine(shadow) + hasher.combine(badge) + hasher.combine(overflow) + hasher.combine(overrides) + } + + public static func == (lhs: StackComponent, rhs: StackComponent) -> Bool { + return lhs.type == rhs.type && + lhs.visible == rhs.visible && + lhs.components == rhs.components && + lhs.size == rhs.size && + lhs.spacing == rhs.spacing && + lhs.backgroundColor == rhs.backgroundColor && + lhs.background == rhs.background && + lhs.dimension == rhs.dimension && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin && + lhs.shape == rhs.shape && + lhs.border == rhs.border && + lhs.shadow == rhs.shadow && + lhs.badge == rhs.badge && + lhs.overflow == rhs.overflow && + lhs.overrides == rhs.overrides + } + } + + final class PartialStackComponent: PaywallPartialComponent { + + public let visible: Bool? + public let size: Size? + public let spacing: CGFloat? + public let backgroundColor: ColorScheme? + public let background: Background? + public let dimension: Dimension? + public let padding: Padding? + public let margin: Padding? + public let shape: Shape? + public let border: Border? + public let shadow: Shadow? + public let overflow: PaywallComponent.StackComponent.Overflow? + public let badge: Badge? + + public init( + visible: Bool? = true, + dimension: Dimension? = nil, + size: Size? = nil, + spacing: CGFloat? = nil, + backgroundColor: ColorScheme? = nil, + background: Background? = nil, + padding: Padding? = nil, + margin: Padding? = nil, + shape: Shape? = nil, + border: Border? = nil, + shadow: Shadow? = nil, + overflow: PaywallComponent.StackComponent.Overflow? = nil, + badge: Badge? = nil + ) { + self.visible = visible + self.size = size + self.spacing = spacing + self.backgroundColor = backgroundColor + self.background = background + self.dimension = dimension + self.padding = padding + self.margin = margin + self.shape = shape + self.border = border + self.shadow = shadow + self.overflow = overflow + self.badge = badge + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(visible) + hasher.combine(size) + hasher.combine(spacing) + hasher.combine(backgroundColor) + hasher.combine(background) + hasher.combine(dimension) + hasher.combine(padding) + hasher.combine(margin) + hasher.combine(shape) + hasher.combine(border) + hasher.combine(shadow) + hasher.combine(overflow) + hasher.combine(badge) + } + + public static func == (lhs: PartialStackComponent, rhs: PartialStackComponent) -> Bool { + return lhs.visible == rhs.visible && + lhs.size == rhs.size && + lhs.spacing == rhs.spacing && + lhs.backgroundColor == rhs.backgroundColor && + lhs.background == rhs.background && + lhs.dimension == rhs.dimension && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin && + lhs.shape == rhs.shape && + lhs.border == rhs.border && + lhs.shadow == rhs.shadow && + lhs.overflow == rhs.overflow && + lhs.badge == rhs.badge + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallStickyFooterComponent.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallStickyFooterComponent.swift new file mode 100644 index 00000000..fce70589 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallStickyFooterComponent.swift @@ -0,0 +1,40 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallStickyFooterComponent.swift +// +// Created by Jay Shortway on 24/10/2024. +// +// swiftlint:disable missing_docs + +import Foundation + +public extension PaywallComponent { + + final class StickyFooterComponent: PaywallComponentBase { + + public let stack: PaywallComponent.StackComponent + + public init( + stack: PaywallComponent.StackComponent + ) { + self.stack = stack + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(stack) + } + + public static func == (lhs: StickyFooterComponent, rhs: StickyFooterComponent) -> Bool { + return lhs.stack == rhs.stack + } + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallTabsComponent.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallTabsComponent.swift new file mode 100644 index 00000000..7664805d --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallTabsComponent.swift @@ -0,0 +1,280 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StackComponent.swift +// +// Created by James Borthwick on 2024-08-20. +// swiftlint:disable missing_docs nesting + +import Foundation + +public extension PaywallComponent { + + final class TabControlButtonComponent: Codable, Sendable, Hashable, Equatable { + + let type: ComponentType + public let tabId: String + public let stack: StackComponent + + public init(tabId: String, stack: StackComponent) { + self.type = .tabControlButton + self.tabId = tabId + self.stack = stack + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(tabId) + hasher.combine(stack) + } + + public static func == (lhs: TabControlButtonComponent, rhs: TabControlButtonComponent) -> Bool { + return lhs.type == rhs.type && lhs.tabId == rhs.tabId && lhs.stack == rhs.stack + } + } + + final class TabControlToggleComponent: Codable, Sendable, Hashable, Equatable { + + let type: ComponentType + public let thumbColorOn: ColorScheme + public let thumbColorOff: ColorScheme + public let trackColorOn: ColorScheme + public let trackColorOff: ColorScheme + + public init(defaultValue: Bool, + thumbColorOn: ColorScheme, + thumbColorOff: ColorScheme, + trackColorOn: ColorScheme, + trackColorOff: ColorScheme) { + self.type = .tabControlToggle + self.thumbColorOn = thumbColorOn + self.thumbColorOff = thumbColorOff + self.trackColorOn = trackColorOn + self.trackColorOff = trackColorOff + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(thumbColorOn) + hasher.combine(thumbColorOff) + hasher.combine(trackColorOn) + hasher.combine(trackColorOff) + } + + public static func == (lhs: TabControlToggleComponent, rhs: TabControlToggleComponent) -> Bool { + return lhs.type == rhs.type && + lhs.thumbColorOn == rhs.thumbColorOn && + lhs.thumbColorOff == rhs.thumbColorOff && + lhs.trackColorOn == rhs.trackColorOn && + lhs.trackColorOff == rhs.trackColorOff + } + } + + final class TabControlComponent: Codable, Sendable, Hashable, Equatable { + + let type: ComponentType + + public init() { + self.type = .tabControl + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + } + + public static func == (lhs: TabControlComponent, rhs: TabControlComponent) -> Bool { + return lhs.type == rhs.type + } + } + + final class TabsComponent: PaywallComponentBase { + + final public class Tab: Codable, Sendable, Hashable, Equatable { + + public let id: String + public let stack: StackComponent + + public init(id: String, stack: PaywallComponent.StackComponent) { + self.id = id + self.stack = stack + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(stack) + } + + public static func == (lhs: Tab, rhs: Tab) -> Bool { + return lhs.id == rhs.id && lhs.stack == rhs.stack + } + } + + final public class TabControl: Codable, Sendable, Hashable, Equatable { + + public enum TabControlType: String, Codable, Sendable, Hashable, Equatable { + case buttons + case toggle + } + + public let type: TabControlType + public let stack: StackComponent + + public init(type: PaywallComponent.TabsComponent.TabControl.TabControlType, + stack: PaywallComponent.StackComponent) { + self.type = type + self.stack = stack + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(stack) + } + + public static func == (lhs: TabControl, rhs: TabControl) -> Bool { + return lhs.type == rhs.type && lhs.stack == rhs.stack + } + } + + let type: ComponentType + public let visible: Bool? + public let size: Size + public let padding: Padding + public let margin: Padding + public let background: Background? + public let shape: Shape? + public let border: Border? + public let shadow: Shadow? + + public let control: TabControl + public let tabs: [Tab] + public let defaultTabId: String? + + public let overrides: ComponentOverrides? + + public init( + visible: Bool? = nil, + size: Size = .init(width: .fill, height: .fit), + padding: Padding = .zero, + margin: Padding = .zero, + background: Background? = nil, + shape: Shape? = nil, + border: Border? = nil, + shadow: Shadow? = nil, + + control: TabControl, + tabs: [Tab], + defaultTabId: String? = nil, + + overrides: ComponentOverrides? = nil + ) { + self.type = .stack + self.visible = visible + self.size = size + self.padding = padding + self.margin = margin + self.background = background + self.shape = shape + self.border = border + self.shadow = shadow + + self.control = control + self.tabs = tabs + self.defaultTabId = defaultTabId + + self.overrides = overrides + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(visible) + hasher.combine(size) + hasher.combine(padding) + hasher.combine(margin) + hasher.combine(background) + hasher.combine(shape) + hasher.combine(border) + hasher.combine(shadow) + hasher.combine(control) + hasher.combine(tabs) + hasher.combine(defaultTabId) + hasher.combine(overrides) + } + + public static func == (lhs: TabsComponent, rhs: TabsComponent) -> Bool { + return lhs.type == rhs.type && + lhs.visible == rhs.visible && + lhs.size == rhs.size && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin && + lhs.background == rhs.background && + lhs.shape == rhs.shape && + lhs.border == rhs.border && + lhs.shadow == rhs.shadow && + lhs.control == rhs.control && + lhs.tabs == rhs.tabs && + lhs.defaultTabId == rhs.defaultTabId && + lhs.overrides == rhs.overrides + } + } + + final class PartialTabsComponent: PaywallPartialComponent { + + public let visible: Bool? + public let size: Size? + public let padding: Padding? + public let margin: Padding? + public let background: Background? + public let shape: Shape? + public let border: Border? + public let shadow: Shadow? + + public init( + visible: Bool? = true, + size: Size? = nil, + padding: Padding? = nil, + margin: Padding? = nil, + background: Background? = nil, + shape: Shape? = nil, + border: Border? = nil, + shadow: Shadow? = nil + ) { + self.visible = visible + self.size = size + self.padding = padding + self.margin = margin + self.background = background + self.shape = shape + self.border = border + self.shadow = shadow + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(visible) + hasher.combine(size) + hasher.combine(padding) + hasher.combine(margin) + hasher.combine(background) + hasher.combine(shape) + hasher.combine(border) + hasher.combine(shadow) + } + + public static func == (lhs: PartialTabsComponent, rhs: PartialTabsComponent) -> Bool { + return lhs.visible == rhs.visible && + lhs.size == rhs.size && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin && + lhs.background == rhs.background && + lhs.shape == rhs.shape && + lhs.border == rhs.border && + lhs.shadow == rhs.shadow + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallTextComponent.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallTextComponent.swift new file mode 100644 index 00000000..b16c124a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallTextComponent.swift @@ -0,0 +1,306 @@ +// +// PaywallTextComponent.swift +// +// +// Created by Josh Holtz on 6/11/24. +// +// swiftlint:disable missing_docs nesting + +import Foundation + +public extension PaywallComponent { + + final class TextComponent: PaywallComponentBase { + + let type: ComponentType + public let visible: Bool? + public let text: LocalizationKey + public let fontName: String? + public let fontWeight: FontWeight + public let color: ColorScheme + public let fontSize: CGFloat + public let horizontalAlignment: HorizontalAlignment + public let backgroundColor: ColorScheme? + public let size: Size + public let padding: Padding + public let margin: Padding + public let fontWeightInt: Int? + + public let overrides: ComponentOverrides? + + public var fontWeightResolved: FontWeight { + fontWeightInt.map { PaywallComponent.fontWeightFrom(integer: $0) } ?? fontWeight + } + + public init( + visible: Bool? = nil, + text: String, + fontName: String? = nil, + fontWeight: FontWeight = .regular, + color: ColorScheme, + backgroundColor: ColorScheme? = nil, + size: Size = .init(width: .fill, height: .fit), + padding: Padding = .zero, + margin: Padding = .zero, + fontSize: CGFloat = 16, + horizontalAlignment: HorizontalAlignment = .center, + overrides: ComponentOverrides? = nil, + fontWeightInt: Int? = nil + ) { + self.type = .text + self.visible = visible + self.text = text + self.fontName = fontName + self.fontWeight = fontWeight + self.color = color + self.backgroundColor = backgroundColor + self.size = size + self.padding = padding + self.margin = margin + self.fontSize = fontSize + self.horizontalAlignment = horizontalAlignment + self.overrides = overrides + self.fontWeightInt = fontWeightInt + } + + private enum CodingKeys: String, CodingKey { + case type + case visible + case text = "textLid" + case fontName + case fontWeight + case color + case fontSize + case horizontalAlignment + case backgroundColor + case size + case padding + case margin + case overrides + case fontWeightInt + } + + required public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.type = try container.decode(ComponentType.self, forKey: .type) + self.visible = try container.decodeIfPresent(Bool.self, forKey: .visible) + self.text = try container.decode(LocalizationKey.self, forKey: .text) + self.fontName = try container.decodeIfPresent(String.self, forKey: .fontName) + self.fontWeight = try container.decode(FontWeight.self, forKey: .fontWeight) + self.color = try container.decode(ColorScheme.self, forKey: .color) + self.horizontalAlignment = try container.decode(HorizontalAlignment.self, forKey: .horizontalAlignment) + self.backgroundColor = try container.decodeIfPresent(ColorScheme.self, forKey: .backgroundColor) + self.size = try container.decode(Size.self, forKey: .size) + self.padding = try container.decode(Padding.self, forKey: .padding) + self.margin = try container.decode(Padding.self, forKey: .margin) + self.overrides = try container.decodeIfPresent( + ComponentOverrides.self, + forKey: .overrides + ) + self.fontWeightInt = try container.decodeIfPresent(Int.self, forKey: .fontWeightInt) + + if let rawFontSize = try? container.decode(CGFloat.self, forKey: .fontSize) { + self.fontSize = rawFontSize + } else if let fontSizeEnum = try? container.decode(FontSize.self, forKey: .fontSize) { + self.fontSize = fontSizeEnum.size + } else { + throw DecodingError.dataCorruptedError(forKey: .fontSize, + in: container, + debugDescription: "Invalid fontSize format") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(type, forKey: .type) + try container.encodeIfPresent(visible, forKey: .visible) + try container.encode(text, forKey: .text) + try container.encodeIfPresent(fontName, forKey: .fontName) + try container.encode(fontWeight, forKey: .fontWeight) + try container.encode(color, forKey: .color) + try container.encode(horizontalAlignment, forKey: .horizontalAlignment) + try container.encodeIfPresent(backgroundColor, forKey: .backgroundColor) + try container.encode(size, forKey: .size) + try container.encode(padding, forKey: .padding) + try container.encode(margin, forKey: .margin) + try container.encodeIfPresent(overrides, forKey: .overrides) + try container.encodeIfPresent(fontWeightInt, forKey: .fontWeightInt) + try container.encode(fontSize, forKey: .fontSize) + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(visible) + hasher.combine(text) + hasher.combine(fontName) + hasher.combine(fontWeight) + hasher.combine(color) + hasher.combine(fontSize) + hasher.combine(horizontalAlignment) + hasher.combine(backgroundColor) + hasher.combine(size) + hasher.combine(padding) + hasher.combine(margin) + hasher.combine(overrides) + hasher.combine(fontWeightInt) + } + + public static func == (lhs: TextComponent, rhs: TextComponent) -> Bool { + return lhs.type == rhs.type && + lhs.visible == rhs.visible && + lhs.text == rhs.text && + lhs.fontName == rhs.fontName && + lhs.fontWeight == rhs.fontWeight && + lhs.color == rhs.color && + lhs.fontSize == rhs.fontSize && + lhs.horizontalAlignment == rhs.horizontalAlignment && + lhs.backgroundColor == rhs.backgroundColor && + lhs.size == rhs.size && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin && + lhs.overrides == rhs.overrides && + lhs.fontWeightInt == rhs.fontWeightInt + } + } + + final class PartialTextComponent: PaywallPartialComponent { + + public let visible: Bool? + public let text: LocalizationKey? + public let fontName: String? + public let fontWeight: FontWeight? + public let color: ColorScheme? + public let fontSize: CGFloat? + public let horizontalAlignment: HorizontalAlignment? + public let backgroundColor: ColorScheme? + public let size: Size? + public let padding: Padding? + public let margin: Padding? + public let fontWeightInt: Int? + + public var fontWeightResolved: FontWeight? { + fontWeightInt.map { PaywallComponent.fontWeightFrom(integer: $0) } ?? fontWeight + } + public init( + visible: Bool? = true, + text: LocalizationKey? = nil, + fontName: String? = nil, + fontWeight: FontWeight? = nil, + color: ColorScheme? = nil, + backgroundColor: ColorScheme? = nil, + size: Size? = nil, + padding: Padding? = nil, + margin: Padding? = nil, + fontSize: CGFloat? = nil, + horizontalAlignment: HorizontalAlignment? = nil, + fontWeightInt: Int? = nil + ) { + self.visible = visible + self.text = text + self.fontName = fontName + self.fontWeight = fontWeight + self.color = color + self.backgroundColor = backgroundColor + self.size = size + self.padding = padding + self.margin = margin + self.fontSize = fontSize + self.horizontalAlignment = horizontalAlignment + self.fontWeightInt = fontWeightInt + } + + private enum CodingKeys: String, CodingKey { + case visible + case text = "textLid" + case fontName + case fontWeight + case color + case fontSize + case horizontalAlignment + case backgroundColor + case size + case padding + case margin + case fontWeightInt + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(visible) + hasher.combine(text) + hasher.combine(fontName) + hasher.combine(fontWeight) + hasher.combine(color) + hasher.combine(fontSize) + hasher.combine(horizontalAlignment) + hasher.combine(backgroundColor) + hasher.combine(size) + hasher.combine(padding) + hasher.combine(margin) + hasher.combine(fontWeightInt) + } + + public static func == (lhs: PartialTextComponent, rhs: PartialTextComponent) -> Bool { + return lhs.visible == rhs.visible && + lhs.text == rhs.text && + lhs.fontName == rhs.fontName && + lhs.fontWeight == rhs.fontWeight && + lhs.color == rhs.color && + lhs.fontSize == rhs.fontSize && + lhs.horizontalAlignment == rhs.horizontalAlignment && + lhs.backgroundColor == rhs.backgroundColor && + lhs.size == rhs.size && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin && + lhs.fontWeightInt == rhs.fontWeightInt + } + } + +} + +private extension PaywallComponent.FontSize { + + var size: CGFloat { + switch self { + case .headingXXL: return 40 + case .headingXL: return 34 + case .headingL: return 28 + case .headingM: return 24 + case .headingS: return 20 + case .headingXS: return 16 + case .bodyXL: return 18 + case .bodyL: return 17 + case .bodyM: return 15 + case .bodyS: return 13 + } + } + +} + +private extension PaywallComponent { + + static func fontWeightFrom(integer weight: Int) -> PaywallComponent.FontWeight { + let clampedWeight = max(100, min(weight, 900)) + + switch clampedWeight { + case 100: return .thin + case 200: return .extraLight + case 300: return .light + case 400: return .regular + case 500: return .medium + case 600: return .semibold + case 700: return .bold + case 800: return .extraBold + case 900: return .black + + default: + let availableWeights = [100, 200, 300, 400, 500, 600, 700, 800, 900] + // swiftlint:disable:next force_unwrapping + let closest = availableWeights.reduce(availableWeights.first!) { currentClosest, candidate in + abs(candidate - weight) < abs(currentClosest - weight) ? candidate : currentClosest + } + return Self.fontWeightFrom(integer: closest) + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallTimelineComponent.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallTimelineComponent.swift new file mode 100644 index 00000000..ad74b495 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallTimelineComponent.swift @@ -0,0 +1,245 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallTimelineComponent.swift +// +// Created by mark on 15/1/25. + +import Foundation + +// swiftlint:disable missing_docs nesting +public extension PaywallComponent { + + final class TimelineComponent: PaywallComponentBase { + + let type: ComponentType + public let visible: Bool? + public let iconAlignment: IconAlignment? + public let itemSpacing: CGFloat? + public let textSpacing: CGFloat? + public let columnGutter: CGFloat? + public let size: Size + public let padding: Padding + public let margin: Padding + public let items: [Item] + + public let overrides: ComponentOverrides? + + public init( + visible: Bool? = nil, + iconAlignment: IconAlignment?, + itemSpacing: CGFloat?, + textSpacing: CGFloat?, + columnGutter: CGFloat?, + size: Size, + padding: Padding, + margin: Padding, + items: [Item], + overrides: ComponentOverrides? + ) { + self.type = .timeline + self.visible = visible + self.iconAlignment = iconAlignment + self.itemSpacing = itemSpacing + self.textSpacing = textSpacing + self.columnGutter = columnGutter + self.size = size + self.padding = padding + self.margin = margin + self.items = items + self.overrides = overrides + } + + public static func == ( + lhs: PaywallComponent.TimelineComponent, + rhs: PaywallComponent.TimelineComponent + ) -> Bool { + return lhs.iconAlignment == rhs.iconAlignment && + lhs.visible == rhs.visible && + lhs.itemSpacing == rhs.itemSpacing && + lhs.textSpacing == rhs.textSpacing && + lhs.columnGutter == rhs.columnGutter && + lhs.size == rhs.size && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin && + lhs.items == rhs.items + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(visible) + hasher.combine(iconAlignment) + hasher.combine(itemSpacing) + hasher.combine(textSpacing) + hasher.combine(columnGutter) + hasher.combine(size) + hasher.combine(padding) + hasher.combine(margin) + hasher.combine(items) + } + + public final class Item: Codable, Sendable, Hashable, Equatable { + + public let title: TextComponent + public let description: TextComponent? + public let icon: IconComponent + public let connector: Connector? + public let overrides: ComponentOverrides? + + public init(title: TextComponent, + description: TextComponent?, + icon: IconComponent, + connector: Connector, + overrides: ComponentOverrides?) { + self.title = title + self.description = description + self.icon = icon + self.connector = connector + self.overrides = overrides + } + + public static func == (lhs: PaywallComponent.TimelineComponent.Item, + rhs: PaywallComponent.TimelineComponent.Item) -> Bool { + return lhs.title == rhs.title && + lhs.description == rhs.description && + lhs.icon == rhs.icon && + lhs.connector == rhs.connector && + lhs.overrides == rhs.overrides + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(title) + hasher.combine(description) + hasher.combine(icon) + hasher.combine(connector) + hasher.combine(overrides) + } + + } + + public final class Connector: Codable, Sendable, Hashable, Equatable { + + public let width: CGFloat + public let color: ColorScheme + public let margin: Padding + + public init(width: CGFloat, color: ColorScheme, margin: Padding) { + self.width = width + self.color = color + self.margin = margin + } + + public static func == (lhs: PaywallComponent.TimelineComponent.Connector, + rhs: PaywallComponent.TimelineComponent.Connector) -> Bool { + return lhs.color == rhs.color && + lhs.width == rhs.width && + lhs.margin == rhs.margin + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(color) + hasher.combine(width) + hasher.combine(margin) + } + } + + public enum IconAlignment: String, Sendable, Codable, Equatable, Hashable { + case title = "title" + case titleAndDescription = "title_and_description" + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try? container.decode(String.self) + self = IconAlignment(rawValue: rawValue ?? "") ?? .title + } + } + } + + final class PartialTimelineComponent: PaywallPartialComponent { + + public let visible: Bool? + public let iconAlignment: PaywallComponent.TimelineComponent.IconAlignment? + public let itemSpacing: CGFloat? + public let textSpacing: CGFloat? + public let columnGutter: CGFloat? + public let size: Size? + public let padding: Padding? + public let margin: Padding? + + public init( + visible: Bool? = nil, + iconAlignment: PaywallComponent.TimelineComponent.IconAlignment?, + itemSpacing: CGFloat?, + textSpacing: CGFloat?, + columnGutter: CGFloat?, + size: Size?, + padding: Padding?, + margin: Padding? + ) { + self.visible = visible + self.iconAlignment = iconAlignment + self.itemSpacing = itemSpacing + self.textSpacing = textSpacing + self.columnGutter = columnGutter + self.size = size + self.padding = padding + self.margin = margin + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(visible) + hasher.combine(iconAlignment) + hasher.combine(itemSpacing) + hasher.combine(textSpacing) + hasher.combine(columnGutter) + hasher.combine(size) + hasher.combine(padding) + hasher.combine(margin) + } + + public static func == ( + lhs: PartialTimelineComponent, + rhs: PartialTimelineComponent + ) -> Bool { + return lhs.iconAlignment == rhs.iconAlignment && + lhs.visible == rhs.visible && + lhs.itemSpacing == rhs.itemSpacing && + lhs.textSpacing == rhs.textSpacing && + lhs.columnGutter == rhs.columnGutter && + lhs.size == rhs.size && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin + } + + } + + final class PartialTimelineItem: PaywallPartialComponent { + + public let visible: Bool? + public let connector: TimelineComponent.Connector? + + public init(visible: Bool?, + connector: TimelineComponent.Connector?) { + self.visible = visible + self.connector = connector + } + + public static func == (lhs: PartialTimelineItem, + rhs: PartialTimelineItem) -> Bool { + lhs.visible == rhs.visible && + lhs.connector == rhs.connector + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(visible) + hasher.combine(connector) + } + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallV2CacheWarming.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallV2CacheWarming.swift new file mode 100644 index 00000000..156aa743 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallV2CacheWarming.swift @@ -0,0 +1,373 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallV2CacheWarming.swift +// +// Created by Josh Holtz on 1/13/25. + +import Foundation + +extension PaywallComponentsData { + + var allImageURLs: [URL] { + var imageUrls = self.componentsConfig.base.allImageURLs + + for (_, localeValues) in self.componentsLocalizations { + for (_, value) in localeValues { + switch value { + case .string: + break + case .image(let image): + imageUrls += image.imageUrls + } + } + } + + return imageUrls + } + + var allLowResVideoUrls: [URLWithValidation] { + return self.componentsConfig.base.allLowResVideoUrls + } + +} + +extension PaywallComponentsData.PaywallComponentsConfig { + + var allImageURLs: [URL] { + let rootStackImageURLs = self.collectAllImageURLs(in: self.stack) + let stickFooterImageURLs = self.stickyFooter.flatMap { + self.collectAllImageURLs(in: $0.stack) + } ?? [] + + return rootStackImageURLs + stickFooterImageURLs + self.background.allImageURLS + } + + var allLowResVideoUrls: [URLWithValidation] { + let rootStackVideoURLs = self.collectAllVideoURLs(in: self.stack) + let stickFooterVideoURLs = self.stickyFooter.flatMap { self.collectAllVideoURLs(in: $0.stack) } ?? [] + + return rootStackVideoURLs + stickFooterVideoURLs + self.background.lowResVideoUrls + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + private func collectAllImageURLs( + in stack: PaywallComponent.StackComponent, + includeHighResInComponentHeirarchy: (PaywallComponent) -> Bool = { component in + // collecting high res images from the sheet is important because async functions + // prevent the proper animation from playing during sheet presentation and by collecting + // the images ahead of time we can synchronously render the image instead. + return component.isSheetButton + } + ) -> [URL] { + + var urls: [URL] = [] + urls += stack.background?.allImageURLS ?? [] + for component in stack.components { + var includeHighResInComponentHeirarchy = includeHighResInComponentHeirarchy + if includeHighResInComponentHeirarchy(component) { + // override to true regardless of children to ensure high res + // image collection after the desired component type was found + includeHighResInComponentHeirarchy = { _ in return true } + } + + switch component { + case .text: + () + case .icon(let icon): + urls += icon.imageUrls + case .image(let image): + urls += image.source.imageUrls + + if let overrides = image.overrides { + urls += overrides.imageUrls + } + + if includeHighResInComponentHeirarchy(component) { + urls += image.source.highResImageUrls + } + + case .stack(let stack): + urls += self.collectAllImageURLs( + in: stack, + includeHighResInComponentHeirarchy: includeHighResInComponentHeirarchy + ) + case .button(let button): + urls += self.collectAllImageURLs( + in: button.stack, + includeHighResInComponentHeirarchy: includeHighResInComponentHeirarchy + ) + + // Collect images from sheet stack + switch button.action { + case .navigateTo(let destination): + switch destination { + case .sheet(sheet: let sheet): + urls += self.collectAllImageURLs( + in: sheet.stack, + includeHighResInComponentHeirarchy: includeHighResInComponentHeirarchy + ) + case .customerCenter, .offerCode, .privacyPolicy, .terms, .webPaywallLink, .url, .unknown: + break + } + case .restorePurchases, .navigateBack, .unknown: + break + } + case .package(let package): + urls += self.collectAllImageURLs( + in: package.stack, + includeHighResInComponentHeirarchy: includeHighResInComponentHeirarchy + ) + case .purchaseButton(let purchaseButton): + urls += self.collectAllImageURLs( + in: purchaseButton.stack, + includeHighResInComponentHeirarchy: includeHighResInComponentHeirarchy + ) + case .stickyFooter(let stickyFooter): + urls += self.collectAllImageURLs( + in: stickyFooter.stack, + includeHighResInComponentHeirarchy: includeHighResInComponentHeirarchy + ) + case .timeline(let component): + for item in component.items { + urls += item.icon.imageUrls + } + case .tabs(let tabs): + urls += self.collectAllImageURLs( + in: tabs.control.stack, + includeHighResInComponentHeirarchy: includeHighResInComponentHeirarchy + ) + for tab in tabs.tabs { + urls += self.collectAllImageURLs( + in: tab.stack, + includeHighResInComponentHeirarchy: includeHighResInComponentHeirarchy + ) + } + case .tabControl: + break + case .tabControlButton(let controlButton): + urls += self.collectAllImageURLs( + in: controlButton.stack, + includeHighResInComponentHeirarchy: includeHighResInComponentHeirarchy + ) + case .tabControlToggle: + break + case .carousel(let carousel): + urls += carousel.pages.flatMap( + { stack in + self.collectAllImageURLs( + in: stack, + includeHighResInComponentHeirarchy: includeHighResInComponentHeirarchy + ) + }) + case .video(let video): + urls += video.imageUrls + case .countdown(let countdown): + urls += self.collectAllImageURLs( + in: countdown.countdownStack, + includeHighResInComponentHeirarchy: includeHighResInComponentHeirarchy + ) + if let endStack = countdown.endStack { + urls += self.collectAllImageURLs( + in: endStack, + includeHighResInComponentHeirarchy: includeHighResInComponentHeirarchy + ) + } + if let fallback = countdown.fallback { + urls += self.collectAllImageURLs( + in: fallback, + includeHighResInComponentHeirarchy: includeHighResInComponentHeirarchy + ) + } + } + } + + return urls + } + + // swiftlint:disable:next cyclomatic_complexity + private func collectAllVideoURLs(in stack: PaywallComponent.StackComponent) -> [URLWithValidation] { + + var urls: [URLWithValidation] = [] + urls += stack.background?.lowResVideoUrls ?? [] + for component in stack.components { + switch component { + case .text: + break + case .icon: + break + case .image: + break + case .stack(let stack): + urls += self.collectAllVideoURLs(in: stack) + case .button(let button): + urls += self.collectAllVideoURLs(in: button.stack) + case .package(let package): + urls += self.collectAllVideoURLs(in: package.stack) + case .purchaseButton(let purchaseButton): + urls += self.collectAllVideoURLs(in: purchaseButton.stack) + case .stickyFooter(let stickyFooter): + urls += self.collectAllVideoURLs(in: stickyFooter.stack) + case .timeline: + break + case .tabs(let tabs): + for tab in tabs.tabs { + urls += self.collectAllVideoURLs(in: tab.stack) + } + case .tabControl: + break + case .tabControlButton(let controlButton): + urls += self.collectAllVideoURLs(in: controlButton.stack) + case .tabControlToggle: + break + case .carousel(let carousel): + urls += carousel.pages.flatMap({ stack in + self.collectAllVideoURLs(in: stack) + }) + case .video(let video): + urls += video.lowResVideoUrls + case .countdown(let countdown): + urls += self.collectAllVideoURLs(in: countdown.countdownStack) + if let endStack = countdown.endStack { + urls += self.collectAllVideoURLs(in: endStack) + } + if let fallback = countdown.fallback { + urls += self.collectAllVideoURLs(in: fallback) + } + } + } + + return urls + } + +} + +private extension PaywallComponent { + var isSheetButton: Bool { + switch self { + case .button(let component): + switch component.action { + case .navigateTo(.sheet): + return true + default: + return false + } + default: + return false + } + } +} + +extension PaywallComponent.IconComponent.Formats { + + func imageUrls(base: URL) -> [URL] { + return [ + base.appendingPathComponent(heic) + ] + } + +} + +private extension PaywallComponent.IconComponent { + + var imageUrls: [URL] { + guard let baseUrl = URL(string: self.baseUrl) else { + return [] + } + + return self.formats.imageUrls(base: baseUrl) + (self.overrides?.imageUrls(base: baseUrl) ?? []) + } + +} + +extension Array where Element == PaywallComponent.ComponentOverride { + + func imageUrls(base: URL) -> [URL] { + return self.compactMap { iconOverrides in + iconOverrides.properties.formats?.imageUrls(base: base) ?? [] + }.flatMap { $0 } + } + +} + +extension Array where Element == PaywallComponent.ComponentOverride { + + var imageUrls: [URL] { + return self.compactMap { iconOverrides in + iconOverrides.properties.source?.imageUrls ?? [] + }.flatMap { $0 } + } + +} + +private extension PaywallComponent.ThemeImageUrls { + + var imageUrls: [URL] { + return [ + self.light.heicLowRes, + self.dark?.heicLowRes + ].compactMap { $0 } + } + + var highResImageUrls: [URL] { + return [ + self.light.heic, + self.dark?.heic + ].compactMap { $0 } + } + +} + +private extension PaywallComponent.VideoComponent { + + var imageUrls: [URL] { + fallbackSource?.imageUrls ?? [] + } + + var lowResVideoUrls: [URLWithValidation] { + let sources: [PaywallComponent.VideoUrls?] = [source.light, source.dark] + return sources.map { source in + if let url = source?.urlLowRes { + return URLWithValidation(url: url, checksum: source?.checksumLowRes) + } else { + return nil + } + } + .compactMap { $0 } + } +} + +private extension PaywallComponent.Background { + var allImageURLS: [URL] { + switch self { + case .image(let imageURLS, _, _): + return imageURLS.imageUrls + case .video(_, let imageURLS, _, _, _, _): + return imageURLS.imageUrls + default: + return [] + } + } + + var lowResVideoUrls: [URLWithValidation] { + switch self { + case .video(let urls, _, _, _, _, _): + let sources: [PaywallComponent.VideoUrls?] = [urls.light, urls.dark] + return sources.compactMap { source in + if let url = source?.urlLowRes { + return URLWithValidation(url: url, checksum: source?.checksumLowRes) + } else { + return nil + } + } + default: + return [] + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallVideoComponent.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallVideoComponent.swift new file mode 100644 index 00000000..3c5fc094 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/PaywallVideoComponent.swift @@ -0,0 +1,208 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallVideoComponent.swift +// +// Created by Jacob Zivan Rakidzich on 8/11/25. +// +// swiftlint:disable missing_docs + +import Foundation + +extension PaywallComponent { + + public final class VideoComponent: PaywallComponentBase { + + let type: ComponentType + public let source: ThemeVideoUrls + public let fallbackSource: ThemeImageUrls? + public let visible: Bool? + public let showControls: Bool + public let autoPlay: Bool + public let loop: Bool + public let muteAudio: Bool + public let size: Size + public let fitMode: FitMode + public let maskShape: MaskShape? + public let colorOverlay: ColorScheme? + public let padding: Padding? + public let margin: Padding? + public let border: Border? + public let shadow: Shadow? + + public let overrides: ComponentOverrides? + + public init( + visible: Bool? = nil, + source: ThemeVideoUrls, + fallbackSource: ThemeImageUrls? = nil, + showControls: Bool = false, + autoPlay: Bool = true, + loop: Bool = true, + muteAudio: Bool = true, + size: Size = .init(width: .fill, height: .fit), + fitMode: FitMode = .fit, + maskShape: MaskShape? = nil, + colorOverlay: ColorScheme? = nil, + padding: Padding? = nil, + margin: Padding? = nil, + border: Border? = nil, + shadow: Shadow? = nil, + overrides: ComponentOverrides? = nil + ) { + self.type = .video + self.source = source + self.fallbackSource = fallbackSource + self.visible = visible + self.showControls = showControls + self.autoPlay = autoPlay + self.loop = loop + self.muteAudio = muteAudio + self.size = size + self.fitMode = fitMode + self.maskShape = maskShape + self.colorOverlay = colorOverlay + self.padding = padding + self.margin = margin + self.border = border + self.shadow = shadow + self.overrides = overrides + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(visible) + hasher.combine(showControls) + hasher.combine(autoPlay) + hasher.combine(loop) + hasher.combine(muteAudio) + hasher.combine(size) + hasher.combine(fitMode) + hasher.combine(maskShape) + hasher.combine(colorOverlay) + hasher.combine(padding) + hasher.combine(margin) + hasher.combine(border) + hasher.combine(shadow) + hasher.combine(overrides) + hasher.combine(source) + hasher.combine(fallbackSource) + } + + public static func == (lhs: VideoComponent, rhs: VideoComponent) -> Bool { + return lhs.type == rhs.type && + lhs.visible == rhs.visible && + lhs.showControls == rhs.showControls && + lhs.autoPlay == rhs.autoPlay && + lhs.loop == rhs.loop && + lhs.muteAudio == rhs.muteAudio && + lhs.size == rhs.size && + lhs.fitMode == rhs.fitMode && + lhs.maskShape == rhs.maskShape && + lhs.colorOverlay == rhs.colorOverlay && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin && + lhs.border == rhs.border && + lhs.shadow == rhs.shadow && + lhs.overrides == rhs.overrides && + lhs.fallbackSource == rhs.fallbackSource && + lhs.source == rhs.source + } + } + + public final class PartialVideoComponent: PaywallPartialComponent { + + public let source: ThemeVideoUrls? + public let fallbackSource: ThemeImageUrls? + public let visible: Bool? + public let showControls: Bool? + public let autoPlay: Bool? + public let loop: Bool? + public let muteAudio: Bool? + public let size: Size? + public let fitMode: FitMode? + public let maskShape: MaskShape? + public let colorOverlay: ColorScheme? + public let padding: Padding? + public let margin: Padding? + public let border: Border? + public let shadow: Shadow? + + public init( + source: ThemeVideoUrls? = nil, + fallbackSource: ThemeImageUrls? = nil, + visible: Bool? = true, + showControls: Bool? = nil, + autoPlay: Bool? = nil, + loop: Bool? = nil, + muteAudio: Bool? = nil, + size: Size? = nil, + fitMode: FitMode? = nil, + maskShape: MaskShape? = nil, + colorOverlay: ColorScheme? = nil, + padding: Padding? = nil, + margin: Padding? = nil, + border: Border? = nil, + shadow: Shadow? = nil + ) { + self.source = source + self.fallbackSource = fallbackSource + self.visible = visible + self.showControls = showControls + self.autoPlay = autoPlay + self.loop = loop + self.muteAudio = muteAudio + self.size = size + self.fitMode = fitMode + self.maskShape = maskShape + self.colorOverlay = colorOverlay + self.padding = padding + self.margin = margin + self.border = border + self.shadow = shadow + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(source) + hasher.combine(fallbackSource) + hasher.combine(visible) + hasher.combine(showControls) + hasher.combine(autoPlay) + hasher.combine(loop) + hasher.combine(muteAudio) + hasher.combine(size) + hasher.combine(fitMode) + hasher.combine(maskShape) + hasher.combine(colorOverlay) + hasher.combine(padding) + hasher.combine(margin) + hasher.combine(border) + hasher.combine(shadow) + hasher.combine(fallbackSource) + } + + public static func == (lhs: PartialVideoComponent, rhs: PartialVideoComponent) -> Bool { + return lhs.visible == rhs.visible && + lhs.showControls == rhs.showControls && + lhs.autoPlay == rhs.autoPlay && + lhs.loop == rhs.loop && + lhs.muteAudio == rhs.muteAudio && + lhs.size == rhs.size && + lhs.fitMode == rhs.fitMode && + lhs.maskShape == rhs.maskShape && + lhs.colorOverlay == rhs.colorOverlay && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin && + lhs.border == rhs.border && + lhs.shadow == rhs.shadow && + lhs.fallbackSource == rhs.fallbackSource && + lhs.source == rhs.source + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Transitions/PaywallAnimation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Transitions/PaywallAnimation.swift new file mode 100644 index 00000000..f0051fb4 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Transitions/PaywallAnimation.swift @@ -0,0 +1,49 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallAnimation.swift +// +// Created by Jacob Zivan Rakidzich on 8/20/25. +// swiftlint:disable missing_docs + +import Foundation + +public extension PaywallComponent { + + struct Animation: PaywallComponentBase { + + public let type: AnimationType + public let msDelay: Int + public let msDuration: Int + + init(type: AnimationType, msDelay: Int, msDuration: Int) { + self.type = type + self.msDelay = msDelay + self.msDuration = msDuration + } + + } + + /// Defines the type of animation to use for paywall transitions. + enum AnimationType: String, PaywallComponentBase { + + case easeIn + case easeInOut + case easeOut + case linear + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try? container.decode(String.self) + self = AnimationType(rawValue: rawValue ?? "") ?? .easeInOut + } + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Transitions/PaywallTransition.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Transitions/PaywallTransition.swift new file mode 100644 index 00000000..965d098e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Components/Transitions/PaywallTransition.swift @@ -0,0 +1,65 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallTransition.swift +// +// Created by Jacob Zivan Rakidzich on 8/20/25. +// swiftlint:disable missing_docs + +import Foundation + +public extension PaywallComponent { + + struct Transition: PaywallComponentBase { + + public let type: TransitionType + public let displacementStrategy: DisplacementStrategy + public let animation: PaywallComponent.Animation? + + init ( + type: TransitionType = .fade, + displacementStrategy: DisplacementStrategy = .greedy, + animation: PaywallComponent.Animation? = nil + ) { + self.type = type + self.displacementStrategy = displacementStrategy + self.animation = animation + } + } + + /// + /// Determines how the view being animated out is displaced by the view being animated in. + /// + /// A `greedy` displacement will result in the space being taken up by the incoming view + /// *before* it attempts to transition into the view hierarchy. + /// + /// A `lazy` displacement will not do this, instead it will result in shifting the layout + /// as the new view inserts itself. + /// + enum DisplacementStrategy: String, PaywallComponentBase { + case greedy, lazy + } + + /// Defines the type of transition to use for paywall transitions. + enum TransitionType: String, PaywallComponentBase { + + case fade + case fadeAndScale = "fade_and_scale" + case scale + case slide + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try? container.decode(String.self) + self = TransitionType(rawValue: rawValue ?? "") ?? .fade + } + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Events/Networking/EventsRequest+Paywall.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Events/Networking/EventsRequest+Paywall.swift new file mode 100644 index 00000000..74b5ec3e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Events/Networking/EventsRequest+Paywall.swift @@ -0,0 +1,145 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// FeatureFeatureEventsRequest+Paywall.swift +// +// Created by Cesar de la Vega on 24/10/24. + +import Foundation + +extension FeatureEventsRequest { + + struct PaywallEvent { + + let id: String? + let version: Int + var type: EventType + var appUserID: String + var paywallID: String? + var sessionID: String + var offeringID: String + var paywallRevision: Int + var timestamp: UInt64 + var displayMode: PaywallViewMode + var darkMode: Bool + var localeIdentifier: String + var exitOfferType: ExitOfferType? + var exitOfferingID: String? + var packageId: String? + var productId: String? + var errorCode: Int? + var errorMessage: String? + + } + +} + +extension FeatureEventsRequest.PaywallEvent { + + enum EventType: String { + + case impression = "paywall_impression" + case cancel = "paywall_cancel" + case close = "paywall_close" + case exitOffer = "paywall_exit_offer" + case purchaseInitiated = "paywall_purchase_initiated" + case purchaseError = "paywall_purchase_error" + + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + init?(storedEvent: StoredFeatureEvent) { + guard let jsonData = storedEvent.encodedEvent.data(using: .utf8) else { + Logger.error(Strings.paywalls.event_cannot_get_encoded_event) + return nil + } + + do { + let paywallEvent = try JSONDecoder.default.decode(PaywallEvent.self, from: jsonData) + let creationData = paywallEvent.creationData + let data = paywallEvent.data + let exitOfferData = paywallEvent.exitOfferData + + self.init( + id: creationData.id.uuidString, + version: Self.version, + type: paywallEvent.eventType, + appUserID: storedEvent.userID, + paywallID: data.paywallIdentifier, + sessionID: data.sessionIdentifier.uuidString, + offeringID: data.offeringIdentifier, + paywallRevision: data.paywallRevision, + timestamp: creationData.date.millisecondsSince1970, + displayMode: data.displayMode, + darkMode: data.darkMode, + localeIdentifier: data.localeIdentifier, + exitOfferType: exitOfferData?.exitOfferType, + exitOfferingID: exitOfferData?.exitOfferingIdentifier, + packageId: data.packageId, + productId: data.productId, + errorCode: data.errorCode, + errorMessage: data.errorMessage + ) + } catch { + Logger.error(Strings.paywalls.event_cannot_deserialize(error)) + return nil + } + } + + private static let version: Int = 1 + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private extension PaywallEvent { + + var eventType: FeatureEventsRequest.PaywallEvent.EventType { + switch self { + case .impression: return .impression + case .cancel: return .cancel + case .close: return .close + case .exitOffer: return .exitOffer + case .purchaseInitiated: return .purchaseInitiated + case .purchaseError: return .purchaseError + } + + } + +} + +// MARK: - Codable + +extension FeatureEventsRequest.PaywallEvent.EventType: Encodable {} +extension FeatureEventsRequest.PaywallEvent: Encodable { + + /// When sending this to the backend `JSONEncoder.KeyEncodingStrategy.convertToSnakeCase` is used + private enum CodingKeys: String, CodingKey { + + case id + case version + case type + case appUserID = "appUserId" + case paywallID = "paywallId" + case sessionID = "sessionId" + case offeringID = "offeringId" + case paywallRevision + case timestamp + case displayMode + case darkMode + case localeIdentifier = "locale" + case exitOfferType + case exitOfferingID = "exitOfferingId" + case packageId = "packageId" + case productId = "productId" + case errorCode + case errorMessage + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Events/PaywallEvent.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Events/PaywallEvent.swift new file mode 100644 index 00000000..54367f93 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Events/PaywallEvent.swift @@ -0,0 +1,280 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallEvent.swift +// +// Created by Nacho Soto on 9/5/23. + +import Foundation + +/// The type of exit offer shown. +public enum ExitOfferType: String, Codable, Sendable { + + /// An exit offer shown when the user attempts to dismiss the paywall without interacting. + case dismiss + +} + +/// An event to be sent by the `RevenueCatUI` SDK. +public enum PaywallEvent: FeatureEvent { + + // swiftlint:disable type_name + + /// An identifier that represents a paywall event. + public typealias ID = UUID + + // swiftlint:enable type_name + + /// An identifier that represents a paywall session. + public typealias SessionID = UUID + + var feature: Feature { + return .paywalls + } + + var eventDiscriminator: String? { + return nil + } + + /// `purchaseInitiated` and `purchaseError` events are only used locally for attribution for now. + /// They should not be sent to the backend until the backend supports them. + var shouldStoreEvent: Bool { + switch self { + case .purchaseInitiated, .purchaseError: + return false + case .impression, .cancel, .close, .exitOffer: + return true + } + } + + /// A `PaywallView` was displayed. + case impression(CreationData, Data) + + /// A purchase was cancelled. + case cancel(CreationData, Data) + + /// A `PaywallView` was closed. + case close(CreationData, Data) + + /// An exit offer is shown to the user. + case exitOffer(CreationData, Data, ExitOfferData) + + /// A purchase was initiated from the paywall. + case purchaseInitiated(CreationData, Data) + + /// A purchase from the paywall failed with an error. + case purchaseError(CreationData, Data) + +} + +extension PaywallEvent { + + /// The creation data of a ``PaywallEvent``. + public struct CreationData { + + // swiftlint:disable missing_docs + public var id: ID + public var date: Date + + public init( + id: ID = .init(), + date: Date = .init() + ) { + self.id = id + self.date = date + } + // swiftlint:enable missing_docs + + } + +} + +extension PaywallEvent { + + /// The content of a ``PaywallEvent``. + public struct Data { + + // swiftlint:disable missing_docs + + public var paywallIdentifier: String? + public var offeringIdentifier: String + public var paywallRevision: Int + public var sessionIdentifier: SessionID + public var displayMode: PaywallViewMode + public var localeIdentifier: String + public var darkMode: Bool + var packageId: String? + var productId: String? + var errorCode: Int? + var errorMessage: String? + + #if !os(tvOS) // For Paywalls V2 + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + public init( + offering: Offering, + paywallComponentsData: PaywallComponentsData, + sessionID: SessionID, + displayMode: PaywallViewMode, + locale: Locale, + darkMode: Bool + ) { + self.init( + paywallIdentifier: paywallComponentsData.id, + offeringIdentifier: offering.identifier, + paywallRevision: paywallComponentsData.revision, + sessionID: sessionID, + displayMode: displayMode, + localeIdentifier: locale.identifier, + darkMode: darkMode + ) + } + #endif + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + public init( + offering: Offering, + paywall: PaywallData, + sessionID: SessionID, + displayMode: PaywallViewMode, + locale: Locale, + darkMode: Bool + ) { + self.init( + paywallIdentifier: paywall.id, + offeringIdentifier: offering.identifier, + paywallRevision: paywall.revision, + sessionID: sessionID, + displayMode: displayMode, + localeIdentifier: locale.identifier, + darkMode: darkMode + ) + } + // swiftlint:enable missing_docs + + init( + paywallIdentifier: String?, + offeringIdentifier: String, + paywallRevision: Int, + sessionID: SessionID, + displayMode: PaywallViewMode, + localeIdentifier: String, + darkMode: Bool, + packageId: String? = nil, + productId: String? = nil, + errorCode: Int? = nil, + errorMessage: String? = nil + ) { + self.paywallIdentifier = paywallIdentifier + self.offeringIdentifier = offeringIdentifier + self.paywallRevision = paywallRevision + self.sessionIdentifier = sessionID + self.displayMode = displayMode + self.localeIdentifier = localeIdentifier + self.darkMode = darkMode + self.packageId = packageId + self.productId = productId + self.errorCode = errorCode + self.errorMessage = errorMessage + } + + } + +} + +extension PaywallEvent { + + /// The data specific to an exit offer event. + public struct ExitOfferData { + + // swiftlint:disable missing_docs + public var exitOfferType: ExitOfferType + public var exitOfferingIdentifier: String + + public init( + exitOfferType: ExitOfferType, + exitOfferingIdentifier: String + ) { + self.exitOfferType = exitOfferType + self.exitOfferingIdentifier = exitOfferingIdentifier + } + // swiftlint:enable missing_docs + + } + +} + +extension PaywallEvent { + + /// - Returns: the underlying ``PaywallEvent/CreationData-swift.struct`` for this event. + public var creationData: CreationData { + switch self { + case let .impression(creationData, _): return creationData + case let .cancel(creationData, _): return creationData + case let .close(creationData, _): return creationData + case let .exitOffer(creationData, _, _): return creationData + case let .purchaseInitiated(creationData, _): return creationData + case let .purchaseError(creationData, _): return creationData + } + } + + /// - Returns: the underlying ``PaywallEvent/Data-swift.struct`` for this event. + public var data: Data { + switch self { + case let .impression(_, data): return data + case let .cancel(_, data): return data + case let .close(_, data): return data + case let .exitOffer(_, data, _): return data + case let .purchaseInitiated(_, data): return data + case let .purchaseError(_, data): return data + } + } + + /// - Returns: the underlying ``PaywallEvent/ExitOfferData-swift.struct`` for exit offer events, nil otherwise. + public var exitOfferData: ExitOfferData? { + switch self { + case .impression, .cancel, .close, .purchaseInitiated, .purchaseError: return nil + case let .exitOffer(_, _, exitOfferData): return exitOfferData + } + } + +} + +// MARK: - + +extension PaywallEvent.Data { + + /// Creates a copy of this data with purchase-related information. + @_spi(Internal) + public func withPurchaseInfo( + packageId: String?, + productId: String?, + errorCode: Int?, + errorMessage: String? + ) -> PaywallEvent.Data { + return PaywallEvent.Data( + paywallIdentifier: self.paywallIdentifier, + offeringIdentifier: self.offeringIdentifier, + paywallRevision: self.paywallRevision, + sessionID: self.sessionIdentifier, + displayMode: self.displayMode, + localeIdentifier: self.localeIdentifier, + darkMode: self.darkMode, + packageId: packageId, + productId: productId, + errorCode: errorCode, + errorMessage: errorMessage + ) + } + +} + +extension PaywallEvent.CreationData: Equatable, Codable, Sendable {} +extension PaywallEvent.Data: Equatable, Codable, Sendable {} +extension PaywallEvent.ExitOfferData: Equatable, Codable, Sendable {} +extension PaywallEvent: Equatable, Codable, Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/ExitOffer.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/ExitOffer.swift new file mode 100644 index 00000000..db01ffa3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/ExitOffer.swift @@ -0,0 +1,41 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ExitOffer.swift +// +// Created by RevenueCat. + +import Foundation + +/// Represents an exit offer that can be shown when a paywall is dismissed. +public struct ExitOffer: Codable, Sendable, Hashable, Equatable { + + /// The identifier of the offering to show as an exit offer. + public let offeringId: String + + /// Creates an exit offer with the specified offering identifier. + /// - Parameter offeringId: The identifier of the offering to show. + public init(offeringId: String) { + self.offeringId = offeringId + } +} + +/// Contains exit offers for different dismissal triggers. +public struct ExitOffers: Codable, Sendable, Hashable, Equatable { + + /// The exit offer to show when the paywall is dismissed. + public let dismiss: ExitOffer? + + /// Creates exit offers configuration. + /// - Parameter dismiss: The exit offer to show on dismissal. + public init(dismiss: ExitOffer? = nil) { + self.dismiss = dismiss + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Locale+Comparison.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Locale+Comparison.swift new file mode 100644 index 00000000..30eb48fa --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/Locale+Comparison.swift @@ -0,0 +1,50 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Locale+Comparison.swift +// +// Created by Jacob Zivan Rakidzich on 11/7/25. + +import Foundation + +extension Locale { + + /// Determine whether or not a Locale matches another + /// - Parameters: + /// - other: Another Locale + /// - stricterMatching: When false, the function will generally just check the language family code, + /// for ios 15 or lower passing in true will ensure that the language code and the language details are both + /// considered after normalizing the data ignoring case & certain special characters + /// - Returns: True or False + func sharesLanguageCode(with other: Locale, stricterMatching: Bool = true) -> Bool { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + if self.language.isEquivalent(to: other.language) { + return true + } else { + if stricterMatching { return false } + } + } + + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + return self.language.languageCode == other.language.languageCode + } else { + if stricterMatching { + return normalizedIdentifier == other.normalizedIdentifier + } + return self.languageCode?.lowercased() == other.languageCode?.lowercased() + } + } + + private var normalizedIdentifier: String { + identifier + .replacingOccurrences(of: "-", with: "") + .replacingOccurrences(of: "_", with: "") + .lowercased() + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/PaywallCacheWarming.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/PaywallCacheWarming.swift new file mode 100644 index 00000000..84b004cb --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/PaywallCacheWarming.swift @@ -0,0 +1,378 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallCacheWarming.swift +// + +// Created by Nacho Soto on 8/7/23. + +import Foundation + +protocol PaywallCacheWarmingType: Sendable { + + @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *) + func warmUpEligibilityCache(offerings: Offerings) async + + @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *) + func warmUpPaywallImagesCache(offerings: Offerings) async + + @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *) + func warmUpPaywallVideosCache(offerings: Offerings) async + + @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *) + func warmUpPaywallFontsCache(offerings: Offerings) async + +#if !os(tvOS) // For Paywalls + + @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *) + func triggerFontDownloadIfNeeded(fontsConfig: UIConfig.FontsConfig) async + +#endif +} + +protocol PaywallFontManagerType: Sendable { + + func fontIsAlreadyInstalled(fontName: String, fontFamily: String?) -> Bool + + @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *) + func installFont(_ font: DownloadableFont) async throws + +} + +@available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *) +actor PaywallCacheWarming: PaywallCacheWarmingType { + + private let introEligibiltyChecker: TrialOrIntroPriceEligibilityCheckerType + private let fontsManager: PaywallFontManagerType + private let fileRepository: FileRepositoryType + + private var hasLoadedEligibility = false + private var hasLoadedImages = false + private var hasLoadedVideos = false + private var ongoingFontDownloads: [URL: Task] = [:] + + init( + introEligibiltyChecker: TrialOrIntroPriceEligibilityCheckerType, + fontsManager: PaywallFontManagerType = DefaultPaywallFontsManager(session: PaywallCacheWarming.downloadSession), + fileRepository: FileRepositoryType = FileRepository.shared + ) { + self.introEligibiltyChecker = introEligibiltyChecker + self.fontsManager = fontsManager + self.fileRepository = fileRepository + } + + func warmUpEligibilityCache(offerings: Offerings) { + guard !self.hasLoadedEligibility else { return } + self.hasLoadedEligibility = true + + let productIdentifiers = offerings.allProductIdentifiersInPaywalls + guard !productIdentifiers.isEmpty else { return } + + Logger.debug(Strings.paywalls.warming_up_eligibility_cache(products: productIdentifiers)) + self.introEligibiltyChecker.checkEligibility(productIdentifiers: productIdentifiers) { _ in } + } + + func warmUpPaywallImagesCache(offerings: Offerings) async { + guard !self.hasLoadedImages else { return } + self.hasLoadedImages = true + + let imageURLs = offerings.allImagesInPaywalls + guard !imageURLs.isEmpty else { return } + + Logger.verbose(Strings.paywalls.warming_up_images(imageURLs: imageURLs)) + + await withTaskGroup(of: Void.self) { group in + for url in imageURLs { + group.addTask { [weak self] in + guard let self = self else { return } + // Preferred method - load with FileRepository + _ = try? await self.fileRepository.generateOrGetCachedFileURL(for: url, withChecksum: nil) + } + } + } + } + + func warmUpPaywallVideosCache(offerings: Offerings) async { + guard !self.hasLoadedVideos else { return } + self.hasLoadedVideos = true + + let videoURLs = offerings.allLowResVideosInPaywalls + guard !videoURLs.isEmpty else { return } + + Logger.verbose(Strings.paywalls.warming_up_videos(videoURLs: videoURLs)) + await withTaskGroup(of: Void.self) { group in + for source in videoURLs { + group.addTask { [weak self] in + _ = try? await self?.fileRepository.generateOrGetCachedFileURL( + for: source.url, + withChecksum: source.checksum + ) + } + } + } + } + + func warmUpPaywallFontsCache(offerings: Offerings) async { + let allFontsInPaywallsNamed = offerings.allFontsInPaywallsNamed + let allFontURLs = Set(allFontsInPaywallsNamed.map(\.url)) + Logger.verbose(Strings.paywalls.warming_up_fonts(fontsURLS: allFontURLs)) + + await withTaskGroup(of: Void.self) { group in + for font in allFontsInPaywallsNamed { + group.addTask { [weak self] in + await self?.installFont(from: font) + } + } + } + } + +#if !os(tvOS) + + /// Downloads and installs the font if it is not already installed. + func triggerFontDownloadIfNeeded(fontsConfig: UIConfig.FontsConfig) async { + guard let downloadableFont = fontsConfig.downloadableFont else { return } + await self.installFont(from: downloadableFont) + } + +#endif + + private func installFont(from font: DownloadableFont) async { + if let existingTask = ongoingFontDownloads[font.url] { + // Already downloading, await the existing task. + Logger.debug(Strings.paywalls.font_download_already_in_progress( + name: font.name, + fontURL: font.url) + ) + await existingTask.value + return + } + + if self.fontsManager.fontIsAlreadyInstalled(fontName: font.name, fontFamily: font.fontFamily) { + // Font already available, no need to download. + return + } + + let task = Task { + do { + try await self.fontsManager.installFont(font) + } catch { + Logger.error(Strings.paywalls.error_installing_font(font.url, error)) + } + } + + ongoingFontDownloads[font.url] = task + await task.value + ongoingFontDownloads[font.url] = nil + } + +} + +@available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *) +extension PaywallCacheWarming { + + static let downloadSession: URLSession = { + return .init( + configuration: { + let configuration: URLSessionConfiguration = .default + configuration.urlCache = PaywallCacheWarming.urlCache + return configuration + }() + ) + }() + + private static let urlCache = URLCache(memoryCapacity: 50_000_000, // 50M + diskCapacity: 200_000_000) // 200MB +} + +// MARK: - Extensions + +internal extension PaywallData { + + /// - Returns: all image URLs contained in this paywall. + var allImageURLs: [URL] { + return self + .allImages + .lazy + .flatMap(\.allImageNames) + .map { self.assetBaseURL.appendingPathComponent($0) } + } + + private var allImages: [PaywallData.Configuration.Images] { + if self.config.tiers.isEmpty { + return [self.config.images] + } else { + let imagesByTier = self.config.imagesByTier + return self.config.tiers + .lazy + .map(\.id) + .compactMap { imagesByTier[$0] } + } + } + +} + +private extension Offerings { + + var offeringsToPreWarm: [Offering] { + // At the moment we only want to pre-warm the current offering to prevent + // apps with many paywalls from downloading a large amount of images + return self.current.map { [$0] } ?? [] + } + + var allProductIdentifiersInPaywalls: Set { + return .init( + self + .offeringsToPreWarm + .lazy + .flatMap(\.productIdentifiersInPaywall) + ) + } + + var allLowResVideosInPaywalls: Set { + return .init( + self + .all + .values + .lazy + .compactMap(\.paywallComponents) + .flatMap(\.data.allLowResVideoUrls) + ) + } + +#if !os(tvOS) // For Paywalls V2 + + var allFontsInPaywallsNamed: [DownloadableFont] { + response.uiConfig? + .app + .allDownloadableFonts ?? [] + } + +#else + + var allFontsInPaywallsNamed: [DownloadableFont] { + [ ] + } + +#endif + + #if !os(tvOS) // For Paywalls V2 + + var allImagesInPaywalls: Set { + return self.allImagesInPaywallsV1 + self.allImagesInPaywallsV2 + } + + #else + + var allImagesInPaywalls: Set { + return self.allImagesInPaywallsV1 + } + + #endif + + private var allImagesInPaywallsV1: Set { + return .init( + self + .offeringsToPreWarm + .lazy + .compactMap(\.paywall) + .flatMap(\.allImageURLs) + ) + } + + #if !os(tvOS) // For Paywalls V2 + + private var allImagesInPaywallsV2: Set { + // Attempting to warm up all low res images for all offerings for Paywalls V2. + // Paywalls V2 paywall are explicitly published so anything that + // is here is intended to be displayed. + // Also only prewarming low res urls + return .init( + self + .all + .values + .lazy + .compactMap(\.paywallComponents) + .flatMap(\.data.allImageURLs) + ) + } + + #endif + +} + +private extension Offering { + + var productIdentifiersInPaywall: Set { + guard let paywall = self.paywall else { return [] } + + let packageTypes = Set(paywall.config.packages) + return Set( + self.availablePackages + .lazy + .filter { packageTypes.contains($0.identifier) } + .map(\.storeProduct.productIdentifier) + ) + } +} + +private extension PaywallData.Configuration.Images { + + var allImageNames: [String] { + return [ + self.header, + self.background, + self.icon + ].compactMap { $0 } + } +} + +/// Business logic object to easily manage the download of fonts. +struct DownloadableFont: Sendable { + + /// The font name. + let name: String + + /// The font family name, if available. + let fontFamily: String? + + let url: URL + let hash: String +} + +#if !os(tvOS) // For Paywalls V2 + +private extension UIConfig.AppConfig { + var allDownloadableFonts: [DownloadableFont] { + fonts.values.compactMap { + $0.downloadableFont + } + } +} + +private extension UIConfig.FontsConfig { + var downloadableFont: DownloadableFont? { + if let webFontInfo = self.ios.webFontInfo { + guard let url = URL(string: webFontInfo.url) else { + Logger.error(PaywallsStrings.error_prefetching_font_invalid_url(name: self.ios.value, + invalidURLString: webFontInfo.url)) + return nil + } + + return DownloadableFont( + name: self.ios.value, + fontFamily: webFontInfo.family, + url: url, + hash: webFontInfo.hash + ) + } + return nil + } +} + +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/PaywallColor.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/PaywallColor.swift new file mode 100644 index 00000000..a33b63a9 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/PaywallColor.swift @@ -0,0 +1,340 @@ +// +// PaywallColor.swift +// +// +// Created by Nacho Soto on 7/14/23. +// + +import Foundation + +#if canImport(SwiftUI) +import SwiftUI +#endif + +#if canImport(UIKit) +import UIKit +#endif + +// swiftlint:disable redundant_string_enum_value + +/// Represents a color to be used by `RevenueCatUI` +public struct PaywallColor { + + /// The possible color schemes, corresponding to the light and dark appearances. + @frozen + public enum ColorScheme: String { + + /// The color scheme that corresponds to a light appearance. + case light = "light" + /// The color scheme that corresponds to a dark appearance. + case dark = "dark" + + } + + /// The original Hex representation for this color. + public var stringRepresentation: String + + #if canImport(SwiftUI) + /// The underlying SwiftUI `Color`. + public var underlyingColor: Color { + // swiftlint:disable:next force_cast + return self._underlyingColor as! Color + } + #endif + + // Only available from iOS 13 + fileprivate var _underlyingColor: (any Sendable)? + +} + +// MARK: - Public constructors + +extension PaywallColor { + + #if canImport(SwiftUI) + + /// Creates a color from a Hex string: `#RRGGBB` or `#RRGGBBAA`. + public init(stringRepresentation: String) throws { + self.init(stringRepresentation: stringRepresentation, color: try Self.parseColor(stringRepresentation)) + } + + #if canImport(UIKit) + + /// Creates a dynamic color for 2 ``ColorScheme``s. + @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) + public init(light: PaywallColor, dark: PaywallColor) { + self.init(stringRepresentation: light.stringRepresentation, + color: .init(light: light.underlyingColor, dark: dark.underlyingColor)) + } + + #endif + + #endif + +} + +// MARK: - Private constructors + +private extension PaywallColor { + + #if canImport(SwiftUI) + + init(stringRepresentation: String, color: Color) { + self.init(stringRepresentation: stringRepresentation, underlyingColor: color) + } + + static func parseColor(_ input: String) throws -> Color { + let red, green, blue, alpha: CGFloat + + guard input.hasPrefix("#") else { + throw Error.invalidStringFormat(input) + } + + let start = input.index(input.startIndex, offsetBy: 1) + let hexColor = String(input[start...]) + + guard hexColor.count == 6 || hexColor.count == 8 else { + throw Error.invalidStringFormat(input) + } + + let scanner = Scanner(string: hexColor) + var hexNumber: UInt64 = 0 + + if scanner.scanHexInt64(&hexNumber) { + // If Alpha channel is missing, it's a fully opaque color. + if hexColor.count == 6 { + hexNumber <<= 8 + hexNumber |= 0xff + } + + red = CGFloat((hexNumber & 0xff000000) >> 24) / 255 + green = CGFloat((hexNumber & 0x00ff0000) >> 16) / 255 + blue = CGFloat((hexNumber & 0x0000ff00) >> 8) / 255 + alpha = CGFloat(hexNumber & 0x000000ff) / 255 + + return .init(red: red, green: green, blue: blue, opacity: alpha) + } else { + throw Error.invalidColor(input) + } + } + + #endif + + /// "Designated" initializer + private init(stringRepresentation: String, underlyingColor: (any Sendable)?) { + self.stringRepresentation = stringRepresentation + self._underlyingColor = underlyingColor + } + +} + +// MARK: - Errors + +private extension PaywallColor { + + enum Error: Swift.Error { + + case invalidStringFormat(String) + case invalidColor(String) + + } + +} + +// MARK: - Extensions + +#if canImport(UIKit) +private extension UIColor { + + @available(iOS 13.0, tvOS 13.0, macCatalyst 13.1, macOS 10.15, watchOS 6.2, *) + convenience init(light: UIColor, dark: UIColor) { + #if os(watchOS) + self.init(cgColor: dark.cgColor) + #else + self.init { trait in + switch trait.userInterfaceStyle { + case .dark: + return dark + case .light, .unspecified: + fallthrough + @unknown default: + return light + } + } + #endif + } + +} +#endif + +#if canImport(SwiftUI) + +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +public extension Color { + + /// Converts a `Color` into a `PaywallColor`. + var asPaywallColor: PaywallColor { + return .init(stringRepresentation: self.stringRepresentation, + color: self) + } + +} + +#if canImport(UIKit) + + private extension Color { + + init(light: UIColor, dark: UIColor) { + self.init(UIColor(light: light, dark: dark)) + } + + @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) + init(light: Color, dark: Color) { + self.init(light: UIColor(light), dark: UIColor(dark)) + } + + } + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public extension UIColor { + + /// Converts a `UIColor` into a `PaywallColor`. + var asPaywallColor: PaywallColor { + return Color(uiColor: self).asPaywallColor + } + +} + +#elseif os(macOS) + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public extension NSColor { + + /// Converts an `NSColor` into a `PaywallColor`. + var asPaywallColor: PaywallColor { + return Color(nsColor: self).asPaywallColor + } + +} + +#endif + +#endif + +// MARK: - Conformances + +// swiftlint:disable missing_docs + +extension PaywallColor.ColorScheme: Equatable {} +extension PaywallColor.ColorScheme: Sendable {} +extension PaywallColor.ColorScheme: Codable {} + +extension PaywallColor: CustomDebugStringConvertible { + + public var debugDescription: String { + return "\(type(of: self)): \(self.stringRepresentation)" + } + +} + +extension PaywallColor: Equatable { + + public static func == (lhs: PaywallColor, rhs: PaywallColor) -> Bool { + return lhs.stringRepresentation == rhs.stringRepresentation + } + +} + +extension PaywallColor: Hashable { + + public func hash(into hasher: inout Hasher) { + hasher.combine(self.stringRepresentation) + } + +} + +extension PaywallColor: Sendable {} +extension PaywallColor: Codable { + + public init(from decoder: Decoder) throws { + try self.init(stringRepresentation: decoder.singleValueContainer().decode(String.self)) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container .encode(self.stringRepresentation) + } + +} + +// swiftlint:enable missing_docs + +// MARK: - + +#if canImport(SwiftUI) && canImport(UIKit) + +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +internal extension Color { + + var rgba: (red: Int, green: Int, blue: Int, alpha: Int) { + let color = UIColor(self) + + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + assert(color.getRed(&red, green: &green, blue: &blue, alpha: &alpha)) + + return (red.rounded, green.rounded, blue.rounded, alpha.rounded) + } + +} + +#elseif os(macOS) + +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +internal extension Color { + + var rgba: (red: Int, green: Int, blue: Int, alpha: Int) { + let color = NSColor(self) + + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + return (red.rounded, green.rounded, blue.rounded, alpha.rounded) + } + +} + +#endif + +#if canImport(SwiftUI) + +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +private extension Color { + + /// - Returns: the color converted to `#RRGGBBAA` or `#RRGGBB`.` + var stringRepresentation: String { + let (red, green, blue, alpha) = self.rgba + + if alpha < 255 { + return String(format: "#%02X%02X%02X%02X", red, green, blue, alpha) + } else { + return String(format: "#%02X%02X%02X", red, green, blue) + } + } + +} + +#endif + +private extension CGFloat { + + var rounded: Int { + return Int((self * 255).rounded()) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/PaywallData+Localization.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/PaywallData+Localization.swift new file mode 100644 index 00000000..5d7c6de5 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/PaywallData+Localization.swift @@ -0,0 +1,142 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallData+Localization.swift +// +// Created by Nacho Soto on 8/10/23. + +import Foundation + +public extension PaywallData { + + /// - Returns: the `Locale` being used with ``PaywallData/LocalizedConfiguration-swift.struct`` + var locale: Locale? { + let singleTier = self.localizedConfiguration(for: Self.localesOrderedByPriority)?.locale + let multiTier = self.localizedConfigurationByTier(for: Self.localesOrderedByPriority)?.locale + + return singleTier ?? multiTier + } + + /// - Returns: the ``PaywallData/LocalizedConfiguration-swift.struct`` to be used + /// based on `Locale.preferredLocales` or the default locale. + /// - Returns: `nil` for multi-tier paywalls. + var localizedConfiguration: LocalizedConfiguration? { + return self.localizedConfiguration(for: Self.localesOrderedByPriority)?.localizedConfiguration + } + + /// - Returns: the ``PaywallData/LocalizedConfiguration-swift.struct`` to be used + /// based on `Locale.preferredLocales` or the default locale. + /// - Returns: `[:]` for single-tier paywalls. + var localizedConfigurationByTier: [String: LocalizedConfiguration]? { + return self.localizedConfigurationByTier(for: Self.localesOrderedByPriority)?.localizedConfiguration + } + + // Visible for testing + internal func localizedConfiguration( + for preferredLocales: [Locale] + ) -> (locale: Locale, localizedConfiguration: LocalizedConfiguration)? { + let defaultLocale = self.defaultLocale.map(Locale.init(identifier: )) + + return Self.localizedConfiguration( + for: preferredLocales, + configForLocale: self.config(for:), + defaultLocalization: self.defaultLocalizedConfiguration(locale: defaultLocale), + fallbackLocalization: self.fallbackLocalizedConfiguration + ) + } + + // Visible for testing + internal func localizedConfigurationByTier( + for preferredLocales: [Locale] + ) -> (locale: Locale, localizedConfiguration: [String: LocalizedConfiguration])? { + let defaultLocale = self.defaultLocale.map(Locale.init(identifier: )) + + return Self.localizedConfiguration( + for: preferredLocales, + configForLocale: self.tiersLocalization(for:), + defaultLocalization: self.defaultTiersLocalized(locale: defaultLocale), + fallbackLocalization: self.fallbackTiersLocalized + ) + } + + // Visible for testing + /// - Returns: The list of locales that paywalls should try to search for. + /// Includes `Locale.preferredLanguages`. + internal static var localesOrderedByPriority: [Locale] { + // Removing the use of Locale.current as it should really only be used for dates, currency formatting, etc + let locales = Purchases.isConfigured ? Purchases.shared.preferredLocales : Locale.preferredLanguages + return locales.map(Locale.init(identifier:)) + } + + private func defaultLocalizedConfiguration(locale: Locale?) -> (String, LocalizedConfiguration)? { + guard let locale else { return nil } + return self.localization.first { $0.0 == locale.identifier } + } + + private func defaultTiersLocalized(locale: Locale?) -> (String, [String: LocalizedConfiguration])? { + guard let locale else { return nil } + return self.localizationByTier.first { $0.0 == locale.identifier } + } + + private var fallbackLocalizedConfiguration: (String, LocalizedConfiguration)? { + return self.localization.first + } + + private var fallbackTiersLocalized: (String, [String: LocalizedConfiguration])? { + return self.localizationByTier.first + } + +} + +// MARK: - + +private extension PaywallData { + + static func localizedConfiguration( + for preferredLocales: [Locale], + configForLocale: @escaping (Locale) -> Value?, + defaultLocalization: (locale: String, value: Value)?, + fallbackLocalization: (locale: String, value: Value)? + ) -> (Locale, Value)? { + guard let (fallbackLocale, fallbackLocalization) = fallbackLocalization else { + Logger.debug(Strings.paywalls.empty_localization) + return nil + } + + // Allows us to search each locale in order of priority, both with the region and without. + // Example: [en_UK, es_ES] => [en_UK, en, es_ES, es] + let locales: [Locale] = preferredLocales.flatMap { [$0, $0.removingRegion].compactMap { $0 } } + + Logger.verbose(Strings.paywalls.looking_up_localization(preferred: preferredLocales, + search: locales)) + + let result: (locale: Locale, value: Value)? = locales + .lazy + .compactMap { locale in + configForLocale(locale) + .map { (locale, $0) } + } + .first { _ in true } // See https://github.com/apple/swift/issues/55374 + + if let result { + Logger.verbose(Strings.paywalls.found_localization(result.locale)) + + return result + } else if let (defaultLocale, defaultLocalization) = defaultLocalization { + Logger.warn(Strings.paywalls.default_localization(localeIdentifier: defaultLocale)) + + return (Locale(identifier: defaultLocale), defaultLocalization) + } else { + Logger.warn(Strings.paywalls.fallback_localization(localeIdentifier: fallbackLocale)) + + return (Locale(identifier: fallbackLocale), fallbackLocalization) + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/PaywallData.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/PaywallData.swift new file mode 100644 index 00000000..8b5414a3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/PaywallData.swift @@ -0,0 +1,830 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallData.swift +// +// Created by Nacho Soto on 7/10/23. + +// swiftlint:disable file_length identifier_name + +import Foundation + +/// The data necessary to display a paywall using the `RevenueCatUI` library. +/// They can be created and configured in the dashboard, then accessed from ``Offering/paywall``. +/// +/// ### Related Articles +/// [Documentation](https://rev.cat/paywalls) +public struct PaywallData { + + /// The unique identifier for this paywall. + public var id: String? + + /// The type of template used to display this paywall. + public var templateName: String + + /// Generic configuration for any paywall. + public var config: Configuration + + /// The base remote URL where assets for this paywall are stored. + public var assetBaseURL: URL + + /// The revision identifier for this paywall. + public var revision: Int { + get { return self._revision } + set { self._revision = newValue } + } + + /// The storefront country codes that should not display cents in prices. + public var zeroDecimalPlaceCountries: [String] { + _zeroDecimalPlaceCountries?.apple ?? [] + } + + internal private(set) var _zeroDecimalPlaceCountries: ZeroDecimalPlaceCountries? + + /// The default locale identifier for this paywall. + public var defaultLocale: String? + + /// Exit offers configuration for this paywall. + public var exitOffers: ExitOffers? + + @DefaultDecodable.Zero + internal private(set) var _revision: Int = 0 + + @DefaultDecodable.EmptyDictionary + internal private(set) var localization: [String: LocalizedConfiguration] + + @DefaultDecodable.EmptyDictionary + internal private(set) var localizationByTier: [String: [String: LocalizedConfiguration]] + +} + +/// Defines the necessary localized information for a paywall. +public protocol PaywallLocalizedConfiguration { + + /// The title of the paywall screen. + var title: String { get } + /// The subtitle of the paywall screen. + var subtitle: String? { get } + /// The content of the main action button for purchasing a subscription. + var callToAction: String { get } + /// The content of the main action button for purchasing a subscription when an intro offer is available. + /// If `nil`, no information regarding trial eligibility will be displayed. + var callToActionWithIntroOffer: String? { get } + /// Description for the offer to be purchased. + var offerDetails: String? { get } + /// Description for the offer to be purchased when an intro offer is available. + /// If `nil`, no information regarding trial eligibility will be displayed. + var offerDetailsWithIntroOffer: String? { get } + /// The name representing each of the packages, most commonly a variable. + var offerName: String? { get } + /// An optional list of features that describe this paywall. + var features: [PaywallData.LocalizedConfiguration.Feature] { get } + /// An optional name representing the ``PaywallData/Tier``. + var tierName: String? { get } + +} + +extension PaywallData { + /// Represents countries where currencies typically have zero decimal places + public struct ZeroDecimalPlaceCountries: Codable, Sendable, Hashable, Equatable { + + /// Storefront country codes that should typically display zero decimal places + public var apple: [String] = [] + + /// Storefront country codes that should typically display zero decimal places. + public init(apple: [String]) { + self.apple = apple + } + + } +} + +extension PaywallData { + + /// Defines the necessary localized information for a paywall. + public struct LocalizedConfiguration: PaywallLocalizedConfiguration { + + // Docs inherited from the protocol + // swiftlint:disable missing_docs + + public var title: String + public var callToAction: String + + @NonEmptyStringDecodable + var _subtitle: String? + @NonEmptyStringDecodable + var _callToActionWithIntroOffer: String? + @NonEmptyStringDecodable + var _offerDetails: String? + @NonEmptyStringDecodable + var _offerDetailsWithIntroOffer: String? + @NonEmptyStringDecodable + var _offerName: String? + @DefaultDecodable.EmptyArray + var _features: [Feature] + @NonEmptyStringDecodable + var _tierName: String? + @DefaultDecodable.EmptyDictionary + var _offerOverrides: [String: OfferOverride] + + public var subtitle: String? { + get { return self._subtitle } + set { self._subtitle = newValue } + } + public var callToActionWithIntroOffer: String? { + get { return self._callToActionWithIntroOffer } + set { self._callToActionWithIntroOffer = newValue } + } + public var offerDetails: String? { + get { return self._offerDetails } + set { self._offerDetails = newValue } + } + public var offerDetailsWithIntroOffer: String? { + get { return self._offerDetailsWithIntroOffer } + set { self._offerDetailsWithIntroOffer = newValue } + } + public var offerName: String? { + get { return self._offerName } + set { self._offerName = newValue } + } + public var offerOverrides: [String: OfferOverride] { + get { return self._offerOverrides } + set { self._offerOverrides = newValue } + } + public var features: [Feature] { + get { return self._features } + set { self._features = newValue } + } + public var tierName: String? { + get { return self._tierName } + set { self._tierName = newValue } + } + + public init( + title: String, + subtitle: String? = nil, + callToAction: String, + callToActionWithIntroOffer: String? = nil, + offerDetails: String? = nil, + offerDetailsWithIntroOffer: String? = nil, + offerName: String? = nil, + offerOverrides: [String: OfferOverride] = [:], + features: [Feature] = [], + tierName: String? = nil + ) { + self.title = title + self._subtitle = subtitle + self.callToAction = callToAction + self._callToActionWithIntroOffer = callToActionWithIntroOffer + self._offerDetails = offerDetails + self._offerDetailsWithIntroOffer = offerDetailsWithIntroOffer + self._offerName = offerName + self._offerOverrides = offerOverrides + self.features = features + self._tierName = tierName + } + + // swiftlint:enable missing_docs + } + + /// - Returns: ``PaywallData/LocalizedConfiguration-swift.struct`` for the given `Locale`, if found. + /// - Note: this allows searching by `Locale` with only language code and missing region (like `en`, `es`, etc). + public func config(for requiredLocale: Locale) -> LocalizedConfiguration? { + return Self.config(for: requiredLocale, localizationByLocale: self.localization) + } + + /// - Returns: ``PaywallData/LocalizedConfiguration-swift.struct`` for all tiers, + /// for the given `Locale`, if found. + /// - Note: this allows searching by `Locale` with only language code and missing region (like `en`, `es`, etc). + public func tiersLocalization(for requiredLocale: Locale) -> [String: LocalizedConfiguration]? { + return Self.config(for: requiredLocale, localizationByLocale: self.localizationByTier) + } + + internal static func config( + for requiredLocale: Locale, + localizationByLocale: [String: Value] + ) -> Value? { + localizationByLocale[requiredLocale.identifier] ?? + localizationByLocale.first { locale, _ in + Locale(identifier: locale).sharesLanguageCode(with: requiredLocale) + }?.value ?? + localizationByLocale.first { locale, _ in + Locale(identifier: locale).sharesLanguageCode(with: requiredLocale, stricterMatching: false) + }?.value + } + +} + +extension PaywallData.LocalizedConfiguration { + + /// An item to be showcased in a paywall. + public struct Feature { + + /// The title of the feature. + public var title: String + /// An optional description of the feature. + public var content: String? + /// An optional icon for the feature. + /// This must be an icon identifier known by `RevenueCatUI`. + public var iconID: String? + + // swiftlint:disable:next missing_docs + public init(title: String, content: String? = nil, iconID: String? = nil) { + self.title = title + self.content = content + self.iconID = iconID + } + + } + +} + +extension PaywallData.LocalizedConfiguration { + + /// Custom displayable overrides for a package + public struct OfferOverride { + + /// Description for the offer to be purchased. + public var offerDetails: String? + /// Description for the offer to be purchased when an intro offer is available. + /// If `nil`, no information regarding trial eligibility will be displayed. + public var offerDetailsWithIntroOffer: String? + /// The name representing each of the packages, most commonly a variable. + public var offerName: String? + /// An optional string to put in a badge on the package. + public var offerBadge: String? + + // swiftlint:disable:next missing_docs + public init( + offerDetails: String? = nil, + offerDetailsWithIntroOffer: String? = nil, + offerName: String? = nil, + offerBadge: String? = nil + ) { + self.offerDetails = offerDetails + self.offerDetailsWithIntroOffer = offerDetailsWithIntroOffer + self.offerName = offerName + self.offerBadge = offerBadge + } + + } + +} + +// MARK: - Configuration + +extension PaywallData { + + /// Generic configuration for any paywall. + public struct Configuration { + + /// The list of package identifiers this paywall will display + public var packages: [String] { + get { self._packages } + set { self._packages = newValue } + } + + /// The package to be selected by default. + public var defaultPackage: String? + + /// The ordered list of tiers in this paywall. + public var tiers: [Tier] { + get { self._tiers } + set { self._tiers = newValue } + } + + /// The images for this template. + public var images: Images { + get { + return Self.merge(source: self._imagesHeic, fallback: self._legacyImages) + } + + set { + self._imagesHeic = newValue + self._legacyImages = nil + } + } + + /// The images for each of the tiers. + public internal(set) var imagesByTier: [String: Images] { + get { self._imagesByTier } + set { self._imagesByTier = newValue } + } + + /// Low resolution images for this template. + public var imagesLowRes: Images { + get { self._imagesHeicLowRes ?? Images() } + set { self._imagesHeicLowRes = newValue } + } + + /// Whether the background image will be blurred (in templates with one). + public var blurredBackgroundImage: Bool { + get { self._blurredBackgroundImage } + set { self._blurredBackgroundImage = newValue } + } + + /// Whether a restore purchases button should be displayed. + public var displayRestorePurchases: Bool { + get { self._displayRestorePurchases } + set { self._displayRestorePurchases = newValue } + } + + /// If set, the paywall will display a terms of service link. + public var termsOfServiceURL: URL? { + get { self._termsOfServiceURL } + set { self._termsOfServiceURL = newValue } + } + + /// If set, the paywall will display a privacy policy link. + public var privacyURL: URL? { + get { self._privacyURL } + set { self._privacyURL = newValue } + } + + /// The set of colors used. + public var colors: ColorInformation + + /// The set of colors for each of the tiers. + public var colorsByTier: [String: ColorInformation] { + get { self._colorsByTier } + set { self._colorsByTier = newValue } + } + + /// Creates a single-tier ``PaywallData/Configuration``. + public init( + packages: [String], + defaultPackage: String? = nil, + images: Images, + imagesLowRes: Images = Images(), + colors: ColorInformation, + blurredBackgroundImage: Bool = false, + displayRestorePurchases: Bool = true, + termsOfServiceURL: URL? = nil, + privacyURL: URL? = nil + ) { + self._packages = packages + self.defaultPackage = defaultPackage + self._imagesHeic = images + self._imagesHeicLowRes = imagesLowRes + self.colors = colors + self._blurredBackgroundImage = blurredBackgroundImage + self._displayRestorePurchases = displayRestorePurchases + self._termsOfServiceURL = termsOfServiceURL + self._privacyURL = privacyURL + } + + /// Creates a multi-tier ``PaywallData/Configuration``. + public init( + images: Images, + imagesByTier: [String: Images] = [:], + colors: ColorInformation, + colorsByTier: [String: ColorInformation] = [:], + tiers: [Tier], + blurredBackgroundImage: Bool = false, + displayRestorePurchases: Bool = true, + termsOfServiceURL: URL? = nil, + privacyURL: URL? = nil + ) { + self._packages = [] + self.defaultPackage = nil + self._imagesHeic = images + self._imagesByTier = imagesByTier + self.colors = colors + self._colorsByTier = colorsByTier + self._tiers = tiers + self._blurredBackgroundImage = blurredBackgroundImage + self._displayRestorePurchases = displayRestorePurchases + self._termsOfServiceURL = termsOfServiceURL + self._privacyURL = privacyURL + } + + @DefaultDecodable.EmptyArray + var _packages: [String] + + var _legacyImages: Images? + var _imagesHeic: Images? + var _imagesHeicLowRes: Images? + + @DefaultDecodable.EmptyArray + var _tiers: [Tier] + + @DefaultDecodable.False + var _blurredBackgroundImage: Bool + + @DefaultDecodable.True + var _displayRestorePurchases: Bool + + @IgnoreDecodeErrors + var _termsOfServiceURL: URL? + + @IgnoreDecodeErrors + var _privacyURL: URL? + + @DefaultDecodable.EmptyDictionary + var _colorsByTier: [String: ColorInformation] + + @DefaultDecodable.EmptyDictionary + var _imagesByTier: [String: Images] + + } + +} + +extension PaywallData.Configuration { + + /// Set of images that can be used by a template. + public struct Images { + + /// Image displayed as a header in a template. + public var header: String? { + get { self._header } + set { self._header = newValue } + } + + /// Image displayed as a background in a template. + public var background: String? { + get { self._background } + set { self._background = newValue } + } + + /// Image displayed as an app icon in a template. + public var icon: String? { + get { self._icon } + set { self._icon = newValue } + } + + @NonEmptyStringDecodable + var _header: String? + @NonEmptyStringDecodable + var _background: String? + @NonEmptyStringDecodable + var _icon: String? + + // swiftlint:disable:next missing_docs + public init(header: String? = nil, background: String? = nil, icon: String? = nil) { + self.header = header + self.background = background + self.icon = icon + } + + } + + fileprivate static func merge(source: Images?, fallback: Images?) -> Images { + return .init( + header: source?.header ?? fallback?.header, + background: source?.background ?? fallback?.background, + icon: source?.icon ?? fallback?.icon + ) + } + + fileprivate static func merge(source: ColorInformation, override: ColorInformation?) -> ColorInformation { + return .init( + light: Self.merge(source: source.light, override: override?.light), + dark: source.dark.map { Self.merge(source: $0, override: override?.dark) } + ) + } + + fileprivate static func merge(source: Colors, override: Colors?) -> Colors { + var result = source + + for property in Colors.properties { + if let override = override?[keyPath: property] { + result[keyPath: property] = override + } + } + + return result + } + +} + +extension PaywallData.Configuration { + + /// The set of colors for all ``PaywallColor/ColorScheme``s. + public struct ColorInformation { + + /// Set of colors for ``PaywallColor/ColorScheme/light``. + public var light: Colors + /// Set of colors for ``PaywallColor/ColorScheme/dark``. + public var dark: Colors? + + // swiftlint:disable:next missing_docs + public init( + light: PaywallData.Configuration.Colors, + dark: PaywallData.Configuration.Colors? = nil + ) { + self.light = light + self.dark = dark + } + + } + + /// The list of colors for a given appearance (light / dark). + public struct Colors { + + /// Color for the background of the paywall. + public var background: PaywallColor? + /// Color for primary text element. + public var text1: PaywallColor? + /// Color for secondary text element. + public var text2: PaywallColor? + /// Color for tertiary text element. + public var text3: PaywallColor? + /// Background color of the main call to action button. + public var callToActionBackground: PaywallColor? + /// Foreground color of the main call to action button. + public var callToActionForeground: PaywallColor? + /// If present, the CTA will create a vertical gradient from ``callToActionBackground`` to this color. + public var callToActionSecondaryBackground: PaywallColor? + /// Primary accent color. + public var accent1: PaywallColor? + /// Secondary accent color + public var accent2: PaywallColor? + /// Tertiary accent color + public var accent3: PaywallColor? + /// Color for the close button of the paywall. + public var closeButton: PaywallColor? + /// Color for the tier selector background color. + public var tierControlBackground: PaywallColor? + /// Color for the tier selector foreground color. + public var tierControlForeground: PaywallColor? + /// Color for the tier selector background color for selected tier. + public var tierControlSelectedBackground: PaywallColor? + /// Color for the tier selector foreground color for selected tier. + public var tierControlSelectedForeground: PaywallColor? + + // swiftlint:disable:next missing_docs + public init( + background: PaywallColor? = nil, + text1: PaywallColor? = nil, + text2: PaywallColor? = nil, + text3: PaywallColor? = nil, + callToActionBackground: PaywallColor? = nil, + callToActionForeground: PaywallColor? = nil, + callToActionSecondaryBackground: PaywallColor? = nil, + accent1: PaywallColor? = nil, + accent2: PaywallColor? = nil, + accent3: PaywallColor? = nil, + closeButton: PaywallColor? = nil, + tierControlBackground: PaywallColor? = nil, + tierControlForeground: PaywallColor? = nil, + tierControlSelectedBackground: PaywallColor? = nil, + tierControlSelectedForeground: PaywallColor? = nil + ) { + self.background = background + self.text1 = text1 + self.text2 = text2 + self.text3 = text3 + self.callToActionBackground = callToActionBackground + self.callToActionForeground = callToActionForeground + self.callToActionSecondaryBackground = callToActionSecondaryBackground + self.accent1 = accent1 + self.accent2 = accent2 + self.accent3 = accent3 + self.closeButton = closeButton + self.tierControlBackground = tierControlBackground + self.tierControlForeground = tierControlForeground + self.tierControlSelectedBackground = tierControlSelectedBackground + self.tierControlSelectedForeground = tierControlSelectedForeground + } + } + +} + +// MARK: - Tiers + +extension PaywallData { + + /// A group of packages that can be displayed together in a multi-tier paywall template. + public struct Tier { + + /// The identifier for this tier. + public var id: String + + /// The list of package identifiers this tier will display + public var packages: [String] + + /// The package to be selected by default. + public var defaultPackage: String + + // swiftlint:disable:next missing_docs + public init(id: String, packages: [String], defaultPackage: String) { + self.id = id + self.packages = packages + self.defaultPackage = defaultPackage + } + + } + +} + +// MARK: - Constructors + +extension PaywallData { + init( + id: String?, + templateName: String, + config: Configuration, + localization: [String: LocalizedConfiguration], + localizationByTier: [String: [String: LocalizedConfiguration]], + assetBaseURL: URL, + revision: Int = 0, + zeroDecimalPlaceCountries: [String] = [], + exitOffers: ExitOffers? = nil + ) { + self.id = id + self.templateName = templateName + self.config = config + self.localization = localization + self.localizationByTier = localizationByTier + self.assetBaseURL = assetBaseURL + self.revision = revision + self._zeroDecimalPlaceCountries = .init(apple: zeroDecimalPlaceCountries) + self.exitOffers = exitOffers + } + + /// Creates a test ``PaywallData`` with one localization. + public init( + id: String? = nil, + templateName: String, + config: Configuration, + localization: LocalizedConfiguration, + assetBaseURL: URL, + revision: Int = 0, + locale: Locale = .current, + zeroDecimalPlaceCountries: [String] = [] + ) { + self.init( + id: id, + templateName: templateName, + config: config, + localization: [locale.identifier: localization], + localizationByTier: [:], + assetBaseURL: assetBaseURL, + revision: revision, + zeroDecimalPlaceCountries: zeroDecimalPlaceCountries + ) + } + + /// Creates a test multi-tier ``PaywallData`` with a single localization. + public init( + id: String? = nil, + templateName: String, + config: Configuration, + localizationByTier: [String: LocalizedConfiguration], + assetBaseURL: URL, + revision: Int = 0, + locale: Locale = .current, + zeroDecimalPlaceCountries: [String] = [] + ) { + self.init( + id: id, + templateName: templateName, + config: config, + localization: [:], + localizationByTier: [locale.identifier: localizationByTier], + assetBaseURL: assetBaseURL, + revision: revision, + zeroDecimalPlaceCountries: zeroDecimalPlaceCountries + ) + } + +} + +// MARK: - + +private extension PaywallData.Configuration.Colors { + + static let properties: Set> = [ + \.background, + \.text1, + \.text2, + \.text3, + \.callToActionBackground, + \.callToActionForeground, + \.callToActionSecondaryBackground, + \.accent1, + \.accent2, + \.accent3 + ] + +} + +// MARK: - Codable + +extension PaywallData.LocalizedConfiguration.Feature: Codable { + + private enum CodingKeys: String, CodingKey { + case title + case content + case iconID = "iconId" + } + +} + +extension PaywallData.LocalizedConfiguration.OfferOverride: Codable {} + +extension PaywallData.LocalizedConfiguration: Codable { + + private enum CodingKeys: String, CodingKey { + case title + case _subtitle = "subtitle" + case callToAction + case _callToActionWithIntroOffer = "callToActionWithIntroOffer" + case _offerDetails = "offerDetails" + case _offerDetailsWithIntroOffer = "offerDetailsWithIntroOffer" + case _offerName = "offerName" + case _features = "features" + case _tierName = "tierName" + case _offerOverrides = "offerOverrides" + } + +} + +extension PaywallData.Configuration.ColorInformation: Codable {} +extension PaywallData.Configuration.Colors: Codable {} + +extension PaywallData.Configuration.Images: Codable { + + private enum CodingKeys: String, CodingKey { + case _header = "header" + case _background = "background" + case _icon = "icon" + } + +} + +extension PaywallData.Tier: Codable {} + +extension PaywallData.Configuration: Codable { + + private enum CodingKeys: String, CodingKey { + case _packages = "packages" + case defaultPackage + case _tiers = "tiers" + case _legacyImages = "images" + case _imagesHeic = "imagesHeic" + case _imagesHeicLowRes = "imagesHeicLowRes" + case _blurredBackgroundImage = "blurredBackgroundImage" + case _displayRestorePurchases = "displayRestorePurchases" + case _termsOfServiceURL = "tosUrl" + case _privacyURL = "privacyUrl" + case colors + case _colorsByTier = "colorsByTier" + case _imagesByTier = "imagesByTier" + } + +} + +extension PaywallData: Codable { + + // Note: these are camel case but converted by the decoder + private enum CodingKeys: String, CodingKey { + case id + case templateName + case config + case localization = "localizedStrings" + case localizationByTier = "localizedStringsByTier" + case assetBaseURL = "assetBaseUrl" + case _revision = "revision" + case _zeroDecimalPlaceCountries = "zeroDecimalPlaceCountries" + case defaultLocale = "defaultLocale" + case exitOffers + } + +} + +// MARK: - Equatable + +extension PaywallData.Tier: Hashable {} +extension PaywallData.LocalizedConfiguration.Feature: Hashable {} +extension PaywallData.LocalizedConfiguration.OfferOverride: Hashable {} +extension PaywallData.LocalizedConfiguration: Hashable {} +extension PaywallData.Configuration.ColorInformation: Hashable {} +extension PaywallData.Configuration.Colors: Hashable {} +extension PaywallData.Configuration.Images: Hashable {} +extension PaywallData.Configuration: Hashable {} +extension PaywallData: Hashable {} + +// MARK: - Sendable + +extension PaywallData.LocalizedConfiguration.Feature: Sendable {} +extension PaywallData.LocalizedConfiguration.OfferOverride: Sendable {} +extension PaywallData.LocalizedConfiguration: Sendable {} +extension PaywallData.Tier: Sendable {} +extension PaywallData.Configuration.ColorInformation: Sendable {} +extension PaywallData.Configuration.Colors: Sendable {} +extension PaywallData.Configuration.Images: Sendable {} +extension PaywallData.Configuration: Sendable {} + +extension PaywallData: Sendable {} + +// MARK: - Identifiable + +extension PaywallData.Tier: Identifiable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/PaywallFontManagerType.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/PaywallFontManagerType.swift new file mode 100644 index 00000000..3ae35b6c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/PaywallFontManagerType.swift @@ -0,0 +1,202 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallFontFetcherType.swift +// +// Created by Facundo Menzella on 30/5/25. + +import CoreText +import Foundation +#if canImport(UIKit) +import UIKit +#endif + +#if canImport(AppKit) +import AppKit +#endif + +protocol FontRegistrar { + func registerFont(at url: URL) throws +} + +struct SystemFontRegistry: FontRegistrar { + + func registerFont(at url: URL) throws { + var errorRef: Unmanaged? + + if !CTFontManagerRegisterFontsForURL(url as CFURL, .process, &errorRef) { + let error = errorRef?.takeUnretainedValue() + if Self.isAlreadyRegisteredError(error) { + // Font is already registered for the process; treat as success. + return + } + + throw DefaultPaywallFontsManager.FontsManagerError.registrationError(error) + } + } + + static func isAlreadyRegisteredError(_ error: Error?) -> Bool { + guard let error = error else { return false } + let nsError = error as NSError + return nsError.domain == (kCTFontManagerErrorDomain as String) + && nsError.code == CTFontManagerError.alreadyRegistered.rawValue + } +} + +protocol FontsFileManaging { + func fileExists(atPath path: String) -> Bool + func createDirectory(at url: URL) throws + func write(_ data: Data, to url: URL) throws + func cachesDirectory() throws -> URL +} + +struct DefaultFontFileManager: FontsFileManaging { + private let fileManager = FileManager.default + + func fileExists(atPath path: String) -> Bool { + fileManager.fileExists(atPath: path) + } + + func createDirectory(at url: URL) throws { + try fileManager.createDirectory(at: url, withIntermediateDirectories: true) + } + + func write(_ data: Data, to url: URL) throws { + try data.write(to: url, options: .atomic) + } + + func cachesDirectory() throws -> URL { + // swiftlint:disable:next avoid_using_directory_apis_directly + guard let url = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first else { + throw CocoaError(.fileNoSuchFile) + } + return url + } +} + +protocol FontDownloadSession { + func data(from url: URL) async throws -> (Data, URLResponse) +} + +extension URLSession: FontDownloadSession {} + +actor DefaultPaywallFontsManager: PaywallFontManagerType { + + enum FontsManagerError: Error { + case invalidResponse + case downloadError(HTTPStatusCode) + case registrationError(Error?) + case hashValidationError(expected: String, actual: String) + } + + private let fontsDirectory: URL? + private let fileManager: FontsFileManaging + private let session: FontDownloadSession + private let registrar: FontRegistrar + + init( + fileManager: FontsFileManaging = DefaultFontFileManager(), + session: FontDownloadSession = URLSession.shared, + registrar: FontRegistrar = SystemFontRegistry() + ) { + self.fileManager = fileManager + self.session = session + self.registrar = registrar + do { + self.fontsDirectory = try DefaultPaywallFontsManager.fontsDirectory(fileManager: fileManager) + } catch { + Logger.error(Strings.paywalls.error_creating_fonts_directory(error)) + self.fontsDirectory = nil + } + } + + nonisolated func fontIsAlreadyInstalled(fontName: String, fontFamily: String?) -> Bool { + var availableFontNames: [String] = [] + #if canImport(UIKit) + if let fontFamily = fontFamily { + availableFontNames = UIFont.fontNames(forFamilyName: fontFamily) + } else { + availableFontNames = UIFont.familyNames.flatMap { + UIFont.fontNames(forFamilyName: $0) + } + } + #elseif canImport(AppKit) + availableFontNames = NSFontManager.shared.availableFonts + #endif + + return availableFontNames.contains(fontName) + } + + func installFont(_ font: DownloadableFont) async throws { + let remoteURL = font.url + guard let destination = self.fileURLForFontAtRemoteURL(remoteURL) else { + return + } + + if !fileManager.fileExists(atPath: destination.path) { + Logger.verbose(Strings.paywalls.triggering_font_download(fontURL: remoteURL)) + let (data, urlResponse) = try await session.data(from: remoteURL) + + guard let httpResponse = urlResponse as? HTTPURLResponse else { + throw FontsManagerError.invalidResponse + } + + let httpStatusCode = HTTPStatusCode(rawValue: httpResponse.statusCode) + guard httpStatusCode.isSuccessfulResponse else { + throw FontsManagerError.downloadError(httpStatusCode) + } + + let expectedHash = font.hash + let actualHash = data.md5String + guard actualHash == expectedHash else { + throw FontsManagerError.hashValidationError(expected: expectedHash, actual: actualHash) + } + + try fileManager.write(data, to: destination) + Logger.debug(Strings.paywalls.font_downloaded_sucessfully(name: font.name, fontURL: remoteURL)) + } + + try registrar.registerFont(at: destination) + } + + // MARK: - Private + + private static func fontsDirectory(fileManager: FontsFileManaging) throws -> URL { + let fontsDirectory = try fileManager + .cachesDirectory() + .appendingPathComponent("RevenueCatFonts", isDirectory: true) + try fileManager.createDirectory(at: fontsDirectory) + return fontsDirectory + } + + private func fileURLForFontAtRemoteURL(_ remoteURL: URL) -> URL? { + guard let fontsDirectory = self.fontsDirectory else { + return nil + } + let fileName = Data(remoteURL.absoluteString.utf8).md5String + "." + remoteURL.pathExtension + return fontsDirectory.appendingPathComponent(fileName, isDirectory: false) + } + +} + +extension DefaultPaywallFontsManager.FontsManagerError: CustomStringConvertible { + + var description: String { + switch self { + case .invalidResponse: + return "Font download failed with an invalid response" + case .downloadError(let statusCode): + return "Font download failed with status code: \(statusCode.rawValue)" + case let .registrationError(error): + return "Font registration error: \(error?.localizedDescription ?? "Unknown error")" + case let .hashValidationError(expected, actual): + return "Downloaded font file is corrupt. Hash mismatch. Expected: \(expected), Actual: \(actual)" + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/PaywallViewMode.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/PaywallViewMode.swift new file mode 100644 index 00000000..73276d0e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/PaywallViewMode.swift @@ -0,0 +1,104 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallViewMode.swift +// +// Created by Nacho Soto on 7/21/23. + +import Foundation + +/// The mode for how a paywall is rendered. +public enum PaywallViewMode { + + /// Paywall is displayed full-screen, with as much information as available. + case fullScreen + + /// Paywall can be displayed as an overlay on top of your own content. + /// Multi-package templates will display the package selection. + @available(watchOS, unavailable) + @available(macOS, unavailable) + case footer + + /// Paywall can be displayed as an overlay on top of your own content. + /// Multi-package templates will include a button to make the package selection visible. + @available(watchOS, unavailable) + @available(macOS, unavailable) + case condensedFooter + + /// The default ``PaywallViewMode``: ``PaywallViewMode/fullScreen``. + public static let `default`: Self = .fullScreen + +} + +extension PaywallViewMode { + + /// Whether this mode is ``PaywallViewMode/fullScreen``. + public var isFullScreen: Bool { + switch self { + case .fullScreen: return true + case .footer, .condensedFooter: return false + } + } + +} + +extension PaywallViewMode { + + var identifier: String { + switch self { + case .fullScreen: return "full_screen" + case .footer: return "footer" + case .condensedFooter: return "condensed_footer" + } + } + +} + +// MARK: - Extensions + +extension PaywallViewMode: CaseIterable { + + // swiftlint:disable:next missing_docs + public static var allCases: [PaywallViewMode] { + #if os(watchOS) || os(macOS) + return [.fullScreen] + #else + return [ + .fullScreen, + .footer, + .condensedFooter + ] + #endif + } + +} + +extension PaywallViewMode: Sendable {} +extension PaywallViewMode: Hashable {} + +extension PaywallViewMode: Codable { + + // swiftlint:disable:next missing_docs + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.identifier) + } + + // swiftlint:disable:next missing_docs + public init(from decoder: Decoder) throws { + let identifier = try decoder.singleValueContainer().decode(String.self) + + self = try Self.modesByIdentifier[identifier] + .orThrow(CodableError.unexpectedValue(Self.self, identifier)) + } + + private static let modesByIdentifier: [String: Self] = Set(Self.allCases) + .dictionaryWithKeys(\.identifier) + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/SubscriptionHistoryTracker.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/SubscriptionHistoryTracker.swift new file mode 100644 index 00000000..5c507fbf --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Paywalls/SubscriptionHistoryTracker.swift @@ -0,0 +1,87 @@ +// +// SubscriptionHistoryTracker.swift +// RevenueCat +// +// Created by Antonio Pallares on 31/7/25. +// + +import Combine +import StoreKit + +// swiftlint:disable missing_docs + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +@_spi(Internal) public actor SubscriptionHistoryTracker { + + @_spi(Internal) public enum Status: Equatable, Sendable { + case hasHistory + case noHistory + case unknown + } + + @_spi(Internal) public var status: AnyPublisher { + return statusSubject.removeDuplicates().eraseToAnyPublisher() + } + + private let statusSubject: CurrentValueSubject + private var cancellables = Set() + private var transactionUpdateTask: Task? + + @_spi(Internal) public init() { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) { + self.statusSubject = CurrentValueSubject(.noHistory) + + Task { + await self.initializeIfAvailable() + } + } else { + self.statusSubject = CurrentValueSubject(.unknown) + } + } + + private func initializeIfAvailable() async { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) { + self.initialize() + } + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + private func initialize() { + self.evaluateSubscriptionHistory() + + self.transactionUpdateTask = Task { [weak self] in + for await _ in StoreKit.Transaction.updates { + await self?.evaluateSubscriptionHistory() + } + } + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + private func evaluateSubscriptionHistory() { + Task { + var found = await StoreKit.Transaction.currentEntitlements.contains { result in + result.isVerifiedAutoRenewable + } + + if !found { + found = await StoreKit.Transaction.all.contains { result in + result.isVerifiedAutoRenewable + } + } + + self.statusSubject.value = found ? .hasHistory : .noHistory + } + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private extension StoreKit.VerificationResult { + + var isVerifiedAutoRenewable: Bool { + if case .verified(let transaction) = self { + return transaction.productType == .autoRenewable + } + return false + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/PrivacyInfo.xcprivacy b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..ef6d673a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/PrivacyInfo.xcprivacy @@ -0,0 +1,34 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePurchaseHistory + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyTracking + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/CachingProductsManager.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/CachingProductsManager.swift new file mode 100644 index 00000000..7732442f --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/CachingProductsManager.swift @@ -0,0 +1,179 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CachingProductsManager.swift +// +// Created by Nacho Soto on 9/14/22. + +import Foundation + +/// `ProductsManagerType` decorator that adds caching behavior on each request. +/// The product results are cached, and it avoids performing concurrent duplicate requests for the same products. +final class CachingProductsManager { + + private let manager: ProductsManagerType + + private let productCache: Atomic<[String: StoreProduct]> = .init([:]) + private let requestCache: Atomic<[Set: [Completion]]> = .init([:]) + + private let _sk2ProductCache: (any Sendable)? + private let _sk2RequestCache: (any Sendable)? + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + private var sk2ProductCache: Atomic<[String: SK2StoreProduct]> { + // swiftlint:disable:next force_cast + return self._sk2ProductCache as! Atomic<[String: SK2StoreProduct]> + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + private var sk2RequestCache: Atomic<[Set: [SK2Completion]]> { + // swiftlint:disable:next force_cast + return self._sk2RequestCache as! Atomic<[Set: [SK2Completion]]> + } + + init(manager: ProductsManagerType) { + assert(!(manager is CachingProductsManager), "Decorating CachingProductsManager with itself") + + self.manager = manager + + if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { + self._sk2ProductCache = Atomic<[String: SK2StoreProduct]>([:]) + self._sk2RequestCache = Atomic<[Set: [SK2Completion]]>([:]) + } else { + self._sk2ProductCache = nil + self._sk2RequestCache = nil + } + } + + func clearCache() { + self.productCache.value.removeAll(keepingCapacity: true) + if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { + self.sk2ProductCache.value.removeAll(keepingCapacity: true) + } + + self.manager.clearCache() + } + +} + +extension CachingProductsManager: ProductsManagerType { + + func products(withIdentifiers identifiers: Set, completion: @escaping Completion) { + Self.products(with: identifiers, + completion: completion, + productCache: self.productCache, + requestCache: self.requestCache) { identifiers, completion in + self.manager.products(withIdentifiers: identifiers, completion: completion) + } + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func sk2Products(withIdentifiers identifiers: Set, completion: @escaping SK2Completion) { + Self.products(with: identifiers, + completion: completion, + productCache: self.sk2ProductCache, + requestCache: self.sk2RequestCache) { identifiers, completion in + self.manager.sk2Products(withIdentifiers: identifiers, completion: completion) + } + } + + func cache(_ product: StoreProductType) { + Self.cache([StoreProduct.from(product: product)], container: self.productCache) + } + + var requestTimeout: TimeInterval { return self.manager.requestTimeout } + +} + +extension CachingProductsManager: Sendable {} + +// MARK: - Private + +private extension CachingProductsManager { + + static func products( + with identifiers: Set, + completion: @escaping (Result, PurchasesError>) -> Void, + productCache: Atomic<[String: T]>, + requestCache: Atomic<[Set: [(Result, PurchasesError>) -> Void]]>, + fetcher: (Set, @escaping (Result, PurchasesError>) -> Void) -> Void + ) { + let cachedProducts = Self.cachedProducts(with: identifiers, productCache: productCache) + let missingProducts = identifiers.subtracting(cachedProducts.keys) + + if missingProducts.isEmpty { + completion(.success(Set(cachedProducts.values))) + } else { + let requestInProgress = Self.save(completion, for: missingProducts, requestCache: requestCache) + guard !requestInProgress else { + Logger.debug(Strings.offering.found_existing_product_request(identifiers: missingProducts)) + return + } + + Logger.debug( + Strings.storeKit.no_cached_products_starting_store_products_request(identifiers: missingProducts) + ) + + fetcher(missingProducts) { result in + if let products = result.value { + Self.cache(products, container: productCache) + } + + for completion in Self.getAndClearRequestCompletion(for: missingProducts, requestCache: requestCache) { + completion( + result.map { Set(cachedProducts.values) + $0 } + ) + } + } + } + } + + static func cachedProducts( + with identifiers: Set, + productCache: Atomic<[String: T]> + ) -> [String: T] { + let productsAlreadyCached = productCache.value.filter { identifiers.contains($0.key) } + + if !productsAlreadyCached.isEmpty { + Logger.debug(Strings.offering.products_already_cached(identifiers: Set(productsAlreadyCached.keys))) + + } + + return productsAlreadyCached + } + + static func cache(_ products: Set, container: Atomic<[String: T]>) { + container.modify { $0 += products.dictionaryAllowingDuplicateKeys { $0.productIdentifier } } + } + + /// - Returns: true if there is already a request in progress for these products. + static func save( + _ completion: @escaping (Result, PurchasesError>) -> Void, + for identifiers: Set, + requestCache: Atomic<[Set: [(Result, PurchasesError>) -> Void]]> + ) -> Bool { + return requestCache.modify { cache in + let existingRequest = cache[identifiers]?.isEmpty == false + + cache[identifiers, default: []].append(completion) + return existingRequest + } + } + + /// - Returns: completion blocks for requests for the given identifiers. + static func getAndClearRequestCompletion( + for identifiers: Set, + requestCache: Atomic<[Set: [(Result, PurchasesError>) -> Void]]> + ) -> [(Result, PurchasesError>) -> Void] { + return requestCache.modify { + return $0.removeValue(forKey: identifiers) ?? [] + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/CachingTrialOrIntroPriceEligibilityChecker.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/CachingTrialOrIntroPriceEligibilityChecker.swift new file mode 100644 index 00000000..889a2f75 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/CachingTrialOrIntroPriceEligibilityChecker.swift @@ -0,0 +1,104 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CachingTrialOrIntroPriceEligibilityChecker.swift +// +// Created by Nacho Soto on 10/27/22. + +import Foundation + +// swiftlint:disable type_name + +/// `TrialOrIntroPriceEligibilityCheckerType` decorator that adds caching behavior on each request. +class CachingTrialOrIntroPriceEligibilityChecker: TrialOrIntroPriceEligibilityCheckerType { + + private let checker: TrialOrIntroPriceEligibilityCheckerType + + private let cache: Atomic<[String: IntroEligibility]> = .init([:]) + + /// Creates a `CachingTrialOrIntroPriceEligibilityChecker` wrapping the underlying checker, + /// or returns `checker` if it already is this type. + static func create( + with checker: TrialOrIntroPriceEligibilityCheckerType + ) -> CachingTrialOrIntroPriceEligibilityChecker { + if let checker = checker as? Self { + return checker + } else { + return CachingTrialOrIntroPriceEligibilityChecker(checker: checker) + } + } + + init(checker: TrialOrIntroPriceEligibilityCheckerType) { + self.checker = checker + } + + func clearCache() { + Logger.debug(Strings.eligibility.clearing_intro_eligibility_cache) + + self.cache.value.removeAll(keepingCapacity: false) + } + +} + +extension CachingTrialOrIntroPriceEligibilityChecker { + + func checkEligibility( + productIdentifiers: Set, + completion: @escaping ReceiveIntroEligibilityBlock + ) { + guard !productIdentifiers.isEmpty else { + completion([:]) + return + } + + let uniqueProductIdentifiers = Set(productIdentifiers) + + // Note: this can suffer from race conditions, but the only downside is performing concurrent requests + // multiple times instead of returning the cached result on the second one. + // It's a fine compromise to keep this implementation simpler. + let cached = self.cache.value.filter { uniqueProductIdentifiers.contains($0.key) } + + if !cached.isEmpty { + Logger.debug(Strings.eligibility.found_cached_eligibility_for_products(Set(cached.keys))) + } + + let missingProducts = uniqueProductIdentifiers.subtracting(cached.keys) + + if missingProducts.isEmpty { + completion(cached) + } else { + self.checker.checkEligibility(productIdentifiers: missingProducts) { result in + let productsToCache = result.filter { $0.value.shouldCache } + + Logger.debug(Strings.eligibility.caching_intro_eligibility_for_products(Set(productsToCache.keys))) + self.cache.modify { $0 += productsToCache } + + completion(cached + result) + } + } + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension CachingTrialOrIntroPriceEligibilityChecker: @unchecked Sendable {} + +// MARK: - Private + +private extension IntroEligibility { + + var shouldCache: Bool { + switch self.status { + case .noIntroOfferExists, .ineligible, .eligible: return true + case .unknown: return false + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Configuration.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Configuration.swift new file mode 100644 index 00000000..d4583e45 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Configuration.swift @@ -0,0 +1,497 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Configuration.swift +// +// Created by Joshua Liebowitz on 5/6/22. + +// swiftlint:disable file_length + +import Foundation + +/** + * ``Configuration`` can be used when configuring the ``Purchases`` instance. It is not required to be used, but + * highly recommended. This class follows a builder pattern. + * + * To configure your `Purchases` instance using this object, follow these steps. + * + * **Steps:** + * 1. Call ``Configuration/builder(withAPIKey:)`` To obtain a ``Configuration/Builder`` object. + * 2. Set this builder's properties using the "`with(`" functions. + * 3. Call ``Configuration/Builder/build()`` to obtain the `Configuration` object. + * 4. Pass the `Configuration` object into ``Purchases/configure(with:)-6oipy``. + * + * ```swift + * let configuration = Configuration.Builder(withAPIKey: "MyKey") + * .with(appUserID: "SomeAppUserID") + * .with(userDefaults: myUserDefaults) + * .with(networkTimeout: 15) + * .with(storeKit1Timeout: 15) + * .build() + * Purchases.configure(with: configuration) + * ``` + */ +@objc(RCConfiguration) public final class Configuration: NSObject { + + static let storeKitRequestTimeoutDefault: TimeInterval = 30 + static let networkTimeoutDefault: TimeInterval = 60 + + let apiKey: String + let appUserID: String? + let observerMode: Bool + let userDefaults: UserDefaults? + let storeKitVersion: StoreKitVersion + let dangerousSettings: DangerousSettings? + let networkTimeout: TimeInterval + let storeKit1Timeout: TimeInterval + let platformInfo: Purchases.PlatformInfo? + let responseVerificationMode: Signing.ResponseVerificationMode + let showStoreMessagesAutomatically: Bool + let preferredLocale: String? + let automaticDeviceIdentifierCollectionEnabled: Bool + internal let diagnosticsEnabled: Bool + + private init(with builder: Builder) { + self.apiKey = builder.apiKey + self.appUserID = builder.appUserID + self.observerMode = builder.observerMode + self.userDefaults = builder.userDefaults + self.storeKitVersion = builder.storeKitVersion + self.dangerousSettings = builder.dangerousSettings + self.storeKit1Timeout = builder.storeKit1Timeout + self.networkTimeout = builder.networkTimeout + self.platformInfo = builder.platformInfo + self.responseVerificationMode = builder.responseVerificationMode + self.showStoreMessagesAutomatically = builder.showStoreMessagesAutomatically + self.diagnosticsEnabled = builder.diagnosticsEnabled + self.preferredLocale = builder.preferredLocale + self.automaticDeviceIdentifierCollectionEnabled = builder.automaticDeviceIdentifierCollectionEnabled + } + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + /// Factory method for the ``Configuration/Builder`` object that is required to create a `Configuration` + @objc public static func builder(withAPIKey apiKey: String) -> Builder { + return Builder(withAPIKey: apiKey) + } + + #else + + /// Factory method for the ``Configuration/Builder`` object that is required to create a `Configuration` + @objc public static func builder(withAPIKey apiKey: String, appUserID: String) -> Builder { + return Builder(withAPIKey: apiKey, appUserID: appUserID) + } + + #endif + + /// The Builder for ```Configuration```. + @objc(RCConfigurationBuilder) public class Builder: NSObject { + + private static let minimumTimeout: TimeInterval = 5 + + private(set) var apiKey: String + private(set) var appUserID: String? + var observerMode: Bool { + switch purchasesAreCompletedBy { + case .revenueCat: + return false + case .myApp: + return true + } + } + private(set) var purchasesAreCompletedBy: PurchasesAreCompletedBy = .revenueCat + private(set) var userDefaults: UserDefaults? + private(set) var dangerousSettings: DangerousSettings? + private(set) var networkTimeout = Configuration.networkTimeoutDefault + private(set) var storeKit1Timeout = Configuration.storeKitRequestTimeoutDefault + private(set) var platformInfo: Purchases.PlatformInfo? + private(set) var responseVerificationMode: Signing.ResponseVerificationMode = .default + private(set) var showStoreMessagesAutomatically: Bool = true + private(set) var diagnosticsEnabled: Bool = false + private(set) var storeKitVersion: StoreKitVersion = .default + + /// The preferred locale for the requests. + /// + /// This locale is included in all requests made by `HTTPClient`. + private(set) var preferredLocale: String? + private(set) var automaticDeviceIdentifierCollectionEnabled: Bool = true + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + /** + * Create a new builder with your API key. + * - Parameter apiKey: The API Key generated for your app from https://app.revenuecat.com/ + */ + @objc public init(withAPIKey apiKey: String) { + self.apiKey = apiKey + } + + #else + + /** + * Create a new builder with your API key. + * - Parameter apiKey: The API Key generated for your app from https://app.revenuecat.com/ + */ + @objc public init(withAPIKey apiKey: String, appUserID: String) { + self.apiKey = apiKey + self.appUserID = appUserID + self.dangerousSettings = DangerousSettings(customEntitlementComputation: true) + } + + #endif + + /// Update your API key. + @objc public func with(apiKey: String) -> Builder { + self.apiKey = apiKey + return self + } + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + /** + * Set an `appUserID`. + * - Parameter appUserID: The unique app user id for this user. This user id will allow users to share their + * purchases and subscriptions across devices. Pass `nil` or an empty string if you want ``Purchases`` + * to generate this for you. + * + * - Note: Best practice is to use a salted hash of your unique app user ids. + * + * - Important: Set this property if you have your own user identifiers that you manage. + */ + @_disfavoredOverload + @objc public func with(appUserID: String?) -> Builder { + self.appUserID = appUserID + return self + } + + @available(*, deprecated, message: """ + The appUserID passed to logIn is a constant string known at compile time. + This is likely a programmer error. This ID is used to identify the current user. + See https://docs.revenuecat.com/docs/user-ids for more information. + """) + // swiftlint:disable:next missing_docs + public func with(appUserID: StaticString) -> Configuration.Builder { + Logger.warn(Strings.identity.logging_in_with_static_string) + return self.with(appUserID: "\(appUserID)") + } + + /** + * Set `purchasesAreCompletedBy`. + * - Parameter purchasesAreCompletedBy: Set this to ``PurchasesAreCompletedBy/myApp`` + * if you have your own IAP implementation and want to use only RevenueCat's backend. + * Default is ``PurchasesAreCompletedBy/revenueCat``. + * - Parameter storeKitVersion: Set the StoreKit version you're using to make purchases. + */ + @objc public func with( + purchasesAreCompletedBy: PurchasesAreCompletedBy, + storeKitVersion: StoreKitVersion + ) -> Configuration.Builder { + self.purchasesAreCompletedBy = purchasesAreCompletedBy + self.storeKitVersion = storeKitVersion + return self + } + + /** + * Set `userDefaults`. + * - Parameter userDefaults: Custom `UserDefaults` to use + */ + @objc public func with(userDefaults: UserDefaults) -> Builder { + self.userDefaults = userDefaults + return self + } + + /** + * Set `dangerousSettings`. + * - Parameter dangerousSettings: Only use if suggested by RevenueCat support team. + */ + @objc public func with(dangerousSettings: DangerousSettings) -> Builder { + self.dangerousSettings = dangerousSettings + return self + } + + /// Set `networkTimeout`. + @objc public func with(networkTimeout: TimeInterval) -> Builder { + self.networkTimeout = clamped(timeout: networkTimeout) + return self + } + + /// Set `storeKit1Timeout`. + @objc public func with(storeKit1Timeout: TimeInterval) -> Builder { + self.storeKit1Timeout = clamped(timeout: storeKit1Timeout) + return self + } + + /// Set `platformInfo`. + @objc public func with(platformInfo: Purchases.PlatformInfo) -> Builder { + self.platformInfo = platformInfo + return self + } + + #endif + + /// Set `showStoreMessagesAutomatically`. Enabled by default. + /// If enabled, if the user has billing issues, has yet to accept a price increase consent, is eligible for a + /// win-back offer, or there are other messages from StoreKit, they will be displayed automatically when + /// the app is initialized. + /// + /// If you want to disable this behavior so that you can customize when these messages are shown, make sure + /// you configure the SDK as early as possible in the app's lifetime, otherwise messages will be displayed + /// automatically. + /// Then use the ``Purchases/showStoreMessages(for:)`` method to display the messages. + /// More information: https://rev.cat/storekit-message + /// - Important: Set this property only if you're using Swift. If you're using ObjC, you won't be able to call + /// the related methods + @objc public func with(showStoreMessagesAutomatically: Bool) -> Builder { + self.showStoreMessagesAutomatically = showStoreMessagesAutomatically + return self + } + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + /// Set ``Configuration/EntitlementVerificationMode``. + /// + /// Defaults to ``Configuration/EntitlementVerificationMode/disabled``. + /// + /// The result of the verification can be obtained from ``EntitlementInfos/verification`` or + /// ``EntitlementInfo/verification``. + /// + /// - Note: This feature requires iOS 13+. + /// - Warning: When changing from ``Configuration/EntitlementVerificationMode/disabled`` + /// to ``Configuration/EntitlementVerificationMode/informational`` + /// the SDK will clear the ``CustomerInfo`` cache. + /// This means that users will need to connect to the internet to get back their entitlements. + /// + /// ### Related Articles + /// - [Documentation](https://rev.cat/trusted-entitlements) + /// + /// ### Related Symbols + /// - ``Configuration/EntitlementVerificationMode`` + /// - ``VerificationResult`` + @objc public func with(entitlementVerificationMode mode: EntitlementVerificationMode) -> Builder { + self.responseVerificationMode = Signing.verificationMode(with: mode) + return self + } + + /// Enabling diagnostics will send some performance and debugging information from the SDK to our servers. + /// Examples of this information include response times, cache hits or error codes. + /// This information will be anonymous so it can't be traced back to the end-user + /// + /// Defaults to `false` + /// + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + @objc public func with(diagnosticsEnabled: Bool) -> Builder { + self.diagnosticsEnabled = diagnosticsEnabled + return self + } + + #endif + + /// Set ``StoreKitVersion``. + /// + /// Defaults to ``StoreKitVersion/default`` which lets the SDK select + /// the most appropriate version of StoreKit. Currently defaults to StoreKit 2. + /// + /// - Note: StoreKit 2 is only available on iOS 15+. StoreKit 1 will be used for previous iOS versions + /// regardless of this setting. + /// + /// ### Related Symbols + /// - ``StoreKitVersion`` + @objc public func with(storeKitVersion version: StoreKitVersion) -> Builder { + self.storeKitVersion = version + return self + } + + /// Set `automaticDeviceIdentifierCollectionEnabled`. This is enabled by default. + /// + /// Enable this setting to allow the collection of identifiers when setting the identifier for an + /// attribution network. For example, when calling``Purchases/setAdjustID(_:)`` + /// or ``Purchases/setAppsflyerID(_:)``, the SDK would collect the device identifiers like + /// IDFA, IDFV or IP, if available, and send them to RevenueCat. + /// This is required by some attribution networks to attribute installs and re-installs. + /// + /// Enabling this setting does NOT mean we will always collect the identifiers. We will only do so when + /// setting an attribution network ID and the user has not limited tracking on their device. + /// + /// With this option disabled you can still collect device identifiers + /// by calling ``Purchases/collectDeviceIdentifiers()`` + @objc public func with(automaticDeviceIdentifierCollectionEnabled: Bool) -> Builder { + self.automaticDeviceIdentifierCollectionEnabled = automaticDeviceIdentifierCollectionEnabled + return self + } + + /// Generate a ``Configuration`` object given the values configured by this builder. + @objc public func build() -> Configuration { + return Configuration(with: self) + } + + private func clamped(timeout: TimeInterval) -> TimeInterval { + guard timeout >= Self.minimumTimeout else { + Logger.warn( + Strings.configure.timeout_lower_than_minimum( + timeout: timeout, + minimum: Self.minimumTimeout + ) + ) + return Self.minimumTimeout + } + + return timeout + } + + /// Overrides the preferred locale for RevenueCatUI components. + /// + /// - Parameter preferredUILocaleOverride: A locale string in the format "language_region" (e.g., "en_US"). + /// + /// Defaults to `nil`, which means using the default user locale for RevenueCatUI components. + public func with(preferredUILocaleOverride: String?) -> Builder { + self.preferredLocale = preferredUILocaleOverride + return self + } + } + +} + +// MARK: - Public Keys + +extension Configuration { + + /// Defines how strict ``EntitlementInfo`` verification ought to be. + /// + /// ### Related Articles + /// - [Documentation](https://rev.cat/trusted-entitlements) + /// + /// ### Related Symbols + /// - ``VerificationResult`` + /// - ``Configuration/Builder/with(entitlementVerificationMode:)`` + /// - ``EntitlementInfos/verification`` + @objc(RCEntitlementVerificationMode) + public enum EntitlementVerificationMode: Int { + + /// The SDK will not perform any entitlement verification. + case disabled = 0 + + /// Enable entitlement verification. + /// + /// If verification fails, this will be indicated with ``VerificationResult/failed`` + /// but parsing will not fail. + /// + /// This can be useful if you want to handle validation failures but still grant access. + case informational = 1 + + /// Enable entitlement verification. + /// + /// If verification fails when fetching ``CustomerInfo`` and/or ``EntitlementInfos`` + /// ``ErrorCode/signatureVerificationFailed`` will be thrown. + @available(*, unavailable, message: "This will be supported in a future release") + case enforced = 2 + + } + +} + +// MARK: - API Key Validation + +// Visible for testing +extension Configuration { + + enum APIKeyValidationResult { + case validApplePlatform + + /// An API key used for the Simulated Store. + /// + /// Note that "Simulated Store" is the internal name of the "Test Store". + case simulatedStore + case otherPlatforms + case legacy + } + + static func validateAndLog(apiKey: String) -> APIKeyValidationResult { + let validationResult = self.validate(apiKey: apiKey) + validationResult.logIfNeeded() + return validationResult + } + + private static let applePlatformKeyPrefixes: Set = ["appl_", "mac_"] + private static let simulatedStoreKeyPrefix = "test_" + + private static func validate(apiKey: String) -> APIKeyValidationResult { + if apiKey.hasPrefix(simulatedStoreKeyPrefix) { + // Simulated Store key format: "test_CtDdmbdWBySmqJeeQUTyrNxETUVkajsJ" + return .simulatedStore + } + + if applePlatformKeyPrefixes.contains(where: { prefix in apiKey.hasPrefix(prefix) }) { + // Apple key format: "apple_CtDdmbdWBySmqJeeQUTyrNxETUVkajsJ" + return .validApplePlatform + } else if apiKey.contains("_") { + // Other platforms format: "otherplatform_CtDdmbdWBySmqJeeQUTyrNxETUVkajsJ" + return .otherPlatforms + } else { + // Legacy key format: "CtDdmbdWBySmqJeeQUTyrNxETUVkajsJ" + return .legacy + } + } +} + +extension Configuration.APIKeyValidationResult { + + fileprivate func logIfNeeded() { + switch self { + case .validApplePlatform: break + case .simulatedStore: Logger.warn(Strings.configure.simulatedStoreAPIKey) + case .legacy: Logger.debug(Strings.configure.legacyAPIKey) + case .otherPlatforms: Logger.error(Strings.configure.invalidAPIKey) + } + } + +} + +// MARK: - Slow Operation Thresholds + +extension Configuration { + + /// Thresholds that determine when `TimingUtil` log warnings for slow operations. + internal enum TimingThreshold: TimingUtil.Duration { + + case productRequest = 3 + case introEligibility = 2 + case purchasedProducts = 1 + + } + +} + +extension Configuration.APIKeyValidationResult { + + func checkForSimulatedStoreAPIKeyInRelease(systemInfo: SystemInfo, apiKey: String) { + #if !DEBUG + guard self == .simulatedStore, !systemInfo.dangerousSettings.uiPreviewMode else { + return + } + + let redactedApiKey = apiKey.asRedactedAPIKey + + // In release builds, we intentionally crash to prevent submitting an app with a Test Store API key. + // + // Also note that developing with a Test Store API key isn't supported when adding the SDK dependency + // as an XCFramework, since the XCFramework is built using the Release configuration. + Task { + let errorMessage = "[RevenueCat]: Test Store API key used in Release build: \(redactedApiKey). " + + "Please configure the App Store app on the RevenueCat dashboard and use its corresponding Apple API key " + + "before releasing. Visit https://rev.cat/sdk-test-store to learn more." + + Logger.error(errorMessage) + + let uiHelper = DefaultSimulatedStorePurchaseUI(systemInfo: systemInfo) + await uiHelper.showTestKeyInReleaseAlert(redactedApiKey: redactedApiKey) + + fatalError(errorMessage) + } + #endif + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/EntitlementInfo.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/EntitlementInfo.swift new file mode 100644 index 00000000..60f59b09 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/EntitlementInfo.swift @@ -0,0 +1,403 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// EntitlementInfo.swift +// +// Created by Joshua Liebowitz on 6/25/21. +// + +import Foundation + +// swiftlint:disable file_length + +/** + Enum of supported stores + */ +@objc(RCStore) public enum Store: Int { + + /// For entitlements granted via Apple App Store. + @objc(RCAppStore) case appStore = 0 + + /// For entitlements granted via Apple Mac App Store. + @objc(RCMacAppStore) case macAppStore = 1 + + /// For entitlements granted via Google Play Store. + @objc(RCPlayStore) case playStore = 2 + + /// For entitlements granted via Stripe. + @objc(RCStripe) case stripe = 3 + + /// For entitlements granted via a promo in RevenueCat. + @objc(RCPromotional) case promotional = 4 + + /// For entitlements granted via an unknown store. + @objc(RCUnknownStore) case unknownStore = 5 + + /// For entitlements granted via the Amazon Store. + @objc(RCAmazon) case amazon = 6 + + /// For entitlements granted via RevenueCat's Web Billing + @objc(RCBilling) case rcBilling = 7 + + /// For entitlements granted via RevenueCat's External Purchases API. + @objc(RCExternal) case external = 8 + + /// For entitlements granted via Paddle. + @objc(RCPaddle) case paddle = 9 + + /// For entitlements granted via the Test Store. + @objc(RCTestStore) case testStore = 10 + + /// For entitlements granted via the Galaxy Store. + @objc(RCGalaxy) case galaxy = 11 + +} + +extension Store: CaseIterable {} +extension Store: Sendable {} + +extension Store: DefaultValueProvider { + + static let defaultValue: Self = .unknownStore + +} + +/** + Enum of supported period types for an entitlement. + */ +@objc(RCPeriodType) public enum PeriodType: Int { + + /// If the entitlement is not under an introductory or trial period. + @objc(RCNormal) case normal = 0 + + /// If the entitlement is under a introductory price period. + @objc(RCIntro) case intro = 1 + + /// If the entitlement is under a trial period. + @objc(RCTrial) case trial = 2 + + /// If the entitlement is under a prepaid period. This is Play Store only. + @objc(RCPrepaid) case prepaid = 3 +} + +extension PeriodType: CaseIterable {} +extension PeriodType: Sendable {} + +extension PeriodType: DefaultValueProvider { + + static let defaultValue: Self = .normal + +} + +/** + The EntitlementInfo object gives you access to all of the information about the status of a user entitlement. + */ +@objc(RCEntitlementInfo) public final class EntitlementInfo: NSObject { + + /** + The entitlement identifier configured in the RevenueCat dashboard + */ + @objc public var identifier: String { self.contents.identifier } + + /** + True if the user has access to this entitlement + - Warning: this is equivalent to ``isActiveInAnyEnvironment`` + + #### Related Symbols + - ``isActiveInCurrentEnvironment`` + */ + @objc public var isActive: Bool { self.contents.isActive } + + /** + True if the underlying subscription is set to renew at the end of + the billing period (``expirationDate``). + */ + @objc public var willRenew: Bool { self.contents.willRenew } + + /** + The last period type this entitlement was in + Either: ``PeriodType/normal``, ``PeriodType/intro``, ``PeriodType/trial`` + */ + @objc public var periodType: PeriodType { self.contents.periodType } + + /** + The latest purchase or renewal date for the entitlement. + */ + @objc public var latestPurchaseDate: Date? { self.contents.latestPurchaseDate } + + /** + The first date this entitlement was purchased + */ + @objc public var originalPurchaseDate: Date? { self.contents.originalPurchaseDate } + + /** + The expiration date for the entitlement, can be `nil` for lifetime access. + If the ``periodType`` is ``PeriodType/trial``, this is the trial expiration date. + */ + @objc public var expirationDate: Date? { self.contents.expirationDate } + + /** + * The store where this entitlement was unlocked from either: ``Store/appStore``, ``Store/macAppStore``, + * ``Store/playStore``, ``Store/stripe``, ``Store/promotional``, or ``Store/unknownStore``. + */ + @objc public var store: Store { self.contents.store } + + /** + The product identifier that unlocked this entitlement + */ + @objc public var productIdentifier: String { self.contents.productIdentifier } + + /** + The product plan identifier that unlocked this entitlement (for a Google Play subscription purchase) + */ + @objc public var productPlanIdentifier: String? { self.contents.productPlanIdentifier } + + /** + False if this entitlement is unlocked via a production purchase + */ + @objc public var isSandbox: Bool { self.contents.isSandbox } + + /** + The date an unsubscribe was detected. Can be `nil`. + + - Note: Entitlement may still be active even if user has unsubscribed. Check the ``isActive`` property. + */ + @objc public var unsubscribeDetectedAt: Date? { self.contents.unsubscribeDetectedAt } + + /** + The date a billing issue was detected. Can be `nil` if there is no + billing issue or an issue has been resolved. + + - Note: Entitlement may still be active even if there is a billing issue. + Check the ``isActive`` property. + */ + @objc public var billingIssueDetectedAt: Date? { self.contents.billingIssueDetectedAt } + + /** + Use this property to determine whether a purchase was made by the current user + or shared to them by a family member. This can be useful for onboarding users who have had + an entitlement shared with them, but might not be entirely aware of the benefits they now have. + */ + @objc public var ownershipType: PurchaseOwnershipType { self.contents.ownershipType } + + /// Whether this entitlement was verified. + /// + /// ### Related Articles + /// - [Documentation](https://rev.cat/trusted-entitlements) + /// + /// ### Related Symbols + /// - ``VerificationResult`` + @objc public var verification: VerificationResult { self.contents.verification } + + // Docs inherited from protocol + // swiftlint:disable:next missing_docs + @objc public let rawData: [String: Any] + + // MARK: - + + public override var description: String { + return """ + <\(String(describing: EntitlementInfo.self)): " + identifier=\(self.identifier), + isActive=\(self.isActive), + willRenew=\(self.willRenew), + periodType=\(self.periodType), + latestPurchaseDate=\(String(describing: self.latestPurchaseDate)), + originalPurchaseDate=\(String(describing: self.originalPurchaseDate)), + expirationDate=\(String(describing: self.expirationDate)), + store=\(self.store), + productIdentifier=\(self.productIdentifier), + productPlanIdentifier=\(self.productPlanIdentifier ?? "null"), + isSandbox=\(self.isSandbox), + unsubscribeDetectedAt=\(String(describing: self.unsubscribeDetectedAt)), + billingIssueDetectedAt=\(String(describing: self.billingIssueDetectedAt)), + ownershipType=\(self.ownershipType), + verification=\(self.contents.verification) + > + """ + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let info = object as? EntitlementInfo else { + return false + } + + if self === info { + return true + } + + return self.contents == info.contents + } + + public override var hash: Int { + var hasher = Hasher() + hasher.combine(self.contents) + + return hasher.finalize() + } + + init( + identifier: String, + entitlement: CustomerInfoResponse.Entitlement, + subscription: CustomerInfoResponse.Subscription, + sandboxEnvironmentDetector: SandboxEnvironmentDetector, + verification: VerificationResult, + requestDate: Date + ) { + self.contents = .init( + identifier: identifier, + isActive: CustomerInfo.isDateActive(expirationDate: entitlement.expiresDate, for: requestDate), + willRenew: Self.willRenewWithExpirationDate(expirationDate: subscription.expiresDate, + store: subscription.store, + unsubscribeDetectedAt: subscription.unsubscribeDetectedAt, + billingIssueDetectedAt: subscription.billingIssuesDetectedAt, + periodType: subscription.periodType), + periodType: subscription.periodType, + latestPurchaseDate: entitlement.purchaseDate, + originalPurchaseDate: subscription.originalPurchaseDate, + expirationDate: subscription.expiresDate, + store: subscription.store, + productIdentifier: entitlement.productIdentifier, + productPlanIdentifier: subscription.productPlanIdentifier, + isSandbox: subscription.isSandbox, + unsubscribeDetectedAt: subscription.unsubscribeDetectedAt, + billingIssueDetectedAt: subscription.billingIssuesDetectedAt, + ownershipType: subscription.ownershipType, + verification: verification + ) + self.sandboxEnvironmentDetector = sandboxEnvironmentDetector + + self.rawData = entitlement.rawData + } + + /// Initializes an ``EntitlementInfo`` instance. + /// Useful for Unit testing purposes, since the other (internal) initializers require a backend response + public init( + identifier: String, + isActive: Bool, + willRenew: Bool, + periodType: PeriodType, + latestPurchaseDate: Date? = nil, + originalPurchaseDate: Date? = nil, + expirationDate: Date? = nil, + store: Store, + productIdentifier: String, + productPlanIdentifier: String? = nil, + isSandbox: Bool, + unsubscribeDetectedAt: Date? = nil, + billingIssueDetectedAt: Date? = nil, + ownershipType: PurchaseOwnershipType, + verification: VerificationResult = .notRequested + ) { + self.contents = .init( + identifier: identifier, + isActive: isActive, + willRenew: willRenew, + periodType: periodType, + latestPurchaseDate: latestPurchaseDate, + originalPurchaseDate: originalPurchaseDate, + expirationDate: expirationDate, + store: store, + productIdentifier: productIdentifier, + productPlanIdentifier: productPlanIdentifier, + isSandbox: isSandbox, + unsubscribeDetectedAt: unsubscribeDetectedAt, + billingIssueDetectedAt: billingIssueDetectedAt, + ownershipType: ownershipType, + verification: verification + ) + self.rawData = [:] + self.sandboxEnvironmentDetector = BundleSandboxEnvironmentDetector.default + } + + // MARK: - + + private let contents: Contents + private let sandboxEnvironmentDetector: SandboxEnvironmentDetector + +} + +extension EntitlementInfo: RawDataContainer {} + +// @unchecked because: +// - `rawData` is `[String: Any]` which can't be `Sendable` +extension EntitlementInfo: @unchecked Sendable {} + +public extension EntitlementInfo { + + /// True if the user has access to this entitlement, + /// - Note: When queried from the sandbox environment, it only returns true if active in sandbox. + /// When queried from production, this only returns true if active in production. + /// + /// #### Related Symbols + /// - ``isActiveInAnyEnvironment`` + @objc var isActiveInCurrentEnvironment: Bool { + return (self.isActiveInAnyEnvironment && + self.isSandbox == self.sandboxEnvironmentDetector.isSandbox) + } + + /// True if the user has access to this entitlement in any environment. + /// + /// #### Related Symbols + /// - ``isActiveInCurrentEnvironment`` + @objc var isActiveInAnyEnvironment: Bool { + return self.isActive + } + +} + +// MARK: - Internal + +extension EntitlementInfo { + + static func willRenewWithExpirationDate(expirationDate: Date?, + store: Store, + unsubscribeDetectedAt: Date?, + billingIssueDetectedAt: Date?, + periodType: PeriodType?) -> Bool { + let isPromo = store == .promotional + let isLifetime = expirationDate == nil + let hasUnsubscribed = unsubscribeDetectedAt != nil + let hasBillingIssues = billingIssueDetectedAt != nil + // This is Play Store only for now. + let isPrepaid = periodType == .prepaid + + return !(isPromo || isLifetime || hasUnsubscribed || hasBillingIssues || isPrepaid) + } + +} + +extension EntitlementInfo: Identifiable { + + /// The stable identity of the entity associated with this instance. + public var id: String { return self.identifier } + +} + +private extension EntitlementInfo { + struct Contents: Equatable, Hashable { + let identifier: String + let isActive: Bool + let willRenew: Bool + let periodType: PeriodType + let latestPurchaseDate: Date? + let originalPurchaseDate: Date? + let expirationDate: Date? + let store: Store + let productIdentifier: String + let productPlanIdentifier: String? + let isSandbox: Bool + let unsubscribeDetectedAt: Date? + let billingIssueDetectedAt: Date? + let ownershipType: PurchaseOwnershipType + let verification: VerificationResult + } +} + +extension EntitlementInfo.Contents: Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/EntitlementInfos.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/EntitlementInfos.swift new file mode 100644 index 00000000..6f59785a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/EntitlementInfos.swift @@ -0,0 +1,151 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// EntitlementInfos.swift +// +// Created by Joshua Liebowitz on 6/28/21. +// + +import Foundation + +/** + This class contains all the entitlements associated to the user. + */ +@objc(RCEntitlementInfos) public final class EntitlementInfos: NSObject { + /** + Dictionary of all EntitlementInfo (``EntitlementInfo``) objects (active and inactive) keyed by entitlement + identifier. This dictionary can also be accessed by using an index subscript on ``EntitlementInfos``, e.g. + `entitlementInfos["pro_entitlement_id"]`. + */ + @objc public let all: [String: EntitlementInfo] + + /// #### Related Symbols + /// - ``all`` + @objc public subscript(key: String) -> EntitlementInfo? { + return self.all[key] + } + + /// Whether these entitlements were verified. + /// + /// ### Related Articles + /// - [Documentation](https://rev.cat/trusted-entitlements) + /// + /// ### Related Symbols + /// - ``VerificationResult`` + @objc public var verification: VerificationResult { return self._verification } + + public override var description: String { + return "<\(NSStringFromClass(Self.self)): " + + "self.all=\(self.all), " + + "self.active=\(self.active)," + + "self.verification=\(self._verification)" + + ">" + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? EntitlementInfos else { + return false + } + + return self.isEqual(to: other) + } + + // MARK: - + + /// Initializes an ``EntitlementInfos`` instance. + /// Useful for Unit testing purposes, since the other (internal) initializers require a backend response + public init( + entitlements: [String: EntitlementInfo] = [:], + verification: VerificationResult = .notRequested + ) { + self.all = entitlements + self._verification = verification + } + + private func isEqual(to other: EntitlementInfos?) -> Bool { + guard let other = other else { + return false + } + + if self === other { + return true + } + + return self.all == other.all && self._verification == other._verification + } + + private let _verification: VerificationResult + +} + +public extension EntitlementInfos { + + /// Dictionary of active ``EntitlementInfo`` objects keyed by their identifiers. + /// - Warning: this is equivalent to ``activeInAnyEnvironment`` + /// + /// #### Related Symbols + /// - ``activeInCurrentEnvironment`` + @objc var active: [String: EntitlementInfo] { + return self.activeInAnyEnvironment + } + + /// Dictionary of active ``EntitlementInfo`` objects keyed by their identifiers. + /// - Note: When queried from the sandbox environment, it only returns entitlements active in sandbox. + /// When queried from production, this only returns entitlements active in production. + /// + /// #### Related Symbols + /// - ``activeInAnyEnvironment`` + @objc var activeInCurrentEnvironment: [String: EntitlementInfo] { + return self.all.filter { $0.value.isActiveInCurrentEnvironment } + } + + /// Dictionary of active ``EntitlementInfo`` objects keyed by their identifiers. + /// - Note: these can be active on any environment. + /// + /// #### Related Symbols + /// - ``activeInCurrentEnvironment`` + @objc var activeInAnyEnvironment: [String: EntitlementInfo] { + return self.all.filter { $0.value.isActiveInAnyEnvironment } + } + +} + +extension EntitlementInfos { + + convenience init( + entitlements: [String: CustomerInfoResponse.Entitlement], + purchases: [String: CustomerInfoResponse.Subscription], + requestDate: Date, + sandboxEnvironmentDetector: SandboxEnvironmentDetector = BundleSandboxEnvironmentDetector.default, + verification: VerificationResult + ) { + let allEntitlements: [String: EntitlementInfo] = .init( + uniqueKeysWithValues: entitlements.compactMap { identifier, entitlement in + guard let subscription = purchases[entitlement.productIdentifier] else { + return nil + } + + return ( + identifier, + EntitlementInfo(identifier: identifier, + entitlement: entitlement, + subscription: subscription, + sandboxEnvironmentDetector: sandboxEnvironmentDetector, + verification: verification, + requestDate: requestDate) + ) + } + ) + + self.init(entitlements: allEntitlements, verification: verification) + } + +} + +extension EntitlementInfos: Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/IntroEligibility.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/IntroEligibility.swift new file mode 100644 index 00000000..d44421f9 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/IntroEligibility.swift @@ -0,0 +1,182 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// IntroEligibility.swift +// +// Created by Joshua Liebowitz on 7/6/21. +// + +import Foundation + +/** + * Enum of different possible states for intro price eligibility status. + * * ``IntroEligibilityStatus/unknown`` RevenueCat doesn't have enough information to determine eligibility. + * * ``IntroEligibilityStatus/ineligible`` The user is not eligible for a free trial or intro pricing for this + * product. + * * ``IntroEligibilityStatus/eligible`` The user is eligible for a free trial or intro pricing for this product. + */ +@objc(RCIntroEligibilityStatus) public enum IntroEligibilityStatus: Int { + + /** + RevenueCat doesn't have enough information to determine eligibility. + */ + case unknown = 0 + + /** + The user is not eligible for a free trial or intro pricing for this product. + */ + case ineligible + + /** + The user is eligible for a free trial or intro pricing for this product. + */ + case eligible + + /** + There is no free trial or intro pricing for this product. + */ + case noIntroOfferExists + +} + +extension IntroEligibilityStatus: CaseIterable, Sendable {} + +extension IntroEligibilityStatus: CustomStringConvertible { + + // swiftlint:disable:next missing_docs + public var description: String { + switch self { + case .eligible: return "\(type(of: self)).eligible" + case .ineligible: return "\(type(of: self)).ineligible" + case .noIntroOfferExists: return "\(type(of: self)).noIntroOfferExists" + + case .unknown: fallthrough + @unknown default: + return "\(type(of: self)).unknown" + } + } + +} + +extension IntroEligibilityStatus { + + /// - Returns: `true` if this eligibility is ``IntroEligibilityStatus/isEligible``. + public var isEligible: Bool { + switch self { + case .unknown, .ineligible, .noIntroOfferExists: + return false + case .eligible: + return true + } + } + +} + +private extension IntroEligibilityStatus { + + enum IntroEligibilityStatusError: LocalizedError { + case invalidStatusCode(Int) + + var errorDescription: String? { + switch self { + case .invalidStatusCode(let code): + return "😿 Invalid status code: \(code)" + } + } + } + + init(statusCode: Int) throws { + guard let result = Self.mapping[statusCode] else { + throw IntroEligibilityStatusError.invalidStatusCode(statusCode) + } + + self = result + } + + private static let mapping: [Int: IntroEligibilityStatus] = Dictionary( + uniqueKeysWithValues: IntroEligibilityStatus.allCases.map { ($0.rawValue, $0) } + ) +} + +// Note about the need for `IntroEligibility`: it only holds a `IntroEligibilityStatus` +// so one might think it's redundant, but it's actually the only way for the APIs that +// a dictionary of Product Identifier -> Eligibility to work in Objective-C. +// `[String: IntroEligibilityStatus]` can't be represented in Obj-C, other than through +// `[String: NSNumber]`, which would be a worse API. + +/** + Holds the introductory price status + */ +@objc(RCIntroEligibility) public final class IntroEligibility: NSObject { + + /** + The introductory price eligibility status + */ + @objc public let status: IntroEligibilityStatus + + init(eligibilityStatus status: IntroEligibilityStatus) { + self.status = status + } + + @objc private override init() { + self.status = .unknown + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? Self else { return false } + + return other.status == self.status + } + + public override var hash: Int { + var hasher = Hasher() + hasher.combine(self.status) + + return hasher.finalize() + } + +} + +extension IntroEligibility { + + public override var description: String { + switch self.status { + case .eligible: + return "Eligible for trial or introductory price." + case .ineligible: + return "Not eligible for trial or introductory price." + case .noIntroOfferExists: + return "Product does not have trial or introductory price." + + case .unknown: fallthrough + @unknown default: + return "Unknown status" + } + } + + public override var debugDescription: String { + let name = "\(type(of: self))" + + switch self.status { + case .eligible: + return "\(name).eligible" + case .ineligible: + return "\(name).ineligible" + case .noIntroOfferExists: + return "\(name).noIntroOfferExists" + case .unknown: + return "\(name).unknown" + @unknown default: + return "Unknown" + } + } + +} + +extension IntroEligibility: Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/IntroEligibilityCalculator.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/IntroEligibilityCalculator.swift new file mode 100644 index 00000000..328b8024 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/IntroEligibilityCalculator.swift @@ -0,0 +1,122 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// IntroEligibilityCalculator.swift +// +// Created by Andrés Boedo on 7/14/20. +// + +import Foundation +import StoreKit + +class IntroEligibilityCalculator { + + private let productsManager: ProductsManagerType + private let receiptParser: PurchasesReceiptParser + + init(productsManager: ProductsManagerType, + receiptParser: PurchasesReceiptParser) { + self.productsManager = productsManager + self.receiptParser = receiptParser + } + + func checkEligibility(with receiptData: Data, + productIdentifiers candidateProductIdentifiers: Set, + completion: @escaping (Result<[String: IntroEligibilityStatus], Error>) -> Void) { + guard candidateProductIdentifiers.count > 0 else { + completion(.success([:])) + return + } + Logger.debug(Strings.customerInfo.checking_intro_eligibility_locally) + + var result = candidateProductIdentifiers.dictionaryWithValues { _ in IntroEligibilityStatus.unknown } + do { + let receipt = try self.receiptParser.parse(from: receiptData) + Logger.debug(Strings.customerInfo.checking_intro_eligibility_locally_from_receipt(receipt)) + + let activeSubscriptionsProductIdentifiers = receipt + .activeSubscriptionsProductIdentifiers + let expiredTrialProductIdentifiers = receipt.expiredTrialProductIdentifiers + let allProductIdentifiers = candidateProductIdentifiers + .union(activeSubscriptionsProductIdentifiers) + .union(expiredTrialProductIdentifiers) + + self.productsManager.products(withIdentifiers: allProductIdentifiers) { + let allProducts = $0.value ?? [] + + let candidateProducts = allProducts.filter { + candidateProductIdentifiers.contains($0.productIdentifier) + } + let activeSubscriptionsProducts = allProducts.filter { + activeSubscriptionsProductIdentifiers.contains($0.productIdentifier) + } + let expiredTrialProducts = allProducts.filter { + expiredTrialProductIdentifiers.contains($0.productIdentifier) + } + + let eligibility = self.checkEligibility( + candidateProducts: candidateProducts, + activeSubscriptionsProducts: activeSubscriptionsProducts, + expiredTrialProducts: expiredTrialProducts + ) + result += eligibility + + Logger.debug( + Strings.customerInfo.checking_intro_eligibility_locally_result(productIdentifiers: result) + ) + completion(.success(result)) + } + } catch { + Logger.error(Strings.customerInfo.checking_intro_eligibility_locally_error(error: error)) + completion(.failure(error)) + } + } +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension IntroEligibilityCalculator: @unchecked Sendable {} + +// MARK: - Private + +private extension IntroEligibilityCalculator { + + func checkEligibility( + candidateProducts: Set, + activeSubscriptionsProducts: Set, + expiredTrialProducts: Set + ) -> [String: IntroEligibilityStatus] { + var result: [String: IntroEligibilityStatus] = [:] + + for candidate in candidateProducts { + guard candidate.subscriptionPeriod != nil else { + result[candidate.productIdentifier] = .unknown + continue + } + let activeSubscriptionInGroup = activeSubscriptionsProducts.contains { + $0.subscriptionGroupIdentifier == candidate.subscriptionGroupIdentifier + } + let expiredTrialInGroup = expiredTrialProducts.contains { + $0.subscriptionGroupIdentifier == candidate.subscriptionGroupIdentifier + } + + if candidate.introductoryDiscount == nil { + result[candidate.productIdentifier] = .noIntroOfferExists + } else { + let isEligible = !activeSubscriptionInGroup && !expiredTrialInGroup + + result[candidate.productIdentifier] = isEligible + ? .eligible + : .ineligible + } + } + return result + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/NonSubscriptionTransaction.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/NonSubscriptionTransaction.swift new file mode 100644 index 00000000..0510d593 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/NonSubscriptionTransaction.swift @@ -0,0 +1,76 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// NonPurchaseTransaction.swift +// +// Created by Nacho Soto on 6/23/22. + +import Foundation + +/// Information that represents a non-subscription purchase made by a user. +/// +/// This can be one of these types of product: +/// - Consumables +/// - Non-consumables +/// - Non-renewing subscriptions +@objc(RCNonSubscriptionTransaction) +public final class NonSubscriptionTransaction: NSObject { + + /// The product identifier. + @objc public let productIdentifier: String + + /// The date that App Store charged the user’s account. + @objc public let purchaseDate: Date + + /// The unique identifier for the transaction created by RevenueCat. + @objc public let transactionIdentifier: String + + /// The unique identifier for the transaction created by the Store. + @objc public let storeTransactionIdentifier: String + + /// The ``Store`` where this transaction was performed. + @objc public let store: Store + + /// Paid price for the subscription + @objc public let price: ProductPaidPrice? + + /// Whether or not the purchase was made in sandbox mode. + @objc public let isSandbox: Bool + + init?(with transaction: CustomerInfoResponse.Transaction, productID: String) { + guard let transactionIdentifier = transaction.transactionIdentifier, + let storeTransactionIdentifier = transaction.storeTransactionIdentifier else { + Logger.error("Couldn't initialize NonSubscriptionTransaction. " + + "Reason: missing data: \(transaction).") + return nil + } + + self.transactionIdentifier = transactionIdentifier + self.storeTransactionIdentifier = storeTransactionIdentifier + self.purchaseDate = transaction.purchaseDate + self.productIdentifier = productID + self.store = transaction.store + self.price = transaction.price.map { ProductPaidPrice(currency: $0.currency, amount: $0.amount) } + self.isSandbox = transaction.isSandbox + } + + public override var description: String { + return """ + <\(String(describing: NonSubscriptionTransaction.self)): + productIdentifier=\(self.productIdentifier) + purchaseDate=\(self.purchaseDate) + transactionIdentifier=\(self.transactionIdentifier) + storeTransactionIdentifier=\(self.storeTransactionIdentifier) + > + """ + } + +} + +extension NonSubscriptionTransaction: Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Offering.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Offering.swift new file mode 100644 index 00000000..6721348a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Offering.swift @@ -0,0 +1,391 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Offering.swift +// +// Created by Joshua Liebowitz on 7/9/21. +// + +import Foundation + +/** + * An offering is a collection of ``Package``s, and they let you control which products + * are shown to users without requiring an app update. + * + * Building paywalls that are dynamic and can react to different product + * configurations gives you maximum flexibility to make remote updates. + * + * #### Related Articles + * - [Displaying Products](https://docs.revenuecat.com/docs/displaying-products) + * - ``Offerings`` + * - ``Package`` + */ +@objc(RCOffering) public final class Offering: NSObject { + + /// Initialize a ``PaywallComponents`` + public struct PaywallComponents { + + /** + Paywall components configuration defined in RevenueCat dashboard. + */ + public let uiConfig: UIConfig + + /** + Paywall components configuration defined in RevenueCat dashboard. + */ + public let data: PaywallComponentsData + + /// Initialize a ``PaywallComponents``. + public init(uiConfig: UIConfig, data: PaywallComponentsData) { + self.uiConfig = uiConfig + self.data = data + } + + } + + /** + Unique identifier defined in RevenueCat dashboard. + */ + @objc public let identifier: String + + /** + Offering description defined in RevenueCat dashboard. + */ + @objc public let serverDescription: String + + private let _metadata: Metadata + + /** + Offering metadata defined in RevenueCat dashboard. + */ + @objc public var metadata: [String: Any] { self._metadata.data } + + /** + Paywall configuration defined in RevenueCat dashboard. + + Use ``hasPaywall`` to check if the offering has a paywall. + */ + public let paywall: PaywallData? + + /** + Paywall components configuration defined in RevenueCat dashboard. + + Use ``hasPaywall`` to check if the offering has a paywall. + */ + public let paywallComponents: PaywallComponents? + + /** + Whether the offering contains a paywall. + */ + public var hasPaywall: Bool { + return paywall != nil || paywallComponents != nil + } + + /** + Draft paywall components configuration defined in RevenueCat dashboard. + */ + @_spi(Internal) public let draftPaywallComponents: PaywallComponents? + + /** + Array of ``Package`` objects available for purchase. + */ + @objc public let availablePackages: [Package] + + /** + Lifetime ``Package`` type configured in the RevenueCat dashboard, if available. + */ + @objc public let lifetime: Package? + + /** + Annual ``Package`` type configured in the RevenueCat dashboard, if available. + */ + @objc public let annual: Package? + + /** + Six month ``Package`` type configured in the RevenueCat dashboard, if available. + */ + @objc public let sixMonth: Package? + + /** + Three month ``Package`` type configured in the RevenueCat dashboard, if available. + */ + @objc public let threeMonth: Package? + + /** + Two month ``Package`` type configured in the RevenueCat dashboard, if available. + */ + @objc public let twoMonth: Package? + + /** + Monthly ``Package`` type configured in the RevenueCat dashboard, if available. + */ + @objc public let monthly: Package? + + /** + Weekly ``Package`` type configured in the RevenueCat dashboard, if available. + */ + @objc public let weekly: Package? + + /** + The url to purchase this package on the web + */ + @objc public let webCheckoutUrl: URL? + + public override var description: String { + return """ + + """ + } + + /** + Retrieves a specific ``Package`` by identifier, use this to access custom package types configured in the + RevenueCat dashboard, e.g. `offering.package(identifier: "custom_package_id")` or + `offering["custom_package_id"]`. + */ + @objc public func package(identifier: String?) -> Package? { + guard let identifier = identifier else { + return nil + } + + return availablePackages + .filter { $0.identifier == identifier } + .first + } + + /// #### Related Symbols + /// - ``package(identifier:)`` + @objc public subscript(key: String) -> Package? { + return package(identifier: key) + } + + // swiftlint:disable cyclomatic_complexity + + /// Initialize an ``Offering`` given a list of ``Package``s. + @objc + public convenience init( + identifier: String, + serverDescription: String, + metadata: [String: Any] = [:], + availablePackages: [Package], + webCheckoutUrl: URL? + ) { + self.init( + identifier: identifier, + serverDescription: serverDescription, + metadata: metadata, + paywall: nil, + paywallComponents: nil, + availablePackages: availablePackages, + webCheckoutUrl: webCheckoutUrl + ) + } + + /// Initialize an ``Offering`` given a list of ``Package``s. + public convenience init( + identifier: String, + serverDescription: String, + metadata: [String: Any] = [:], + paywall: PaywallData? = nil, + paywallComponents: PaywallComponents? = nil, + availablePackages: [Package], + webCheckoutUrl: URL? + ) { + self.init( + identifier: identifier, + serverDescription: serverDescription, + metadata: metadata, + paywall: paywall, + paywallComponents: paywallComponents, + draftPaywallComponents: nil, + availablePackages: availablePackages, + webCheckoutUrl: webCheckoutUrl + ) + } + + init( + identifier: String, + serverDescription: String, + metadata: [String: Any] = [:], + paywall: PaywallData? = nil, + paywallComponents: PaywallComponents? = nil, + draftPaywallComponents: PaywallComponents?, + availablePackages: [Package], + webCheckoutUrl: URL? + ) { + self.identifier = identifier + self.serverDescription = serverDescription + self.availablePackages = availablePackages + self._metadata = Metadata(data: metadata) + self.paywall = paywall + self.paywallComponents = paywallComponents + self.draftPaywallComponents = draftPaywallComponents + self.webCheckoutUrl = webCheckoutUrl + + var foundPackages: [PackageType: Package] = [:] + + var lifetime: Package? + var annual: Package? + var sixMonth: Package? + var threeMonth: Package? + var twoMonth: Package? + var monthly: Package? + var weekly: Package? + + for package in availablePackages { + Self.checkForNilAndLogReplacement(previousPackages: foundPackages, newPackage: package) + + switch package.packageType { + case .lifetime: lifetime = package + case .annual: annual = package + case .sixMonth: sixMonth = package + case .threeMonth: threeMonth = package + case .twoMonth: twoMonth = package + case .monthly: monthly = package + case .weekly: weekly = package + case .custom where package.storeProduct.productCategory == .nonSubscription: + // Non-subscription product, ignoring + continue + + case .custom: + Logger.debug(Strings.offering.custom_package_type(package)) + continue + + case .unknown: + Logger.warn(Strings.offering.unknown_package_type(package)) + continue + } + + foundPackages[package.packageType] = package + } + + self.lifetime = lifetime + self.annual = annual + self.sixMonth = sixMonth + self.threeMonth = threeMonth + self.twoMonth = twoMonth + self.monthly = monthly + self.weekly = weekly + + super.init() + } + + // swiftlint:enable cyclomatic_complexity + +} + +@_spi(Internal) +public extension Offering { + + /// Copies the Offering and sets the given `presentedOfferingContext` on all `availablePackages` + func withPresentedOfferingContext(_ presentedOfferingContext: PresentedOfferingContext) -> Self { + return Self( + identifier: identifier, + serverDescription: serverDescription, + metadata: metadata, + paywall: paywall, + paywallComponents: paywallComponents, + draftPaywallComponents: draftPaywallComponents, + availablePackages: availablePackages.map { $0.withPresentedOfferingContext(presentedOfferingContext) }, + webCheckoutUrl: webCheckoutUrl + ) + } +} + +fileprivate extension Package { + func withPresentedOfferingContext(_ presentedOfferingContext: PresentedOfferingContext) -> Self { + return Self( + identifier: identifier, + packageType: packageType, + storeProduct: storeProduct, + presentedOfferingContext: presentedOfferingContext, + webCheckoutUrl: webCheckoutUrl + ) + } +} + +extension Offering { + + /// - Returns: The `metadata` value associated to `key` for the expected type, + /// or `default` if not found or it's not the expected type. + public func getMetadataValue(for key: String, default: T) -> T { + guard let rawValue = self.metadata[key], let value = rawValue as? T else { + return `default` + } + return value + } + + /// - Returns: The `metadata` value associated to `key` for the expected `Decodable` type, + /// or `nil` if not found or if the content couldn't be deserialized to the expected type. + /// - Note: This decodes JSON using `JSONDecoder.KeyDecodingStrategy.convertFromSnakeCase`. + public func getMetadataValue(for key: String) -> T? { + guard let value = self.metadata[key] else { return nil } + + if JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value) { + return try? JSONDecoder.default.decode( + T.self, + jsonData: data, + logErrors: true + ) + } else if let value = value as? T { + return value + } else { + return nil + } + } + +} + +extension Offering: Identifiable { + + /// The stable identity of the entity associated with this instance. + public var id: String { return self.identifier } + +} + +extension Offering.PaywallComponents: Sendable {} + +extension Offering: Sendable {} + +// MARK: - Private + +private extension Offering { + + struct Metadata { + let data: [String: Any] + } + +} + +private extension Offering { + + static func checkForNilAndLogReplacement(previousPackages: [PackageType: Package], newPackage: Package) { + if let package = previousPackages[newPackage.packageType] { + Logger.warn(Strings.offering.overriding_package(old: package.identifier, + new: newPackage.identifier)) + } + } + +} + +private func valueOrEmpty(_ value: T?) -> String { + return value?.description ?? "" +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Offerings.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Offerings.swift new file mode 100644 index 00000000..27236dac --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Offerings.swift @@ -0,0 +1,253 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Offerings.swift +// +// Created by Joshua Liebowitz on 7/12/21. +// + +import Foundation + +/** + * This class contains all the offerings configured in RevenueCat dashboard. + * Offerings let you control which products are shown to users without requiring an app update. + * + * Building paywalls that are dynamic and can react to different product + * configurations gives you maximum flexibility to make remote updates. + * + * #### Related Articles + * - [Displaying Products](https://docs.revenuecat.com/docs/displaying-products) + * - ``Offering`` + * - ``Package`` + */ +@objc(RCOfferings) public final class Offerings: NSObject { + + internal struct Placements { + let fallbackOfferingId: String? + let offeringIdsByPlacement: [String: String?] + } + + internal struct Targeting { + let revision: Int + let ruleId: String + } + + /** + Dictionary of all Offerings (``Offering``) objects keyed by their identifier. This dictionary can also be accessed + by using an index subscript on ``Offerings``, e.g. `offerings["offering_id"]`. To access the current offering use + ``Offerings/current``. + */ + @objc public let all: [String: Offering] + + /** + Current ``Offering`` configured in the RevenueCat dashboard. + */ + @objc public var current: Offering? { + guard let currentOfferingID = currentOfferingID else { + return nil + } + return all[currentOfferingID]?.copyWith(targeting: self.targeting) + } + + internal var response: OfferingsResponse { + return self.contents.response + } + internal let contents: Offerings.Contents + + /// Indicates whether this ``Offerings`` object was loaded from the disk cache. + /// + /// `false` when loaded from memory cache or fetched from the network. + internal let loadedFromDiskCache: Bool + + private let currentOfferingID: String? + private let placements: Placements? + private let targeting: Targeting? + + init( + offerings: [String: Offering], + currentOfferingID: String?, + placements: Placements?, + targeting: Targeting?, + contents: Offerings.Contents, + loadedFromDiskCache: Bool + ) { + self.all = offerings + self.currentOfferingID = currentOfferingID + self.placements = placements + self.targeting = targeting + self.contents = contents + self.loadedFromDiskCache = loadedFromDiskCache + } + +} + +extension Offerings.Placements: Sendable {} +extension Offerings.Targeting: Sendable {} +extension Offerings: Sendable {} + +public extension Offerings { + + /** + Retrieves a specific offering by its identifier, use this to access additional offerings configured in the + RevenueCat dashboard, e.g. `offerings.offering(identifier: "offering_id")` or `offerings[@"offering_id"]`. + To access the current offering use ``Offerings/current``. + */ + @objc func offering(identifier: String?) -> Offering? { + guard let identifier = identifier else { + return nil + } + + return all[identifier] + } + + /// #### Related Symbols + /// - ``offering(identifier:)`` + @objc subscript(key: String) -> Offering? { + return offering(identifier: key) + } + + @objc override var description: String { + var description = "")>" + return description + } + + /** + Retrieves a current offering for a placement identifier, use this to access offerings defined by targeting + placements configured in the RevenueCat dashboard, + e.g. `offerings.currentOffering(forPlacement: "placement_id")`. + */ + @objc(currentOfferingForPlacement:) + func currentOffering(forPlacement placementIdentifier: String) -> Offering? { + guard let placements = self.placements else { + return nil + } + + let returnOffering: Offering? + if let explicitOfferingId: String? = placements.offeringIdsByPlacement[placementIdentifier] { + // Don't use fallback since placement id was explicity set in the dictionary + returnOffering = explicitOfferingId.flatMap { self.all[$0] } + } else { + // Use fallback since the placement didn't exist + returnOffering = placements.fallbackOfferingId.flatMap { self.all[$0]} + } + + return returnOffering?.copyWith(placementIdentifier: placementIdentifier, + targeting: self.targeting) + } +} + +private extension Offering { + func copyWith( + placementIdentifier: String? = nil, + targeting: Offerings.Targeting? = nil + ) -> Offering { + if placementIdentifier == nil && targeting == nil { + return self + } + + let updatedPackages = self.availablePackages.map { pkg in + let oldContext = pkg.presentedOfferingContext + + let newContext = PresentedOfferingContext( + offeringIdentifier: pkg.presentedOfferingContext.offeringIdentifier, + placementIdentifier: placementIdentifier ?? oldContext.placementIdentifier, + targetingContext: targeting.flatMap { .init(revision: $0.revision, + ruleId: $0.ruleId) } ?? oldContext.targetingContext + ) + + return Package(identifier: pkg.identifier, + packageType: pkg.packageType, + storeProduct: pkg.storeProduct, + presentedOfferingContext: newContext, + webCheckoutUrl: pkg.webCheckoutUrl + ) + } + + return Offering(identifier: self.identifier, + serverDescription: self.serverDescription, + metadata: self.metadata, + paywall: self.paywall, + paywallComponents: self.paywallComponents, + availablePackages: updatedPackages, + webCheckoutUrl: self.webCheckoutUrl + ) + } +} + +extension Offerings { + + /// Internal enum representing the original source of the ``OfferingsResponse`` object. + enum OriginalSource: String, Codable { + /// Main server + case main + + /// Load shedder server + case loadShedder = "load_shedder" + + /// Fallback URL server + case fallbackUrl = "fallback_url" + + init(httpResponseOriginalSource: HTTPResponseOriginalSource) { + switch httpResponseOriginalSource { + case .mainServer: + self = .main + case .loadShedder: + self = .loadShedder + case .fallbackUrl: + self = .fallbackUrl + } + } + } + +} + +extension Offerings { + + /// The actual contents of a ``Offerings``: the offerings response and other SDK-generated metadata. + struct Contents { + var response: OfferingsResponse + var originalSource: Offerings.OriginalSource + + init(response: OfferingsResponse, httpResponseOriginalSource: HTTPResponseOriginalSource) { + self.response = response + self.originalSource = Offerings.OriginalSource(httpResponseOriginalSource: httpResponseOriginalSource) + } + } +} + +/// `Codable` implementation that puts the content of`response` and `originalSource` +/// at the same level instead of nested. +extension Offerings.Contents: Codable { + + private enum CodingKeys: String, CodingKey { + + case response + case originalSource + + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try self.response.encode(to: encoder) + try container.encode(self.originalSource, forKey: .originalSource) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.response = try OfferingsResponse(from: decoder) + self.originalSource = try container.decodeIfPresent(Offerings.OriginalSource.self, + forKey: .originalSource) ?? .main + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/OfferingsFactory.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/OfferingsFactory.swift new file mode 100644 index 00000000..86c9c354 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/OfferingsFactory.swift @@ -0,0 +1,141 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// OfferingsFactory.swift +// +// Created by César de la Vega on 7/13/21. +// + +import Foundation +import StoreKit + +class OfferingsFactory { + + func createOfferings(from storeProductsByID: [String: StoreProduct], + contents: Offerings.Contents, + loadedFromDiskCache: Bool) -> Offerings? { + let data = contents.response + let offerings: [String: Offering] = data + .offerings + .compactMap { offeringData in + createOffering(from: storeProductsByID, + offering: offeringData, + uiConfig: data.uiConfig) + } + .dictionaryAllowingDuplicateKeys { $0.identifier } + + guard !offerings.isEmpty else { + return nil + } + + return Offerings(offerings: offerings, + currentOfferingID: data.currentOfferingId, + placements: createPlacement(with: data.placements), + targeting: data.targeting.flatMap { .init(revision: $0.revision, ruleId: $0.ruleId) }, + contents: contents, + loadedFromDiskCache: loadedFromDiskCache) + } + + func createOffering( + from storeProductsByID: [String: StoreProduct], + offering: OfferingsResponse.Offering, + uiConfig: UIConfig? + ) -> Offering? { + let availablePackages: [Package] = offering.packages.compactMap { package in + createPackage(with: package, productsByID: storeProductsByID, offeringIdentifier: offering.identifier) + } + + guard !availablePackages.isEmpty else { + #if ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION && !DEBUG + Logger.debug(Strings.offering.offering_empty(offeringIdentifier: offering.identifier)) + #else + Logger.warn(Strings.offering.offering_empty(offeringIdentifier: offering.identifier)) + #endif + return nil + } + + let paywallComponents: Offering.PaywallComponents? = { + if let uiConfig, let paywallComponents = offering.paywallComponents { + return .init( + uiConfig: uiConfig, + data: paywallComponents + ) + } + return nil + }() + + let paywallDraftComponents: Offering.PaywallComponents? = { + if let uiConfig, let paywallDraftComponents = offering.draftPaywallComponents { + return .init( + uiConfig: uiConfig, + data: paywallDraftComponents + ) + } + return nil + }() + + return Offering(identifier: offering.identifier, + serverDescription: offering.description, + metadata: offering.metadata.mapValues(\.asAny), + paywall: offering.paywall, + paywallComponents: paywallComponents, + draftPaywallComponents: paywallDraftComponents, + availablePackages: availablePackages, + webCheckoutUrl: offering.webCheckoutUrl) + } + + func createPackage( + with data: OfferingsResponse.Offering.Package, + productsByID: [String: StoreProduct], + offeringIdentifier: String + ) -> Package? { + guard let product = productsByID[data.platformProductIdentifier] else { + return nil + } + + return .init(package: data, + product: product, + offeringIdentifier: offeringIdentifier, + webCheckoutUrl: data.webCheckoutUrl) + } + + func createPlacement( + with data: OfferingsResponse.Placements? + ) -> Offerings.Placements? { + guard let data else { + return nil + } + + return .init(fallbackOfferingId: data.fallbackOfferingId, + offeringIdsByPlacement: data.offeringIdsByPlacement) + } +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension OfferingsFactory: @unchecked Sendable {} + +// MARK: - Private + +private extension Package { + + convenience init( + package: OfferingsResponse.Offering.Package, + product: StoreProduct, + offeringIdentifier: String, + webCheckoutUrl: URL? + ) { + self.init(identifier: package.identifier, + packageType: Package.packageType(from: package.identifier), + storeProduct: product, + offeringIdentifier: offeringIdentifier, + webCheckoutUrl: webCheckoutUrl) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/OfferingsManager.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/OfferingsManager.swift new file mode 100644 index 00000000..debee337 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/OfferingsManager.swift @@ -0,0 +1,647 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// OfferingsManager.swift +// +// Created by Juanpe Catalán on 8/8/21. + +import Foundation +import StoreKit + +// swiftlint:disable file_length + +class OfferingsManager { + + private let deviceCache: DeviceCache + private let operationDispatcher: OperationDispatcher + private let systemInfo: SystemInfo + private let backend: Backend + private let offeringsFactory: OfferingsFactory + private let productsManager: ProductsManagerType + private let diagnosticsTracker: DiagnosticsTrackerType? + private let dateProvider: DateProvider + + init(deviceCache: DeviceCache, + operationDispatcher: OperationDispatcher, + systemInfo: SystemInfo, + backend: Backend, + offeringsFactory: OfferingsFactory, + productsManager: ProductsManagerType, + diagnosticsTracker: DiagnosticsTrackerType?, + dateProvider: DateProvider = DateProvider()) { + self.deviceCache = deviceCache + self.operationDispatcher = operationDispatcher + self.systemInfo = systemInfo + self.backend = backend + self.offeringsFactory = offeringsFactory + self.productsManager = productsManager + self.diagnosticsTracker = diagnosticsTracker + self.dateProvider = dateProvider + } + + func offerings( + appUserID: String, + fetchPolicy: FetchPolicy = .default, + fetchCurrent: Bool = false, + trackDiagnostics: Bool = true, + completion: (@MainActor @Sendable (Result) -> Void)? + ) { + self.trackGetOfferingsStartedIfNeeded(trackDiagnostics: trackDiagnostics) + let startTime = self.dateProvider.now() + + self.systemInfo.isApplicationBackgrounded { isAppBackgrounded in + + guard !fetchCurrent && !self.systemInfo.dangerousSettings.uiPreviewMode else { + self.fetchFromNetwork(appUserID: appUserID, + fetchPolicy: fetchPolicy) { [weak self] result in + self?.trackGetOfferingsResultIfNeeded(trackDiagnostics: trackDiagnostics, + startTime: startTime, + cacheStatus: .notChecked, + error: result.error, + requestedProductIds: result.value?.requestedProductIds, + notFoundProductIds: result.value?.notFoundProductIds) + completion?(result.map(\.offerings)) + } + return + } + + guard let memoryCachedOfferings = self.cachedOfferings else { + self.fetchFromNetwork(appUserID: appUserID, + fetchPolicy: fetchPolicy) { [weak self] result in + self?.trackGetOfferingsResultIfNeeded(trackDiagnostics: trackDiagnostics, + startTime: startTime, + cacheStatus: .notFound, + error: result.error, + requestedProductIds: result.value?.requestedProductIds, + notFoundProductIds: result.value?.notFoundProductIds) + completion?(result.map(\.offerings)) + } + return + } + + let cacheStatus = self.deviceCache.offeringsCacheStatus(isAppBackgrounded: isAppBackgrounded) + Logger.debug(Strings.offering.vending_offerings_cache_from_memory) + self.trackGetOfferingsResultIfNeeded(trackDiagnostics: trackDiagnostics, + startTime: startTime, + cacheStatus: cacheStatus, + error: nil, + requestedProductIds: nil, + notFoundProductIds: nil) + + self.dispatchCompletionOnMainThreadIfPossible(completion, + value: .success(memoryCachedOfferings)) + + if cacheStatus == .stale { + self.updateOfferingsCache(appUserID: appUserID, + isAppBackgrounded: isAppBackgrounded, + fetchPolicy: fetchPolicy, + completion: nil) + } + } + } + + var cachedOfferings: Offerings? { + return self.deviceCache.cachedOfferings + } + + func updateOfferingsCache( + appUserID: String, + isAppBackgrounded: Bool, + fetchPolicy: FetchPolicy = .default, + completion: (@MainActor @Sendable (Result) -> Void)? + ) { + // We keep track of preferred locales at the time of launching the request + let preferredLocales = systemInfo.preferredLocales + self.backend.offerings.getOfferings(appUserID: appUserID, isAppBackgrounded: isAppBackgrounded) { result in + switch result { + case let .success(contents): + self.handleOfferingsBackendResult(with: contents, + appUserID: appUserID, + fetchPolicy: fetchPolicy, + preferredLocales: preferredLocales, + completion: completion) + + case let .failure(backendError) where backendError.shouldFallBackToCachedOfferings: + + // If error fetching offerings, attempt to load them from disk cache. + self.fetchCachedOfferingsFromDisk(appUserID: appUserID, + fetchPolicy: fetchPolicy) { offerings in + if let offerings = offerings { + Logger.warn(Strings.offering.error_fetching_offerings_using_disk_cache) + self.dispatchCompletionOnMainThreadIfPossible(completion, value: .success(offerings)) + } else { + self.handleOfferingsUpdateError(.backendError(backendError), + completion: completion) + } + } + + case let .failure(error): + self.handleOfferingsUpdateError(.backendError(error), completion: completion) + } + } + } + + func getMissingProductIDs(productIDsFromStore: Set, + productIDsFromBackend: Set) -> Set { + guard !productIDsFromBackend.isEmpty else { + return [] + } + + return productIDsFromBackend.subtracting(productIDsFromStore) + } + + func invalidateCachedOfferings(appUserID: String) { + self.deviceCache.clearOfferingsCache(appUserID: appUserID) + } + + func invalidateAndReFetchCachedOfferingsIfAppropiate(appUserID: String) { + let cachedOfferings = self.deviceCache.cachedOfferings + self.invalidateCachedOfferings(appUserID: appUserID) + + if cachedOfferings != nil { + self.offerings(appUserID: appUserID, + fetchPolicy: .ignoreNotFoundProducts, + trackDiagnostics: false) { @Sendable _ in } + } + } + +} + +private extension OfferingsManager { + + func fetchFromNetwork( + appUserID: String, + fetchPolicy: FetchPolicy = .default, + completion: (@MainActor @Sendable (Result) -> Void)? + ) { + Logger.debug(Strings.offering.no_cached_offerings_fetching_from_network) + + self.systemInfo.isApplicationBackgrounded { isAppBackgrounded in + self.updateOfferingsCache(appUserID: appUserID, + isAppBackgrounded: isAppBackgrounded, + fetchPolicy: fetchPolicy, + completion: completion) + } + } + + func fetchCachedOfferingsFromDisk( + appUserID: String, + fetchPolicy: FetchPolicy, + completion: (@escaping @Sendable (OfferingsResultData?) -> Void) + ) { + guard let contents = self.deviceCache.cachedOfferingsContents(appUserID: appUserID) else { + completion(nil) + return + } + + self.createOfferings( + from: contents, + loadedFromDiskCache: true, + fetchPolicy: fetchPolicy, + completion: { [cache = self.deviceCache] result in + switch result { + case let .success(offeringsResultData): + Logger.debug(Strings.offering.vending_offerings_cache_from_disk) + + // Cache in memory but as stale, so it can be re-updated when possible + cache.cacheInMemory(offerings: offeringsResultData.offerings) + cache.forceOfferingsCacheStale() + + completion(offeringsResultData) + + case .failure: + completion(nil) + } + } + ) + } + + func createOfferings( + from contents: Offerings.Contents, + loadedFromDiskCache: Bool, + fetchPolicy: FetchPolicy, + completion: @escaping (@Sendable (Result) -> Void) + ) { + let productIdentifiers = contents.response.productIdentifiers + + guard !productIdentifiers.isEmpty else { + let errorMessage = Strings.offering.configuration_error_no_products_for_offering( + apiKeyValidationResult: self.systemInfo.apiKeyValidationResult + ).description + completion(.failure(.configurationError(errorMessage, underlyingError: nil))) + return + } + + self.fetchProducts(withIdentifiers: productIdentifiers, fromResponse: contents.response) { result in + let products = result.value ?? [] + + guard products.isEmpty == false else { + // Check if empty products is likely caused by https://github.com/RevenueCat/purchases-ios/issues/4954 + // There is a widely reported bug in the iOS 18.4 Simulator affecting some HTTP requests + let showSimulatorWarning = self.systemInfo.isSubjectToKnownIssue_18_4_sim() + completion(.failure(Self.createErrorForEmptyResult(result.error, + showSimulatorWarning: showSimulatorWarning))) + return + } + + let productsByID = products.dictionaryWithKeys { $0.productIdentifier } + + let missingProductIDs = self.getMissingProductIDs(productIDsFromStore: Set(productsByID.keys), + productIDsFromBackend: productIdentifiers) + if !missingProductIDs.isEmpty { + switch fetchPolicy { + case .ignoreNotFoundProducts: + Logger.appleWarning( + Strings.offering.cannot_find_product_configuration_error(identifiers: missingProductIDs) + ) + + case .failIfProductsAreMissing: + completion(.failure(.missingProducts(identifiers: missingProductIDs))) + return + } + } + + if let createdOfferings = self.offeringsFactory.createOfferings(from: productsByID, + contents: contents, + loadedFromDiskCache: loadedFromDiskCache) { + completion(.success(OfferingsResultData(offerings: createdOfferings, + requestedProductIds: productIdentifiers, + notFoundProductIds: missingProductIDs))) + } else { + completion(.failure(.noOfferingsFound())) + } + } + } + + func handleOfferingsBackendResult( + with contents: Offerings.Contents, + appUserID: String, + fetchPolicy: FetchPolicy, + preferredLocales: [String], + completion: (@MainActor @Sendable (Result) -> Void)? + ) { + self.createOfferings(from: contents, loadedFromDiskCache: false, fetchPolicy: fetchPolicy) { result in + switch result { + case let .success(offeringsResultData): + Logger.rcSuccess(Strings.offering.offerings_stale_updated_from_network) + + self.deviceCache.cache(offerings: offeringsResultData.offerings, + preferredLocales: preferredLocales, + appUserID: appUserID) + self.dispatchCompletionOnMainThreadIfPossible(completion, value: .success(offeringsResultData)) + + case let .failure(error): + self.handleOfferingsUpdateError(error, completion: completion) + } + } + } + + private static func createErrorForEmptyResult(_ error: PurchasesError?, + showSimulatorWarning: Bool = false) -> OfferingsManager.Error { + if let purchasesError = error, + case ErrorCode.productRequestTimedOut = purchasesError.error { + return .timeout(purchasesError) + } else if showSimulatorWarning { + return .configurationError(Strings.offering.known_issue_ios_18_4_simulator_products_not_found.description, + underlyingError: error?.asPublicError) + } else { + return .configurationError(Strings.offering.configuration_error_products_not_found.description, + underlyingError: error?.asPublicError) + } + } + + func handleOfferingsUpdateError( + _ error: Error, + completion: (@MainActor @Sendable (Result) -> Void)? + ) { + Logger.appleError(Strings.offering.fetching_offerings_error(error: error, + underlyingError: error.underlyingError)) + self.dispatchCompletionOnMainThreadIfPossible(completion, value: .failure(error)) + } + + func dispatchCompletionOnMainThreadIfPossible( + _ completion: (@MainActor @Sendable (T) -> Void)?, + value: T + ) { + if let completion = completion { + self.operationDispatcher.dispatchOnMainActor { + completion(value) + } + } + } + + private func fetchProducts( + withIdentifiers identifiers: Set, + fromResponse response: OfferingsResponse, + completion: @escaping ProductsManagerType.Completion + ) { + if self.systemInfo.dangerousSettings.uiPreviewMode { + let previewProducts = self.createPreviewProducts(productIdentifiers: identifiers, fromResponse: response) + completion(.success(previewProducts)) + } else { + self.productsManager.products(withIdentifiers: identifiers, completion: completion) + } + } + + // MARK: - For UI Preview mode + + /// Generates a set of dummy `StoreProduct`s with hardcoded information exclusively for UI Preview mode. + private func createPreviewProducts( + productIdentifiers: Set, + fromResponse response: OfferingsResponse + ) -> Set { + let packagesByProductID = response.packages.dictionaryAllowingDuplicateKeys { $0.platformProductIdentifier } + let products = productIdentifiers.map { identifier -> StoreProduct in + let productType = self.inferredPreviewProductType(from: packagesByProductID[identifier], + productIdentifier: identifier) + + let introductoryDiscount: TestStoreProductDiscount? = { + // To allow introductory offers in UI Preview mode, + // all dummy yearly subscriptions have a 1-week free trial + guard productType.period?.unit == .year else { return nil } + return TestStoreProductDiscount( + identifier: "intro", + price: 0, + localizedPriceString: "$0.00", + paymentMode: .freeTrial, + subscriptionPeriod: SubscriptionPeriod(value: 1, unit: .week), + numberOfPeriods: 1, + type: .introductory + ) + }() + + let testProduct = TestStoreProduct( + localizedTitle: "PRO \(productType.type)", + price: Decimal(productType.price), + currencyCode: "USD", + localizedPriceString: String(format: "$%.2f", productType.price), + productIdentifier: identifier, + productType: productType.period == nil ? .nonConsumable : .autoRenewableSubscription, + localizedDescription: productType.type + (productType.period == nil ? "" : " subscription"), + subscriptionGroupIdentifier: productType.period == nil ? nil : "group", + subscriptionPeriod: productType.period, + introductoryDiscount: introductoryDiscount, + discounts: [], + locale: Locale(identifier: "en_US") + ) + + return testProduct.toStoreProduct() + } + + return Set(products) + } + + private func inferredPreviewProductType( + from package: OfferingsResponse.Offering.Package?, + productIdentifier: String + ) -> PreviewProductType { + if let package, + let previewProductType = PreviewProductType(packageType: Package.packageType(from: package.identifier)) { + return previewProductType + } else { + // Try to guess basing on the product identifier + let id = productIdentifier.lowercased() + + let packageType: PackageType + if id.contains("lifetime") || id.contains("forever") || id.contains("permanent") { + packageType = .lifetime + } else if id.contains("annual") || id.contains("year") { + packageType = .annual + } else if id.contains("sixmonth") || id.contains("6month") { + packageType = .sixMonth + } else if id.contains("threemonth") || id.contains("3month") || id.contains("quarter") { + packageType = .threeMonth + } else if id.contains("twomonth") || id.contains("2month") { + packageType = .twoMonth + } else if id.contains("month") { + packageType = .monthly + } else if id.contains("week") { + packageType = .weekly + } else { + packageType = .custom + } + return PreviewProductType(packageType: packageType) ?? .default + } + } + + func trackGetOfferingsStartedIfNeeded(trackDiagnostics: Bool) { + if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *), + trackDiagnostics, + let diagnosticsTracker = self.diagnosticsTracker { + diagnosticsTracker.trackOfferingsStarted() + } + } + + // swiftlint:disable:next function_parameter_count + func trackGetOfferingsResultIfNeeded(trackDiagnostics: Bool, + startTime: Date, + cacheStatus: CacheStatus, + error: Error?, + requestedProductIds: Set?, + notFoundProductIds: Set?) { + if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *), + trackDiagnostics, + let diagnosticsTracker = self.diagnosticsTracker { + + let responseTime = self.dateProvider.now().timeIntervalSince(startTime) + + diagnosticsTracker.trackOfferingsResult(requestedProductIds: requestedProductIds, + notFoundProductIds: notFoundProductIds, + errorMessage: error?.localizedDescription, + errorCode: error?.asPurchasesError.errorCode, + // WIP Add verification result property once we + // expose verification result in offerings object + verificationResult: nil, + cacheStatus: cacheStatus, + responseTime: responseTime) + } + } +} + +extension OfferingsManager { + + /// Determines the behavior when products in an `Offering` are not found + internal enum FetchPolicy { + + case ignoreNotFoundProducts + case failIfProductsAreMissing + + static let `default`: Self = .ignoreNotFoundProducts + + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension OfferingsManager: @unchecked Sendable {} + +// MARK: - Errors + +extension OfferingsManager { + + enum Error: Swift.Error { + + case backendError(BackendError) + case configurationError(String, PublicError?, ErrorSource) + case timeout(PurchasesError) + case noOfferingsFound(ErrorSource) + case missingProducts(identifiers: Set, ErrorSource) + + } + +} + +extension OfferingsManager.Error: PurchasesErrorConvertible { + + var asPurchasesError: PurchasesError { + switch self { + case let .backendError(backendError): + return backendError.asPurchasesError + + case let .timeout(underlyingError): + return underlyingError + + case let .configurationError(errorMessage, underlyingError, source): + return ErrorUtils.configurationError(message: errorMessage, + underlyingError: underlyingError, + fileName: source.file, + functionName: source.function, + line: source.line) + + case let .noOfferingsFound(source): + return ErrorUtils.unexpectedBackendResponseError(fileName: source.file, + functionName: source.function, + line: source.line) + + case let .missingProducts(identifiers, source): + return ErrorUtils.configurationError( + message: Strings.offering.cannot_find_product_configuration_error(identifiers: identifiers).description, + fileName: source.file, + functionName: source.function, + line: source.line + ) + } + } + + static func configurationError( + _ errorMessage: String, + underlyingError: NSError?, + file: String = #fileID, + function: String = #function, + line: UInt = #line + ) -> Self { + return .configurationError(errorMessage, underlyingError, .init(file: file, function: function, line: line)) + } + + static func noOfferingsFound( + file: String = #fileID, + function: String = #function, + line: UInt = #line + ) -> Self { + return .noOfferingsFound(.init(file: file, function: function, line: line)) + } + + static func missingProducts( + identifiers: Set, + file: String = #fileID, + function: String = #function, + line: UInt = #line + ) -> Self { + return .missingProducts(identifiers: identifiers, .init(file: file, function: function, line: line)) + } + +} + +extension OfferingsManager.Error: CustomNSError { + + var errorUserInfo: [String: Any] { + return [ + NSUnderlyingErrorKey: self.underlyingError as NSError? as Any + ] + } + + var errorDescription: String? { + switch self { + case .backendError: return nil + case let .timeout(underlyingError): return underlyingError.error.localizedDescription + case let .configurationError(message, _, _): return message + case .noOfferingsFound: return nil + case .missingProducts: return nil + } + } + + fileprivate var underlyingError: Error? { + switch self { + case let .backendError(.networkError(error)): return error + case let .backendError(error): return error + case let .timeout(underlyingError): return underlyingError + case let .configurationError(_, error, _): return error + case .noOfferingsFound: return nil + case .missingProducts: return nil + } + } + +} + +struct OfferingsResultData { + let offerings: Offerings + let requestedProductIds: Set + let notFoundProductIds: Set +} + +/// For UI Preview mode only. +private struct PreviewProductType { + let type: String + let price: Double + let period: SubscriptionPeriod? + + static let `default` = PreviewProductType(type: "lifetime", price: 249.99, period: nil) + + private init(type: String, price: Double, period: SubscriptionPeriod?) { + self.type = type + self.price = price + self.period = period + } + + init?(packageType: PackageType) { + switch packageType { + case .lifetime: + self = PreviewProductType(type: "lifetime", + price: 199.99, + period: nil) + case .annual: + self = PreviewProductType(type: "yearly", + price: 59.99, + period: SubscriptionPeriod(value: 1, unit: .year)) + case .sixMonth: + self = PreviewProductType(type: "6 months", + price: 30.99, + period: SubscriptionPeriod(value: 3, unit: .month)) + case .threeMonth: + self = PreviewProductType(type: "3 months", + price: 15.99, + period: SubscriptionPeriod(value: 3, unit: .month)) + case .twoMonth: + self = PreviewProductType(type: "monthly", + price: 11.49, + period: SubscriptionPeriod(value: 2, unit: .month)) + case .monthly: + self = PreviewProductType(type: "monthly", + price: 5.99, + period: SubscriptionPeriod(value: 1, unit: .month)) + case .weekly: + self = PreviewProductType(type: "weekly", + price: 1.99, + period: SubscriptionPeriod(value: 1, unit: .week)) + case .unknown, .custom: + return nil + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Package.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Package.swift new file mode 100644 index 00000000..e9e8a70c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Package.swift @@ -0,0 +1,217 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Package.swift +// +// Created by Andrés Boedo on 6/18/21. +// + +import Foundation + +/// +/// Stores information about how a ``Package`` was presented. +/// +@objc(RCPresentedOfferingContext) public final class PresentedOfferingContext: NSObject { + + /// + /// Stores information a targeting rule + /// + @objc(RCTargetingContext) public final class TargetingContext: NSObject { + /// The revision of the targeting used to obtain this object. + @objc public let revision: Int + + /// The rule id from the targeting used to obtain this object. + @objc public let ruleId: String + + /// Initializes a ``TargetingContext`` + @objc + public init(revision: Int, ruleId: String) { + self.revision = revision + self.ruleId = ruleId + } + } + + /// The identifier of the ``Offering`` containing this ``Package``. + @objc public let offeringIdentifier: String + + /// The placement identifier this ``Package`` was obtained from. + @objc public let placementIdentifier: String? + + /// The targeting rule this ``Package`` was obtained from. + @objc public let targetingContext: TargetingContext? + + /// Initialize a ``PresentedOfferingContext``. + @objc + public init( + offeringIdentifier: String, + placementIdentifier: String?, + targetingContext: TargetingContext? + ) { + self.offeringIdentifier = offeringIdentifier + self.placementIdentifier = placementIdentifier + self.targetingContext = targetingContext + super.init() + } + + /// Initialize a ``PresentedOfferingContext``. + @objc + public convenience init( + offeringIdentifier: String + ) { + self.init(offeringIdentifier: offeringIdentifier, placementIdentifier: nil, targetingContext: nil) + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? PresentedOfferingContext else { return false } + + return ( + self.offeringIdentifier == other.offeringIdentifier + ) + } + + public override var hash: Int { + var hasher = Hasher() + hasher.combine(self.offeringIdentifier) + + return hasher.finalize() + } +} + +/// +/// Packages help abstract platform-specific products by grouping equivalent products across iOS, Android, and web. +/// A package is made up of three parts: ``identifier``, ``packageType``, and underlying ``StoreProduct``. +/// +/// #### Related Articles +/// - [Displaying Packages](https://docs.revenuecat.com/docs/displaying-products#displaying-packages) +/// - ``Offering`` +/// - ``Offerings`` +/// +@objc(RCPackage) public final class Package: NSObject { + + /// The identifier for this Package. + @objc public let identifier: String + /// The type configured for this package. + @objc public let packageType: PackageType + /// The underlying ``storeProduct`` + @objc public let storeProduct: StoreProduct + + //// The information about the ``Offering`` containing this Package + @objc public let presentedOfferingContext: PresentedOfferingContext + + /// The price of this product using ``StoreProduct/priceFormatter``. + @objc public var localizedPriceString: String { + return storeProduct.localizedPriceString + } + + /// The price of the ``StoreProduct/introductoryDiscount`` formatted using ``StoreProduct/priceFormatter``. + /// - Returns: `nil` if there is no `introductoryDiscount`. + @objc public var localizedIntroductoryPriceString: String? { + return self.storeProduct.localizedIntroductoryPriceString + } + + /// The url to purchase this package on the web + @objc public let webCheckoutUrl: URL? + + /// Initialize a ``Package``. + @objc + public convenience init( + identifier: String, + packageType: PackageType, + storeProduct: StoreProduct, + offeringIdentifier: String, + webCheckoutUrl: URL? + ) { + self.init( + identifier: identifier, + packageType: packageType, + storeProduct: storeProduct, + presentedOfferingContext: .init(offeringIdentifier: offeringIdentifier), + webCheckoutUrl: webCheckoutUrl + ) + } + + /// Initialize a ``Package``. + @objc + public init( + identifier: String, + packageType: PackageType, + storeProduct: StoreProduct, + presentedOfferingContext: PresentedOfferingContext, + webCheckoutUrl: URL? + ) { + self.identifier = identifier + self.packageType = packageType + self.storeProduct = storeProduct + self.presentedOfferingContext = presentedOfferingContext + self.webCheckoutUrl = webCheckoutUrl + + super.init() + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? Package else { return false } + + return ( + self.identifier == other.identifier && + self.packageType == other.packageType && + self.storeProduct == other.storeProduct && + self.presentedOfferingContext == other.presentedOfferingContext + ) + } + + public override var hash: Int { + var hasher = Hasher() + hasher.combine(self.identifier) + hasher.combine(self.packageType) + hasher.combine(self.storeProduct) + hasher.combine(self.presentedOfferingContext) + + return hasher.finalize() + } + +} + +@objc public extension Package { + + /** + * - Parameter packageType: A ``PackageType``. + * - Returns: an optional description of the packageType. + */ + static func string(from packageType: PackageType) -> String? { + return packageType.description + } + + /** + * - Parameter string: A string that maps to a enumeration value of type ``PackageType`` + * - Returns: a ``PackageType`` for the given string. + */ + static func packageType(from string: String) -> PackageType { + if let packageType = PackageType.typesByDescription[string] { + return packageType + } + + return string.hasPrefix("$rc_") ? .unknown : .custom + } + + /// - Returns: the identifier of the ``Offering`` containing this Package. + var offeringIdentifier: String { + return self.presentedOfferingContext.offeringIdentifier + } +} + +extension Package: Identifiable { + + /// The stable identity of the entity associated with this instance. + public var id: String { return self.identifier } + +} + +extension Package: Sendable {} +extension PresentedOfferingContext: Sendable {} +extension PresentedOfferingContext.TargetingContext: Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/PackageType.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/PackageType.swift new file mode 100644 index 00000000..a3eded84 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/PackageType.swift @@ -0,0 +1,113 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PackageType.swift +// +// Created by Nacho Soto on 5/26/22. + +import Foundation + +/// +/// Enumeration of all possible ``Package`` types, as configured on the package. +/// +/// #### Related Articles +/// - ``Package`` +/// - [Displaying Products](https://docs.revenuecat.com/docs/displaying-products) +/// +@objc(RCPackageType) public enum PackageType: Int { + + /// A package that was defined with an unknown identifier. + case unknown = -2, + /// A package that was defined with a custom identifier. + custom, + /// A package configured with the predefined lifetime identifier. + lifetime, + /// A package configured with the predefined annual identifier. + annual, + /// A package configured with the predefined six month identifier. + sixMonth, + /// A package configured with the predefined three month identifier. + threeMonth, + /// A package configured with the predefined two month identifier. + twoMonth, + /// A package configured with the predefined monthly identifier. + monthly, + /// A package configured with the predefined weekly identifier. + weekly +} + +extension PackageType: CaseIterable {} + +extension PackageType: Sendable {} + +extension PackageType: CustomDebugStringConvertible { + + /// A textual description of the type suitable for debugging. + public var debugDescription: String { + let className = String(describing: PackageType.self) + + switch self { + case .unknown: return "\(className).unknown" + case .custom: return "\(className).custom" + default: return "\(className).\(self.description ?? "")" + } + } + +} + +extension PackageType: Codable { + + // swiftlint:disable:next missing_docs + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + if let description = self.description { + try container.encode(description) + } else { + try container.encodeNil() + } + } + + // swiftlint:disable:next missing_docs + public init(from decoder: Decoder) throws { + do { + self = Package.packageType(from: try decoder.singleValueContainer().decode(String.self)) + } catch { + ErrorUtils.logDecodingError(error, type: Self.self) + self = .unknown + } + } + +} + +extension PackageType { + + // swiftlint:disable:next missing_docs + @_spi(Internal) public var description: String? { + switch self { + case .unknown: return nil + case .custom: return nil + case .lifetime: return "$rc_lifetime" + case .annual: return "$rc_annual" + case .sixMonth: return "$rc_six_month" + case .threeMonth: return "$rc_three_month" + case .twoMonth: return "$rc_two_month" + case .monthly: return "$rc_monthly" + case .weekly: return "$rc_weekly" + } + } + + // swiftlint:disable force_unwrapping + static let typesByDescription: [String: PackageType] = PackageType + .allCases + .filter { $0.description != nil } + .dictionaryWithKeys { $0.description! } + // swiftlint:enable force_unwrapping + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ProductRequestData+Initialization.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ProductRequestData+Initialization.swift new file mode 100644 index 00000000..f3bd56be --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ProductRequestData+Initialization.swift @@ -0,0 +1,90 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ProductRequestData+Initialization.swift +// +// Created by Juanpe Catalán on 8/7/21. +// + +import Foundation +import StoreKit + +extension ProductRequestData { + + /// Initializes a `ProductRequestData` from a `StoreProduct` + init(with product: StoreProduct, storeCountry: String?) { + let paymentMode = Self.extractPaymentMode(for: product) + let introPrice = Self.extractIntroPrice(for: product) + + let normalDuration = Self.extractNormalDuration(for: product) + let introDuration = Self.extractIntroDuration(for: product) + let introDurationType = Self.extractIntroDurationType(for: product) + + let subscriptionGroup = Self.extractSubscriptionGroup(for: product) + let discounts = Self.extractDiscounts(for: product) + + self.init( + productIdentifier: product.productIdentifier, + paymentMode: paymentMode, + currencyCode: product.priceFormatter?.currencyCode, + storeCountry: storeCountry, + price: product.price as Decimal, + normalDuration: normalDuration, + introDuration: introDuration, + introDurationType: introDurationType, + introPrice: introPrice as Decimal?, + subscriptionGroup: subscriptionGroup, + discounts: discounts + ) + } + +} + +// MARK: - private methods + +private extension ProductRequestData { + + static func extractIntroDurationType(for product: StoreProduct) -> StoreProductDiscount.PaymentMode? { + return product.introductoryDiscount?.paymentMode + } + + static func extractSubscriptionGroup(for product: StoreProduct) -> String? { + return product.subscriptionGroupIdentifier + } + + static func extractDiscounts(for product: StoreProduct) -> [StoreProductDiscount]? { + return product.discounts + } + + static func extractPaymentMode(for product: StoreProduct) -> StoreProductDiscount.PaymentMode? { + return product.introductoryDiscount?.paymentMode + } + + static func extractIntroPrice(for product: StoreProduct) -> NSDecimalNumber? { + return product.introductoryDiscount?.price as NSDecimalNumber? + } + + static func extractNormalDuration(for product: StoreProduct) -> String? { + if let subscriptionPeriod = product.subscriptionPeriod, + subscriptionPeriod.value != 0 { + return ISOPeriodFormatter.string(fromProductSubscriptionPeriod: subscriptionPeriod) + } else { + return nil + } + } + + static func extractIntroDuration(for product: StoreProduct) -> String? { + if let subscriptionPeriod = product.introductoryDiscount?.subscriptionPeriod { + return ISOPeriodFormatter.string(fromProductSubscriptionPeriod: subscriptionPeriod) + } else { + return nil + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ProductRequestData.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ProductRequestData.swift new file mode 100644 index 00000000..6b0485ca --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ProductRequestData.swift @@ -0,0 +1,105 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ProductRequestData.swift +// +// Created by Joshua Liebowitz on 7/2/21. +// + +import Foundation +import StoreKit + +/// Encapsulates ``StoreProductType`` information to be sent to the backend +/// when posting receipts. +/// - SeeAlso: `Backend/post(receiptData:appUserID:isRestore:productData:...` +struct ProductRequestData { + + let productIdentifier: String + let paymentMode: StoreProductDiscount.PaymentMode? + let currencyCode: String? + let storeCountry: String? + let price: Decimal + let normalDuration: String? + let introDuration: String? + let introDurationType: StoreProductDiscount.PaymentMode? + let introPrice: Decimal? + let subscriptionGroup: String? + let discounts: [StoreProductDiscount]? + + var cacheKey: String { + var key = + """ + \(productIdentifier)-\(price)-\(currencyCode ?? "")-\(storeCountry ?? "")-\ + \(paymentMode?.rawValue ?? -1)-\(introPrice ?? 0)-\(subscriptionGroup ?? "")-\(normalDuration ?? "")-\ + \(introDuration ?? "")-\(introDurationType?.rawValue ?? -1) + """ + + guard let discounts = discounts else { + return key + } + + for offer in discounts { + key += "-\(offer.offerIdentifier ?? "null offer id")" + } + return key + } + +} + +extension ProductRequestData: Encodable { + + enum CodingKeys: String, CodingKey { + + case productIdentifier = "productId" + case paymentMode + case currencyCode = "currency" + case storeCountry + case price + case normalDuration + case introDuration + case trialDuration + case introPrice = "introductoryPrice" + case subscriptionGroup = "subscriptionGroupId" + case discounts = "offers" + + } + + // Note: prices are encoded price as `String` (using `NSDecimalNumber.description`) + // to preserve precision and avoid values like "1.89999999" + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(self.productIdentifier, forKey: .productIdentifier) + + try container.encodeIfPresent(self.paymentMode, forKey: .paymentMode) + try container.encode(self.currencyCode, forKey: .currencyCode) + try container.encode(self.storeCountry, forKey: .storeCountry) + try container.encode((self.price as NSDecimalNumber).description, forKey: .price) + try container.encodeIfPresent(self.subscriptionGroup, forKey: .subscriptionGroup) + try container.encodeIfPresent(self.discounts, forKey: .discounts) + + try container.encodeIfPresent((self.introPrice as NSDecimalNumber?)?.description, + forKey: .introPrice) + + try container.encodeIfPresent(self.normalDuration, forKey: .normalDuration) + + if let introDuration = self.introDuration { + switch self.introDurationType { + case .payUpFront: + try container.encode(introDuration, forKey: .introDuration) + + case .freeTrial: + try container.encode(introDuration, forKey: .trialDuration) + + default: break + } + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ProductsManager.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ProductsManager.swift new file mode 100644 index 00000000..d53c4eeb --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ProductsManager.swift @@ -0,0 +1,173 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ProductsManager.swift +// +// Created by Andrés Boedo on 7/14/20. +// + +import Foundation +import StoreKit + +// MARK: - + +/// Basic implemenation of a `ProductsManagerType` +class ProductsManager: NSObject, ProductsManagerType { + + private let productsFetcherSK1: ProductsFetcherSK1 + private let diagnosticsTracker: DiagnosticsTrackerType? + private let systemInfo: SystemInfo + private let dateProvider: DateProvider + + private let _productsFetcherSK2: (any Sendable)? + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + private var productsFetcherSK2: ProductsFetcherSK2 { + // swiftlint:disable:next force_cast force_unwrapping + return self._productsFetcherSK2! as! ProductsFetcherSK2 + } + + init( + productsRequestFactory: ProductsRequestFactory = ProductsRequestFactory(), + diagnosticsTracker: DiagnosticsTrackerType?, + systemInfo: SystemInfo, + requestTimeout: TimeInterval, + dateProvider: DateProvider = DateProvider() + ) { + self.productsFetcherSK1 = ProductsFetcherSK1(productsRequestFactory: productsRequestFactory, + requestTimeout: requestTimeout) + self.diagnosticsTracker = diagnosticsTracker + self.systemInfo = systemInfo + self.dateProvider = dateProvider + + if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { + self._productsFetcherSK2 = ProductsFetcherSK2() + } else { + self._productsFetcherSK2 = nil + } + } + + func products(withIdentifiers identifiers: Set, completion: @escaping Completion) { + let startTime = self.dateProvider.now() + if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *), + self.systemInfo.storeKitVersion.isStoreKit2EnabledAndAvailable { + self.sk2Products(withIdentifiers: identifiers) { result in + let notFoundProducts = identifiers.subtracting(result.value?.map(\.productIdentifier) ?? []) + self.trackProductsRequestIfNeeded(startTime, + requestedProductIds: identifiers, + notFoundProductIds: notFoundProducts, + storeKitVersion: .storeKit2, + error: result.error) + completion(result.map { Set($0.map(StoreProduct.from(product:))) }) + } + } else { + self.sk1Products(withIdentifiers: identifiers) { result in + let notFoundProducts = identifiers.subtracting(result.value?.map(\.productIdentifier) ?? []) + self.trackProductsRequestIfNeeded(startTime, + requestedProductIds: identifiers, + notFoundProductIds: notFoundProducts, + storeKitVersion: .storeKit1, + error: result.error) + completion(result.map { Set($0.map(StoreProduct.from(product:))) }) + } + } + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func sk2Products(withIdentifiers identifiers: Set, completion: @escaping SK2Completion) { + Async.call(with: completion) { + do { + let products = try await self.productsFetcherSK2.products(identifiers: identifiers) + + Logger.debug(Strings.storeKit.store_product_request_finished) + return Set(products) + } catch let error as NSError { + Logger.debug(Strings.storeKit.store_products_request_failed(error)) + throw ErrorUtils.storeProblemError(error: error) + } + } + } + + // This class does not implement caching. + // See `CachingProductsManager`. + func cache(_ product: StoreProductType) {} + func clearCache() { + self.productsFetcherSK1.clearCache() + } + + var requestTimeout: TimeInterval { + return self.productsFetcherSK1.requestTimeout + } + +} + +// MARK: - private + +private extension ProductsManager { + + func sk1Products(withIdentifiers identifiers: Set, + completion: @escaping (Result, PurchasesError>) -> Void) { + return self.productsFetcherSK1.products(withIdentifiers: identifiers, completion: completion) + } + + func trackProductsRequestIfNeeded(_ startTime: Date, + requestedProductIds: Set, + notFoundProductIds: Set, + storeKitVersion: StoreKitVersion, + error: PurchasesError?) { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *), + let diagnosticsTracker = self.diagnosticsTracker { + let responseTime = self.dateProvider.now().timeIntervalSince(startTime) + let errorMessage = (error?.userInfo[NSUnderlyingErrorKey] as? Error)?.localizedDescription + ?? error?.localizedDescription + let errorCode = error?.errorCode + let storeKitErrorDescription = StoreKitErrorUtils.extractStoreKitErrorDescription(from: error) + diagnosticsTracker.trackProductsRequest(wasSuccessful: error == nil, + storeKitVersion: storeKitVersion, + errorMessage: errorMessage, + errorCode: errorCode, + storeKitErrorDescription: storeKitErrorDescription, + storefront: self.systemInfo.storefront?.countryCode, + requestedProductIds: requestedProductIds, + notFoundProductIds: notFoundProductIds, + responseTime: responseTime) + } + } + +} + +// MARK: - ProductsManagerType async + +extension ProductsManagerType { + + /// `async` overload for `products(withIdentifiers:)` + func products(withIdentifiers identifiers: Set) async throws -> Set { + return try await Async.call { completion in + self.products(withIdentifiers: identifiers, completion: completion) + } + } + + /// `async` overload for `sk2Products(withIdentifiers:)` + /// + /// - Throws: `PurchasesError`. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func sk2Products(withIdentifiers identifiers: Set) async throws -> Set { + return try await Async.call { completion in + self.sk2Products(withIdentifiers: identifiers, completion: completion) + } + } + +} + +// MARK: - + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +// However it contains no mutable state, and its members are all `Sendable`. +extension ProductsManager: @unchecked Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ProductsManagerFactory.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ProductsManagerFactory.swift new file mode 100644 index 00000000..ec4a3073 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ProductsManagerFactory.swift @@ -0,0 +1,37 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ProductsManagerFactory.swift +// +// Created by Antonio Pallares on 25/7/25. + +import Foundation + +enum ProductsManagerFactory { + + // swiftlint:disable:next function_parameter_count + static func createManager(apiKeyValidationResult: Configuration.APIKeyValidationResult, + diagnosticsTracker: DiagnosticsTrackerType?, + systemInfo: SystemInfo, + backend: Backend, + deviceCache: DeviceCache, + requestTimeout: TimeInterval) -> ProductsManagerType { + if apiKeyValidationResult == .simulatedStore { + return SimulatedStoreProductsManager(backend: backend, + deviceCache: deviceCache, + requestTimeout: requestTimeout) + } else { + return ProductsManager(productsRequestFactory: ProductsRequestFactory(), + diagnosticsTracker: diagnosticsTracker, + systemInfo: systemInfo, + requestTimeout: requestTimeout) + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ProductsManagerType.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ProductsManagerType.swift new file mode 100644 index 00000000..822aa8c6 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ProductsManagerType.swift @@ -0,0 +1,43 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ProductsManagerType.swift +// +// Created by Antonio Pallares on 25/7/25. + +import Foundation + +/// Protocol for a type that can fetch and cache ``StoreProduct``s. +/// The basic interface only has a completion-blocked based API, but default `async` overloads are provided. +protocol ProductsManagerType: Sendable { + + typealias Completion = (Result, PurchasesError>) -> Void + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + typealias SK2Completion = (Result, PurchasesError>) -> Void + + /// Fetches the ``StoreProduct``s with the given identifiers + /// The returned products will be SK1 or SK2 backed depending on the implementation and configuration. + func products(withIdentifiers identifiers: Set, completion: @escaping Completion) + + /// Fetches the `SK2StoreProduct`s with the given identifiers. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func sk2Products(withIdentifiers identifiers: Set, completion: @escaping SK2Completion) + + /// Adds the products to the internal cache + /// If the type implementing this protocol doesn't have a caching mechanism then this method does nothing. + func cache(_ product: StoreProductType) + + /// Removes all elements from its internal cache + /// If the type implementing this protocol doesn't have a caching mechanism then this method does nothing. + func clearCache() + + var requestTimeout: TimeInterval { get } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ProductsRequestFactory.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ProductsRequestFactory.swift new file mode 100644 index 00000000..ee0a59d1 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ProductsRequestFactory.swift @@ -0,0 +1,26 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Created by Andrés Boedo on 8/12/20. +// + +import Foundation +import StoreKit + +class ProductsRequestFactory { + + func request(productIdentifiers: Set) -> SKProductsRequest { + return SKProductsRequest(productIdentifiers: productIdentifiers) + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension ProductsRequestFactory: @unchecked Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/PurchaseOwnershipType.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/PurchaseOwnershipType.swift new file mode 100644 index 00000000..21a841b3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/PurchaseOwnershipType.swift @@ -0,0 +1,44 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PurchaseOwnershipType.swift +// +// Created by Joshua Liebowitz on 6/24/21. +// + +import Foundation + +/// The types used to describe whether a transaction was purchased by the user, +/// or is available to them through Family Sharing. +@objc(RCPurchaseOwnershipType) public enum PurchaseOwnershipType: Int { + + /** + The purchase was made directly by this user. + */ + case purchased = 0 + /** + The purchase has been shared to this user by a family member. + */ + case familyShared = 1 + + /** + The ownership type could not be determined. + */ + case unknown = 2 + +} + +extension PurchaseOwnershipType: CaseIterable {} +extension PurchaseOwnershipType: Sendable {} + +extension PurchaseOwnershipType: DefaultValueProvider { + + static let defaultValue: Self = .purchased + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/Attribution.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/Attribution.swift new file mode 100644 index 00000000..0131a030 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/Attribution.swift @@ -0,0 +1,614 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Attribution.swift +// +// Created by Joshua Liebowitz on 6/8/22. + +import Foundation + +// swiftlint:disable file_length + +/** + * This class is responsible for all explicit attribution APIs as well as subscriber attributes that RevenueCat offers. + * The attributes are additional structured information on a user. Since attributes are writable using a public key + * they should not be used for managing secure or sensitive information such as subscription status, coins, etc. + * + * Key names starting with "$" are reserved names used by RevenueCat. For a full list of key restrictions refer + * [to our guide](https://docs.revenuecat.com/docs/subscriber-attributes) + */ +@objc(RCAttribution) public final class Attribution: NSObject { + + // internal for testing purposes + var automaticAdServicesAttributionTokenCollection: Bool = false + + private let subscriberAttributesManager: SubscriberAttributesManager + private let currentUserProvider: CurrentUserProvider + private let attributionPoster: AttributionPoster + private let systemInfo: SystemInfo + + private var appUserID: String { self.currentUserProvider.currentAppUserID } + + weak var delegate: AttributionDelegate? + + init(subscriberAttributesManager: SubscriberAttributesManager, + currentUserProvider: CurrentUserProvider, + attributionPoster: AttributionPoster, + systemInfo: SystemInfo) { + self.subscriberAttributesManager = subscriberAttributesManager + self.currentUserProvider = currentUserProvider + self.attributionPoster = attributionPoster + self.systemInfo = systemInfo + + super.init() + + self.subscriberAttributesManager.delegate = self + } + +} + +// should match OS availability in https://developer.apple.com/documentation/ad_services +@available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +public extension Attribution { + + /** + * Enable automatic collection of AdServices attribution token. + */ + @objc func enableAdServicesAttributionTokenCollection() { + self.automaticAdServicesAttributionTokenCollection = true + + self.postAdServicesTokenOncePerInstallIfNeeded() + } + + internal func postAdServicesTokenOncePerInstallIfNeeded() { + if self.automaticAdServicesAttributionTokenCollection, + self.automaticAdServicesTokenPostingEnabled { + self.attributionPoster.postAdServicesTokenOncePerInstallIfNeeded() + } + } + + private var automaticAdServicesTokenPostingEnabled: Bool { + /// In custom entitlements computation mode, ad services token is sent only through `PostReceiptOperation` + return !self.systemInfo.dangerousSettings.customEntitlementComputation + } + +} + +#if !CUSTOM_ENTITLEMENTS_COMPUTATION + +public extension Attribution { + + /** + * Automatically collect subscriber attributes associated with the device identifiers + * - `$idfa` + * - `$idfv` + * - `$ip` + */ + @objc func collectDeviceIdentifiers() { + self.subscriberAttributesManager.collectDeviceIdentifiers(forAppUserID: appUserID) + } + + /** + * Subscriber attributes are useful for storing additional, structured information on a user. + * Since attributes are writable using a public key they should not be used for + * managing secure or sensitive information such as subscription status, coins, etc. + * + * Key names starting with "$" are reserved names used by RevenueCat. For a full list of key + * restrictions refer [to our guide](https://docs.revenuecat.com/docs/subscriber-attributes) + * + * - Parameter attributes: Map of attributes by key. Set the value as an empty string to delete an attribute. + */ + @objc func setAttributes(_ attributes: [String: String]) { + self.subscriberAttributesManager.setAttributes(attributes, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the email address for the user. + * + * #### Related Articles + * - [Subscriber attributes](https://docs.revenuecat.com/docs/subscriber-attributes) + * + * - Parameter email: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setEmail(_ email: String?) { + self.subscriberAttributesManager.setEmail(email, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the phone number for the user. + * + * #### Related Articles + * - [Subscriber attributes](https://docs.revenuecat.com/docs/subscriber-attributes) + * + * - Parameter phoneNumber: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setPhoneNumber(_ phoneNumber: String?) { + self.subscriberAttributesManager.setPhoneNumber(phoneNumber, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the display name for the user. + * + * #### Related Articles + * - [Subscriber attributes](https://docs.revenuecat.com/docs/subscriber-attributes) + * + * - Parameter displayName: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setDisplayName(_ displayName: String?) { + self.subscriberAttributesManager.setDisplayName(displayName, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the push token for the user. + * + * #### Related Articles + * - [Subscriber attributes](https://docs.revenuecat.com/docs/subscriber-attributes) + * + * - Parameter pushToken: `nil` will delete the subscriber attribute. + * + * #### Related Symbols + * - ``Attribution/setPushTokenString(_:)`` + */ + @objc func setPushToken(_ pushToken: Data?) { + self.subscriberAttributesManager.setPushToken(pushToken, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the push token for the user. + * + * #### Related Articles + * - [Subscriber attributes](https://docs.revenuecat.com/docs/subscriber-attributes) + * + * - Parameter pushToken: `nil` will delete the subscriber attribute. + * + * #### Related Symbols + * - ``Attribution/setPushToken(_:)`` + */ + @objc func setPushTokenString(_ pushToken: String?) { + self.subscriberAttributesManager.setPushTokenString(pushToken, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the Adjust Id for the user. + * Required for the RevenueCat Adjust integration. + * + * #### Related Articles + * - [Adjust RevenueCat Integration](https://docs.revenuecat.com/docs/adjust) + * + *- Parameter adjustID: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setAdjustID(_ adjustID: String?) { + self.subscriberAttributesManager.setAdjustID(adjustID, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the Appsflyer Id for the user. + * Required for the RevenueCat Appsflyer integration. + * + * #### Related Articles + * - [AppsFlyer RevenueCat Integration](https://docs.revenuecat.com/docs/appsflyer) + * + *- Parameter appsflyerID: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setAppsflyerID(_ appsflyerID: String?) { + self.subscriberAttributesManager.setAppsflyerID(appsflyerID, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the Facebook SDK Anonymous Id for the user. + * Recommended for the RevenueCat Facebook integration. + * + * #### Related Articles + * - [Facebook Ads RevenueCat Integration](https://docs.revenuecat.com/docs/facebook-ads) + * + *- Parameter fbAnonymousID: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setFBAnonymousID(_ fbAnonymousID: String?) { + self.subscriberAttributesManager.setFBAnonymousID(fbAnonymousID, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the mParticle Id for the user. + * Recommended for the RevenueCat mParticle integration. + * + * #### Related Articles + * - [mParticle RevenueCat Integration](https://docs.revenuecat.com/docs/mparticle) + * + *- Parameter mparticleID: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setMparticleID(_ mparticleID: String?) { + self.subscriberAttributesManager.setMparticleID(mparticleID, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the OneSignal Player ID for the user. + * Required for the RevenueCat OneSignal integration. Deprecated for OneSignal versions above v9.0. + * + * #### Related Articles + * - [OneSignal RevenueCat Integration](https://docs.revenuecat.com/docs/onesignal) + * + *- Parameter onesignalID: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setOnesignalID(_ onesignalID: String?) { + self.subscriberAttributesManager.setOnesignalID(onesignalID, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the OneSignal User ID for the user. + * Required for the RevenueCat OneSignal integration with versions v11.0 and above. + * + * #### Related Articles + * - [OneSignal RevenueCat Integration](https://docs.revenuecat.com/docs/onesignal) + * + *- Parameter onesignalUserID: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setOnesignalUserID(_ onesignalUserID: String?) { + self.subscriberAttributesManager.setOnesignalUserID(onesignalUserID, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the Airship Channel ID for the user. + * Required for the RevenueCat Airship integration. + * + * #### Related Articles + * - [AirShip RevenueCat Integration](https://docs.revenuecat.com/docs/airship) + * + *- Parameter airshipChannelID: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setAirshipChannelID(_ airshipChannelID: String?) { + self.subscriberAttributesManager.setAirshipChannelID(airshipChannelID, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the CleverTap ID for the user. + * Required for the RevenueCat CleverTap integration. + * + * #### Related Articles + * - [CleverTap RevenueCat Integration](https://docs.revenuecat.com/docs/clevertap) + * + *- Parameter cleverTapID: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setCleverTapID(_ cleverTapID: String?) { + self.subscriberAttributesManager.setCleverTapID(cleverTapID, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the Airbridge Device ID for the user. + * Recommended for the RevenueCat Airbridge integration. + * + * #### Related Articles + * - [Airbridge RevenueCat Integration](https://docs.revenuecat.com/docs/airbridge) + * + * - Parameter airbridgeDeviceID: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setAirbridgeDeviceID(_ airbridgeDeviceID: String?) { + self.subscriberAttributesManager.setAirbridgeDeviceID(airbridgeDeviceID, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the Kochava Device ID for the user. + * Recommended for the RevenueCat Kochava integration. + * + * #### Related Articles + * - [Kochava RevenueCat Integration](https://docs.revenuecat.com/docs/kochava) + * + * - Parameter kochavaDeviceID: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setKochavaDeviceID(_ kochavaDeviceID: String?) { + self.subscriberAttributesManager.setKochavaDeviceID(kochavaDeviceID, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the Solar Engine Distinct ID for the user. + * Recommended for the RevenueCat Solar Engine integration. + * + * - Parameter solarEngineDistinctId: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setSolarEngineDistinctId(_ solarEngineDistinctId: String?) { + self.subscriberAttributesManager.setSolarEngineDistinctId(solarEngineDistinctId, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the Solar Engine Account ID for the user. + * Recommended for the RevenueCat Solar Engine integration. + * + * - Parameter solarEngineAccountId: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setSolarEngineAccountId(_ solarEngineAccountId: String?) { + self.subscriberAttributesManager.setSolarEngineAccountId(solarEngineAccountId, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the Solar Engine Visitor ID for the user. + * Recommended for the RevenueCat Solar Engine integration. + * + * - Parameter solarEngineVisitorId: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setSolarEngineVisitorId(_ solarEngineVisitorId: String?) { + self.subscriberAttributesManager.setSolarEngineVisitorId(solarEngineVisitorId, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the Mixpanel Distinct ID for the user. + * Optional for the RevenueCat Mixpanel integration. + * + * #### Related Articles + * - [Mixpanel RevenueCat Integration](https://docs.revenuecat.com/docs/mixpanel) + * + *- Parameter mixpanelDistinctID: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setMixpanelDistinctID(_ mixpanelDistinctID: String?) { + self.subscriberAttributesManager.setMixpanelDistinctID(mixpanelDistinctID, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the Firebase App Instance ID for the user. + * Required for the RevenueCat Firebase integration. + * + * #### Related Articles + * - [Firebase RevenueCat Integration](https://docs.revenuecat.com/docs/firebase-integration) + * + *- Parameter firebaseAppInstanceID: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setFirebaseAppInstanceID(_ firebaseAppInstanceID: String?) { + self.subscriberAttributesManager.setFirebaseAppInstanceID(firebaseAppInstanceID, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the Tenjin analytics installation ID for the user. + * Required for the RevenueCat Tenjin integration. + * + *- Parameter firebaseAppInstanceID: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setTenjinAnalyticsInstallationID(_ tenjinAnalyticsInstallationID: String?) { + self.subscriberAttributesManager.setTenjinAnalyticsInstallationID(tenjinAnalyticsInstallationID, + appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the PostHog User ID for the user. + * Optional for the RevenueCat PostHog integration. + * + *- Parameter postHogUserID: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setPostHogUserID(_ postHogUserID: String?) { + self.subscriberAttributesManager.setPostHogUserID(postHogUserID, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the Amplitude User ID for the user. + * Optional for the RevenueCat Amplitude integration. + * + * #### Related Articles + * - [Amplitude RevenueCat Integration](https://www.revenuecat.com/docs/amplitude) + * + *- Parameter amplitudeUserID: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setAmplitudeUserID(_ amplitudeUserID: String?) { + self.subscriberAttributesManager.setAmplitudeUserID(amplitudeUserID, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the Amplitude Device ID for the user. + * Optional for the RevenueCat Amplitude integration. + * + * #### Related Articles + * - [Amplitude RevenueCat Integration](https://www.revenuecat.com/docs/amplitude) + * + *- Parameter amplitudeDeviceID: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setAmplitudeDeviceID(_ amplitudeDeviceID: String?) { + self.subscriberAttributesManager.setAmplitudeDeviceID(amplitudeDeviceID, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the install media source for the user. + * + * #### Related Articles + * - [Subscriber attributes](https://docs.revenuecat.com/docs/subscriber-attributes) + * + * - Parameter mediaSource: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setMediaSource(_ mediaSource: String?) { + self.subscriberAttributesManager.setMediaSource(mediaSource, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the install campaign for the user. + * + * #### Related Articles + * - [Subscriber attributes](https://docs.revenuecat.com/docs/subscriber-attributes) + * + * - Parameter campaign: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setCampaign(_ campaign: String?) { + self.subscriberAttributesManager.setCampaign(campaign, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the install ad group for the user + * + * #### Related Articles + * - [Subscriber attributes](https://docs.revenuecat.com/docs/subscriber-attributes) + * + * - Parameter adGroup: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setAdGroup(_ adGroup: String?) { + self.subscriberAttributesManager.setAdGroup(adGroup, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the install ad for the user + * + * #### Related Articles + * - [Subscriber attributes](https://docs.revenuecat.com/docs/subscriber-attributes) + * + * - Parameter installAd: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setAd(_ installAd: String?) { + self.subscriberAttributesManager.setAd(installAd, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the install keyword for the user + * + * #### Related Articles + * - [Subscriber attributes](https://docs.revenuecat.com/docs/subscriber-attributes) + * + * - Parameter keyword: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setKeyword(_ keyword: String?) { + self.subscriberAttributesManager.setKeyword(keyword, appUserID: appUserID) + } + + /** + * Subscriber attribute associated with the install ad creative for the user. + * + * #### Related Articles + * - [Subscriber attributes](https://docs.revenuecat.com/docs/subscriber-attributes) + * + * - Parameter creative: Empty String or `nil` will delete the subscriber attribute. + */ + @objc func setCreative(_ creative: String?) { + self.subscriberAttributesManager.setCreative(creative, appUserID: appUserID) + } + + /** + * Sets conversion data from AppsFlyer's `onConversionDataSuccess` callback. + * + * This method extracts relevant attribution fields from the AppsFlyer conversion data + * and sets the corresponding RevenueCat subscriber attributes. Note that this method will + * never unset any attributes, even when passed `nil`. To unset attributes, call the setter + * method for the individual attribute that should be unset with a `nil` value. + * + * The following attributes are set based on the conversion data: + * - `$mediaSource`: From `media_source`, or "Organic" if `af_status` is "Organic" + * - `$campaign`: From `campaign` + * - `$adGroup`: From `adgroup`, with fallback to `adset` + * - `$ad`: From `af_ad`, with fallback to `ad_id` + * - `$keyword`: From `af_keywords`, with fallback to `keyword` + * - `$creative`: From `creative`, with fallback to `af_creative` + * + * #### Related Articles + * - [AppsFlyer RevenueCat Integration](https://docs.revenuecat.com/docs/appsflyer) + * - [AppsFlyer Conversion Data](https://dev.appsflyer.com/hc/docs/conversion-data-ios) + * + * - Parameter data: The conversion data dictionary from AppsFlyer's `onConversionDataSuccess`. + */ + @objc func setAppsFlyerConversionData(_ data: [AnyHashable: Any]?) { + self.subscriberAttributesManager.setAppsFlyerConversionData(data, appUserID: appUserID) + } + +} + +#endif + +// @unchecked because: +// - It contains mutable state (`weak var delegate`). +extension Attribution: @unchecked Sendable {} + +extension Attribution: SubscriberAttributesManagerDelegate { + + func subscriberAttributesManager( + _ manager: SubscriberAttributesManager, + didFinishSyncingAttributes attributes: SubscriberAttribute.Dictionary, + forUserID userID: String + ) { + self.delegate?.attribution(didFinishSyncingAttributes: attributes, forUserID: userID) + } + +} + +extension Attribution { + + /// - Parameter syncedAttribute: will be called for every attribute that is updated + /// - Parameter completion: will be called once all attributes have completed syncing + /// - Returns: the number of attributes that will be synced + @discardableResult + func syncSubscriberAttributes( + syncedAttribute: (@Sendable (PurchasesError?) -> Void)? = nil, + completion: (@Sendable () -> Void)? = nil + ) -> Int { + return self.subscriberAttributesManager.syncAttributesForAllUsers(currentAppUserID: self.appUserID, + syncedAttribute: syncedAttribute, + completion: completion) + } + + func unsyncedAttributesByKey(appUserID: String) -> SubscriberAttribute.Dictionary { + self.subscriberAttributesManager.unsyncedAttributesByKey(appUserID: appUserID) + } + + var unsyncedAdServicesToken: String? { + get async { + return self.automaticAdServicesAttributionTokenCollection + ? await self.attributionPoster.adServicesTokenToPostIfNeeded + : nil + } + } + + func unsyncedAdServicesToken(_ completion: @escaping (String?) -> Void) { + guard self.automaticAdServicesAttributionTokenCollection else { + completion(nil) + return + } + + Async.call(with: completion) { + await self.attributionPoster.adServicesTokenToPostIfNeeded + } + } + + @discardableResult + func syncAttributesForAllUsers(currentAppUserID: String, + syncedAttribute: (@Sendable (PurchasesError?) -> Void)? = nil, + completion: (@Sendable () -> Void)? = nil) -> Int { + self.subscriberAttributesManager.syncAttributesForAllUsers(currentAppUserID: currentAppUserID, + syncedAttribute: syncedAttribute, + completion: completion) + } + + func markAttributesAsSynced(_ attributesToSync: SubscriberAttribute.Dictionary?, appUserID: String) { + self.subscriberAttributesManager.markAttributesAsSynced(attributesToSync, appUserID: appUserID) + } + + func markAdServicesTokenAsSynced(_ token: String, appUserID: String) { + self.attributionPoster.markAdServicesToken(token, asSyncedFor: appUserID) + } + + func markSyncedIfNeeded( + subscriberAttributes: SubscriberAttribute.Dictionary?, + adServicesToken: String?, + appUserID: String, + error: BackendError? + ) { + if let error = error { + guard error.successfullySynced else { return } + + if let attributeErrors = (error as NSError).subscriberAttributesErrors, !attributeErrors.isEmpty { + Logger.error(Strings.attribution.subscriber_attributes_error( + errors: attributeErrors + )) + } + } + + self.markAttributesAsSynced(subscriberAttributes, appUserID: appUserID) + if let adServicesToken = adServicesToken { + self.markAdServicesTokenAsSynced(adServicesToken, appUserID: appUserID) + } + } + +} + +protocol AttributionDelegate: AnyObject, Sendable { + + func attribution(didFinishSyncingAttributes attributes: SubscriberAttribute.Dictionary, + forUserID userID: String) + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/LocalTransactionMetadata.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/LocalTransactionMetadata.swift new file mode 100644 index 00000000..ba11b125 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/LocalTransactionMetadata.swift @@ -0,0 +1,231 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// LocalTransactionMetadata.swift +// +// Created by Antonio Pallares on 8/1/26. + +import Foundation + +/* + Contains ephemeral data associated with a purchase that may be lost during retry attempts. + This data will be cached before posting receipts and cleared upon a successful post attempt. + */ +internal struct LocalTransactionMetadata: Codable, Sendable { + + /// The identifier of the transaction this metadata is associated with. + let transactionId: String + + private let productDataWrapper: ProductRequestDataEncodedWrapper? + + /// Product request data (product info, pricing, discounts, etc.). + var productData: ProductRequestData? { + return productDataWrapper?.productRequestData + } + + private let purchasedTransactionDataWrapper: PurchasedTransactionDataEncodedWrapper + + /// Entity containing metadata about the purchase. + var transactionData: PurchasedTransactionData { + return self.purchasedTransactionDataWrapper.purchasedTransactionData + } + /// The receipt at the time of the transaction. + let encodedAppleReceipt: EncodedAppleReceipt + + /// The value of ``Purchases.purchasesAreCompletedBy`` at the time of the transaction. + let originalPurchasesAreCompletedBy: PurchasesAreCompletedBy + + /// Indicates whether this purchase was initiated by the SDK. + /// - `true` when the purchase was initiated via any of the SDK's `purchase()` methods. + /// - `false` when the purchase was detected in the SK1/SK2 queue but was triggered outside the SDK. + let sdkOriginated: Bool + + init( + transactionId: String, + productData: ProductRequestData?, + transactionData: PurchasedTransactionData, + encodedAppleReceipt: EncodedAppleReceipt, + originalPurchasesAreCompletedBy: PurchasesAreCompletedBy, + sdkOriginated: Bool + ) { + self.transactionId = transactionId + self.productDataWrapper = productData.map(ProductRequestDataEncodedWrapper.init) + self.purchasedTransactionDataWrapper = PurchasedTransactionDataEncodedWrapper( + purchasedTransactionData: transactionData + ) + self.encodedAppleReceipt = encodedAppleReceipt + self.originalPurchasesAreCompletedBy = originalPurchasesAreCompletedBy + self.sdkOriginated = sdkOriginated + } + +} + +// MARK: - Codable wrappers + +// Some existing types are not trivial to make `Codable`, or they are public and/or their existing `Encodable` +// implementation is not suitable for a lossless decoding/encoding. +// These wrappers allow us to encode/decode them without modifying their existing implementations. + +private struct ProductRequestDataEncodedWrapper: Sendable, Codable { + + // We persist every `ProductRequestData` stored property, so encoding/decoding is lossless. + // Note: we intentionally do not rely on `ProductRequestData: Encodable` because that `Encodable` implementation + // is destined for sending data to the backend, and not all information is encoded. Decoding it would result in a + // `ProductRequestData` missing some information from the original one. + + private let productIdentifier: String + private let paymentModeRawValue: Int? + private let currencyCode: String? + private let storeCountry: String? + private let priceString: String + private let normalDuration: String? + private let introDuration: String? + private let introDurationTypeRawValue: Int? + private let introPriceString: String? + private let subscriptionGroup: String? + private let discounts: [StoreProductDiscountEncodedWrapper]? + + init(productRequestData: ProductRequestData) { + self.productIdentifier = productRequestData.productIdentifier + self.paymentModeRawValue = productRequestData.paymentMode?.rawValue + self.currencyCode = productRequestData.currencyCode + self.storeCountry = productRequestData.storeCountry + self.priceString = Self.encodeDecimal(productRequestData.price) + self.normalDuration = productRequestData.normalDuration + self.introDuration = productRequestData.introDuration + self.introDurationTypeRawValue = productRequestData.introDurationType?.rawValue + self.introPriceString = productRequestData.introPrice.map(Self.encodeDecimal(_:)) + self.subscriptionGroup = productRequestData.subscriptionGroup + self.discounts = productRequestData.discounts?.map(StoreProductDiscountEncodedWrapper.init(discount:)) + } + + var productRequestData: ProductRequestData { + return .init( + productIdentifier: self.productIdentifier, + paymentMode: self.paymentModeRawValue.flatMap(StoreProductDiscount.PaymentMode.init(rawValue:)), + currencyCode: self.currencyCode, + storeCountry: self.storeCountry, + price: Self.decodeDecimal(from: self.priceString) ?? 0, + normalDuration: self.normalDuration, + introDuration: self.introDuration, + introDurationType: self.introDurationTypeRawValue.flatMap(StoreProductDiscount.PaymentMode.init(rawValue:)), + introPrice: self.introPriceString.flatMap(Self.decodeDecimal(from:)), + subscriptionGroup: self.subscriptionGroup, + discounts: self.discounts?.map({ $0.discount }) + ) + } + + // Encode decimals as strings to preserve exact precision. + private static func encodeDecimal(_ decimal: Decimal) -> String { + return (decimal as NSDecimalNumber).description + } + + private static func decodeDecimal(from string: String) -> Decimal? { + return Decimal(string: string) + } +} + +private struct PurchasedTransactionDataEncodedWrapper: Codable { + private let presentedPaywall: PaywallEvent? + private let unsyncedAttributes: SubscriberAttribute.Dictionary? + private let metadata: [String: String]? + private let aadAttributionToken: String? + private let storeCountry: String? + + // Raw properties of PresentedOfferingContext, to avoid making it `Codable` because it's public + private let offeringIdentifier: String? + private let placementIdentifier: String? + private let targetingContextRevision: Int? + private let targetingContextRuleId: String? + + init(purchasedTransactionData: PurchasedTransactionData) { + self.presentedPaywall = purchasedTransactionData.presentedPaywall + self.unsyncedAttributes = purchasedTransactionData.unsyncedAttributes + self.metadata = purchasedTransactionData.metadata + self.aadAttributionToken = purchasedTransactionData.aadAttributionToken + self.storeCountry = purchasedTransactionData.storeCountry + self.offeringIdentifier = purchasedTransactionData.presentedOfferingContext?.offeringIdentifier + self.placementIdentifier = purchasedTransactionData.presentedOfferingContext?.placementIdentifier + self.targetingContextRevision = purchasedTransactionData.presentedOfferingContext?.targetingContext?.revision + self.targetingContextRuleId = purchasedTransactionData.presentedOfferingContext?.targetingContext?.ruleId + } + + var purchasedTransactionData: PurchasedTransactionData { + return PurchasedTransactionData( + presentedOfferingContext: self.presentedOfferingContext, + presentedPaywall: self.presentedPaywall, + unsyncedAttributes: self.unsyncedAttributes, + metadata: self.metadata, + aadAttributionToken: self.aadAttributionToken, + storeCountry: self.storeCountry + ) + } + + private var presentedOfferingContext: PresentedOfferingContext? { + guard let offeringIdentifier = self.offeringIdentifier else { + return nil + } + let targetingContext: PresentedOfferingContext.TargetingContext? = { + if let revision = self.targetingContextRevision, + let ruleId = self.targetingContextRuleId { + return .init(revision: revision, ruleId: ruleId) + } else { + return nil + } + }() + + return PresentedOfferingContext( + offeringIdentifier: offeringIdentifier, + placementIdentifier: self.placementIdentifier, + targetingContext: targetingContext + ) + } +} + +/// Wrapper around `StoreProductDiscountType` to make it `Codable`. +private struct StoreProductDiscountEncodedWrapper: StoreProductDiscountType, Codable { + let offerIdentifier: String? + let currencyCode: String? + let localizedPriceString: String + let paymentMode: StoreProductDiscount.PaymentMode + let subscriptionPeriod: SubscriptionPeriod + let numberOfPeriods: Int + let type: StoreProductDiscount.DiscountType + + // Note: price is encoded as `String` (using `NSDecimalNumber.description`) + // to preserve precision and avoid values like "1.89999999" + private let priceString: String + + var price: Decimal { + return Self.decodeDecimal(from: self.priceString) ?? 0 + } + + init(discount: StoreProductDiscount) { + self.offerIdentifier = discount.offerIdentifier + self.currencyCode = discount.currencyCode + self.priceString = Self.encodeDecimal(discount.price) + self.localizedPriceString = discount.localizedPriceString + self.paymentMode = discount.paymentMode + self.subscriptionPeriod = discount.subscriptionPeriod + self.numberOfPeriods = discount.numberOfPeriods + self.type = discount.type + } + + var discount: StoreProductDiscount { + return StoreProductDiscount(self) + } + + private static func encodeDecimal(_ decimal: Decimal) -> String { + return (decimal as NSDecimalNumber).description + } + + private static func decodeDecimal(from string: String) -> Decimal? { + return Decimal(string: string) + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/LocalTransactionMetadataStore.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/LocalTransactionMetadataStore.swift new file mode 100644 index 00000000..10f3177d --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/LocalTransactionMetadataStore.swift @@ -0,0 +1,119 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// LocalTransactionMetadataStore.swift +// +// Created by Antonio Pallares on 15/12/25. +// + +import Foundation + +/// Protocol for storing and retrieving local transaction metadata. +protocol LocalTransactionMetadataStoreType: Sendable { + + /// Store transaction metadata for a given transaction ID. + func storeMetadata(_ metadata: LocalTransactionMetadata, forTransactionId transactionId: String) + + /// Retrieve transaction metadata for a given transaction ID. + func getMetadata(forTransactionId transactionId: String) -> LocalTransactionMetadata? + + /// Remove transaction metadata for a given transaction ID. + func removeMetadata(forTransactionId transactionId: String) + + /// Retrieve all stored transaction metadata. + func getAllStoredMetadata() -> [LocalTransactionMetadata] +} + +/// Cache for storing local transaction metadata persistently on disk. +final class LocalTransactionMetadataStore: LocalTransactionMetadataStoreType { + + private static let storeKeyPrefix = "local_transaction_metadata_" + + private let cache: SynchronizedLargeItemCache + + init( + apiKey: String, + fileManager: LargeItemCacheType = FileManager.default, + applicationSupportDirectory: URL? = nil + ) { + self.cache = SynchronizedLargeItemCache( + cache: fileManager, + basePath: "local-transaction-metadata-\(apiKey)", + directoryType: .applicationSupport(overrideURL: applicationSupportDirectory) + ) + } + + /// Store transaction metadata for a given transaction ID. + func storeMetadata(_ metadata: LocalTransactionMetadata, forTransactionId transactionId: String) { + guard self.getMetadata(forTransactionId: transactionId) == nil else { + Logger.debug( + TransactionMetadataStrings.metadata_already_exists_for_transaction( + transactionId: transactionId + ) + ) + return + } + + let key = self.getStoreKey(for: transactionId) + + self.cache.set(codable: metadata, forKey: key) + } + + /// Retrieve transaction metadata for a given transaction ID. + func getMetadata(forTransactionId transactionId: String) -> LocalTransactionMetadata? { + let key = self.getStoreKey(for: transactionId) + do { + return try self.cache.value(forKey: key) + } catch { + Logger.error("Error loading transaction metadata from cache: \(error.localizedDescription)") + self.cache.removeObject(forKey: key) + return nil + } + } + + /// Remove transaction metadata for a given transaction ID. + func removeMetadata(forTransactionId transactionId: String) { + guard self.getMetadata(forTransactionId: transactionId) != nil else { + Logger.debug( + TransactionMetadataStrings.metadata_not_found_to_clear_for_transaction( + transactionId: transactionId + ) + ) + return + } + + let key = self.getStoreKey(for: transactionId) + self.cache.removeObject(forKey: key) + } + + /// Retrieve all stored transaction metadata. + func getAllStoredMetadata() -> [LocalTransactionMetadata] { + let keys = self.cache.allKeys() + return keys.compactMap { key -> LocalTransactionMetadata? in + do { + return try self.cache.value(forKey: key) + } catch { + Logger.error("Error loading transaction metadata from cache: \(error.localizedDescription)") + self.cache.removeObject(forKey: key) + return nil + } + } + } + + // MARK: - Private helper methods + + private func getStoreKey(for identifier: String) -> String { + return Self.storeKeyPrefix + identifier.asData.sha1String + } + + private struct CacheKey: DeviceCacheKeyType { + let rawValue: String + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/PurchaseParams.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/PurchaseParams.swift new file mode 100644 index 00000000..4cfdce7f --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/PurchaseParams.swift @@ -0,0 +1,203 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PurchaseParams.swift +// +// Created by mark on 17/10/24. + +import Foundation + +/** + * ``PurchaseParams`` can be used to add configuration options when making a purchase. + * This class follows the builder pattern. + * + * Example making a purchase using ``PurchaseParams``: + * + * ```swift + * let params = PurchaseParams.Builder(package: package) + * .with(metadata: ["key": "value"]) + * .with(promotionalOffer: promotionalOffer) + * .build() + * Purchases.shared.purchase(params) + * ``` + */ +@objc(RCPurchaseParams) public final class PurchaseParams: NSObject, Sendable { + + let package: Package? + let product: StoreProduct? + let promotionalOffer: PromotionalOffer? + let quantity: Int? + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + let winBackOffer: WinBackOffer? + let metadata: [String: String]? + + #endif + + #if ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + let introductoryOfferEligibilityJWS: String? + let promotionalOfferOptions: StoreKit2PromotionalOfferPurchaseOptions? + + #endif + + private init(with builder: Builder) { + self.promotionalOffer = builder.promotionalOffer + self.product = builder.product + self.package = builder.package + self.quantity = builder.quantity + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + self.winBackOffer = builder.winBackOffer + self.metadata = builder.metadata + + #endif + + #if ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + self.introductoryOfferEligibilityJWS = builder.introductoryOfferEligibilityJWS + self.promotionalOfferOptions = builder.promotionalOfferOptions + #endif + } + + /// The Builder for ```PurchaseParams```. + @objc(RCPurchaseParamsBuilder) public class Builder: NSObject { + private(set) var promotionalOffer: PromotionalOffer? + private(set) var package: Package? + private(set) var product: StoreProduct? + private(set) var quantity: Int? + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + private(set) var winBackOffer: WinBackOffer? + private(set) var metadata: [String: String]? + + #endif + + #if ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + private(set) var introductoryOfferEligibilityJWS: String? + private(set) var promotionalOfferOptions: StoreKit2PromotionalOfferPurchaseOptions? + + #endif + + /** + * Create a new builder with a ``Package``. + * + * - Parameter package: The ``Package`` the user intends to purchase. + */ + @objc public init(package: Package) { + self.package = package + } + + /** + * Create a new builder with a ``StoreProduct``. + * + * Use this initializer if you are not using the ``Offerings`` system to purchase a ``StoreProduct``. + * If you are using the ``Offerings`` system, use ``PurchaseParams/Builder/init(package:)`` instead. + * + * - Parameter product: The ``StoreProduct`` the user intends to purchase. + */ + @objc public init(product: StoreProduct) { + self.product = product + } + + /** + * Set `promotionalOffer`. + * - Parameter promotionalOffer: The ``PromotionalOffer`` to apply to the purchase. + */ + @objc public func with(promotionalOffer: PromotionalOffer) -> Self { + self.promotionalOffer = promotionalOffer + return self + } + + /** + * Set `quantity`. + * - Parameter quantity: The number of items to purchase. Must be between 1 and 10 (inclusive). + * If not specified, StoreKit will use its default quantity (typically 1). + * - Throws: ``ErrorCode/purchaseInvalidError`` if quantity is less than 1 or greater than 10. + */ + @objc public func with(quantity: Int) -> Self { + self.quantity = quantity + return self + } + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + #if ENABLE_TRANSACTION_METADATA + /** + * Set `metadata`. + * - Parameter metadata: Key-value pairs of metadata to attatch to the purchase. + */ + @objc public func with(metadata: [String: String]) -> Self { + self.metadata = metadata + return self + } + #endif + + /** + * Sets a win-back offer for the purchase. + * - Parameter winBackOffer: The ``WinBackOffer`` to apply to the purchase. + * + * Fetch a winBackOffer to use with this function with ``Purchases/eligibleWinBackOffers(forProduct:)`` + * or ``Purchases/eligibleWinBackOffers(forProduct:completion)``. + * + * Availability: iOS 18.0+, macOS 15.0+, tvOS 18.0+, watchOS 11.0+, visionOS 2.0+ + */ + @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) + @objc public func with(winBackOffer: WinBackOffer) -> Self { + self.winBackOffer = winBackOffer + return self + } + + #endif + + #if ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + // swiftlint:disable line_length + /** + * Sets an introductoryOfferEligibility JWS to be included with the purchase. StoreKit 2 only. + * - Parameter introductoryOfferEligibilityJWS: The ``introductoryOfferEligibilityJWS`` to apply to the purchase. + * + * Refer to https://developer.apple.com/documentation/storekit/product/purchaseoption/introductoryoffereligibility(compactjws:) + * for more information. + * + * Availability: iOS 15.0+, macOS 15.4+, tvOS 18.4+, watchOS 11.4+, visionOS 2.4+ + */ + @available(iOS 15.0, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) + @objc public func with(introductoryOfferEligibilityJWS: String) -> Self { + self.introductoryOfferEligibilityJWS = introductoryOfferEligibilityJWS + return self + } + + // swiftlint:disable line_length + /** + * Sets a promotionalOfferOptions to be included with the purchase. StoreKit 2 only. + * - Parameter promotionalOfferOptions: The ``promotionalOfferOptions`` to apply to the purchase. + * + * Refer to https://developer.apple.com/documentation/storekit/product/purchaseoption/promotionaloffer(_:compactjws:) + * for more information. + * + * Availability: iOS 15.0+, macOS 26.0+, tvOS 26.0+, watchOS 26.0+, visionOS 26.0+ + */ + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + @objc public func with(promotionalOfferOptions: StoreKit2PromotionalOfferPurchaseOptions) -> Self { + self.promotionalOfferOptions = promotionalOfferOptions + return self + } + + #endif + + /// Generate a ``Configuration`` object given the values configured by this builder. + @objc public func build() -> PurchaseParams { + return PurchaseParams(with: self) + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/Purchases.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/Purchases.swift new file mode 100644 index 00000000..526f726e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/Purchases.swift @@ -0,0 +1,2517 @@ +// +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Purchases.swift +// +// Created by Joshua Liebowitz on 8/18/21. +// + +// swiftlint:disable file_length type_body_length + +// Docs are inherited from `PurchasesType` and `PurchasesSwiftType`: +// swiftlint:disable missing_docs + +import Foundation +import StoreKit + +// MARK: Block definitions + +/** + Result for ``Purchases/purchase(product:)``. + Counterpart of `PurchaseCompletedBlock` for `async` APIs. + Note that `transaction` will be `nil` when ``Purchases/purchasesAreCompletedBy`` + is ``PurchasesAreCompletedBy/myApp`` + */ +public typealias PurchaseResultData = (transaction: StoreTransaction?, + customerInfo: CustomerInfo, + userCancelled: Bool) + +/** + Completion block for ``Purchases/purchase(product:completion:)`` + */ +public typealias PurchaseCompletedBlock = @MainActor @Sendable (StoreTransaction?, + CustomerInfo?, + PublicError?, + Bool) -> Void + +/** + Completion block for ``Purchases/getStorefront(completion:)`` + */ +public typealias GetStorefrontBlock = @MainActor @Sendable (Storefront?) -> Void + +/** + Block for starting purchases in ``PurchasesDelegate/purchases(_:readyForPromotedProduct:purchase:)`` + */ +public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void + +/** + * ``Purchases`` is the entry point for RevenueCat.framework. It should be instantiated as soon as your app has a unique + * user id for your user. This can be when a user logs in if you have accounts or on launch if you can generate a random + * user identifier. + * - Warning: Only one instance of Purchases should be instantiated at a time! Use a configure method to let the + * framework handle the singleton instance for you. + */ +@objc(RCPurchases) public final class Purchases: NSObject, PurchasesType, PurchasesSwiftType { + + /// Returns the already configured instance of ``Purchases``. + /// - Warning: this method will crash with `fatalError` if ``Purchases`` has not been initialized through + /// ``Purchases/configure(withAPIKey:)`` or one of its overloads. + /// If there's a chance that may have not happened yet, you can use ``isConfigured`` to check if it's safe to call. + /// + /// ### Related symbols + /// - ``isConfigured`` + @objc(sharedPurchases) + public static var shared: Purchases { + guard let purchases = Self.purchases.value else { + fatalError(Strings.purchase.purchases_nil.description) + } + + return purchases + } + + private static let purchases: Atomic = nil + + /// Returns `true` if RevenueCat has already been initialized through ``Purchases/configure(withAPIKey:)`` + /// or one of is overloads. + @objc public static var isConfigured: Bool { Self.purchases.value != nil } + + /** + * The delegate for ``Purchases`` responsible for handling updating your app's state in response to updated + * customer info or promotional product purchases. + * + * - Warning: The delegate is not retained by ``Purchases``, so your app must retain a reference to the delegate + * to prevent it from being unintentionally deallocated. + */ + @objc public var delegate: PurchasesDelegate? { + get { self.privateDelegate } + set { + guard newValue !== self.privateDelegate else { + Logger.warn(Strings.purchase.purchases_delegate_set_multiple_times) + return + } + + if newValue == nil { + Logger.info(Strings.purchase.purchases_delegate_set_to_nil) + } + + self.privateDelegate = newValue + + if newValue != nil { + Logger.debug(Strings.configure.delegate_set) + } + + if !self.systemInfo.dangerousSettings.customEntitlementComputation { + // Sends cached customer info (if exists) to delegate as latest + // customer info may have already been observed and sent by the monitor + self.sendCachedCustomerInfoToDelegateIfExists() + } + } + } + + private weak var privateDelegate: PurchasesDelegate? + + /// Listener for receiving tracked feature events as dictionaries. + /// Set this to monitor events tracked by RevenueCatUI features (paywalls, customer center). + @_spi(Internal) public var eventsListener: EventsListener? { + get { self.eventsManager?.eventsListener } + set { self.eventsManager?.eventsListener = newValue } + } + + private let operationDispatcher: OperationDispatcher + + /** + * Used to set the log level. Useful for debugging issues with the lovely team @RevenueCat. + * + * #### Related Symbols + * - ``logHandler`` + * - ``verboseLogHandler`` + */ + @objc public static var logLevel: LogLevel { + get { Logger.logLevel } + set { Logger.logLevel = newValue } + } + + /** + * Set this property to your proxy URL before configuring ``Purchases`` *only* if you've received a proxy key value + * from your RevenueCat contact. + */ + @objc public static var proxyURL: URL? { + get { SystemInfo.proxyURL } + set { SystemInfo.proxyURL = newValue } + } + + /** + * Set this property to true *only* if you're transitioning an existing Mac app from the Legacy + * Mac App Store into the Universal Store, and you've configured your RevenueCat app accordingly. + * Contact RevenueCat support before using this. + */ + @objc public static var forceUniversalAppStore: Bool { + get { SystemInfo.forceUniversalAppStore } + set { SystemInfo.forceUniversalAppStore = newValue } + } + + /** + * Set this property to true *only* when testing the ask-to-buy / SCA purchases flow. + * More information [available here](https://rev.cat/ask-to-buy). + * #### Related Articles + * - [Approve what kids buy with Ask to Buy](https://rev.cat/approve-kids-purchases-apple) + */ + @available(iOS 8.0, macOS 10.14, watchOS 6.2, macCatalyst 13.0, *) + @objc public static var simulatesAskToBuyInSandbox: Bool { + get { StoreKit1Wrapper.simulatesAskToBuyInSandbox } + set { StoreKit1Wrapper.simulatesAskToBuyInSandbox = newValue } + } + + /** + * Indicates whether the user is allowed to make payments. + * [More information on when this might be `false` here](https://rev.cat/can-make-payments-apple) + */ + @objc public static func canMakePayments() -> Bool { StoreKit1Wrapper.canMakePayments() } + + /** + * Set a custom log handler for redirecting logs to your own logging system. + * + * By default, this sends ``LogLevel/info``, ``LogLevel/warn``, and ``LogLevel/error`` messages. + * If you wish to receive Debug level messages, set the log level to ``LogLevel/debug``. + * + * - Note:``verboseLogHandler`` provides additional information. + * + * #### Related Symbols + * - ``verboseLogHandler`` + * - ``logLevel`` + */ + @objc public static var logHandler: LogHandler { + get { + return { level, message in + self.verboseLogHandler(level, message, nil, nil, 0) + } + } + + set { + self.verboseLogHandler = { level, message, _, _, _ in + newValue(level, message) + } + } + } + + /** + * Set a custom log handler for redirecting logs to your own logging system. + * + * By default, this sends ``LogLevel/info``, ``LogLevel/warn``, and ``LogLevel/error`` messages. + * If you wish to receive Debug level messages, set the log level to ``LogLevel/debug``. + * + * - Note: you can use ``logHandler`` if you don't need filename information. + * + * #### Related Symbols + * - ``logHandler`` + * - ``logLevel`` + */ + @objc public static var verboseLogHandler: VerboseLogHandler { + get { + return { level, message, file, function, line in + Logger.internalLogHandler(level, message, "", file, function, line) + } + } + + set { + Logger.internalLogHandler = { level, message, _, file, function, line in + newValue(level, message, file, function, line) + } + } + } + + /// Useful for tests that override the log handler. + internal static func restoreLogHandler() { + Logger.internalLogHandler = Logger.defaultLogHandler + } + + /** + * Setting this to `true` adds additional information to the default log handler: + * Filename, line, and method data. + * You can also access that information for your own logging system by using ``verboseLogHandler``. + * + * #### Related Symbols + * - ``verboseLogHandler`` + * - ``logLevel`` + */ + @objc public static var verboseLogs: Bool { + get { return Logger.verbose } + set { Logger.verbose = newValue } + } + + /// Current version of the ``Purchases`` framework. + @objc public static var frameworkVersion: String { SystemInfo.frameworkVersion } + + @objc public let attribution: Attribution + + @objc public var purchasesAreCompletedBy: PurchasesAreCompletedBy { + get { self.systemInfo.finishTransactions ? .revenueCat : .myApp } + set { self.systemInfo.finishTransactions = newValue.finishTransactions } + } + + @objc public var storeFrontCountryCode: String? { + systemInfo.storefront?.countryCode + } + + @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + @_spi(Experimental) @objc public var storeFrontLocale: Locale? { + systemInfo.storefront.map { storefront in + Locale(components: .init( + languageCode: nil, + script: nil, + languageRegion: .init(storefront.countryCode) + )) + } + } + + private let attributionFetcher: AttributionFetcher + private let attributionPoster: AttributionPoster + private let backend: Backend + private let deviceCache: DeviceCache + private let paywallCache: PaywallCacheWarmingType? + private let identityManager: IdentityManager + private let userDefaults: UserDefaults + private let notificationCenter: NotificationCenter + private let offeringsFactory: OfferingsFactory + private let offeringsManager: OfferingsManager + private let offlineEntitlementsManager: OfflineEntitlementsManager + private let productsManager: ProductsManagerType + private let customerInfoManager: CustomerInfoManager + private let eventsManager: EventsManagerType? + + private var _adTracker: Any? + + /// The ad tracker for reporting ad impressions, clicks, and revenue to RevenueCat. + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + @_spi(Experimental) @objc public var adTracker: AdTracker { + if let tracker = _adTracker as? AdTracker { + return tracker + } + let tracker = AdTracker(eventsManager: self.eventsManager) + _adTracker = tracker + return tracker + } + + private let trialOrIntroPriceEligibilityChecker: CachingTrialOrIntroPriceEligibilityChecker + private let purchasedProductsFetcher: PurchasedProductsFetcherType? + private let purchasesOrchestrator: PurchasesOrchestrator + private let transactionMetadataSyncHelper: TransactionMetadataSyncHelper + private let receiptFetcher: ReceiptFetcher + private let requestFetcher: StoreKitRequestFetcher + private let paymentQueueWrapper: EitherPaymentQueueWrapper + fileprivate let systemInfo: SystemInfo + private let storeMessagesHelper: StoreMessagesHelperType? + private var customerInfoObservationDisposable: (() -> Void)? + private let healthManager: SDKHealthManager + + private let syncAttributesAndOfferingsIfNeededRateLimiter = RateLimiter(maxCalls: 5, period: 60) + private let overridePreferredUILocaleRateLimiter = RateLimiter(maxCalls: 2, period: 60) + private let diagnosticsTracker: DiagnosticsTrackerType? + private let virtualCurrencyManager: VirtualCurrencyManagerType + + @_spi(Internal) public let subscriptionHistoryTracker = SubscriptionHistoryTracker() + + // swiftlint:disable:next function_body_length cyclomatic_complexity + convenience init(apiKey: String, + appUserID: String?, + userDefaults: UserDefaults? = nil, + applicationSupportDirectory: URL? = nil, + observerMode: Bool = false, + platformInfo: PlatformInfo? = Purchases.platformInfo, + responseVerificationMode: Signing.ResponseVerificationMode, + storeKitVersion: StoreKitVersion = .default, + storeKitTimeout: TimeInterval = Configuration.storeKitRequestTimeoutDefault, + networkTimeout: TimeInterval = Configuration.networkTimeoutDefault, + dangerousSettings: DangerousSettings? = nil, + showStoreMessagesAutomatically: Bool, + diagnosticsEnabled: Bool = false, + preferredLocale: String?, + automaticDeviceIdentifierCollectionEnabled: Bool = true + ) { + if userDefaults != nil { + Logger.debug(Strings.configure.using_custom_user_defaults) + } + + let operationDispatcher: OperationDispatcher = .default + let receiptRefreshRequestFactory = ReceiptRefreshRequestFactory() + let fetcher = StoreKitRequestFetcher(requestFactory: receiptRefreshRequestFactory, + operationDispatcher: operationDispatcher) + + let apiKeyValidationResult = Configuration.validateAndLog(apiKey: apiKey) + + let systemInfo = SystemInfo( + platformInfo: platformInfo, + finishTransactions: !observerMode, + operationDispatcher: operationDispatcher, + storeKitVersion: storeKitVersion, + apiKeyValidationResult: apiKeyValidationResult, + responseVerificationMode: responseVerificationMode, + dangerousSettings: dangerousSettings, + preferredLocalesProvider: PreferredLocalesProvider(preferredLocaleOverride: preferredLocale) + ) + + apiKeyValidationResult.checkForSimulatedStoreAPIKeyInRelease(systemInfo: systemInfo, apiKey: apiKey) + + let receiptFetcher = ReceiptFetcher(requestFetcher: fetcher, systemInfo: systemInfo) + let eTagManager = ETagManager() + let attributionTypeFactory = AttributionTypeFactory() + let attributionFetcher = AttributionFetcher(attributionFactory: attributionTypeFactory, systemInfo: systemInfo) + let userDefaults = userDefaults ?? UserDefaults.computeDefault() + let deviceCache = DeviceCache(systemInfo: systemInfo, userDefaults: userDefaults) + + let diagnosticsFileHandler: DiagnosticsFileHandlerType? = { + guard diagnosticsEnabled, + dangerousSettings?.uiPreviewMode != true, + #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) else { return nil } + return DiagnosticsFileHandler() + }() + + let diagnosticsTracker: DiagnosticsTrackerType? = { + if let handler = diagnosticsFileHandler, #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) { + return DiagnosticsTracker(diagnosticsFileHandler: handler) + } else { + if diagnosticsEnabled { + Logger.error(Strings.diagnostics.could_not_create_diagnostics_tracker) + } + } + return nil + }() + + let purchasedProductsFetcher = OfflineCustomerInfoCreator.createPurchasedProductsFetcherIfAvailable( + diagnosticsTracker: diagnosticsTracker + ) + let transactionFetcher = StoreKit2TransactionFetcher(diagnosticsTracker: diagnosticsTracker) + + let backend = Backend( + apiKey: apiKey, + systemInfo: systemInfo, + httpClientTimeout: networkTimeout, + eTagManager: eTagManager, + operationDispatcher: operationDispatcher, + attributionFetcher: attributionFetcher, + offlineCustomerInfoCreator: .createIfAvailable( + with: purchasedProductsFetcher, + productEntitlementMappingFetcher: deviceCache, + tracker: diagnosticsTracker, + observerMode: observerMode + ), + diagnosticsTracker: diagnosticsTracker + ) + + let paymentQueueWrapper: EitherPaymentQueueWrapper = systemInfo.storeKitVersion.isStoreKit2EnabledAndAvailable + ? .right(.init()) + : .left(.init( + operationDispatcher: operationDispatcher, + observerMode: observerMode, + sandboxEnvironmentDetector: systemInfo, + diagnosticsTracker: diagnosticsTracker + )) + + let simulatedStorePurchaseHandler = SimulatedStorePurchaseHandler(systemInfo: systemInfo) + + let offeringsFactory = OfferingsFactory() + let receiptParser = PurchasesReceiptParser.default + let transactionsManager = TransactionsManager(receiptParser: receiptParser) + + let productsManager = CachingProductsManager( + manager: ProductsManagerFactory.createManager(apiKeyValidationResult: apiKeyValidationResult, + diagnosticsTracker: diagnosticsTracker, + systemInfo: systemInfo, + backend: backend, + deviceCache: deviceCache, + requestTimeout: storeKitTimeout) + ) + + let localTransactionMetadataStore = LocalTransactionMetadataStore( + apiKey: apiKey, + applicationSupportDirectory: applicationSupportDirectory + ) + let transactionPoster = TransactionPoster( + productsManager: productsManager, + receiptFetcher: receiptFetcher, + transactionFetcher: transactionFetcher, + backend: backend, + paymentQueueWrapper: paymentQueueWrapper, + systemInfo: systemInfo, + operationDispatcher: operationDispatcher, + localTransactionMetadataStore: localTransactionMetadataStore + ) + + let offlineEntitlementsManager = OfflineEntitlementsManager(deviceCache: deviceCache, + operationDispatcher: operationDispatcher, + api: backend.offlineEntitlements, + systemInfo: systemInfo) + + let customerInfoManager: CustomerInfoManager + if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) { + customerInfoManager = CustomerInfoManager(offlineEntitlementsManager: offlineEntitlementsManager, + operationDispatcher: operationDispatcher, + deviceCache: deviceCache, + backend: backend, + transactionFetcher: transactionFetcher, + transactionPoster: transactionPoster, + systemInfo: systemInfo, + diagnosticsTracker: diagnosticsTracker) + } else { + customerInfoManager = CustomerInfoManager(offlineEntitlementsManager: offlineEntitlementsManager, + operationDispatcher: operationDispatcher, + deviceCache: deviceCache, + backend: backend, + transactionFetcher: transactionFetcher, + transactionPoster: transactionPoster, + systemInfo: systemInfo) + } + + let attributionDataMigrator = AttributionDataMigrator() + let subscriberAttributesManager = SubscriberAttributesManager( + backend: backend, + deviceCache: deviceCache, + operationDispatcher: operationDispatcher, + attributionFetcher: attributionFetcher, + attributionDataMigrator: attributionDataMigrator, + automaticDeviceIdentifierCollectionEnabled: automaticDeviceIdentifierCollectionEnabled) + let identityManager = IdentityManager(deviceCache: deviceCache, + systemInfo: systemInfo, + backend: backend, + customerInfoManager: customerInfoManager, + attributeSyncing: subscriberAttributesManager, + appUserID: appUserID + ) + + let eventsManager: EventsManagerType? + do { + if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) { + let adEventStore: AdEventStoreType? = try? AdEventStore.createDefault( + applicationSupportDirectory: applicationSupportDirectory + ) + eventsManager = EventsManager( + internalAPI: backend.internalAPI, + userProvider: identityManager, + store: try FeatureEventStore.createDefault( + applicationSupportDirectory: applicationSupportDirectory + ), + systemInfo: systemInfo, + adEventStore: adEventStore + ) + Logger.verbose(Strings.paywalls.event_manager_initialized) + } else { + Logger.verbose(Strings.paywalls.event_manager_not_initialized_not_available) + eventsManager = nil + } + } catch { + Logger.verbose(Strings.paywalls.event_manager_failed_to_initialize(error)) + eventsManager = nil + } + + let attributionPoster = AttributionPoster(deviceCache: deviceCache, + currentUserProvider: identityManager, + backend: backend, + attributionFetcher: attributionFetcher, + subscriberAttributesManager: subscriberAttributesManager, + systemInfo: systemInfo) + let subscriberAttributes = Attribution(subscriberAttributesManager: subscriberAttributesManager, + currentUserProvider: identityManager, + attributionPoster: attributionPoster, + systemInfo: systemInfo) + let introCalculator = IntroEligibilityCalculator(productsManager: productsManager, receiptParser: receiptParser) + let offeringsManager = OfferingsManager(deviceCache: deviceCache, + operationDispatcher: operationDispatcher, + systemInfo: systemInfo, + backend: backend, + offeringsFactory: offeringsFactory, + productsManager: productsManager, + diagnosticsTracker: diagnosticsTracker) + let manageSubsHelper = ManageSubscriptionsHelper(systemInfo: systemInfo, + customerInfoManager: customerInfoManager, + currentUserProvider: identityManager) + let beginRefundRequestHelper = BeginRefundRequestHelper(systemInfo: systemInfo, + customerInfoManager: customerInfoManager, + currentUserProvider: identityManager) + + let storeMessagesHelper: StoreMessagesHelperType? + + #if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + if #available(iOS 16.0, *) { + storeMessagesHelper = StoreMessagesHelper(systemInfo: systemInfo, + showStoreMessagesAutomatically: showStoreMessagesAutomatically) + } else { + storeMessagesHelper = nil + } + #else + storeMessagesHelper = nil + #endif + + let winBackOfferEligibilityCalculator: WinBackOfferEligibilityCalculatorType? + if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) { + winBackOfferEligibilityCalculator = WinBackOfferEligibilityCalculator(systemInfo: systemInfo) + } else { + winBackOfferEligibilityCalculator = nil + } + + let notificationCenter: NotificationCenter = .default + let purchasesOrchestrator: PurchasesOrchestrator = { + if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) { + let diagnosticsSynchronizer: DiagnosticsSynchronizer? + if diagnosticsEnabled { + if let diagnosticsFileHandler = diagnosticsFileHandler { + let synchronizedUserDefaults = SynchronizedUserDefaults(userDefaults: userDefaults) + diagnosticsSynchronizer = DiagnosticsSynchronizer(internalAPI: backend.internalAPI, + handler: diagnosticsFileHandler, + tracker: diagnosticsTracker, + userDefaults: synchronizedUserDefaults) + Task { + await diagnosticsFileHandler.updateDelegate(diagnosticsSynchronizer) + } + } else { + Logger.error(Strings.diagnostics.could_not_create_diagnostics_tracker) + diagnosticsSynchronizer = nil + } + } else { + diagnosticsSynchronizer = nil + } + let storeKit2ObserverModePurchaseDetector = StoreKit2ObserverModePurchaseDetector( + deviceCache: deviceCache, + allTransactionsProvider: SK2AllTransactionsProvider() + ) + + return .init( + productsManager: productsManager, + paymentQueueWrapper: paymentQueueWrapper, + simulatedStorePurchaseHandler: simulatedStorePurchaseHandler, + systemInfo: systemInfo, + subscriberAttributes: subscriberAttributes, + operationDispatcher: operationDispatcher, + receiptFetcher: receiptFetcher, + receiptParser: receiptParser, + transactionFetcher: transactionFetcher, + customerInfoManager: customerInfoManager, + backend: backend, + transactionPoster: transactionPoster, + currentUserProvider: identityManager, + transactionsManager: transactionsManager, + deviceCache: deviceCache, + offeringsManager: offeringsManager, + manageSubscriptionsHelper: manageSubsHelper, + beginRefundRequestHelper: beginRefundRequestHelper, + storeKit2TransactionListener: StoreKit2TransactionListener(delegate: nil, + diagnosticsTracker: diagnosticsTracker), + storeKit2StorefrontListener: StoreKit2StorefrontListener( + delegate: nil, + userDefaults: userDefaults + ), + storeKit2ObserverModePurchaseDetector: storeKit2ObserverModePurchaseDetector, + storeMessagesHelper: storeMessagesHelper, + diagnosticsSynchronizer: diagnosticsSynchronizer, + diagnosticsTracker: diagnosticsTracker, + winBackOfferEligibilityCalculator: winBackOfferEligibilityCalculator, + eventsManager: eventsManager, + webPurchaseRedemptionHelper: WebPurchaseRedemptionHelper(backend: backend, + identityManager: identityManager, + customerInfoManager: customerInfoManager) + ) + } else { + return .init( + productsManager: productsManager, + paymentQueueWrapper: paymentQueueWrapper, + simulatedStorePurchaseHandler: simulatedStorePurchaseHandler, + systemInfo: systemInfo, + subscriberAttributes: subscriberAttributes, + operationDispatcher: operationDispatcher, + receiptFetcher: receiptFetcher, + receiptParser: receiptParser, + transactionFetcher: transactionFetcher, + customerInfoManager: customerInfoManager, + backend: backend, + transactionPoster: transactionPoster, + currentUserProvider: identityManager, + transactionsManager: transactionsManager, + deviceCache: deviceCache, + offeringsManager: offeringsManager, + manageSubscriptionsHelper: manageSubsHelper, + beginRefundRequestHelper: beginRefundRequestHelper, + storeMessagesHelper: storeMessagesHelper, + diagnosticsTracker: diagnosticsTracker, + winBackOfferEligibilityCalculator: winBackOfferEligibilityCalculator, + eventsManager: eventsManager, + webPurchaseRedemptionHelper: WebPurchaseRedemptionHelper(backend: backend, + identityManager: identityManager, + customerInfoManager: customerInfoManager) + ) + } + }() + + let trialOrIntroPriceChecker = CachingTrialOrIntroPriceEligibilityChecker.create( + with: TrialOrIntroPriceEligibilityChecker(systemInfo: systemInfo, + receiptFetcher: receiptFetcher, + introEligibilityCalculator: introCalculator, + backend: backend, + currentUserProvider: identityManager, + operationDispatcher: operationDispatcher, + productsManager: productsManager, + diagnosticsTracker: diagnosticsTracker) + ) + + let paywallCache: PaywallCacheWarmingType? + + if #available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *) { + paywallCache = PaywallCacheWarming( + introEligibiltyChecker: trialOrIntroPriceChecker + ) + } else { + paywallCache = nil + } + + let virtualCurrencyManager = VirtualCurrencyManager( + identityManager: identityManager, + deviceCache: deviceCache, + backend: backend, + systemInfo: systemInfo + ) + let healthManager = SDKHealthManager(backend: backend, identityManager: identityManager) + + let transactionMetadataSyncHelper = TransactionMetadataSyncHelper( + customerInfoManager: customerInfoManager, + attribution: subscriberAttributes, + currentUserProvider: identityManager, + operationDispatcher: operationDispatcher, + transactionPoster: transactionPoster + ) + + self.init(appUserID: appUserID, + requestFetcher: fetcher, + receiptFetcher: receiptFetcher, + attributionFetcher: attributionFetcher, + attributionPoster: attributionPoster, + backend: backend, + paymentQueueWrapper: paymentQueueWrapper, + userDefaults: userDefaults, + notificationCenter: notificationCenter, + systemInfo: systemInfo, + offeringsFactory: offeringsFactory, + deviceCache: deviceCache, + paywallCache: paywallCache, + identityManager: identityManager, + subscriberAttributes: subscriberAttributes, + operationDispatcher: operationDispatcher, + customerInfoManager: customerInfoManager, + eventsManager: eventsManager, + productsManager: productsManager, + offeringsManager: offeringsManager, + offlineEntitlementsManager: offlineEntitlementsManager, + purchasesOrchestrator: purchasesOrchestrator, + purchasedProductsFetcher: purchasedProductsFetcher, + trialOrIntroPriceEligibilityChecker: trialOrIntroPriceChecker, + storeMessagesHelper: storeMessagesHelper, + diagnosticsTracker: diagnosticsTracker, + virtualCurrencyManager: virtualCurrencyManager, + healthManager: healthManager, + transactionMetadataSyncHelper: transactionMetadataSyncHelper + ) + } + + // swiftlint:disable:next function_body_length + init(appUserID: String?, + requestFetcher: StoreKitRequestFetcher, + receiptFetcher: ReceiptFetcher, + attributionFetcher: AttributionFetcher, + attributionPoster: AttributionPoster, + backend: Backend, + paymentQueueWrapper: EitherPaymentQueueWrapper, + userDefaults: UserDefaults, + notificationCenter: NotificationCenter, + systemInfo: SystemInfo, + offeringsFactory: OfferingsFactory, + deviceCache: DeviceCache, + paywallCache: PaywallCacheWarmingType?, + identityManager: IdentityManager, + subscriberAttributes: Attribution, + operationDispatcher: OperationDispatcher, + customerInfoManager: CustomerInfoManager, + eventsManager: EventsManagerType?, + productsManager: ProductsManagerType, + offeringsManager: OfferingsManager, + offlineEntitlementsManager: OfflineEntitlementsManager, + purchasesOrchestrator: PurchasesOrchestrator, + purchasedProductsFetcher: PurchasedProductsFetcherType?, + trialOrIntroPriceEligibilityChecker: CachingTrialOrIntroPriceEligibilityChecker, + storeMessagesHelper: StoreMessagesHelperType?, + diagnosticsTracker: DiagnosticsTrackerType?, + virtualCurrencyManager: VirtualCurrencyManagerType, + healthManager: SDKHealthManager, + transactionMetadataSyncHelper: TransactionMetadataSyncHelper + ) { + + if systemInfo.dangerousSettings.customEntitlementComputation { + Logger.info(Strings.configure.custom_entitlements_computation_enabled) + } + + if systemInfo.dangerousSettings.customEntitlementComputation + && appUserID == nil && identityManager.currentUserIsAnonymous { + fatalError(Strings.configure.custom_entitlements_computation_enabled_but_no_app_user_id.description) + } + + Logger.debug(Strings.configure.debug_enabled) + if systemInfo.observerMode { + Logger.debug(Strings.configure.observer_mode_enabled) + } + Logger.debug(Strings.configure.sdk_version(Self.frameworkVersion)) + Logger.debug(Strings.configure.bundle_id(SystemInfo.bundleIdentifier)) + Logger.debug(Strings.configure.system_version(SystemInfo.systemVersion)) + Logger.debug(Strings.configure.is_simulator(SystemInfo.isRunningInSimulator)) + Logger.user(Strings.configure.initial_app_user_id(isSet: appUserID != nil)) + Logger.debug(Strings.configure.response_verification_mode(systemInfo.responseVerificationMode)) + Logger.debug(Strings.configure.storekit_version(systemInfo.storeKitVersion)) + + self.requestFetcher = requestFetcher + self.receiptFetcher = receiptFetcher + self.attributionFetcher = attributionFetcher + self.attributionPoster = attributionPoster + self.backend = backend + self.paymentQueueWrapper = paymentQueueWrapper + self.offeringsFactory = offeringsFactory + self.deviceCache = deviceCache + self.paywallCache = paywallCache + self.identityManager = identityManager + self.userDefaults = userDefaults + self.notificationCenter = notificationCenter + self.systemInfo = systemInfo + self.attribution = subscriberAttributes + self.operationDispatcher = operationDispatcher + self.customerInfoManager = customerInfoManager + self.eventsManager = eventsManager + self.productsManager = productsManager + self.offeringsManager = offeringsManager + self.offlineEntitlementsManager = offlineEntitlementsManager + self.purchasesOrchestrator = purchasesOrchestrator + self.purchasedProductsFetcher = purchasedProductsFetcher + self.trialOrIntroPriceEligibilityChecker = trialOrIntroPriceEligibilityChecker + self.storeMessagesHelper = storeMessagesHelper + self.diagnosticsTracker = diagnosticsTracker + self.virtualCurrencyManager = virtualCurrencyManager + self.healthManager = healthManager + self.transactionMetadataSyncHelper = transactionMetadataSyncHelper + + super.init() + + Logger.verbose(Strings.configure.purchases_init(self, paymentQueueWrapper)) + + #if os(iOS) || targetEnvironment(macCatalyst) || os(macOS) + if #available(iOS 16.4, macOS 14.4, *), systemInfo.storeKitVersion.isStoreKit2EnabledAndAvailable { + purchasesOrchestrator.setSK2PurchaseIntentListener(StoreKit2PurchaseIntentListener()) + } + #endif + + self.purchasesOrchestrator.delegate = self + + // Don't update caches or run health checks in the background to avoid too many users + // hitting the backend concurrently when launched through a notification at the same time. + self.performInitialForegroundSetup() + + if self.systemInfo.dangerousSettings.autoSyncPurchases { + self.paymentQueueWrapper.sk1Wrapper?.delegate = purchasesOrchestrator + } else { + Logger.warn(Strings.configure.autoSyncPurchasesDisabled) + } + + /// If SK1 is not enabled, `PaymentQueueWrapper` needs to handle transactions + /// for promotional offers to work. + self.paymentQueueWrapper.sk2Wrapper?.delegate = purchasesOrchestrator + + self.subscribeToAppStateNotifications() + + self.attributionPoster.postPostponedAttributionDataIfNeeded() + + self.customerInfoObservationDisposable = customerInfoManager.monitorChanges { [weak self] old, new in + guard let self = self else { return } + self.handleCustomerInfoChanged(from: old, to: new) + } + + self.transactionMetadataSyncHelper.syncIfNeeded( + allowSharingAppStoreAccount: purchasesOrchestrator.allowSharingAppStoreAccount + ) + } + + deinit { + Logger.verbose(Strings.configure.purchases_deinit(self)) + + self.notificationCenter.removeObserver(self) + self.paymentQueueWrapper.sk1Wrapper?.delegate = nil + self.paymentQueueWrapper.sk2Wrapper?.delegate = nil + self.customerInfoObservationDisposable?() + self.privateDelegate = nil + } + + static func clearSingleton() { + self.purchases.modify { purchases in + purchases?.delegate = nil + purchases = nil + } + } + + /// - Parameter purchases: this is an `@autoclosure` to be able to clear the previous instance + /// from memory before creating the new one. + @discardableResult + static func setDefaultInstance(_ purchases: @autoclosure () -> Purchases) -> Purchases { + return self.purchases.modify { currentInstance in + if currentInstance != nil { + #if DEBUG + if ProcessInfo.isRunningRevenueCatTests { + preconditionFailure(Strings.configure.purchase_instance_already_set.description) + } + #endif + Logger.info(Strings.configure.purchase_instance_already_set) + + // Clear existing instance to avoid multiple concurrent instances in memory. + currentInstance = nil + } + + let newInstance = purchases() + currentInstance = newInstance + return newInstance + } + } + +} + +// MARK: Attribution + +extension Purchases { + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + private func post(attributionData data: [String: Any], + fromNetwork network: AttributionNetwork, + forNetworkUserId networkUserId: String?) { + attributionPoster.post(attributionData: data, fromNetwork: network, networkUserId: networkUserId) + } + #endif +} + +// MARK: Identity + +public extension Purchases { + + /// Parses a deep link URL to verify it's a RevenueCat web purchase redemption link + /// - Seealso: ``Purchases/redeemWebPurchase(_:)`` + @objc static func parseAsWebPurchaseRedemption(_ url: URL) -> WebPurchaseRedemption? { + return DeepLinkParser.parseAsWebPurchaseRedemption(url) + } + + @objc var appUserID: String { self.identityManager.currentAppUserID } + + @objc var isAnonymous: Bool { self.identityManager.currentUserIsAnonymous } + + @objc var isSandbox: Bool { return self.systemInfo.isSandbox } + + @objc func getOfferings(completion: @escaping (Offerings?, PublicError?) -> Void) { + self.getOfferings(fetchPolicy: .default, completion: completion) + } + + internal func getOfferings( + fetchPolicy: OfferingsManager.FetchPolicy, + fetchCurrent: Bool = false, + completion: @escaping (Offerings?, PublicError?) -> Void + ) { + self.offeringsManager.offerings(appUserID: self.appUserID, + fetchPolicy: fetchPolicy, + fetchCurrent: fetchCurrent) { @Sendable result in + completion(result.value, result.error?.asPublicError) + } + } + + func offerings() async throws -> Offerings { + return try await self.offerings(fetchPolicy: .default) + } + + var cachedOfferings: Offerings? { + return self.offeringsManager.cachedOfferings + } + + internal func offerings(fetchPolicy: OfferingsManager.FetchPolicy) async throws -> Offerings { + return try await self.offeringsAsync(fetchPolicy: fetchPolicy) + } + +} + +#if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + +public extension Purchases { + + @available(*, deprecated, message: """ + The appUserID passed to logIn is a constant string known at compile time. + This is likely a programmer error. This ID is used to identify the current user. + See https://docs.revenuecat.com/docs/user-ids for more information. + """) + func logIn(_ appUserID: StaticString, completion: @escaping (CustomerInfo?, Bool, PublicError?) -> Void) { + Logger.warn(Strings.identity.logging_in_with_static_string) + + self.logIn("\(appUserID)", completion: completion) + } + + // Favor `StaticString` overload (`String` is not convertible to `StaticString`). + // This allows us to provide a compile-time warning to developers who accidentally + // call logIn with hardcoded user ids in their app + @_disfavoredOverload + @objc(logIn:completion:) + func logIn(_ appUserID: String, completion: @escaping (CustomerInfo?, Bool, PublicError?) -> Void) { + self.identityManager.logIn(appUserID: appUserID) { result in + self.operationDispatcher.dispatchOnMainThread { + completion(result.value?.info, result.value?.created ?? false, result.error?.asPublicError) + } + + guard case .success = result else { + return + } + + self.systemInfo.isApplicationBackgrounded { isAppBackgrounded in + self.updateOfferingsCache(isAppBackgrounded: isAppBackgrounded) + } + } + } + + func logIn(_ appUserID: StaticString) async throws -> (customerInfo: CustomerInfo, created: Bool) { + Logger.warn(Strings.identity.logging_in_with_static_string) + + return try await self.logIn("\(appUserID)") + } + + // Favor `StaticString` overload (`String` is not convertible to `StaticString`). + // This allows us to provide a compile-time warning to developers who accidentally + // call logIn with hardcoded user ids in their app + @_disfavoredOverload + func logIn(_ appUserID: String) async throws -> (customerInfo: CustomerInfo, created: Bool) { + return try await self.logInAsync(appUserID) + } + + @objc func logOut(completion: ((CustomerInfo?, PublicError?) -> Void)?) { + guard !self.systemInfo.dangerousSettings.customEntitlementComputation else { + completion?(nil, NewErrorUtils.featureNotAvailableInCustomEntitlementsComputationModeError().asPublicError) + return + } + + self.identityManager.logOut { error in + guard error == nil else { + if let completion = completion { + self.operationDispatcher.dispatchOnMainThread { + completion(nil, error?.asPublicError) + } + } + return + } + + self.updateAllCaches { + completion?($0.value, $0.error) + } + } + } + + func logOut() async throws -> CustomerInfo { + return try await logOutAsync() + } + + @objc func syncAttributesAndOfferingsIfNeeded(completion: @escaping (Offerings?, PublicError?) -> Void) { + guard syncAttributesAndOfferingsIfNeededRateLimiter.shouldProceed() else { + Logger.warn( + Strings.identity.sync_attributes_and_offerings_rate_limit_reached( + maxCalls: syncAttributesAndOfferingsIfNeededRateLimiter.maxCalls, + period: Int(syncAttributesAndOfferingsIfNeededRateLimiter.period) + ) + ) + self.getOfferings(fetchPolicy: .default, completion: completion) + return + } + + self.syncSubscriberAttributes(completion: { + self.getOfferings(fetchPolicy: .default, fetchCurrent: true, completion: completion) + }) + } + + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) + func syncAttributesAndOfferingsIfNeeded() async throws -> Offerings? { + return try await syncAttributesAndOfferingsIfNeededAsync() + } + + @objc func getStorefront(completion: @escaping GetStorefrontBlock) { + Task { + let storefront = await Storefront.currentStorefront + self.operationDispatcher.dispatchOnMainActor { + completion(storefront) + } + } + } + + func getStorefront() async -> Storefront? { + return await getStorefrontAsync() + } + +} + +#endif + +// - MARK: - Custom entitlement computation API + +extension Purchases { + +#if ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + /// + /// Updates the current appUserID to a new one, without associating the two. + /// - Important: This method is **only available** in Custom Entitlements Computation mode. + /// Receipts posted by the SDK to the RevenueCat backend after calling this method will be sent + /// with the newAppUserID. + /// + @objc(switchUserToNewAppUserID:) + public func switchUser(to newAppUserID: String) { + self.internalSwitchUser(to: newAppUserID) + } + + /// Queries whether a purchase made by the current appUserId is allowed by the app's restore behavior. + /// + /// For more information, see https://www.revenuecat.com/docs/projects/restore-behavior + /// + /// - Parameter completion: Completion containing whether or not a purchase will be allowed and an optional error. + /// If the error is non-nil, the result will be `nil`. + /// + /// Only supported for StoreKit 2. + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + public func isPurchaseAllowedByRestoreBehavior( + completion: @escaping (Bool?, PublicError?) -> Void + ) { + Async.call( + with: { result in + OperationDispatcher.dispatchOnMainActor { + completion(result.value, result.error?.asPublicError) + } + }, + asyncMethod: { + try await self.isPurchaseAllowedByRestoreBehavior() + } + ) + } + + /// Queries whether a purchase made by the current appUserId is allowed by the app's restore behavior. + /// + /// For more information, see https://www.revenuecat.com/docs/projects/restore-behavior + /// + /// Only supported for StoreKit 2. + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + public func isPurchaseAllowedByRestoreBehavior() async throws -> Bool { + try await purchasesOrchestrator.isPurchaseAllowedByRestoreBehavior() + } + +#endif + + internal func internalSwitchUser(to newAppUserID: String) { + guard self.identityManager.currentAppUserID != newAppUserID else { + Logger.warn(Strings.identity.switching_user_same_app_user_id(newUserID: newAppUserID)) + return + } + + self.identityManager.switchUser(to: newAppUserID) + + self.systemInfo.isApplicationBackgrounded { isBackgrounded in + self.updateOfferingsCache(isAppBackgrounded: isBackgrounded) + } + } + +} + +// MARK: Purchasing + +public extension Purchases { + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + @objc func getCustomerInfo(completion: @escaping (CustomerInfo?, PublicError?) -> Void) { + self.getCustomerInfo(fetchPolicy: .default, completion: completion) + } + + @objc func getCustomerInfo( + fetchPolicy: CacheFetchPolicy, + completion: @escaping (CustomerInfo?, PublicError?) -> Void + ) { + self.customerInfoManager.customerInfo(appUserID: self.appUserID, + fetchPolicy: fetchPolicy, + trackDiagnostics: true) { @Sendable result in + completion(result.value, result.error?.asPublicError) + } + } + + func customerInfo() async throws -> CustomerInfo { + return try await self.customerInfo(fetchPolicy: .default) + } + + func customerInfo(fetchPolicy: CacheFetchPolicy) async throws -> CustomerInfo { + return try await self.customerInfoAsync(fetchPolicy: fetchPolicy) + } + + var cachedCustomerInfo: CustomerInfo? { + return try? self.customerInfoManager.cachedCustomerInfo(appUserID: self.appUserID) + } + + #endif + + var customerInfoStream: AsyncStream { + return self.customerInfoManager.customerInfoStream + } + + @objc(getProductsWithIdentifiers:completion:) + func getProducts(_ productIdentifiers: [String], completion: @escaping ([StoreProduct]) -> Void) { + purchasesOrchestrator.products(withIdentifiers: productIdentifiers, completion: completion) + } + + func products(_ productIdentifiers: [String]) async -> [StoreProduct] { + return await productsAsync(productIdentifiers) + } + + @objc(purchaseProduct:withCompletion:) + func purchase(product: StoreProduct, completion: @escaping PurchaseCompletedBlock) { + purchasesOrchestrator.purchase(product: product, + package: nil, + promotionalOffer: nil, + metadata: nil, + trackDiagnostics: true, + completion: completion) + } + + func purchase(product: StoreProduct) async throws -> PurchaseResultData { + return try await purchaseAsync(product: product) + } + + @objc(purchasePackage:withCompletion:) + func purchase(package: Package, completion: @escaping PurchaseCompletedBlock) { + purchasesOrchestrator.purchase(product: package.storeProduct, + package: package, + promotionalOffer: nil, + metadata: nil, + trackDiagnostics: true, + completion: completion) + } + + func purchase(package: Package) async throws -> PurchaseResultData { + return try await purchaseAsync(package: package) + } + + @objc func restorePurchases(completion: ((CustomerInfo?, PublicError?) -> Void)? = nil) { + self.purchasesOrchestrator.restorePurchases { @Sendable in + completion?($0.value, $0.error?.asPublicError) + } + } + + func restorePurchases() async throws -> CustomerInfo { + return try await self.restorePurchasesAsync() + } + + @objc(purchaseWithParams:completion:) + func purchase(_ params: PurchaseParams, completion: @escaping PurchaseCompletedBlock) { + purchasesOrchestrator.purchase(params: params, trackDiagnostics: true, completion: completion) + } + + func purchase(_ params: PurchaseParams) async throws -> PurchaseResultData { + return try await purchaseAsync(params) + } + + @objc(purchaseProduct:withPromotionalOffer:completion:) + func purchase(product: StoreProduct, + promotionalOffer: PromotionalOffer, + completion: @escaping PurchaseCompletedBlock) { + purchasesOrchestrator.purchase(product: product, + package: nil, + promotionalOffer: promotionalOffer.signedData, + metadata: nil, + trackDiagnostics: true, + completion: completion) + } + + func purchase(product: StoreProduct, promotionalOffer: PromotionalOffer) async throws -> PurchaseResultData { + return try await purchaseAsync(product: product, promotionalOffer: promotionalOffer) + } + + @objc(purchasePackage:withPromotionalOffer:completion:) + func purchase(package: Package, promotionalOffer: PromotionalOffer, completion: @escaping PurchaseCompletedBlock) { + purchasesOrchestrator.purchase(product: package.storeProduct, + package: package, + promotionalOffer: promotionalOffer.signedData, + metadata: nil, + trackDiagnostics: true, + completion: completion) + } + + func purchase(package: Package, promotionalOffer: PromotionalOffer) async throws -> PurchaseResultData { + return try await purchaseAsync(package: package, promotionalOffer: promotionalOffer) + } + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + @objc func invalidateCustomerInfoCache() { + self.customerInfoManager.clearCustomerInfoCache(forAppUserID: appUserID) + } + + @objc func syncPurchases(completion: ((CustomerInfo?, PublicError?) -> Void)?) { + self.purchasesOrchestrator.syncPurchases { @Sendable in + completion?($0.value, $0.error?.asPublicError) + } + } + + func syncPurchases() async throws -> CustomerInfo { + return try await syncPurchasesAsync() + } + + #endif + + @objc(checkTrialOrIntroDiscountEligibility:completion:) + func checkTrialOrIntroDiscountEligibility(productIdentifiers: [String], + completion: @escaping ([String: IntroEligibility]) -> Void) { + self.trialOrIntroPriceEligibilityChecker.checkEligibility(productIdentifiers: Set(productIdentifiers), + completion: completion) + } + + func checkTrialOrIntroDiscountEligibility(productIdentifiers: [String]) async -> [String: IntroEligibility] { + return await checkTrialOrIntroductoryDiscountEligibilityAsync(productIdentifiers) + } + + func checkTrialOrIntroDiscountEligibility(packages: [Package]) async -> [Package: IntroEligibility] { + let result = await self.checkTrialOrIntroDiscountEligibility( + productIdentifiers: packages.map(\.storeProduct.productIdentifier) + ) + + return Set(packages) + .dictionaryWithValues { (package: Package) in + result[package.storeProduct.productIdentifier] ?? .init(eligibilityStatus: .unknown) + } + } + + @objc(checkTrialOrIntroDiscountEligibilityForProduct:completion:) + func checkTrialOrIntroDiscountEligibility(product: StoreProduct, + completion: @escaping (IntroEligibilityStatus) -> Void) { + trialOrIntroPriceEligibilityChecker.checkEligibility(product: product, completion: completion) + } + + func checkTrialOrIntroDiscountEligibility(product: StoreProduct) async -> IntroEligibilityStatus { + return await checkTrialOrIntroductoryDiscountEligibilityAsync(product) + } + +#if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + @available(iOS 13.4, macCatalyst 13.4, *) + @objc func showPriceConsentIfNeeded() { + self.paymentQueueWrapper.paymentQueueWrapperType.showPriceConsentIfNeeded() + } +#endif + +#if os(iOS) || VISION_OS + + @available(iOS 14.0, *) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @available(macOS, unavailable) + @available(macCatalyst, unavailable) + @objc func presentCodeRedemptionSheet() { + if #available(iOS 15.0, *) { + self.diagnosticsTracker?.trackApplePresentCodeRedemptionSheetRequest() + } + self.paymentQueueWrapper.paymentQueueWrapperType.presentCodeRedemptionSheet() + } +#endif + + @objc(getPromotionalOfferForProductDiscount:withProduct:withCompletion:) + func getPromotionalOffer(forProductDiscount discount: StoreProductDiscount, + product: StoreProduct, + completion: @escaping (PromotionalOffer?, PublicError?) -> Void) { + self.purchasesOrchestrator.promotionalOffer(forProductDiscount: discount, + product: product) { result in + completion(result.value, result.error?.asPublicError) + } + } + + func promotionalOffer(forProductDiscount discount: StoreProductDiscount, + product: StoreProduct) async throws -> PromotionalOffer { + return try await promotionalOfferAsync(forProductDiscount: discount, product: product) + } + + func eligiblePromotionalOffers(forProduct product: StoreProduct) async -> [PromotionalOffer] { + return await eligiblePromotionalOffersAsync(forProduct: product) + } + +#if os(iOS) || os(macOS) || VISION_OS + + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @available(iOS 13.0, macOS 10.15, *) + @objc func showManageSubscriptions(completion: @escaping (PublicError?) -> Void) { + self.purchasesOrchestrator.showManageSubscription { error in + completion(error?.asPublicError) + } + } + + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @available(iOS 13.0, macOS 10.15, *) + func showManageSubscriptions() async throws { + return try await self.showManageSubscriptionsAsync() + } + +#endif + +#if os(iOS) || VISION_OS + + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @objc(beginRefundRequestForProduct:completion:) + func beginRefundRequest(forProduct productID: String) async throws -> RefundRequestStatus { + return try await purchasesOrchestrator.beginRefundRequest(forProduct: productID) + } + + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @objc(beginRefundRequestForEntitlement:completion:) + func beginRefundRequest(forEntitlement entitlementID: String) async throws -> RefundRequestStatus { + return try await purchasesOrchestrator.beginRefundRequest(forEntitlement: entitlementID) + } + + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @objc(beginRefundRequestForActiveEntitlementWithCompletion:) + func beginRefundRequestForActiveEntitlement() async throws -> RefundRequestStatus { + return try await purchasesOrchestrator.beginRefundRequestForActiveEntitlement() + } + +#endif + +#if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + + @available(iOS 16.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func showStoreMessages(for types: Set = Set(StoreMessageType.allCases)) async { + await self.storeMessagesHelper?.showStoreMessages(types: types) + } + +#endif + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + func recordPurchase( + _ purchaseResult: StoreKit.Product.PurchaseResult + ) async throws -> StoreTransaction? { + do { + return try await self.purchasesOrchestrator.handleRecordPurchase(purchaseResult) + } catch { + throw NewErrorUtils.purchasesError(withUntypedError: error).asPublicError + } + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + @objc(recordPurchaseForProductID:completion:) + func recordPurchase( + productID: String, + completion: @escaping (StoreTransaction?, PublicError?) -> Void + ) { + Task { + let result = await StoreKit.Transaction.latest(for: productID) + + guard let result = result else { + OperationDispatcher.dispatchOnMainActor { + completion(nil, NewErrorUtils.storeProblemError( + withMessage: "No transaction found for product ID: \(productID)" + ).asPublicError) + } + return + } + + do { + let transaction = try await self.recordPurchase(.success(result)) + OperationDispatcher.dispatchOnMainActor { + completion(transaction, nil) + } + } catch { + let publicError = NewErrorUtils.purchasesError(withUntypedError: error).asPublicError + OperationDispatcher.dispatchOnMainActor { + completion(nil, publicError) + } + } + } + } + + func redeemWebPurchase( + webPurchaseRedemption: WebPurchaseRedemption, + completion: @escaping (CustomerInfo?, PublicError?) -> Void + ) { + self.purchasesOrchestrator.redeemWebPurchase(webPurchaseRedemption: webPurchaseRedemption, + completion: completion) + } + + func redeemWebPurchase(_ webPurchaseRedemption: WebPurchaseRedemption) async -> WebPurchaseRedemptionResult { + return await self.purchasesOrchestrator.redeemWebPurchase(webPurchaseRedemption) + } +} + +// MARK: - Virtual Currencies +#if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION +public extension Purchases { + + @objc func getVirtualCurrencies( + completion: @escaping @Sendable (VirtualCurrencies?, PublicError?) -> Void + ) { + Task { + do { + let virtualCurrencies = try await self.virtualCurrencies() + OperationDispatcher.dispatchOnMainActor { + completion(virtualCurrencies, nil) + } + } catch { + let publicError = NewErrorUtils.purchasesError(withUntypedError: error).asPublicError + OperationDispatcher.dispatchOnMainActor { + completion(nil, publicError) + } + } + } + } + + @objc var cachedVirtualCurrencies: VirtualCurrencies? { + return self.virtualCurrencyManager.cachedVirtualCurrencies() + } + + func virtualCurrencies() async throws -> VirtualCurrencies { + do { + return try await self.virtualCurrencyManager.virtualCurrencies() + } catch { + let publicError = NewErrorUtils.purchasesError(withUntypedError: error).asPublicError + throw publicError + } + } + + @objc func invalidateVirtualCurrenciesCache() { + self.virtualCurrencyManager.invalidateVirtualCurrenciesCache() + } +} +#endif + +// swiftlint:enable missing_docs + +// MARK: - Paywalls & Customer Center + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +public extension Purchases { + + /// Used by `RevenueCatUI` to keep track of ``PaywallEvent``s. + func track(paywallEvent: PaywallEvent) async { + self.purchasesOrchestrator.track(paywallEvent: paywallEvent) + await self.eventsManager?.track(featureEvent: paywallEvent) + } + + /// Used by `RevenueCatUI` to keep track of ``CustomerCenterEvent``s. + @_spi(Internal) func track(customerCenterEvent: any CustomerCenterEventType) { + operationDispatcher.dispatchOnWorkerThread { + // If we make CustomerCenterEventType implement FeatureEvent, we have to make FeatureEvent public + guard let event = customerCenterEvent as? FeatureEvent else { return } + await self.eventsManager?.track(featureEvent: event) + } + } + + /// Used by `RevenueCatUI` to download Customer Center data + @_spi(Internal) func loadCustomerCenter() async throws -> CustomerCenterConfigData { + let response = try await Async.call { completion in + self.backend.customerCenterConfig.getCustomerCenterConfig(appUserID: self.appUserID, + isAppBackgrounded: false) { result in + completion(result.mapError(\.asPublicError)) + } + } + + return CustomerCenterConfigData(from: response) + } + + /// Used by `RevenueCatUI` to create a support ticket + @_spi(Internal) func createTicket(customerEmail: String, ticketDescription: String) async throws -> Bool { + let response = try await Async.call { completion in + self.backend.customerCenterConfig.postCreateTicket(appUserID: self.appUserID, + customerEmail: customerEmail, + ticketDescription: ticketDescription) { result in + completion(result.mapError(\.asPublicError)) + } + } + + return response.sent + } + +#if !os(tvOS) + + /// Used by `RevenueCatUI` to notify `RevenueCat` when a font in a paywall fails to load. + @_spi(Internal) func failedToLoadFontWithConfig(_ fontConfig: UIConfig.FontsConfig) { + self.operationDispatcher.dispatchOnWorkerThread { + await self.paywallCache?.triggerFontDownloadIfNeeded(fontsConfig: fontConfig) + } + } + +#endif + + /// Used by `RevenueCatUI` to download and cache paywall images. + @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *) + static let paywallImageDownloadSession: URLSession = PaywallCacheWarming.downloadSession + +} + +// MARK: - Preferred locale + +extension Purchases { + /// Overrides the preferred locale for RevenueCatUI components. + /// - Parameter locale: A locale string in the format "language_region" (e.g., "en_US"). + /// Use `nil` to remove the override and use the default user locale determined by the system. + /// + /// Setting this will affect the display of RevenueCat UI components, such as the Paywalls. + /// - Important: This method only takes effect after `Purchases` has been configured. + public func overridePreferredUILocale(_ locale: String?) { + guard locale != self.systemInfo.preferredLocaleOverride else { + return + } + + self.systemInfo.overridePreferredLocale(locale) + + if self.overridePreferredUILocaleRateLimiter.shouldProceed() { + // Refetches new offerings with preferred locale + self.getOfferings(fetchPolicy: .default, fetchCurrent: true) { _, _ in + // No-op + } + } + } +} + +// MARK: Configuring Purchases + +public extension Purchases { + + /** + * Configures an instance of the Purchases SDK with a specified ``Configuration``. + * + * The instance will be set as a singleton. + * You should access the singleton instance using ``Purchases/shared`` + * + * - Parameter configuration: The ``Configuration`` object you wish to use to configure ``Purchases`` + * + * - Returns: An instantiated ``Purchases`` object that has been set as a singleton. + * + * - Important: See ``Configuration/Builder`` for more information about configurable properties. + * + * ### Example + * + * ```swift + * Purchases.configure( + * with: Configuration.Builder(withAPIKey: Constants.apiKey) + * .with(purchasesAreCompletedBy: .revenueCat) + * .with(appUserID: "") + * .build() + * ) + * ``` + * + */ + @objc(configureWithConfiguration:) + @discardableResult static func configure(with configuration: Configuration) -> Purchases { + configure(withAPIKey: configuration.apiKey, + appUserID: configuration.appUserID, + observerMode: configuration.observerMode, + userDefaults: configuration.userDefaults, + platformInfo: configuration.platformInfo, + responseVerificationMode: configuration.responseVerificationMode, + storeKitVersion: configuration.storeKitVersion, + storeKitTimeout: configuration.storeKit1Timeout, + networkTimeout: configuration.networkTimeout, + dangerousSettings: configuration.dangerousSettings, + showStoreMessagesAutomatically: configuration.showStoreMessagesAutomatically, + diagnosticsEnabled: configuration.diagnosticsEnabled, + preferredLocale: configuration.preferredLocale, + automaticDeviceIdentifierCollectionEnabled: configuration.automaticDeviceIdentifierCollectionEnabled + ) + } + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + /** + * Configures an instance of the Purchases SDK with a specified ``Configuration/Builder``. + * + * The instance will be set as a singleton. + * You should access the singleton instance using ``Purchases/shared`` + * + * - Parameter builder: The ``Configuration/Builder`` object you wish to use to configure ``Purchases`` + * + * - Returns: An instantiated ``Purchases`` object that has been set as a singleton. + * + * - Important: See ``Configuration/Builder`` for more information about configurable properties. + * + * ### Example + * + * ```swift + * Purchases.configure( + * with: .init(withAPIKey: Constants.apiKey) + * .with(purchasesAreCompletedBy: .revenueCat) + * .with(appUserID: "") + * ) + * ``` + * + */ + @objc(configureWithConfigurationBuilder:) + @discardableResult static func configure(with builder: Configuration.Builder) -> Purchases { + return Self.configure(with: builder.build()) + } + + /** + * Configures an instance of the Purchases SDK with a specified API key. + * + * The instance will be set as a singleton. + * You should access the singleton instance using ``Purchases/shared`` + * + * - Note: Use this initializer if your app does not have an account system. + * ``Purchases`` will generate a unique identifier for the current device and persist it to `NSUserDefaults`. + * This also affects the behavior of ``Purchases/restorePurchases(completion:)``. + * + * - Parameter apiKey: The API Key generated for your app from https://app.revenuecat.com/ + * + * - Returns: An instantiated ``Purchases`` object that has been set as a singleton. + */ + @objc(configureWithAPIKey:) + @discardableResult static func configure(withAPIKey apiKey: String) -> Purchases { + Self.configure(withAPIKey: apiKey, appUserID: nil) + } + + /** + * Configures an instance of the Purchases SDK with a specified API key and app user ID. + * + * The instance will be set as a singleton. + * You should access the singleton instance using ``Purchases/shared`` + * + * - Note: Best practice is to use a salted hash of your unique app user ids. + * + * - Warning: Use this initializer if you have your own user identifiers that you manage. + * + * - Parameter apiKey: The API Key generated for your app from https://app.revenuecat.com/ + * + * - Parameter appUserID: The unique app user id for this user. This user id will allow users to share their + * purchases and subscriptions across devices. Pass `nil` or an empty string if you want ``Purchases`` + * to generate this for you. + * + * - Returns: An instantiated ``Purchases`` object that has been set as a singleton. + */ + @_disfavoredOverload + @objc(configureWithAPIKey:appUserID:) + @discardableResult static func configure(withAPIKey apiKey: String, appUserID: String?) -> Purchases { + Self.configure(withAPIKey: apiKey, + appUserID: appUserID, + purchasesAreCompletedBy: .revenueCat, + storeKitVersion: .default) + } + + @available(*, deprecated, message: """ + The appUserID passed to logIn is a constant string known at compile time. + This is likely a programmer error. This ID is used to identify the current user. + See https://docs.revenuecat.com/docs/user-ids for more information. + """) + // swiftlint:disable:next missing_docs + @discardableResult static func configure(withAPIKey apiKey: String, appUserID: StaticString) -> Purchases { + Logger.warn(Strings.identity.logging_in_with_static_string) + return Self.configure(withAPIKey: apiKey, + appUserID: "\(appUserID)", + purchasesAreCompletedBy: .revenueCat, + storeKitVersion: .default) + } + + /** + * Configures an instance of the Purchases SDK with a specified API key, app user ID, purchasesAreCompletedBy + * setting, and StoreKit version. + * + * Use this constructor if you want to set purchasesAreCompletedBy. The instance of the Purchases SDK + * will be set as a singleton. You should access the singleton instance using ``Purchases/shared``. + * + * - Parameter apiKey: The API Key generated for your app from https://app.revenuecat.com/ + * + * - Parameter appUserID: The unique app user id for this user. This user id will allow users to share their + * purchases and subscriptions across devices. Pass `nil` or an empty string if you want ``Purchases`` + * to generate this for you. + * + * - Parameter purchasesAreCompletedBy: Set this to ``PurchasesAreCompletedBy/myApp`` + * if you have your own IAP implementation and want to use only RevenueCat's backend. + * Default is ``PurchasesAreCompletedBy/revenueCat``. + * + * - Parameter storeKitVersion: The StoreKit version Purchases will use to process your purchases. + * + * - Returns: An instantiated ``Purchases`` object that has been set as a singleton. + * + * - Warning: If purchasesAreCompletedBy is ``PurchasesAreCompletedBy/myApp`` + * and storeKitVersion is ``StoreKitVersion/storeKit2``, ensure that you're + * calling ``Purchases/recordPurchase(_:)`` after making a purchase. + */ + @_disfavoredOverload + @objc(configureWithAPIKey:appUserID:purchasesAreCompletedBy:storeKitVersion:) + @discardableResult static func configure(withAPIKey apiKey: String, + appUserID: String?, + purchasesAreCompletedBy: PurchasesAreCompletedBy, + storeKitVersion: StoreKitVersion) -> Purchases { + return Self.configure( + with: Configuration + .builder(withAPIKey: apiKey) + .with(appUserID: appUserID) + .with(purchasesAreCompletedBy: purchasesAreCompletedBy, storeKitVersion: storeKitVersion) + .build() + ) + } + + @available(*, deprecated, message: """ + The appUserID passed to logIn is a constant string known at compile time. + This is likely a programmer error. This ID is used to identify the current user. + See https://docs.revenuecat.com/docs/user-ids for more information. + """) + // swiftlint:disable:next missing_docs + @discardableResult static func configure(withAPIKey apiKey: String, + appUserID: StaticString, + purchasesAreCompletedBy: PurchasesAreCompletedBy, + storeKitVersion: StoreKitVersion) -> Purchases { + Logger.warn(Strings.identity.logging_in_with_static_string) + + return Self.configure( + with: Configuration + .builder(withAPIKey: apiKey) + .with(appUserID: "\(appUserID)") + .with(purchasesAreCompletedBy: purchasesAreCompletedBy, storeKitVersion: storeKitVersion) + .build() + ) + } + + #else + + /** + * Configures an instance of the Purchases SDK with a specified API key and + * app user ID in Custom Entitlements Computation mode. + + * - Warning: Configuring in Custom Entitlements Computation mode should only be enabled after + * being instructed to do so by the RevenueCat team. + * Apps configured in this mode will not have anonymous IDs, will not be able to use logOut methods, + * and will not have their CustomerInfo cache refreshed automatically. + * + * ## Custom Entitlements Computation mode + * This mode is intended for apps that will use RevenueCat to manage payment flows, + * but **will not** use RevenueCat's SDK to compute entitlements. + * Apps using this mode will instead rely on webhooks to get notified when purchases go through + * and to merge information between RevenueCat's servers + * and their own. + * + * In this mode, the RevenueCat SDK will never generate anonymous IDs. Instead, it can only be configured + * with a known appUserID, and the logOut methods + * will return an error if called. To change users, call ``logIn(_:)-arja``. + * + * The instance will be set as a singleton. + * You should access the singleton instance using ``Purchases/shared``. + * + * - Note: Best practice is to use a salted hash of your unique app user ids. + * + * - Parameter apiKey: The API Key generated for your app from https://app.revenuecat.com/ + * + * - Parameter appUserID: The unique app user id for this user. This user id will allow users to share their + * purchases and subscriptions across devices. Pass `nil` or an empty string if you want ``Purchases`` + * to generate this for you. + * + * - Returns: An instantiated ``Purchases`` object that has been set as a singleton. + */ + @objc(configureInCustomEntitlementsModeWithApiKey:appUserID:) + @discardableResult static func configureInCustomEntitlementsComputationMode(apiKey: String, + appUserID: String) -> Purchases { + Self.configureInCustomEntitlementsComputationMode( + apiKey: apiKey, + appUserID: appUserID, + showStoreMessagesAutomatically: true + ) + } + + /** + * Configures an instance of the Purchases SDK with a specified API key and + * app user ID in Custom Entitlements Computation mode. + + * - Warning: Configuring in Custom Entitlements Computation mode should only be enabled after + * being instructed to do so by the RevenueCat team. + * Apps configured in this mode will not have anonymous IDs, will not be able to use logOut methods, + * and will not have their CustomerInfo cache refreshed automatically. + * + * ## Custom Entitlements Computation mode + * This mode is intended for apps that will use RevenueCat to manage payment flows, + * but **will not** use RevenueCat's SDK to compute entitlements. + * Apps using this mode will instead rely on webhooks to get notified when purchases go through + * and to merge information between RevenueCat's servers + * and their own. + * + * In this mode, the RevenueCat SDK will never generate anonymous IDs. Instead, it can only be configured + * with a known appUserID, and the logOut methods + * will return an error if called. To change users, call ``logIn(_:)-arja``. + * + * The instance will be set as a singleton. + * You should access the singleton instance using ``Purchases/shared``. + * + * - Note: Best practice is to use a salted hash of your unique app user ids. + * + * - Parameter apiKey: The API Key generated for your app from https://app.revenuecat.com/ + * + * - Parameter appUserID: The unique app user id for this user. This user id will allow users to share their + * purchases and subscriptions across devices. Pass `nil` or an empty string if you want ``Purchases`` + * to generate this for you. + * + * - Parameter showStoreMessagesAutomatically: Enabled by default. If enabled, if the user has + * billing issues, has yet to accept a price increase consent or there are other messages from StoreKit, they will + * be displayed automatically when the app is initialized. + * + * - Returns: An instantiated ``Purchases`` object that has been set as a singleton. + */ + @objc(configureInCustomEntitlementsModeWithApiKey:appUserID:showStoreMessagesAutomatically:) + @discardableResult static func configureInCustomEntitlementsComputationMode( + apiKey: String, + appUserID: String, + showStoreMessagesAutomatically: Bool = true + ) -> Purchases { + Self.configure( + with: Configuration.Builder(withAPIKey: apiKey, appUserID: appUserID) + .with(showStoreMessagesAutomatically: showStoreMessagesAutomatically) + .build() + ) + } + + #endif + + // swiftlint:disable:next function_parameter_count + @discardableResult internal static func configure( + withAPIKey apiKey: String, + appUserID: String?, + observerMode: Bool, + userDefaults: UserDefaults?, + applicationSupportDirectory: URL? = nil, + platformInfo: PlatformInfo?, + responseVerificationMode: Signing.ResponseVerificationMode, + storeKitVersion: StoreKitVersion, + storeKitTimeout: TimeInterval, + networkTimeout: TimeInterval, + dangerousSettings: DangerousSettings?, + showStoreMessagesAutomatically: Bool, + diagnosticsEnabled: Bool, + preferredLocale: String?, + automaticDeviceIdentifierCollectionEnabled: Bool = true + ) -> Purchases { + return self.setDefaultInstance( + .init(apiKey: apiKey, + appUserID: appUserID, + userDefaults: userDefaults, + applicationSupportDirectory: applicationSupportDirectory, + observerMode: observerMode, + platformInfo: platformInfo, + responseVerificationMode: responseVerificationMode, + storeKitVersion: storeKitVersion, + storeKitTimeout: storeKitTimeout, + networkTimeout: networkTimeout, + dangerousSettings: dangerousSettings, + showStoreMessagesAutomatically: showStoreMessagesAutomatically, + diagnosticsEnabled: diagnosticsEnabled, + preferredLocale: preferredLocale, + automaticDeviceIdentifierCollectionEnabled: automaticDeviceIdentifierCollectionEnabled) + ) + } + +} + +// MARK: Delegate implementation + +extension Purchases: PurchasesOrchestratorDelegate { + + /** + * Called when a user initiates a promoted in-app purchase from the App Store. + * + * If your app is able to handle a purchase at the current time, run the `startPurchase` block. + * + * If the app is not in a state to make a purchase: cache the `startPurchase` block, then call it + * when the app is ready to make the promoted purchase. + * + * If the purchase should never be made, you don't need to ever call the `startPurchase` block + * and ``Purchases`` will not proceed with promoted purchases. + * + * - Parameter product: ``StoreProduct`` the product that was selected from the app store. + * - Parameter startPurchase: Method that begins the purchase flow for the promoted purchase. + * If the app is ready to start the purchase flow when this delegate method is called, then this method + * should be called right away. Otherwise, the method should be stored as a property in memory, and then called + * once the app is ready to start the purchase flow. + * When the purchase completes, the result will be part of the callback parameters. + */ + func readyForPromotedProduct(_ product: StoreProduct, + purchase startPurchase: @escaping StartPurchaseBlock) { + + switch self.systemInfo.storeKitVersion.effectiveVersion { + case .storeKit1: + // Calling the delegate method on the main actor causes test failures on iOS 14-16, so instead + // we dispatch to the main thread, which doesn't cause the failures. + OperationDispatcher.default.dispatchOnMainThread { + self.delegate?.purchases?(self, readyForPromotedProduct: product, purchase: startPurchase) + } + case .storeKit2: + // Ensure that the delegate method is called on the main actor for StoreKit 2. + OperationDispatcher.default.dispatchOnMainActor { + self.delegate?.purchases?(self, readyForPromotedProduct: product, purchase: startPurchase) + } + } + + } + +#if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + @available(iOS 13.4, macCatalyst 13.4, *) + var shouldShowPriceConsent: Bool { + self.delegate?.shouldShowPriceConsent ?? true + } +#endif + +} + +// MARK: Deprecated + +public extension Purchases { + + /** + * Enable debug logging. Useful for debugging issues with the lovely team @RevenueCat. + */ + @available(*, deprecated, message: "use Purchases.logLevel instead") + @objc static var debugLogsEnabled: Bool { + get { logLevel == .debug } + set { logLevel = newValue ? .debug : .info } + } + + /** + * Deprecated + */ + @available(*, deprecated, message: """ + Configure behavior through the RevenueCat dashboard instead. If you have configured the \"Legacy\" restore + behavior in the [RevenueCat Dashboard](app.revenuecat.com) and are currently setting this to `true`, keep + this setting active. + """ + ) + @objc var allowSharingAppStoreAccount: Bool { + get { purchasesOrchestrator.allowSharingAppStoreAccount } + set { purchasesOrchestrator.allowSharingAppStoreAccount = newValue } + } + + /** + * Deprecated. Where responsibility for completing purchase transactions lies. + */ + @available(*, deprecated, message: "Use ``purchasesAreCompletedBy`` instead.") + @objc var finishTransactions: Bool { + get { self.systemInfo.finishTransactions } + set { self.systemInfo.finishTransactions = newValue } + } + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + /** + * Deprecated + */ + @available(*, deprecated, message: "Use the set functions instead") + @objc static func addAttributionData(_ data: [String: Any], fromNetwork network: AttributionNetwork) { + self.addAttributionData(data, from: network, forNetworkUserId: nil) + } + + /** + * Send your attribution data to RevenueCat so you can track the revenue generated by your different campaigns. + * + * - Parameter data: Dictionary provided by the network. + * - Parameter network: Enum for the network the data is coming from, see ``AttributionNetwork`` for supported + * networks. + * - Parameter networkUserId: User Id that should be sent to the network. Default is the current App User Id. + * + * #### Related articles + * - [Attribution](https://docs.revenuecat.com/docs/attribution) + */ + @available(*, deprecated, message: "Use the set functions instead") + @objc(addAttributionData:fromNetwork:forNetworkUserId:) + static func addAttributionData(_ data: [String: Any], + from network: AttributionNetwork, + forNetworkUserId networkUserId: String?) { + if Self.isConfigured { + Self.shared.post(attributionData: data, fromNetwork: network, forNetworkUserId: networkUserId) + } else { + AttributionPoster.store(postponedAttributionData: data, + fromNetwork: network, + forNetworkUserId: networkUserId) + } + } + + #endif + +} + +// @unchecked because: +// - It contains `NotificationCenter`, which isn't thread-safe as of Swift 5.7. +// - It has a mutable `privateDelegate` (this isn't actually thread-safe!) +// - It has a mutable `customerInfoObservationDisposable` because it's late-initialized in the constructor +// +// One could argue this warrants making this class non-Sendable, but the annotation allows its usage in +// async contexts in a much more simple way without errors like: +// "Capture of 'self' with non-sendable type 'Purchases' in a `@Sendable` closure" +extension Purchases: @unchecked Sendable {} + +// MARK: Internal + +extension Purchases { + + /// Used when purchasing through `SwiftUI` paywalls. + @_spi(Internal) public func cachePresentedOfferingContext(_ context: PresentedOfferingContext, + productIdentifier: String) { + Logger.debug(Strings.purchase.caching_presented_offering_identifier( + offeringID: context.offeringIdentifier, + productID: productIdentifier + )) + + self.purchasesOrchestrator.cachePresentedOfferingContext( + context, + productIdentifier: productIdentifier + ) + } + + // swiftlint:disable missing_docs + @_spi(Internal) public var preferredLocales: [String] { + return self.systemInfo.preferredLocales + } + + // `preferredLocales` will always include the preferred locale override if set, so this + // property is only useful for reading the override value + // swiftlint:disable missing_docs + @_spi(Internal) public var preferredLocaleOverride: String? { + return self.systemInfo.preferredLocaleOverride + } +} + +extension Purchases: InternalPurchasesType { + + internal func healthRequest(signatureVerification: Bool) async throws { + do { + try await self.backend.healthRequest(signatureVerification: signatureVerification) + } catch { + throw NewErrorUtils.purchasesError(withUntypedError: error) + } + } + + #if DEBUG + internal func healthReport() async -> PurchasesDiagnostics.SDKHealthReport { + await self.healthManager.healthReport() + } + #endif + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func productEntitlementMapping() async throws -> ProductEntitlementMapping { + let response = try await Async.call { completion in + self.backend.offlineEntitlements.getProductEntitlementMapping(isAppBackgrounded: false) { result in + completion(result.mapError(\.asPublicError)) + } + } + + return response.toMapping() + } + + var responseVerificationMode: Signing.ResponseVerificationMode { + return self.systemInfo.responseVerificationMode + } + +} + +/// Necessary because `ErrorUtils` inside of `Purchases` finds the obsoleted type. +private typealias NewErrorUtils = ErrorUtils + +internal extension Purchases { + + var isStoreKit1Configured: Bool { + return self.paymentQueueWrapper.sk1Wrapper != nil + } + + var isStoreKit2EnabledAndAvailable: Bool { + return self.systemInfo.storeKitVersion.isStoreKit2EnabledAndAvailable + } + + #if DEBUG + + /// - Returns: the parsed `AppleReceipt` + /// + /// - Warning: this is only meant for integration tests, as a way to debug purchase failures. + func fetchReceipt(_ policy: ReceiptRefreshPolicy) async throws -> AppleReceipt? { + let receipt = await self.receiptFetcher.receiptData(refreshPolicy: policy) + + return try receipt.map { try PurchasesReceiptParser.default.parse(from: $0) } + } + + #endif + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + /// - Parameter syncedAttribute: will be called for every attribute that is updated + /// - Parameter completion: will be called once all attributes have completed syncing + /// - Returns: the number of attributes that will be synced + @discardableResult + func syncSubscriberAttributes( + syncedAttribute: (@Sendable (PublicError?) -> Void)? = nil, + completion: (@Sendable () -> Void)? = nil + ) -> Int { + return self.attribution.syncAttributesForAllUsers( + currentAppUserID: self.appUserID, + syncedAttribute: { @Sendable in syncedAttribute?($0?.asPublicError) }, + completion: completion + ) + } + + #endif + +} + +#if DEBUG + +// MARK: - Exposed data for testing only + +internal extension Purchases { + + var networkTimeout: TimeInterval { + return self.backend.networkTimeout + } + + var storeKitTimeout: TimeInterval { + return self.productsManager.requestTimeout + } + + var observerMode: Bool { + return self.systemInfo.observerMode + } + + var configuredUserDefaults: UserDefaults { + return self.userDefaults + } + + var offlineCustomerInfoEnabled: Bool { + return self.backend.offlineCustomerInfoEnabled && self.systemInfo.supportsOfflineEntitlements + } + + var publicKey: Signing.PublicKey? { + return self.systemInfo.responseVerificationMode.publicKey + } + + var receiptURL: URL? { + return self.receiptFetcher.receiptURL + } + + func invalidateOfferingsCache() { + self.offeringsManager.invalidateCachedOfferings(appUserID: self.appUserID) + } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func flushPaywallEvents(count: Int) async throws -> Int { + return try await self.eventsManager?.flushFeatureEvents(batchSize: count) ?? 0 + } + +} + +#endif + +// MARK: Private + +private extension Purchases { + + func handleCustomerInfoChanged(from old: CustomerInfo?, to new: CustomerInfo) { + if old != nil { + self.trialOrIntroPriceEligibilityChecker.clearCache() + self.purchasedProductsFetcher?.clearCache() + } + + self.delegate?.purchases?(self, receivedUpdated: new) + } + + @objc func applicationDidBecomeActive() { + purchasesOrchestrator.handleApplicationDidBecomeActive() + } + + @objc func applicationWillEnterForeground() { + Logger.debug(Strings.configure.application_foregrounded) + + self.systemInfo.isAppBackgroundedState = false + + // Note: it's important that we observe "will enter foreground" instead of + // "did become active" so that we don't trigger cache updates in the middle + // of purchases due to pop-ups stealing focus from the app. + self.updateAllCachesIfNeeded(isAppBackgrounded: false) + self.dispatchSyncSubscriberAttributes() + self.transactionMetadataSyncHelper.syncIfNeeded( + allowSharingAppStoreAccount: self.purchasesOrchestrator.allowSharingAppStoreAccount + ) + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + #if os(iOS) || os(macOS) || VISION_OS + if #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) { + self.attribution.postAdServicesTokenOncePerInstallIfNeeded() + } + #endif + + self.purchasesOrchestrator.postEventsIfNeeded(delayed: true) + + #endif + } + + @objc func applicationDidEnterBackground() { + self.systemInfo.isAppBackgroundedState = true + } + + @objc func applicationWillResignActive() { + self.dispatchSyncSubscriberAttributes() + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + self.purchasesOrchestrator.postEventsIfNeeded() + #endif + } + + func subscribeToAppStateNotifications() { + self.notificationCenter.addObserver(self, + selector: #selector(self.applicationWillEnterForeground), + name: SystemInfo.applicationWillEnterForegroundNotification, + object: nil) + + self.notificationCenter.addObserver(self, + selector: #selector(self.applicationWillResignActive), + name: SystemInfo.applicationWillResignActiveNotification, + object: nil) + + self.notificationCenter.addObserver(self, + selector: #selector(self.applicationDidEnterBackground), + name: SystemInfo.applicationDidEnterBackgroundNotification, + object: nil) + + self.notificationCenter.addObserver(self, + selector: #selector(self.applicationDidBecomeActive), + name: SystemInfo.applicationDidBecomeActiveNotification, + object: nil) + } + + func dispatchSyncSubscriberAttributes() { + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + self.operationDispatcher.dispatchOnWorkerThread { + self.syncSubscriberAttributes() + } + #endif + } + + private func performInitialForegroundSetup() { + self.systemInfo.isApplicationBackgrounded { [weak self] isBackgrounded in + guard !isBackgrounded, let self = self else { return } + + self.operationDispatcher.dispatchOnWorkerThread { [weak self] in + self?.updateAllCaches(isAppBackgrounded: isBackgrounded, completion: nil) + + // Run the health check after all cache operations have been + // enqueued on the serial queue, so it doesn't block user-facing requests. + #if DEBUG && !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + self?.enqueueHealthCheckIfNeeded() + #endif + } + } + } + + #if DEBUG && !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + private func enqueueHealthCheckIfNeeded() { + // This is a workaround and needs to be fixed at some point. Explanation: + // The StoreKit integration tests are very time sensitive and set very + // short expiry times for most products. This results in flakiness, which + // is further aggravated by the fact that the health check adds an extra async + // method and thus an extra delay. + // To avoid this, we skip the health check when running integration tests. + // This is not ideal, and we should consider making the tests more resilient + // in the future. + guard !ProcessInfo.isRunningIntegrationTests else { return } + + let appUserID = self.appUserID + + Task { [weak self] in + guard let availability = try? await self?.backend.healthReportAvailabilityRequest( + appUserID: appUserID + ), availability.reportLogs else { + return + } + await self?.healthManager.logSDKHealthReportOutcome() + } + } + #endif + + func updateAllCachesIfNeeded(isAppBackgrounded: Bool) { + guard !self.systemInfo.dangerousSettings.uiPreviewMode else { + // No need to update caches every time when in UI preview mode. + // Only needed at configuration time + return + } + + if !self.systemInfo.dangerousSettings.customEntitlementComputation { + self.customerInfoManager.fetchAndCacheCustomerInfoIfStale(appUserID: self.appUserID, + isAppBackgrounded: isAppBackgrounded, + completion: nil) + self.offlineEntitlementsManager.updateProductsEntitlementsCacheIfStale( + isAppBackgrounded: isAppBackgrounded, + completion: nil + ) + } + + if self.deviceCache.isOfferingsCacheStale(isAppBackgrounded: isAppBackgrounded) { + self.updateOfferingsCache(isAppBackgrounded: isAppBackgrounded) + } + } + + func updateAllCaches(completion: ((Result) -> Void)?) { + self.systemInfo.isApplicationBackgrounded { isAppBackgrounded in + self.updateAllCaches(isAppBackgrounded: isAppBackgrounded, + completion: completion) + } + } + + func updateAllCaches( + isAppBackgrounded: Bool, + completion: ((Result) -> Void)? + ) { + Logger.verbose(Strings.purchase.updating_all_caches) + + if self.systemInfo.dangerousSettings.customEntitlementComputation || + self.systemInfo.dangerousSettings.uiPreviewMode { + if let completion = completion { + let error = NewErrorUtils.featureNotAvailableInCustomEntitlementsComputationModeError() + completion(.failure(error.asPublicError)) + } + } else { + self.customerInfoManager.fetchAndCacheCustomerInfo(appUserID: self.appUserID, + isAppBackgrounded: isAppBackgrounded) { @Sendable in + completion?($0.mapError { $0.asPublicError }) + } + + self.offlineEntitlementsManager.updateProductsEntitlementsCacheIfStale( + isAppBackgrounded: isAppBackgrounded, + completion: nil + ) + } + + self.updateOfferingsCache(isAppBackgrounded: isAppBackgrounded) + } + + // Used when delegate is being set + func sendCachedCustomerInfoToDelegateIfExists() { + guard let info = try? self.customerInfoManager.cachedCustomerInfo(appUserID: self.appUserID) else { + return + } + + self.delegate?.purchases?(self, receivedUpdated: info) + self.customerInfoManager.setLastSentCustomerInfo(info) + } + + private func updateOfferingsCache(isAppBackgrounded: Bool) { + self.offeringsManager.updateOfferingsCache( + appUserID: self.appUserID, + isAppBackgrounded: isAppBackgrounded + ) { [weak self] offeringsResultData in + if #available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *), + let offerings = offeringsResultData.value?.offerings { + self?.warmUpCaches(offerings: offerings) + } + } + } + + @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *) + private func warmUpCaches(offerings: Offerings) { + guard let cache = self.paywallCache else { + return + } + self.operationDispatcher.dispatchOnWorkerThread { + await cache.warmUpEligibilityCache(offerings: offerings) + } + self.operationDispatcher.dispatchOnWorkerThread { + await cache.warmUpPaywallImagesCache(offerings: offerings) + } + self.operationDispatcher.dispatchOnWorkerThread { + await cache.warmUpPaywallFontsCache(offerings: offerings) + } + } + +} + +// MARK: - Win-Back Offers +#if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION +@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +extension Purchases { + + /** + * Returns the win-back offers that the subscriber is eligible for on the provided product. + * + * - Parameter product: The product to check for eligible win-back offers. + * - Returns: The win-back offers on the given product that a subscriber is eligible for. + * - Important: Win-back offers are only supported when the SDK is running with StoreKit 2 enabled. + */ + public func eligibleWinBackOffers( + forProduct product: StoreProduct + ) async throws -> [WinBackOffer] { + return try await self.purchasesOrchestrator.eligibleWinBackOffers(forProduct: product) + } + + /** + * Returns the win-back offers that the subscriber is eligible for on the provided package. + * + * - Parameter package: The package to check for eligible win-back offers. + * - Returns: The win-back offers on the given product that a subscriber is eligible for. + * - Important: Win-back offers are only supported when the SDK is running with StoreKit 2 enabled. + */ + @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) + public func eligibleWinBackOffers( + forPackage package: Package + ) async throws -> [WinBackOffer] { + return try await self.eligibleWinBackOffers(forProduct: package.storeProduct) + } + + /** + * Returns the win-back offers that the subscriber is eligible for on the provided product. + * + * - Parameter product: The product to check for eligible win-back offers. + * - Parameter completion: A completion block that is called with the eligible win-back + * offers for the provided product. + * - Important: Win-back offers are only supported when the SDK is running with StoreKit 2 enabled. + */ + @objc public func eligibleWinBackOffers( + forProduct product: StoreProduct, + completion: @escaping @Sendable ([WinBackOffer]?, PublicError?) -> Void + ) { + Task { + do { + let eligibleWinBackOffers = try await self.eligibleWinBackOffers(forProduct: product) + OperationDispatcher.dispatchOnMainActor { + completion(eligibleWinBackOffers, nil) + } + } catch { + let publicError = NewErrorUtils.purchasesError(withUntypedError: error).asPublicError + OperationDispatcher.dispatchOnMainActor { + completion(nil, publicError) + } + } + } + } + + /** + * Returns the win-back offers that the subscriber is eligible for on the provided package. + * + * - Parameter package: The package to check for eligible win-back offers. + * - Parameter completion: A completion block that is called with the eligible win-back + * offers for the provided product. + * - Important: Win-back offers are only supported when the SDK is running with StoreKit 2 enabled. + */ + @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) + @objc public func eligibleWinBackOffers( + forPackage package: Package, + completion: @escaping @Sendable ([WinBackOffer]?, PublicError?) -> Void + ) { + self.eligibleWinBackOffers( + forProduct: package.storeProduct + ) { winBackOffers, error in + completion(winBackOffers, error) + } + } +} +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/PurchasesAreCompletedBy.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/PurchasesAreCompletedBy.swift new file mode 100644 index 00000000..df3f9144 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/PurchasesAreCompletedBy.swift @@ -0,0 +1,39 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PurchasesAreCompletedBy.swift +// +// Created by James Borthwick on 2024-05-30. + +import Foundation + +/// Where responsibility for completing purchase transactions lies. +@objc(RCPurchasesAreCompletedBy) +public enum PurchasesAreCompletedBy: Int { + + /// Purchase transactions are to be finished by RevenueCat. + case revenueCat + + /// Purchase transactions are to be finished by your app. + case myApp + +} + +extension PurchasesAreCompletedBy { + var finishTransactions: Bool { + self == .revenueCat + } + + var observerMode: Bool { + !self.finishTransactions + } +} + +extension PurchasesAreCompletedBy: Sendable {} +extension PurchasesAreCompletedBy: Codable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/PurchasesDelegate.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/PurchasesDelegate.swift new file mode 100644 index 00000000..70f79b03 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/PurchasesDelegate.swift @@ -0,0 +1,99 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PurchasesDelegate.swift +// +// Created by Joshua Liebowitz on 8/18/21. +// + +import Foundation + +/** + * Delegate for ``Purchases`` responsible for handling updating your app's state in response to updated customer info + * or promotional product purchases. + * + * - Note: Delegate methods can be called at any time after the `delegate` is set, not just in response to + * `customerInfo:` calls. Ensure your app is capable of handling these calls at anytime if `delegate` is set. + */ +@objc(RCPurchasesDelegate) public protocol PurchasesDelegate: NSObjectProtocol { + + /** + * - Note: Deprecated, use purchases(_ purchases: Purchases, receivedUpdated customerInfo: CustomerInfo) or + * objc: purchases:receivedUpdatedCustomerInfo: + */ + @available(swift, obsoleted: 1, renamed: "purchases(_:receivedUpdated:)") + @available(iOS, obsoleted: 1) + @available(macOS, obsoleted: 1) + @available(tvOS, obsoleted: 1) + @available(watchOS, obsoleted: 1) + @objc(purchases:didReceiveUpdatedPurchaserInfo:) + optional func purchases(_ purchases: Purchases, didReceiveUpdated purchaserInfo: CustomerInfo) + + /** + * Called whenever ``Purchases`` receives updated customer info. This may happen periodically + * throughout the life of the app if new information becomes available (e.g. UIApplicationDidBecomeActive).* + * - Parameter purchases: Related ``Purchases`` object + * - Parameter customerInfo: Updated ``CustomerInfo`` + */ + @objc(purchases:receivedUpdatedCustomerInfo:) + optional func purchases(_ purchases: Purchases, receivedUpdated customerInfo: CustomerInfo) + + /** + * Called when a user initiates a promotional in-app purchase from the App Store. + * If your app is able to handle a purchase at the current time, run the deferment block in this method. + * If the app is not in a state to make a purchase: cache the `startPurchase` block, + * then call the `startPurchase` block when the app is ready to make the promotional purchase. + * + * If the purchase should never be made, you don't need to ever call the block and + * ``Purchases`` will not proceed with the promotional purchase. + * + * This can be tested by opening a link like: + * itms-services://?action=purchaseIntent&bundleId=&productIdentifier= + * + * - Parameter product: `StoreProduct` the product that was selected from the app store + * - Parameter startPurchase: call this block when the app is ready to handle the purchase + * + * ### Related Articles: + * - [Apple Documentation](https://rev.cat/testing-promoted-in-app-purchases) + */ + @objc optional func purchases(_ purchases: Purchases, + readyForPromotedProduct product: StoreProduct, + purchase startPurchase: @escaping StartPurchaseBlock) + + @available(iOS, obsoleted: 1, renamed: "purchases(_:readyForPromotedProduct:purchase:)") + @available(tvOS, obsoleted: 1, renamed: "purchases(_:readyForPromotedProduct:purchase:)") + @available(watchOS, obsoleted: 1, renamed: "purchases(_:readyForPromotedProduct:purchase:)") + @available(macOS, obsoleted: 1, renamed: "purchases(_:readyForPromotedProduct:purchase:)") + @available(macCatalyst, obsoleted: 1, renamed: "purchases(_:readyForPromotedProduct:purchase:)") + // swiftlint:disable:next missing_docs + @objc optional func purchases(_ purchases: Purchases, + shouldPurchasePromoProduct product: StoreProduct, + defermentBlock makeDeferredPurchase: @escaping StartPurchaseBlock) + + /** + * The default return value for this optional method is true. By default, the system displays the price consent + * sheet when you increase the subscription price in App Store Connect and the subscriber hasn’t yet taken action. + * + * The system calls your delegate’s method, if appropriate, when RevenueCat starts observing the `SKPaymentQueue`, + * and any time the app comes to foreground. + * + * If you return false, the system won’t show the price consent sheet. You can choose to display it later by + * calling ``Purchases/showPriceConsentIfNeeded()``. + * You may want to delay showing the sheet if it would interrupt your user’s interaction in your app. + * + * ### Related Articles + * - [Apple Documentation](https://rev.cat/testing-promoted-in-app-purchases) + */ + @available(iOS 13.4, macCatalyst 13.4, *) + @available(macOS, unavailable) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + @objc optional var shouldShowPriceConsent: Bool { get } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift new file mode 100644 index 00000000..93117dc2 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift @@ -0,0 +1,2374 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PurchasesOrchestrator.swift +// +// Created by Andrés Boedo on 10/8/21. + +import Foundation +import StoreKit + +@objc protocol PurchasesOrchestratorDelegate { + + func readyForPromotedProduct(_ product: StoreProduct, + purchase startPurchase: @escaping StartPurchaseBlock) + + @available(iOS 13.4, macCatalyst 13.4, *) + @available(macOS, unavailable) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + var shouldShowPriceConsent: Bool { get } + +} + +// swiftlint:disable file_length type_body_length function_body_length +final class PurchasesOrchestrator { + + var finishTransactions: Bool { self.systemInfo.finishTransactions } + var observerMode: Bool { self.systemInfo.observerMode } + + var allowSharingAppStoreAccount: Bool { + get { self._allowSharingAppStoreAccount.value ?? self.currentUserProvider.currentUserIsAnonymous } + set { self._allowSharingAppStoreAccount.value = newValue } + } + + /// - Note: this is not thread-safe + @objc weak var delegate: PurchasesOrchestratorDelegate? + + private let _allowSharingAppStoreAccount: Atomic = nil + private let presentedOfferingContextsByProductID: Atomic<[String: CachedPresentedOfferingContext]> = .init([:]) + private let purchaseInitiatedPaywall: Atomic = nil + private let purchaseCompleteCallbacksByProductID: Atomic<[String: PurchaseCompletedBlock]> = .init([:]) + private let isSyncingCachedTransactionMetadata: Atomic = .init(false) + + private var appUserID: String { self.currentUserProvider.currentAppUserID } + private var unsyncedAttributes: SubscriberAttribute.Dictionary { + self.attribution.unsyncedAttributesByKey(appUserID: self.appUserID) + } + + private let productsManager: ProductsManagerType + private let paymentQueueWrapper: EitherPaymentQueueWrapper + private let simulatedStorePurchaseHandler: SimulatedStorePurchaseHandlerType + private let systemInfo: SystemInfo + private let attribution: Attribution + private let operationDispatcher: OperationDispatcher + private let receiptFetcher: ReceiptFetcher + private let receiptParser: PurchasesReceiptParser + private let transactionFetcher: StoreKit2TransactionFetcherType + private let customerInfoManager: CustomerInfoManager + private let backend: Backend + private let transactionPoster: TransactionPosterType + private let currentUserProvider: CurrentUserProvider + private let transactionsManager: TransactionsManager + private let deviceCache: DeviceCache + private let offeringsManager: OfferingsManager + private let manageSubscriptionsHelper: ManageSubscriptionsHelper + private let beginRefundRequestHelper: BeginRefundRequestHelper + private let storeMessagesHelper: StoreMessagesHelperType? + private let winBackOfferEligibilityCalculator: WinBackOfferEligibilityCalculatorType? + private let eventsManager: EventsManagerType? + private let webPurchaseRedemptionHelper: WebPurchaseRedemptionHelperType + private let dateProvider: DateProvider + + let notificationCenter: NotificationCenter + + // Can't have these properties with `@available`. + // swiftlint:disable identifier_name + var _storeKit2TransactionListener: Any? + var _storeKit2PurchaseIntentListener: Any? + var _storeKit2StorefrontListener: Any? + var _diagnosticsSynchronizer: Any? + var _diagnosticsTracker: Any? + var _storeKit2ObserverModePurchaseDetector: Any? + // swiftlint:enable identifier_name + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + var storeKit2TransactionListener: StoreKit2TransactionListenerType { + // swiftlint:disable:next force_cast force_unwrapping + return self._storeKit2TransactionListener! as! StoreKit2TransactionListenerType + } + + @available(iOS 16.4, macOS 14.4, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + @available(visionOS, unavailable) + var storeKit2PurchaseIntentListener: StoreKit2PurchaseIntentListenerType { + // swiftlint:disable:next force_cast force_unwrapping + return self._storeKit2PurchaseIntentListener! as! StoreKit2PurchaseIntentListenerType + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + var storeKit2StorefrontListener: StoreKit2StorefrontListener { + // swiftlint:disable:next force_cast force_unwrapping + return self._storeKit2StorefrontListener! as! StoreKit2StorefrontListener + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + var diagnosticsSynchronizer: DiagnosticsSynchronizerType? { + return self._diagnosticsSynchronizer as? DiagnosticsSynchronizerType + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + var diagnosticsTracker: DiagnosticsTrackerType? { + return self._diagnosticsTracker as? DiagnosticsTrackerType + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + var storeKit2ObserverModePurchaseDetector: StoreKit2ObserverModePurchaseDetectorType? { + return self._storeKit2ObserverModePurchaseDetector as? StoreKit2ObserverModePurchaseDetectorType + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + convenience init(productsManager: ProductsManagerType, + paymentQueueWrapper: EitherPaymentQueueWrapper, + simulatedStorePurchaseHandler: SimulatedStorePurchaseHandlerType, + systemInfo: SystemInfo, + subscriberAttributes: Attribution, + operationDispatcher: OperationDispatcher, + receiptFetcher: ReceiptFetcher, + receiptParser: PurchasesReceiptParser, + transactionFetcher: StoreKit2TransactionFetcherType, + customerInfoManager: CustomerInfoManager, + backend: Backend, + transactionPoster: TransactionPoster, + currentUserProvider: CurrentUserProvider, + transactionsManager: TransactionsManager, + deviceCache: DeviceCache, + offeringsManager: OfferingsManager, + manageSubscriptionsHelper: ManageSubscriptionsHelper, + beginRefundRequestHelper: BeginRefundRequestHelper, + storeKit2TransactionListener: StoreKit2TransactionListenerType, + storeKit2StorefrontListener: StoreKit2StorefrontListener, + storeKit2ObserverModePurchaseDetector: StoreKit2ObserverModePurchaseDetectorType, + storeMessagesHelper: StoreMessagesHelperType?, + diagnosticsSynchronizer: DiagnosticsSynchronizerType?, + diagnosticsTracker: DiagnosticsTrackerType?, + winBackOfferEligibilityCalculator: WinBackOfferEligibilityCalculatorType?, + eventsManager: EventsManagerType?, + webPurchaseRedemptionHelper: WebPurchaseRedemptionHelperType, + dateProvider: DateProvider = DateProvider(), + notificationCenter: NotificationCenter = .default + ) { + self.init( + productsManager: productsManager, + paymentQueueWrapper: paymentQueueWrapper, + simulatedStorePurchaseHandler: simulatedStorePurchaseHandler, + systemInfo: systemInfo, + subscriberAttributes: subscriberAttributes, + operationDispatcher: operationDispatcher, + receiptFetcher: receiptFetcher, + receiptParser: receiptParser, + transactionFetcher: transactionFetcher, + customerInfoManager: customerInfoManager, + backend: backend, + transactionPoster: transactionPoster, + currentUserProvider: currentUserProvider, + transactionsManager: transactionsManager, + deviceCache: deviceCache, + offeringsManager: offeringsManager, + manageSubscriptionsHelper: manageSubscriptionsHelper, + beginRefundRequestHelper: beginRefundRequestHelper, + storeMessagesHelper: storeMessagesHelper, + diagnosticsTracker: diagnosticsTracker, + winBackOfferEligibilityCalculator: winBackOfferEligibilityCalculator, + eventsManager: eventsManager, + webPurchaseRedemptionHelper: webPurchaseRedemptionHelper, + dateProvider: dateProvider, + notificationCenter: notificationCenter + ) + + self._diagnosticsSynchronizer = diagnosticsSynchronizer + + self._storeKit2TransactionListener = storeKit2TransactionListener + self._storeKit2StorefrontListener = storeKit2StorefrontListener + self._storeKit2ObserverModePurchaseDetector = storeKit2ObserverModePurchaseDetector + + storeKit2StorefrontListener.delegate = self + if systemInfo.storeKitVersion.isStoreKit2EnabledAndAvailable { + storeKit2StorefrontListener.listenForStorefrontChanges() + } + + #if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + if #available(iOS 16.0, *), let helper = storeMessagesHelper { + Task { + do { + try await helper.deferMessagesIfNeeded() + } catch { + Logger.error(Strings.storeKit.could_not_defer_store_messages(error)) + } + } + } + #endif + + Task { + await setSK2DelegateAndStartListening() + } + + Task { + await syncDiagnosticsIfNeeded() + } + } + + init(productsManager: ProductsManagerType, + paymentQueueWrapper: EitherPaymentQueueWrapper, + simulatedStorePurchaseHandler: SimulatedStorePurchaseHandlerType, + systemInfo: SystemInfo, + subscriberAttributes: Attribution, + operationDispatcher: OperationDispatcher, + receiptFetcher: ReceiptFetcher, + receiptParser: PurchasesReceiptParser, + transactionFetcher: StoreKit2TransactionFetcherType, + customerInfoManager: CustomerInfoManager, + backend: Backend, + transactionPoster: TransactionPoster, + currentUserProvider: CurrentUserProvider, + transactionsManager: TransactionsManager, + deviceCache: DeviceCache, + offeringsManager: OfferingsManager, + manageSubscriptionsHelper: ManageSubscriptionsHelper, + beginRefundRequestHelper: BeginRefundRequestHelper, + storeMessagesHelper: StoreMessagesHelperType?, + diagnosticsTracker: DiagnosticsTrackerType?, + winBackOfferEligibilityCalculator: WinBackOfferEligibilityCalculatorType?, + eventsManager: EventsManagerType?, + webPurchaseRedemptionHelper: WebPurchaseRedemptionHelperType, + dateProvider: DateProvider = DateProvider(), + notificationCenter: NotificationCenter = .default + ) { + self.productsManager = productsManager + self.paymentQueueWrapper = paymentQueueWrapper + self.simulatedStorePurchaseHandler = simulatedStorePurchaseHandler + self.systemInfo = systemInfo + self.attribution = subscriberAttributes + self.operationDispatcher = operationDispatcher + self.receiptFetcher = receiptFetcher + self.receiptParser = receiptParser + self.transactionFetcher = transactionFetcher + self.customerInfoManager = customerInfoManager + self.backend = backend + self.transactionPoster = transactionPoster + self.currentUserProvider = currentUserProvider + self.transactionsManager = transactionsManager + self.deviceCache = deviceCache + self.offeringsManager = offeringsManager + self.manageSubscriptionsHelper = manageSubscriptionsHelper + self.beginRefundRequestHelper = beginRefundRequestHelper + self.storeMessagesHelper = storeMessagesHelper + self._diagnosticsTracker = diagnosticsTracker + self.winBackOfferEligibilityCalculator = winBackOfferEligibilityCalculator + self.eventsManager = eventsManager + self.webPurchaseRedemptionHelper = webPurchaseRedemptionHelper + self.dateProvider = dateProvider + self.notificationCenter = notificationCenter + + Logger.verbose(Strings.purchase.purchases_orchestrator_init(self)) + } + + deinit { + Logger.verbose(Strings.purchase.purchases_orchestrator_deinit(self)) + } + + func redeemWebPurchase(_ webPurchaseRedemption: WebPurchaseRedemption) async -> WebPurchaseRedemptionResult { + return await self.webPurchaseRedemptionHelper.handleRedeemWebPurchase( + redemptionToken: webPurchaseRedemption.redemptionToken + ) + } + + func redeemWebPurchase( + webPurchaseRedemption: WebPurchaseRedemption, + completion: @escaping (CustomerInfo?, PublicError?) -> Void + ) { + Task { + let result = await self.redeemWebPurchase(webPurchaseRedemption) + switch result { + + case let .success(customerInfo): + completion(customerInfo, nil) + case let .error(error): + completion(nil, error) + case .invalidToken: + let userInfo: [String: Any] = [:] + let error = PurchasesError(error: .invalidWebPurchaseToken, userInfo: userInfo) + completion(nil, error.asPublicError) + case .purchaseBelongsToOtherUser: + let userInfo: [String: Any] = [:] + let error = PurchasesError(error: .purchaseBelongsToOtherUser, userInfo: userInfo) + completion(nil, error.asPublicError) + case let .expired(obfuscatedEmail): + let userInfo: [NSError.UserInfoKey: Any] = [ + .obfuscatedEmail: obfuscatedEmail + ] + let error = PurchasesError(error: .expiredWebPurchaseToken, userInfo: userInfo) + completion(nil, error.asPublicError) + } + } + } + + func restorePurchases(completion: (@Sendable (Result) -> Void)?) { + if self.systemInfo.isSimulatedStoreAPIKey { + Logger.debug(Strings.purchase.restore_purchases_simulated_store) + self.customerInfoManager.customerInfo(appUserID: self.appUserID, fetchPolicy: .default) { result in + completion?(result.mapError({ $0.asPurchasesError })) + } + return + } + + self.syncPurchases(receiptRefreshPolicy: .always, + isRestore: true, + initiationSource: .restore, + completion: completion) + } + + func syncPurchases(completion: (@Sendable (Result) -> Void)? = nil) { + if self.systemInfo.isSimulatedStoreAPIKey { + Logger.debug(Strings.purchase.sync_purchases_simulated_store) + self.customerInfoManager.customerInfo(appUserID: self.appUserID, fetchPolicy: .default) { result in + completion?(result.mapError({ $0.asPurchasesError })) + } + return + } + + self.syncPurchases(receiptRefreshPolicy: .never, + isRestore: allowSharingAppStoreAccount, + initiationSource: .restore, + completion: completion) + } + + func products(withIdentifiers identifiers: [String], completion: @escaping ([StoreProduct]) -> Void) { + let productIdentifiersSet = Set(identifiers) + self.trackProductsStartedIfNeeded(requestedProductIds: productIdentifiersSet) + let startTime = self.dateProvider.now() + guard !productIdentifiersSet.isEmpty else { + operationDispatcher.dispatchOnMainThread { completion([]) } + return + } + + self.productsManager.products(withIdentifiers: productIdentifiersSet) { products in + let notFoundProductIds = productIdentifiersSet.subtracting( + products.map { $0.map(\.productIdentifier) }.value.map { Set($0) } ?? [] + ) + let error = products.error + self.trackProductsResultIfNeeded(requestedProductIds: productIdentifiersSet, + notFoundProductIds: notFoundProductIds, + error: error, + startTime: startTime) + self.operationDispatcher.dispatchOnMainThread { + completion(Array(products.value ?? [])) + } + } + } + + func productsFromOptimalStoreKitVersion(withIdentifiers identifiers: [String], + completion: @escaping ([StoreProduct]) -> Void) { + let productIdentifiersSet = Set(identifiers) + guard !productIdentifiersSet.isEmpty else { + operationDispatcher.dispatchOnMainThread { completion([]) } + return + } + + productsManager.products(withIdentifiers: productIdentifiersSet) { products in + self.operationDispatcher.dispatchOnMainThread { + completion(Array(products.value ?? [])) + } + } + } + + func promotionalOffer(forProductDiscount productDiscount: StoreProductDiscountType, + product: StoreProductType, + completion: @escaping @Sendable (Result) -> Void) { + guard let discountIdentifier = productDiscount.offerIdentifier else { + self.operationDispatcher.dispatchOnMainActor { + completion(.failure(ErrorUtils.productDiscountMissingIdentifierError())) + } + return + } + + guard let subscriptionGroupIdentifier = product.subscriptionGroupIdentifier else { + self.operationDispatcher.dispatchOnMainActor { + completion(.failure(ErrorUtils.productDiscountMissingSubscriptionGroupIdentifierError())) + } + return + } + + if self.systemInfo.storeKitVersion.isStoreKit2EnabledAndAvailable, + #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { + self.sk2PromotionalOffer(forProductDiscount: productDiscount, + discountIdentifier: discountIdentifier, + product: product, + subscriptionGroupIdentifier: subscriptionGroupIdentifier) { result in + self.operationDispatcher.dispatchOnMainActor { + completion(result) + } + } + } else { + self.sk1PromotionalOffer(forProductDiscount: productDiscount, + discountIdentifier: discountIdentifier, + product: product, + subscriptionGroupIdentifier: subscriptionGroupIdentifier) { result in + self.operationDispatcher.dispatchOnMainActor { + completion(result) + } + } + + } + } + + func purchase(params: PurchaseParams, trackDiagnostics: Bool, completion: @escaping PurchaseCompletedBlock) { + var product = params.product + if product == nil { + product = params.package?.storeProduct + } + guard let product = product else { + // Should never happen since PurchaseParams.Builder initializer requires a product or a package + fatalError("Missing product in PurchaseParams") + } + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + let winBackOffer = params.winBackOffer + let metadata = params.metadata + + #else + + let winBackOffer: WinBackOffer? = nil + let metadata: [String: String]? = nil + + #endif + + #if ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + let introductoryOfferEligibilityJWS = params.introductoryOfferEligibilityJWS + let promotionalOfferOptions = params.promotionalOfferOptions + + #else + + let introductoryOfferEligibilityJWS: String? = nil + let promotionalOfferOptions: StoreKit2PromotionalOfferPurchaseOptions? = nil + + #endif + + // Validate quantity if provided + if let quantity = params.quantity { + guard quantity >= 1 && quantity <= 10 else { + let errorMessage = Strings.purchase.invalid_quantity(quantity: quantity).description + let error = ErrorUtils.purchaseInvalidError(message: errorMessage) + self.operationDispatcher.dispatchOnMainActor { + completion(nil, nil, error.asPublicError, false) + } + return + } + } + + purchase(product: product, + package: params.package, + promotionalOffer: params.promotionalOffer?.signedData, + winBackOffer: winBackOffer, + introductoryOfferEligibilityJWS: introductoryOfferEligibilityJWS, + promotionalOfferOptions: promotionalOfferOptions, + metadata: metadata, + quantity: params.quantity, + trackDiagnostics: trackDiagnostics, + completion: completion) + } + + func purchase(product: StoreProduct, + package: Package?, + promotionalOffer: PromotionalOffer.SignedData? = nil, + winBackOffer: WinBackOffer? = nil, + introductoryOfferEligibilityJWS: String? = nil, + promotionalOfferOptions: StoreKit2PromotionalOfferPurchaseOptions? = nil, + metadata: [String: String]? = nil, + quantity: Int? = nil, + trackDiagnostics: Bool, + completion: @escaping PurchaseCompletedBlock) { + Self.logPurchase(product: product, package: package, offer: promotionalOffer) + + self.trackPurchaseStartedIfNeeded(trackDiagnostics: trackDiagnostics, + productId: product.productIdentifier, + productType: product.productType) + let startTime = self.dateProvider.now() + + let completionWithTracking: PurchaseCompletedBlock = + { [weak self] transaction, customerInfo, error, userCancelled in + self?.trackPurchaseResultIfNeeded(trackDiagnostics: trackDiagnostics, + productId: product.productIdentifier, + productType: product.productType, + verificationResult: customerInfo?.entitlements.verification, + error: error, + startTime: startTime) + completion(transaction, customerInfo, error, userCancelled) + } + + if let sk1Product = product.sk1Product { + guard let storeKit1Wrapper = self.storeKit1Wrapper(orFailWith: completionWithTracking) else { return } + let payment = storeKit1Wrapper.payment(with: sk1Product, discount: promotionalOffer?.sk1PromotionalOffer) + self.purchase(sk1Product: sk1Product, + payment: payment, + package: package, + quantity: quantity, + wrapper: storeKit1Wrapper, + completion: completionWithTracking) + } else if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *), + let sk2Product = product.sk2Product { + self.purchase(sk2Product: sk2Product, + package: package, + promotionalOffer: promotionalOffer, + winBackOffer: winBackOffer, + introductoryOfferEligibilityJWS: introductoryOfferEligibilityJWS, + promotionalOfferOptions: promotionalOfferOptions, + metadata: metadata, + quantity: quantity, + completion: completionWithTracking) + } else if let simulatedStoreProduct = product.testStoreProduct { + self.handlePurchase(simulatedStoreProduct: simulatedStoreProduct, + metadata: metadata, + completion: completionWithTracking) + } else { + fatalError("Unrecognized product: \(product)") + } + } + + func purchase(sk1Product: SK1Product, + promotionalOffer: PromotionalOffer.SignedData, + package: Package?, + quantity: Int? = nil, + wrapper: StoreKit1Wrapper, + completion: @escaping PurchaseCompletedBlock) { + let discount = promotionalOffer.sk1PromotionalOffer + let payment = wrapper.payment(with: sk1Product, discount: discount) + self.purchase(sk1Product: sk1Product, + payment: payment, + package: package, + quantity: quantity, + wrapper: wrapper, + completion: completion) + } + + func purchase(sk1Product: SK1Product, + payment: SKMutablePayment, + package: Package?, + quantity: Int? = nil, + wrapper: StoreKit1Wrapper, + completion: @escaping PurchaseCompletedBlock) { + /** + * Note: this only extracts the product identifier from `SKPayment`, ignoring the `SK1Product.identifier` + * because `storeKit1Wrapper(_:, updatedTransaction:)` only has a transaction and not the product. + * If the transaction is mising a product id, then we wouldn't be able to find the callback + * in `purchaseCompleteCallbacksByProductID`, and therefore + * we wouldn't be able to notify of the purchase result. + */ + + guard let productIdentifier = payment.extractProductIdentifier() else { + self.operationDispatcher.dispatchOnMainActor { + completion(nil, + nil, + ErrorUtils.storeProblemError( + withMessage: Strings.purchase.could_not_purchase_product_id_not_found.description + ).asPublicError, + false) + } + return + } + + if !self.finishTransactions { + Logger.warn(Strings.purchase.purchasing_with_observer_mode_and_finish_transactions_false_warning) + } + + payment.applicationUsername = self.appUserID + if let quantity = quantity { + payment.quantity = quantity + } + + self.cachePresentedOfferingContext(package: package, productIdentifier: productIdentifier) + + self.productsManager.cache(StoreProduct(sk1Product: sk1Product)) + + let startTime = self.dateProvider.now() + let promotionalOfferID = payment.paymentDiscount?.identifier + + let addPayment: Bool = self.addPurchaseCompletedCallback( + productIdentifier: productIdentifier, + completion: { [weak self] transaction, customerInfo, error, cancelled in + guard let self = self else { return } + + self.trackPurchaseAttemptEventIfNeeded(startTime, + successful: !cancelled && error == nil, + productId: productIdentifier, + promotionalOfferId: promotionalOfferID, + winBackOfferApplied: false, // SK2 only + storeKitVersion: .storeKit1, + purchaseResult: nil, // SK2 only + error: error) + if !cancelled { + if let error = error { + Logger.rcPurchaseError(Strings.purchase.product_purchase_failed( + productIdentifier: productIdentifier, + error: error + )) + } else { + Logger.rcPurchaseSuccess(Strings.purchase.purchased_product( + productIdentifier: productIdentifier + )) + + self.postFeatureEventsIfNeeded() + } + } + + completion(transaction, customerInfo, error, cancelled) + } + ) + + if addPayment { + wrapper.add(payment) + } + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + // swiftlint:disable:next function_parameter_count + func purchase(sk2Product product: SK2Product, + package: Package?, + promotionalOffer: PromotionalOffer.SignedData?, + winBackOffer: WinBackOffer?, + introductoryOfferEligibilityJWS: String?, + promotionalOfferOptions: StoreKit2PromotionalOfferPurchaseOptions?, + metadata: [String: String]? = nil, + quantity: Int? = nil, + completion: @escaping PurchaseCompletedBlock) { + _ = Task { + do { + let result: PurchaseResultData = try await self.purchase( + sk2Product: product, + package: package, + promotionalOffer: promotionalOffer, + winBackOffer: winBackOffer?.discount.sk2Discount, + introductoryOfferEligibilityJWS: introductoryOfferEligibilityJWS, + promotionalOfferOptions: promotionalOfferOptions, + metadata: metadata, + quantity: quantity + ) + + if !result.userCancelled { + Logger.rcPurchaseSuccess(Strings.purchase.purchased_product( + productIdentifier: product.id + )) + } + + DispatchQueue.main.async { + completion(result.transaction, + result.customerInfo, + // Forward an error if purchase was cancelled to match SK1 behavior. + result.userCancelled ? ErrorUtils.purchaseCancelledError().asPublicError : nil, + result.userCancelled) + } + } catch let error { + Logger.rcPurchaseError(Strings.purchase.product_purchase_failed( + productIdentifier: product.id, + error: error + )) + let publicError = ErrorUtils.purchasesError(withUntypedError: error).asPublicError + let userCancelled = publicError.isCancelledError + + DispatchQueue.main.async { + completion(nil, nil, publicError, userCancelled) + } + } + } + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + // swiftlint:disable:next function_body_length cyclomatic_complexity + func purchase(sk2Product: SK2Product, + package: Package?, + promotionalOffer: PromotionalOffer.SignedData? = nil, + winBackOffer: Product.SubscriptionOffer? = nil, + introductoryOfferEligibilityJWS: String?, + promotionalOfferOptions: StoreKit2PromotionalOfferPurchaseOptions?, + metadata: [String: String]? = nil, + quantity: Int? = nil) async throws -> PurchaseResultData { + let result: Product.PurchaseResult + var options: Set = [.simulatesAskToBuyInSandbox(Purchases.simulatesAskToBuyInSandbox)] + + if let uuid = UUID(uuidString: self.appUserID) { + Logger.debug(Strings.storeKit.sk2_purchasing_added_uuid_option(uuid)) + options.insert(.appAccountToken(uuid)) + } + + if let quantity = quantity { + options.insert(.quantity(quantity)) + } + + let startTime = self.dateProvider.now() + var winBackOfferApplied: Bool = false + + do { + if let signedData = promotionalOffer { + Logger.debug(Strings.storeKit.sk2_purchasing_added_promotional_offer_option(signedData.identifier)) + options.insert(try signedData.sk2PurchaseOption) + } + + if let winBackOffer, #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) { + // Win-back offers weren't introduced until iOS 18 and Xcode 16, which shipped with + // version 6.0 of the Swift compiler. The win-back symbols won't be found if compiled on + // Xcode < 16.0, so we need to ensure that the Swift compiler 6.0 or higher is available. +#if compiler(>=6.0) + Logger.debug( + Strings.storeKit.sk2_purchasing_added_winback_offer_option(winBackOffer.id ?? "unknown ID") + ) + options.insert(.winBackOffer(winBackOffer)) + winBackOfferApplied = true +#endif + } + + if let introductoryOfferEligibilityJWS, + // We omit the iOS version availability check here because it's value is the same as this function's + // availability requirement. Including it here generates a warning that we'd like to avoid. + #available(macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) { + + // introductoryOfferEligibility wasn't introduced until iOS 18.4 and Xcode 16.3, which shipped with + // version 6.1 of the Swift compiler. + #if compiler(>=6.1) + Logger.debug( + Strings.storeKit.sk2_purchasing_added_custom_introductory_offer_eligibility_jws + ) + options.insert(.introductoryOfferEligibility(compactJWS: introductoryOfferEligibilityJWS)) + #endif + } + + if let promotionalOfferOptions { + // iOS, tvOS, watchOS, visionOS, & macOS version availability + // checks are made by this function's availability requirement + // promotionalOffer wasn't introduced until iOS 26.0 and Xcode 26.0, which shipped with + // version 6.2 of the Swift compiler. + #if compiler(>=6.2) + Logger.debug( + Strings.storeKit.sk2_purchasing_added_custom_promotional_offer_jws( + offerID: promotionalOfferOptions.offerID + ) + ) + + // We use formUnion since Product.PurchaseOption.promotionalOffer returns an array of purchase options + options.formUnion( + Product.PurchaseOption.promotionalOffer( + promotionalOfferOptions.offerID, + compactJWS: promotionalOfferOptions.compactJWS + ) + ) + #endif + } + + self.cachePresentedOfferingContext(package: package, productIdentifier: sk2Product.id) + + result = try await self.purchase(sk2Product, options) + + // The `purchase(sk2Product)` call can throw a `StoreKitError.userCancelled` error. + // This detects if `Product.PurchaseResult.userCancelled` is true. + let handleResult = try await self.storeKit2TransactionListener + .handle(purchaseResult: result, fromTransactionUpdate: false) + + let transaction: StoreTransaction? + let userCancelled: Bool + + switch handleResult { + case .userCancelled: + userCancelled = true + transaction = nil + if self.systemInfo.dangerousSettings.customEntitlementComputation { + throw ErrorUtils.purchaseCancelledError() + } + case let .successfulVerifiedTransaction(verifiedTransaction): + userCancelled = false + transaction = verifiedTransaction + } + + let customerInfo: CustomerInfo + + if let transaction = transaction { + customerInfo = try await self.handlePurchasedTransaction(transaction, .purchase, metadata) + self.postFeatureEventsIfNeeded() + } else { + // `transaction` would be `nil` for `Product.PurchaseResult.pending` and + // `Product.PurchaseResult.userCancelled`. + customerInfo = try await self.customerInfoManager.customerInfo(appUserID: self.appUserID, + fetchPolicy: .cachedOrFetched) + } + + self.trackPurchaseAttemptEventIfNeeded(startTime, + successful: !userCancelled, + productId: sk2Product.id, + promotionalOfferId: promotionalOffer?.identifier, + winBackOfferApplied: winBackOfferApplied, + storeKitVersion: .storeKit2, + purchaseResult: .init(purchaseResult: result), + error: nil) + return (transaction, customerInfo, userCancelled) + } catch { + return try await self.handleSK2ProductPurchaseError(error, + startTime: startTime, + productId: sk2Product.id, + promotionalOfferId: promotionalOffer?.identifier, + winBackOfferApplied: winBackOfferApplied) + } + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + private func handleSK2ProductPurchaseError( + _ error: Error, + startTime: Date, + productId: String, + promotionalOfferId: String?, + winBackOfferApplied: Bool + ) async throws -> PurchaseResultData { + + if case StoreKitError.userCancelled = error { + guard !self.systemInfo.dangerousSettings.customEntitlementComputation else { + throw ErrorUtils.purchaseCancelledError() + } + + self.trackPurchaseAttemptEventIfNeeded(startTime, + successful: false, + productId: productId, + promotionalOfferId: promotionalOfferId, + winBackOfferApplied: winBackOfferApplied, + storeKitVersion: .storeKit2, + purchaseResult: .userCancelled, + error: StoreKitError.userCancelled.asPublicError) + + let customerInfo = try await self.customerInfoManager.customerInfo(appUserID: self.appUserID, + fetchPolicy: .cachedOrFetched) + return (transaction: nil, customerInfo: customerInfo, userCancelled: true) + } else { + guard !self.systemInfo.dangerousSettings.customEntitlementComputation else { + throw error + } + + let purchasesError: PurchasesError + switch error { + case let pError as PurchasesError: + purchasesError = pError + case let signedDataError as PromotionalOffer.SignedData.Error: + purchasesError = ErrorUtils.invalidPromotionalOfferError(error: signedDataError, + message: signedDataError.localizedDescription) + case let backendError as BackendError: + purchasesError = backendError.asPurchasesError + default: + purchasesError = ErrorUtils.purchasesError(withStoreKitError: error) + } + + self.trackPurchaseAttemptEventIfNeeded(startTime, + successful: false, + productId: productId, + promotionalOfferId: promotionalOfferId, + winBackOfferApplied: winBackOfferApplied, + storeKitVersion: .storeKit2, + purchaseResult: nil, + error: purchasesError.asPublicError) + + throw purchasesError + } + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + private func purchase( + _ product: SK2Product, + _ options: Set + ) async throws -> Product.PurchaseResult { + #if VISION_OS + return try await product.purchase(confirmIn: try self.systemInfo.currentWindowScene, + options: options) + #else + return try await product.purchase(options: options) + #endif + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func promotionalOffer( + forProductDiscount discount: StoreProductDiscountType, + product: StoreProductType + ) async throws -> PromotionalOffer { + return try await Async.call { completion in + self.promotionalOffer(forProductDiscount: discount, + product: product, + completion: completion) + } + } + + func cachePresentedOfferingContext(_ context: PresentedOfferingContext, productIdentifier: String) { + let cached = CachedPresentedOfferingContext(context: context, cacheDate: self.dateProvider.now()) + self.presentedOfferingContextsByProductID.modify { $0[productIdentifier] = cached } + } + + func track(paywallEvent: PaywallEvent) { + switch paywallEvent { + case .purchaseInitiated: + // The presentedOfferingContext is cached separately by `cachePresentedOfferingContext`, + // both when the purchase is initiated through the SDK's purchase method + // and when initiated from a paywall (via PurchaseHandler). + self.cachePurchaseInitiatedPaywall(paywallEvent) + + case .cancel, .purchaseError: + self.clearPurchaseInitiatedPaywall() + if let productId = paywallEvent.data.productId { + self.clearCachedPresentedOfferingContext(for: productId) + } else { + Logger.error(Strings.paywalls.missing_product_id_for_paywall_event) + } + + case .impression, .close, .exitOffer: + break + } + } + + func postEventsIfNeeded(delayed: Bool = false) { + guard #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *), + let manager = self.eventsManager else { return } + + if delayed { + self.operationDispatcher.dispatchOnWorkerThread(jitterableDelay: .long) { + manager.flushAllEventsWithBackgroundTask(batchSize: EventsManager.defaultEventBatchSize) + } + } else { + // When backgrounding, the app only has about 5 seconds to perform work + manager.flushAllEventsWithBackgroundTask(batchSize: EventsManager.defaultEventBatchSize) + } + } + + func postFeatureEventsIfNeeded(delayed: Bool = false) { + guard #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *), + let manager = self.eventsManager else { return } + + if delayed { + self.operationDispatcher.dispatchOnWorkerThread(jitterableDelay: .long) { + manager.flushFeatureEventsWithBackgroundTask(batchSize: EventsManager.defaultEventBatchSize) + } + } else { + // When backgrounding, the app only has about 5 seconds to perform work + manager.flushFeatureEventsWithBackgroundTask(batchSize: EventsManager.defaultEventBatchSize) + } + } + +#if os(iOS) || os(macOS) || VISION_OS + + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func showManageSubscription(completion: @escaping (PurchasesError?) -> Void) { + self.manageSubscriptionsHelper.showManageSubscriptions { result in + switch result { + case .failure(let error): + completion(error) + case .success: + completion(nil) + } + } + } +#endif + +#if os(iOS) || VISION_OS + + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func beginRefundRequest(forProduct productID: String) async throws -> RefundRequestStatus { + return try await beginRefundRequestHelper.beginRefundRequest(forProduct: productID) + } + + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func beginRefundRequestForActiveEntitlement() async throws -> RefundRequestStatus { + return try await beginRefundRequestHelper.beginRefundRequestForActiveEntitlement() + } + + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func beginRefundRequest(forEntitlement entitlementID: String) async throws -> RefundRequestStatus { + return try await beginRefundRequestHelper.beginRefundRequest(forEntitlement: entitlementID) + } + +#endif + + @available(iOS 16.4, macOS 14.4, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + @available(visionOS, unavailable) + internal func setSK2PurchaseIntentListener( + _ storeKit2PurchaseIntentListener: StoreKit2PurchaseIntentListenerType + ) { + // We can't inject StoreKit2PurchaseIntentListener in the constructor since + // it has different availability requirements than the constructor. + + if systemInfo.storeKitVersion == .storeKit2 { + self._storeKit2PurchaseIntentListener = storeKit2PurchaseIntentListener + Task { + await self.storeKit2PurchaseIntentListener.set(delegate: self) + await self.storeKit2PurchaseIntentListener.listenForPurchaseIntents() + } + } + } + +} + +// MARK: - Private + +extension PurchasesOrchestrator { + + /// - Returns: `StoreKit1Wrapper` if it's set, otherwise forwards an error to `completion` and returns `nil` + private func storeKit1Wrapper(orFailWith completion: @escaping PurchaseCompletedBlock) -> StoreKit1Wrapper? { + guard let storeKit1Wrapper = self.paymentQueueWrapper.sk1Wrapper else { + self.operationDispatcher.dispatchOnMainActor { + completion(nil, + nil, + ErrorUtils.configurationError( + message: Strings.storeKit.sk1_product_with_sk2_enabled.description + ).asPublicError, + false) + } + return nil + } + + return storeKit1Wrapper + } + +} + +// MARK: - StoreKit1WrapperDelegate + +extension PurchasesOrchestrator: StoreKit1WrapperDelegate { + + func storeKit1Wrapper(_ storeKit1Wrapper: StoreKit1Wrapper, updatedTransaction transaction: SKPaymentTransaction) { + let storeTransaction = StoreTransaction(sk1Transaction: transaction) + + switch transaction.transactionState { + // For observer mode. Should only come from calls to `restoreCompletedTransactions`, + // which the SDK does not currently use. + case .restored: + self.handleSK1PurchasedTransaction(storeTransaction, + storefront: storeKit1Wrapper.currentStorefront, + restored: true) + case .purchased: + self.handleSK1PurchasedTransaction(storeTransaction, + storefront: storeKit1Wrapper.currentStorefront, + restored: false) + case .purchasing: + break + case .failed: + self.handleFailedTransaction(transaction) + case .deferred: + self.handleDeferredTransaction(transaction) + @unknown default: + Logger.appleWarning(Strings.storeKit.sk1_unknown_transaction_state(transaction.transactionState)) + } + } + + func storeKit1Wrapper(_ storeKit1Wrapper: StoreKit1Wrapper, + removedTransaction transaction: SKPaymentTransaction) { + // unused for now + } + + func storeKit1Wrapper(_ storeKit1Wrapper: StoreKit1Wrapper, + shouldAddStorePayment payment: SKPayment, + for product: SK1Product) -> Bool { + self.productsManager.cache(StoreProduct(sk1Product: product)) + guard let delegate = self.delegate else { return false } + + guard let productIdentifier = payment.extractProductIdentifier() else { + return false + } + + let storeProduct = StoreProduct(sk1Product: product) + delegate.readyForPromotedProduct(storeProduct) { completion in + let addPayment = self.addPurchaseCompletedCallback( + productIdentifier: productIdentifier, + completion: completion + ) + if addPayment { + storeKit1Wrapper.add(payment) + } + } + + // See `SKPaymentTransactionObserver.paymentQueue(_:shouldAddStorePayment:for:)` + // Returns `false` to indicate that the app will defer the purchase and be handled + // when the user calls the purchase callback. + return false + } + + func storeKit1Wrapper(_ storeKit1Wrapper: StoreKit1Wrapper, + didRevokeEntitlementsForProductIdentifiers productIdentifiers: [String]) { + Logger.debug(Strings.purchase.entitlements_revoked_syncing_purchases(productIdentifiers: productIdentifiers)) + syncPurchases { @Sendable _ in + Logger.debug(Strings.purchase.purchases_synced) + } + } + + @available(iOS 13.4, macCatalyst 13.4, *) + @available(macOS, unavailable) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + var storeKit1WrapperShouldShowPriceConsent: Bool { + return self.delegate?.shouldShowPriceConsent ?? true + } + + func storeKit1WrapperDidChangeStorefront(_ storeKit1Wrapper: StoreKit1Wrapper) { + handleStorefrontChange() + } + +} + +extension PurchasesOrchestrator: PaymentQueueWrapperDelegate { + + #if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + @available(iOS 13.4, macCatalyst 13.4, *) + var paymentQueueWrapperShouldShowPriceConsent: Bool { + return self.storeKit1WrapperShouldShowPriceConsent + } + #endif + + func paymentQueueWrapper( + _ wrapper: PaymentQueueWrapper, + shouldAddStorePayment payment: SKPayment, + for product: SK1Product + ) -> Bool { + // `PurchasesOrchestrator` becomes `PaymentQueueWrapperDelegate` only + // when `StoreKit1Wrapper` is not initialized, which means that promoted purchases + // need to be handled as a SK2 purchase. + // This method converts the `SKPayment` into an SK2 purchase by fetching the product again. + if self.paymentQueueWrapper.sk1Wrapper != nil { + Logger.warn(Strings.purchase.payment_queue_wrapper_delegate_call_sk1_enabled) + assertionFailure(Strings.purchase.payment_queue_wrapper_delegate_call_sk1_enabled.description) + } + + guard let delegate = self.delegate else { return false } + + let productIdentifier = product.productIdentifier + + self.productsManager.products(withIdentifiers: [productIdentifier]) { result in + guard let product = result.value?.first(where: { $0.productIdentifier == productIdentifier }) else { + Logger.warn(Strings.purchase.promo_purchase_product_not_found(productIdentifier: productIdentifier)) + return + } + + let startPurchase: StartPurchaseBlock + + if let discount = payment.paymentDiscount.map(PromotionalOffer.SignedData.init) { + startPurchase = { completion in + self.purchase(product: product, + package: nil, + promotionalOffer: discount, + metadata: nil, + trackDiagnostics: false) { transaction, customerInfo, error, cancelled in + completion(transaction, customerInfo, error, cancelled) + } + } + } else { + startPurchase = { completion in + self.purchase(product: product, + package: nil, + promotionalOffer: nil, + metadata: nil, + trackDiagnostics: false) { transaction, customerInfo, error, cancelled in + completion(transaction, customerInfo, error, cancelled) + } + } + } + + delegate.readyForPromotedProduct(product, purchase: startPurchase) + } + + // See `SKPaymentTransactionObserver.paymentQueue(_:shouldAddStorePayment:for:)` + // Returns `false` to indicate that the app will defer the purchase and be handled + // when the user calls the purchase callback. + return false + } + +} + +// @unchecked because: +// - It has a mutable `delegate` because it needs to be, as `weak`. +// - It has mutable `_storeKit2TransactionListener` and `_storeKit2StorefrontListener`, which are necessary +// due to the availability annotations +extension PurchasesOrchestrator: @unchecked Sendable {} + +// MARK: Transaction state updates. + +private extension PurchasesOrchestrator { + + func handleFailedTransaction(_ transaction: SKPaymentTransaction) { + let storeTransaction = StoreTransaction(sk1Transaction: transaction) + + if let error = transaction.error, + let completion = self.getAndRemovePurchaseCompletedCallback(forTransaction: storeTransaction) { + let purchasesError = ErrorUtils.purchasesError(withSKError: error) + + let isCancelled = purchasesError.isCancelledError + + if isCancelled { + if self.systemInfo.dangerousSettings.customEntitlementComputation { + self.operationDispatcher.dispatchOnMainActor { + completion(storeTransaction, + nil, + purchasesError.asPublicError, + true) + } + } else { + self.customerInfoManager.customerInfo(appUserID: self.appUserID, + fetchPolicy: .cachedOrFetched) { @Sendable customerInfo in + self.operationDispatcher.dispatchOnMainActor { + completion(storeTransaction, + customerInfo.value, + purchasesError.asPublicError, + true) + } + } + } + } else { + self.operationDispatcher.dispatchOnMainActor { + completion(storeTransaction, + nil, + purchasesError.asPublicError, + false) + } + } + } + + self.transactionPoster.finishTransactionIfNeeded(storeTransaction, completion: {}) + } + + func handleDeferredTransaction(_ transaction: SKPaymentTransaction) { + let userCancelled = transaction.error?.isCancelledError ?? false + let storeTransaction = StoreTransaction(sk1Transaction: transaction) + + guard let completion = self.getAndRemovePurchaseCompletedCallback(forTransaction: storeTransaction) else { + return + } + + self.operationDispatcher.dispatchOnMainActor { + completion( + storeTransaction, + nil, + ErrorUtils.paymentDeferredError().asPublicError, + userCancelled + ) + } + } + + // swiftlint:disable:next function_parameter_count + func trackPurchaseAttemptEventIfNeeded(_ startTime: Date, + successful: Bool, + productId: String, + promotionalOfferId: String?, + winBackOfferApplied: Bool, + storeKitVersion: StoreKitVersion, + purchaseResult: DiagnosticsEvent.PurchaseResult?, + error: PublicError?) { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *), + let diagnosticsTracker = self.diagnosticsTracker { + let responseTime = self.dateProvider.now().timeIntervalSince(startTime) + let errorMessage = (error?.userInfo[NSUnderlyingErrorKey] as? Error)?.localizedDescription + ?? error?.localizedDescription + let errorCode = error?.code + let storeKitErrorDescription = StoreKitErrorUtils.extractStoreKitErrorDescription(from: error) + diagnosticsTracker.trackPurchaseAttempt(wasSuccessful: successful, + storeKitVersion: storeKitVersion, + errorMessage: errorMessage, + errorCode: errorCode, + storeKitErrorDescription: storeKitErrorDescription, + storefront: self.systemInfo.storefront?.countryCode, + productId: productId, + promotionalOfferId: promotionalOfferId, + winBackOfferApplied: winBackOfferApplied, + purchaseResult: purchaseResult, + responseTime: responseTime) + } + } + + func trackPurchaseStartedIfNeeded(trackDiagnostics: Bool, + productId: String, + productType: StoreProduct.ProductType) { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *), trackDiagnostics { + self.diagnosticsTracker?.trackPurchaseStarted(productId: productId, productType: productType) + } + } + + // swiftlint:disable:next function_parameter_count + func trackPurchaseResultIfNeeded(trackDiagnostics: Bool, + productId: String, + productType: StoreProduct.ProductType, + verificationResult: VerificationResult?, + error: PublicError?, + startTime: Date) { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *), trackDiagnostics, + let diagnosticsTracker = self.diagnosticsTracker { + let responseTime = self.dateProvider.now().timeIntervalSince(startTime) + diagnosticsTracker.trackPurchaseResult(productId: productId, + productType: productType, + verificationResult: verificationResult, + errorMessage: error?.localizedDescription, + errorCode: error?.asErrorCode?.rawValue, + responseTime: responseTime) + } + } + + func trackProductsStartedIfNeeded(requestedProductIds: Set) { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *), + let diagnosticsTracker = self.diagnosticsTracker { + diagnosticsTracker.trackProductsStarted(requestedProductIds: requestedProductIds) + } + } + + func trackProductsResultIfNeeded(requestedProductIds: Set, + notFoundProductIds: Set?, + error: PurchasesError?, + startTime: Date) { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *), + let diagnosticsTracker = self.diagnosticsTracker { + let responseTime = self.dateProvider.now().timeIntervalSince(startTime) + diagnosticsTracker.trackProductsResult(requestedProductIds: requestedProductIds, + notFoundProductIds: notFoundProductIds, + errorMessage: error?.localizedDescription, + errorCode: error?.errorCode, + responseTime: responseTime) + } + } + + func trackSyncOrRestorePurchasesStartedIfNeeded(_ receiptRefreshPolicy: ReceiptRefreshPolicy) { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *), + let diagnosticsTracker = self.diagnosticsTracker { + let isRestore = receiptRefreshPolicy == .always + if isRestore { + diagnosticsTracker.trackRestorePurchasesStarted() + } else { + diagnosticsTracker.trackSyncPurchasesStarted() + } + } + } + + func trackSyncOrRestorePurchasesResultIfNeeded(_ receiptRefreshPolicy: ReceiptRefreshPolicy, + startTime: Date, + error: PurchasesError?) { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *), + let diagnosticsTracker = self.diagnosticsTracker { + let responseTime = self.dateProvider.now().timeIntervalSince(startTime) + let isRestore = receiptRefreshPolicy == .always + if isRestore { + diagnosticsTracker.trackRestorePurchasesResult(errorMessage: error?.localizedDescription, + errorCode: error?.errorCode, + responseTime: responseTime) + } else { + diagnosticsTracker.trackSyncPurchasesResult(errorMessage: error?.localizedDescription, + errorCode: error?.errorCode, + responseTime: responseTime) + } + } + } + + #if compiler(>=5.10) && !os(tvOS) && !os(watchOS) && !os(visionOS) + + @available(iOS 16.4, macOS 14.4, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + @available(visionOS, unavailable) + func trackApplePurchaseIntentReceivedIfNeeded(purchaseIntent: any StoreKit2PurchaseIntentType) { + var offerId: String? + var offerType: String? + + #if compiler(>=6.0) + if #available(iOS 18.0, macOS 15.0, *) { + offerId = purchaseIntent.offer?.id + offerType = purchaseIntent.offer?.type.rawValue + } + #endif + + self.diagnosticsTracker?.trackPurchaseIntentReceived(productId: purchaseIntent.product.id, + offerId: offerId, + offerType: offerType) + } + + #endif + + /// - Parameter restored: whether the transaction state was `.restored` instead of `.purchased`. + private func purchaseSource( + for productIdentifier: String, + restored: Bool + ) -> PostReceiptSource { + let initiationSource: PostReceiptSource.InitiationSource = { + // Having a purchase completed callback implies that the transation comes from an explicit call + // to `purchase()` instead of a StoreKit transaction notification. + let hasPurchaseCallback = self.purchaseCompleteCallbacksByProductID.value.keys.contains(productIdentifier) + + switch (hasPurchaseCallback, restored) { + case (true, false): return .purchase + // Note that restores initiated through the SDK with `restorePurchases` + // won't use this method since those set the initiation source explicitly. + case (true, true): return .restore + case (false, _): return .queue + } + }() + + return .init(isRestore: self.allowSharingAppStoreAccount, + initiationSource: initiationSource) + } + +} + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +extension PurchasesOrchestrator: StoreKit2TransactionListenerDelegate { + + func storeKit2TransactionListener( + _ listener: StoreKit2TransactionListenerType, + updatedTransaction transaction: StoreTransactionType + ) async throws { + // Only attribute offering context and paywall data for transactions that are not known + // to be renewals. When the reason is `nil` (i.e. iOS < 17), we still attempt + // attribution because the product-ID and date matching in `getAndRemovePresentedOfferingContext` + // and `getAndRemovePurchaseInitiatedPaywall` will safely return nil for non-matching transactions, + // making the misattribution case extremely unlikely. + let isKnownRenewal = transaction.reason == .renewal + let offeringContext = isKnownRenewal ? nil : self.getAndRemovePresentedOfferingContext(for: transaction) + let paywall = isKnownRenewal ? nil : self.getAndRemovePurchaseInitiatedPaywall(for: transaction) + + let storefront = await self.storefront(from: transaction) + let subscriberAttributes = self.unsyncedAttributes + let adServicesToken = await self.attribution.unsyncedAdServicesToken + let transactionData: PurchasedTransactionData = .init( + presentedOfferingContext: offeringContext, + presentedPaywall: paywall, + unsyncedAttributes: subscriberAttributes, + aadAttributionToken: adServicesToken, + storeCountry: storefront?.countryCode + ) + let purchaseSource: PostReceiptSource = .init( + isRestore: self.allowSharingAppStoreAccount, + initiationSource: .queue + ) + + let transaction = StoreTransaction.from(transaction: transaction) + let result: Result = await self.transactionPoster.handlePurchasedTransaction( + transaction, + data: transactionData, + postReceiptSource: purchaseSource, + currentUserID: self.appUserID + ) + + if case let .success(customerInfo) = result { + let purchaseData = PurchaseResultData(transaction, customerInfo, false) + self.notificationCenter.post(name: .purchaseCompleted, object: purchaseData) + } + + self.handlePostReceiptResult(result, transactionData: transactionData) + + if let error = result.error { + throw error + } + } + + private func storefront(from transaction: StoreTransactionType) async -> StorefrontType? { + return await transaction.storefrontOrCurrent + // If we couldn't determine storefront from SK2, try SK1: + ?? self.paymentQueueWrapper.sk1Wrapper?.currentStorefront + } + +} + +@available(iOS 16.4, macOS 14.4, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +extension PurchasesOrchestrator: StoreKit2PurchaseIntentListenerDelegate { + + // swiftlint:disable:next function_body_length + func storeKit2PurchaseIntentListener( + _ listener: any StoreKit2PurchaseIntentListenerType, + purchaseIntent: StorePurchaseIntent + ) async { + // Making the extension unavailable on tvOS & watchOS doesn't + // stop the compiler from checking availability in the functions. + // We also need to ensure that we're on Xcode >= 15.3, since that is when + // PurchaseIntents were first made available on macOS. + #if compiler(>=5.10) && !os(tvOS) && !os(watchOS) && !os(visionOS) + + guard let purchaseIntent = purchaseIntent.purchaseIntent else { return } + let storeProduct = StoreProduct(sk2Product: purchaseIntent.product) + + self.trackApplePurchaseIntentReceivedIfNeeded(purchaseIntent: purchaseIntent) + + delegate?.readyForPromotedProduct(storeProduct) { completion in + + var attemptedToPurchaseWithASubscriptionOffer = false + + if #available(iOS 18.0, macOS 15.0, *) { + #if compiler(>=6.0) + if let offer = purchaseIntent.offer { + switch offer.type { + + // The `OfferType.winBack` case was added in iOS 18.0, but + // it's not recognized by Xcode versions <16.0 + case .winBack: + Task { + do { + attemptedToPurchaseWithASubscriptionOffer = true + + let result = try await self.purchase( + sk2Product: purchaseIntent.product, + package: nil, + promotionalOffer: nil, + winBackOffer: offer, + introductoryOfferEligibilityJWS: nil, + promotionalOfferOptions: nil + ) + + self.operationDispatcher.dispatchOnMainActor { + completion(result.transaction, result.customerInfo, nil, result.userCancelled) + } + } catch { + self.operationDispatcher.dispatchOnMainActor { + completion( + nil, + nil, + ErrorUtils.purchasesError(withUntypedError: error).asPublicError, + false + ) + } + } + } + default: + // PurchaseIntents are only supported for promoted purchases on the App Store + // and win-back offers, so we don't want to handle any other offers here. + break + } + } + #endif + } + + if !attemptedToPurchaseWithASubscriptionOffer { + self.purchase( + product: storeProduct, + package: nil, + trackDiagnostics: false + ) { transaction, customerInfo, publicError, userCancelled in + self.operationDispatcher.dispatchOnMainActor { + completion(transaction, customerInfo, publicError, userCancelled) + } + } + } + } + + #endif + } +} + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +extension PurchasesOrchestrator: StoreKit2StorefrontListenerDelegate { + + func storefrontValuesUpdated(with storefront: StorefrontType) { + self.handleStorefrontChange() + } + +} + +// MARK: Private funcs + +private extension PurchasesOrchestrator { + + /// - Returns: whether the callback was added + @discardableResult + func addPurchaseCompletedCallback( + productIdentifier: String, + completion: @escaping PurchaseCompletedBlock + ) -> Bool { + guard !productIdentifier.trimmingWhitespacesAndNewLines.isEmpty else { + self.operationDispatcher.dispatchOnMainActor { + completion( + nil, + nil, + ErrorUtils.storeProblemError( + withMessage: Strings.purchase.could_not_purchase_product_id_not_found.description + ).asPublicError, + false + ) + } + return false + } + + return self.purchaseCompleteCallbacksByProductID.modify { callbacks in + guard callbacks[productIdentifier] == nil else { + self.operationDispatcher.dispatchOnMainActor { + completion(nil, nil, ErrorUtils.operationAlreadyInProgressError().asPublicError, false) + } + return false + } + + callbacks[productIdentifier] = completion + return true + } + } + + func getAndRemovePurchaseCompletedCallback( + forTransaction transaction: StoreTransaction + ) -> PurchaseCompletedBlock? { + return self.purchaseCompleteCallbacksByProductID.modify { + return $0.removeValue(forKey: transaction.productIdentifier) + } + } + + private func syncPurchases(receiptRefreshPolicy: ReceiptRefreshPolicy, + isRestore: Bool, + initiationSource: PostReceiptSource.InitiationSource, + completion: (@Sendable (Result) -> Void)?) { + self.trackSyncOrRestorePurchasesStartedIfNeeded(receiptRefreshPolicy) + let startTime = self.dateProvider.now() + // Don't log anything unless the flag was explicitly set. + let allowSharingAppStoreAccountSet = self._allowSharingAppStoreAccount.value != nil + if allowSharingAppStoreAccountSet, !self.allowSharingAppStoreAccount { + Logger.warn(Strings.purchase.restorepurchases_called_with_allow_sharing_appstore_account_false) + } + + let completionWithTracking: (@Sendable (Result) -> Void) = { [weak self] result in + self?.trackSyncOrRestorePurchasesResultIfNeeded(receiptRefreshPolicy, + startTime: startTime, + error: result.error) + completion?(result) + } + + if self.systemInfo.storeKitVersion.isStoreKit2EnabledAndAvailable, + #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { + self.syncPurchasesSK2(isRestore: isRestore, + initiationSource: initiationSource, + completion: completionWithTracking) + } else { + self.syncPurchasesSK1(receiptRefreshPolicy: receiptRefreshPolicy, + isRestore: isRestore, + initiationSource: initiationSource, + completion: completionWithTracking) + } + } + + func syncPurchasesSK1(receiptRefreshPolicy: ReceiptRefreshPolicy, + isRestore: Bool, + initiationSource: PostReceiptSource.InitiationSource, + completion: (@Sendable (Result) -> Void)?) { + let currentAppUserID = self.appUserID + let unsyncedAttributes = self.unsyncedAttributes + + // Refresh the receipt and post to backend, this will allow the transactions to be transferred. + // https://rev.cat/apple-restoring-purchased-products + self.receiptFetcher.receiptData(refreshPolicy: receiptRefreshPolicy) { receiptData, receiptURL in + guard let receiptData = receiptData, !receiptData.isEmpty else { + if self.systemInfo.isSandbox { + Logger.appleWarning(Strings.receipt.no_sandbox_receipt_restore) + } + + if let completion = completion { + self.operationDispatcher.dispatchOnMainThread { + completion(.failure(ErrorUtils.missingReceiptFileError(receiptURL))) + } + } + return + } + + self.operationDispatcher.dispatchOnWorkerThread { + let hasTransactions = self.transactionsManager.customerHasTransactions(receiptData: receiptData) + let cachedCustomerInfo = try? self.customerInfoManager.cachedCustomerInfo(appUserID: currentAppUserID) + + if !hasTransactions, + let customerInfo = cachedCustomerInfo, + customerInfo.originalPurchaseDate != nil { + if let completion = completion { + self.operationDispatcher.dispatchOnMainThread { + completion(.success(customerInfo)) + } + } + + return + } + + self.createProductRequestData(with: receiptData) { productRequestData in + let transactionData = PurchasedTransactionData(presentedOfferingContext: nil, + unsyncedAttributes: unsyncedAttributes, + storeCountry: productRequestData?.storeCountry) + + self.backend.post( + receipt: .receipt(receiptData), + productData: productRequestData, + transactionData: transactionData, + postReceiptSource: .init(isRestore: isRestore, initiationSource: initiationSource), + observerMode: self.observerMode, + originalPurchaseCompletedBy: nil, + appUserID: currentAppUserID + ) { result in + self.handlePostReceiptResult(result, + transactionData: transactionData, + completion: completion) + } + } + } + } + } + + // swiftlint:disable function_body_length + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + private func syncPurchasesSK2(isRestore: Bool, + initiationSource: PostReceiptSource.InitiationSource, + completion: (@Sendable (Result) -> Void)?) { + let currentAppUserID = self.appUserID + let unsyncedAttributes = self.unsyncedAttributes + + _ = Task { + let transaction = await self.transactionFetcher.firstVerifiedTransaction + let appTransactionJWS = await self.transactionFetcher.appTransactionJWS + + guard let transaction = transaction, let jwsRepresentation = transaction.jwsRepresentation else { + // No transactions are present. If we have the originalPurchaseDate and originalApplicationVersion + // in the cached CustomerInfo, return it. Otherwise, post the AppTransaction. + let cachedCustomerInfo = try? self.customerInfoManager.cachedCustomerInfo(appUserID: currentAppUserID) + + if let cachedCustomerInfo, + cachedCustomerInfo.originalPurchaseDate != nil, + cachedCustomerInfo.originalApplicationVersion != nil { + self.operationDispatcher.dispatchOnMainActor { + completion?(.success(cachedCustomerInfo)) + } + return + } + + guard let appTransactionJWS else { + // The AppTransaction is not present, and the cached CustomerInfo is either nil + // or is missing the originalPurchaseDate and/or originalApplicationVersion. + // + // In this scenario, we don't want to POST a receipt to the backend since we are missing + // both a receipt and an AppTransaction. + Logger.warn(Strings.storeKit.sk2_sync_purchases_no_transaction_or_apptransaction_found) + + if let cachedCustomerInfo { + // If we have a cached CustomerInfo, it's unlikely that the backend has received + // originalPurchaseDate or originalApplicationVersion since the cache was last + // updated, so return the cached copy. + self.operationDispatcher.dispatchOnMainActor { + completion?(.success(cachedCustomerInfo)) + } + return + } else { + self.customerInfoManager.customerInfo( + appUserID: currentAppUserID, + fetchPolicy: .fetchCurrent + ) { result in + switch result { + case .success(let customerInfo): + completion?(.success(customerInfo)) + return + case .failure(let backendError): + completion?(.failure(backendError.asPurchasesError)) + return + } + } + return + } + } + + let transactionData: PurchasedTransactionData = .init( + presentedOfferingContext: nil, + unsyncedAttributes: unsyncedAttributes + ) + let purchaseSource: PostReceiptSource = .init( + isRestore: isRestore, + initiationSource: initiationSource + ) + + self.backend.post(receipt: .empty, + productData: nil, + transactionData: transactionData, + postReceiptSource: purchaseSource, + observerMode: self.observerMode, + originalPurchaseCompletedBy: nil, + appTransaction: appTransactionJWS, + appUserID: currentAppUserID) { result in + + self.handlePostReceiptResult(result, + transactionData: transactionData, + completion: completion) + } + return + } + + let transactionData: PurchasedTransactionData = .init( + presentedOfferingContext: nil, + unsyncedAttributes: unsyncedAttributes, + storeCountry: transaction.storefront?.countryCode + ) + let purchaseSource: PostReceiptSource = .init(isRestore: isRestore, initiationSource: initiationSource) + + let receipt = await self.encodedReceipt(transaction: transaction, jwsRepresentation: jwsRepresentation) + + self.transactionPoster.postReceiptFromSyncedSK2Transaction( + transaction, + data: transactionData, + receipt: receipt, + postReceiptSource: purchaseSource, + appTransactionJWS: appTransactionJWS, + currentUserID: currentAppUserID + ) { result in + self.handlePostReceiptResult(result, + transactionData: transactionData, + completion: completion) + } + } + } + + func handlePostReceiptResult( + _ result: Result, + transactionData: PurchasedTransactionData?, + completion: (@Sendable (Result) -> Void)? = nil + ) { + if let customerInfo = try? result.get() { + self.customerInfoManager.cache(customerInfo: customerInfo, appUserID: self.appUserID) + } + + self.attribution.markSyncedIfNeeded( + subscriberAttributes: transactionData?.unsyncedAttributes, + adServicesToken: transactionData?.aadAttributionToken, + appUserID: self.appUserID, + error: result.error + ) + + if let completion = completion { + self.operationDispatcher.dispatchOnMainThread { + completion(result.mapError { $0.asPurchasesError }) + } + } + } + + func handleSK1PurchasedTransaction(_ purchasedTransaction: StoreTransaction, + storefront: StorefrontType?, + restored: Bool) { + // Don't attribute offering context or paywall data for restored transactions + let offeringContext = restored ? nil : self.getAndRemovePresentedOfferingContext(for: purchasedTransaction) + let paywall = restored ? nil : self.getAndRemovePurchaseInitiatedPaywall(for: purchasedTransaction) + let unsyncedAttributes = self.unsyncedAttributes + self.attribution.unsyncedAdServicesToken { adServicesToken in + let transactionData: PurchasedTransactionData = .init( + presentedOfferingContext: offeringContext, + presentedPaywall: paywall, + unsyncedAttributes: unsyncedAttributes, + aadAttributionToken: adServicesToken, + storeCountry: storefront?.countryCode + ) + let purchaseSource = self.purchaseSource(for: purchasedTransaction.productIdentifier, + restored: restored) + + self.transactionPoster.handlePurchasedTransaction( + purchasedTransaction, + data: transactionData, + postReceiptSource: purchaseSource, + currentUserID: self.appUserID + ) { result in + + self.handlePostReceiptResult(result, transactionData: transactionData) + + if let completion = self.getAndRemovePurchaseCompletedCallback(forTransaction: purchasedTransaction) { + self.operationDispatcher.dispatchOnMainActor { + completion(purchasedTransaction, + result.value, + result.error?.asPublicError, + result.error?.isCancelledError ?? false + ) + } + } + } + } + } + + func purchase( + sk1Product: SK1Product, + package: Package, + wrapper: StoreKit1Wrapper, + completion: @escaping PurchaseCompletedBlock + ) { + let payment = wrapper.payment(with: sk1Product) + purchase(sk1Product: sk1Product, + payment: payment, + package: package, + wrapper: wrapper, + completion: completion) + } + + func handleStorefrontChange() { + self.productsManager.clearCache() + self.offeringsManager.invalidateAndReFetchCachedOfferingsIfAppropiate(appUserID: self.appUserID) + } + + func cachePresentedOfferingContext(package: Package?, productIdentifier: String) { + if let package = package { + self.cachePresentedOfferingContext(package.presentedOfferingContext, + productIdentifier: productIdentifier) + } + } + + func cachePurchaseInitiatedPaywall(_ paywall: PaywallEvent) { + Logger.verbose(Strings.paywalls.caching_purchase_initiated_paywall) + self.purchaseInitiatedPaywall.value = paywall + } + + func clearPurchaseInitiatedPaywall() { + Logger.verbose(Strings.paywalls.clearing_purchase_initiated_paywall) + self.purchaseInitiatedPaywall.value = nil + } + + /// Wraps a `PresentedOfferingContext` with the date it was cached, used to verify + /// that the cached context corresponds to a specific transaction. + struct CachedPresentedOfferingContext { + let context: PresentedOfferingContext + let cacheDate: Date + } + + func clearCachedPresentedOfferingContext(for productIdentifier: String) { + self.presentedOfferingContextsByProductID.modify { $0.removeValue(forKey: productIdentifier) } + } + + func getAndRemovePresentedOfferingContext(for transaction: StoreTransactionType) -> PresentedOfferingContext? { + return self.presentedOfferingContextsByProductID.modify { cache in + guard let cached = cache[transaction.productIdentifier] else { + return nil + } + + guard cached.cacheDate <= transaction.purchaseDate else { + return nil + } + + cache.removeValue(forKey: transaction.productIdentifier) + return cached.context + } + } + + func getAndRemovePurchaseInitiatedPaywall(for transaction: StoreTransactionType) -> PaywallEvent? { + return self.purchaseInitiatedPaywall.modify { cachedPaywall in + guard let paywall = cachedPaywall else { + return nil + } + + let shouldAttributePaywallToPurchase = paywall.data.productId == transaction.productIdentifier + && paywall.creationData.date <= transaction.purchaseDate + + guard shouldAttributePaywallToPurchase else { + return nil + } + + cachedPaywall = nil + return paywall + } + } + + /// Computes a `ProductRequestData` for an active subscription found in the receipt, + /// or `nil` if there is any issue fetching it. + func createProductRequestData( + with receiptData: Data, + completion: @escaping (ProductRequestData?) -> Void + ) { + guard let receipt = try? self.receiptParser.parse(from: receiptData), + let productIdentifier = receipt.mostRecentActiveSubscription?.productId else { + completion(nil) + return + } + + self.createProductRequestData(with: productIdentifier, completion: completion) + } + + func createProductRequestData( + with productIdentifier: String, + completion: @escaping (ProductRequestData?) -> Void + ) { + self.productsManager.products(withIdentifiers: [productIdentifier]) { products in + let result = products.value?.first.map { + ProductRequestData(with: $0, storeCountry: self.systemInfo.storefront?.countryCode) + } + + completion(result) + } + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func sk2PromotionalOffer(forProductDiscount productDiscount: StoreProductDiscountType, + discountIdentifier: String, + product: StoreProductType, + subscriptionGroupIdentifier: String, + completion: @escaping @Sendable (Result) -> Void) { + + _ = Task { + let transaction = await self.transactionFetcher.firstVerifiedAutoRenewableTransaction + guard let transaction = transaction, let jwsRepresentation = transaction.jwsRepresentation else { + // Promotional offers require an existing or expired subscription to redeem a promotional offer. + // Fail early if there are no transactions. + completion(.failure(ErrorUtils.ineligibleError())) + return + } + + let receipt = await self.encodedReceipt(transaction: transaction, jwsRepresentation: jwsRepresentation) + + self.handlePromotionalOffer(forProductDiscount: productDiscount, + discountIdentifier: discountIdentifier, + product: product, + subscriptionGroupIdentifier: subscriptionGroupIdentifier, + receipt: receipt) { result in + completion(result) + } + } + } + + func sk1PromotionalOffer(forProductDiscount productDiscount: StoreProductDiscountType, + discountIdentifier: String, + product: StoreProductType, + subscriptionGroupIdentifier: String, + completion: @escaping @Sendable (Result) -> Void) { + self.receiptFetcher.receiptData(refreshPolicy: .onlyIfEmpty) { receiptData, receiptURL in + guard let receiptData = receiptData, !receiptData.isEmpty else { + let underlyingError = ErrorUtils.missingReceiptFileError(receiptURL) + + // Promotional offers require existing purchases. + // If no receipt is found, this is most likely in sandbox with no purchases, + // so producing an "ineligible" error is better. + completion(.failure(ErrorUtils.ineligibleError(error: underlyingError))) + + return + } + + self.operationDispatcher.dispatchOnWorkerThread { + if !self.receiptParser.receiptHasTransactions(receiptData: receiptData) { + // Promotional offers require existing purchases. + // Fail early if receipt has no transactions. + completion(.failure(ErrorUtils.ineligibleError())) + return + } + self.handlePromotionalOffer(forProductDiscount: productDiscount, + discountIdentifier: discountIdentifier, + product: product, + subscriptionGroupIdentifier: subscriptionGroupIdentifier, + receipt: .receipt(receiptData)) { result in + completion(result) + } + } + } + } + + // swiftlint:disable:next function_parameter_count + func handlePromotionalOffer(forProductDiscount productDiscount: StoreProductDiscountType, + discountIdentifier: String, + product: StoreProductType, + subscriptionGroupIdentifier: String, + receipt: EncodedAppleReceipt, + completion: @escaping @Sendable (Result) -> Void) { + self.backend.offerings.post(offerIdForSigning: discountIdentifier, + productIdentifier: product.productIdentifier, + subscriptionGroup: subscriptionGroupIdentifier, + receipt: receipt, + appUserID: self.appUserID) { result in + let result: Result = result + .map { data in + let signedData = PromotionalOffer.SignedData(identifier: discountIdentifier, + keyIdentifier: data.keyIdentifier, + nonce: data.nonce, + signature: data.signature, + timestamp: data.timestamp) + + return .init(discount: productDiscount, signedData: signedData) + } + .mapError { $0.asPurchasesError } + + completion(result) + } + } + +} + +// MARK: - Simulated Store Purchases + +private extension PurchasesOrchestrator { + + func handlePurchase(simulatedStoreProduct: SimulatedStoreProduct, + metadata: [String: String]?, + completion: @escaping PurchaseCompletedBlock) { + if self.systemInfo.isSimulatedStoreAPIKey { + self.purchase(simulatedStoreProduct: simulatedStoreProduct, metadata: metadata, completion: completion) + } else { + self.handleTestProductNotAvailableForPurchase(completion) + } + } + + private func purchase(simulatedStoreProduct: SimulatedStoreProduct, + metadata: [String: String]?, + completion: @escaping PurchaseCompletedBlock) { + Task { + let result = await self.simulatedStorePurchaseHandler.purchase(product: simulatedStoreProduct) + switch result { + case .cancel: + let customerInfo = try? await self.customerInfoManager.customerInfo(appUserID: self.appUserID, + fetchPolicy: .cachedOrFetched) + await completion(nil, customerInfo, ErrorUtils.purchaseCancelledError().asPublicError, true) + case .failure(let purchasesError): + await completion(nil, nil, purchasesError.asPublicError, false) + case .success(let transaction): + do { + let customerInfo = try await self.handlePurchasedTransaction(transaction, .purchase, metadata) + await completion(transaction, customerInfo, nil, false) + } catch { + let purchasesError = ErrorUtils.purchasesError(withUntypedError: error) + await completion(nil, nil, purchasesError.asPublicError, false) + } + } + } + } + + private func handleTestProductNotAvailableForPurchase(_ completion: @escaping PurchaseCompletedBlock) { + self.operationDispatcher.dispatchOnMainActor { + completion( + nil, + nil, + ErrorUtils.productNotAvailableForPurchaseError().asPublicError, + false + ) + } + } + +} + +private extension PurchasesOrchestrator { + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func encodedReceipt(transaction: StoreTransactionType, jwsRepresentation: String) async -> EncodedAppleReceipt { + if transaction.environment == .xcode { + return .sk2receipt(await self.transactionFetcher.fetchReceipt(containing: transaction)) + } else { + return .jws(jwsRepresentation) + } + } + + static func logPurchase(product: StoreProduct, + package: Package?, + offer: PromotionalOffer.SignedData? = nil, + metadata: [String: String]? = nil) { + let string: PurchaseStrings = .purchasing_product(product, package, offer, metadata) + Logger.purchase(string) + } + +} + +// MARK: - Record Purchase (Observer Mode SK2) + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension PurchasesOrchestrator { + + /// Handles a purchase result from `recordPurchase` API for observer mode with SK2. + /// - Parameter purchaseResult: The `Product.PurchaseResult` from the developer's StoreKit 2 purchase + /// - Returns: The `StoreTransaction` if the purchase was successful, `nil` if cancelled or pending + func handleRecordPurchase( + _ purchaseResult: StoreKit.Product.PurchaseResult + ) async throws -> StoreTransaction? { + guard self.systemInfo.observerMode else { + throw ErrorUtils.configurationError( + message: Strings.configure.record_purchase_requires_purchases_made_by_my_app.description + ) + } + guard self.systemInfo.storeKitVersion == .storeKit2 else { + throw ErrorUtils.configurationError( + message: Strings.configure.sk2_required.description + ) + } + + let handleResult = try await self.storeKit2TransactionListener.handle( + purchaseResult: purchaseResult, + fromTransactionUpdate: false + ) + + switch handleResult { + case .userCancelled: + return nil + case let .successfulVerifiedTransaction(transaction): + // Using .queue initiation source since this is an externally-initiated purchase recorded by the developer + _ = try await self.handlePurchasedTransaction(transaction, .queue, nil) + return transaction + } + } + +} + +// MARK: - isPurchaseAllowedByRestoreBehavior +extension PurchasesOrchestrator { + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func isPurchaseAllowedByRestoreBehavior() async throws -> Bool { + guard self.systemInfo.storeKitVersion == .storeKit2 else { + throw ErrorUtils.featureNotSupportedWithStoreKit1Error() + } + + guard let transaction = await self.transactionFetcher.oldestVerifiedTransaction, + let jwsRepresentation = transaction.jwsRepresentation else { + // If the user has never made a purchase, then the receipt can't be tied to another + // RevenueCat subscriber, and thus the purchase will be allowed + return true + } + + let response = try await Async.call { completion in + self.backend.isPurchaseAllowedByRestoreBehavior( + appUserID: self.appUserID, + transactionJWS: jwsRepresentation, + isAppBackgrounded: self.systemInfo.isAppBackgroundedState, + completion: completion + ) + } + + return response.isPurchaseAllowedByRestoreBehavior + } +} + +// MARK: - Async extensions + +extension PurchasesOrchestrator { + + private func handlePurchasedTransaction( + _ transaction: StoreTransaction, + _ initiationSource: PostReceiptSource.InitiationSource, + _ metadata: [String: String]? + ) async throws -> CustomerInfo { + let offeringContext = self.getAndRemovePresentedOfferingContext(for: transaction) + let paywall = self.getAndRemovePurchaseInitiatedPaywall(for: transaction) + let unsyncedAttributes = self.unsyncedAttributes + let adServicesToken = await self.attribution.unsyncedAdServicesToken + let transactionData: PurchasedTransactionData = .init( + presentedOfferingContext: offeringContext, + presentedPaywall: paywall, + unsyncedAttributes: unsyncedAttributes, + metadata: metadata, + aadAttributionToken: adServicesToken, + storeCountry: transaction.storefront?.countryCode + ) + let purchaseSource: PostReceiptSource = .init(isRestore: self.allowSharingAppStoreAccount, + initiationSource: initiationSource) + + let result = await self.transactionPoster.handlePurchasedTransaction( + transaction, + data: transactionData, + postReceiptSource: purchaseSource, + currentUserID: self.appUserID + ) + + self.handlePostReceiptResult(result, transactionData: transactionData) + + return try result + .mapError(\.asPurchasesError) + .get() + } + + // Do not use this method from outside this class, use `syncPurchases` instead. + // This method is only intended to be used from unit tests. + func syncPurchases(receiptRefreshPolicy: ReceiptRefreshPolicy, + isRestore: Bool, + initiationSource: PostReceiptSource.InitiationSource) async throws -> CustomerInfo { + return try await Async.call { completion in + self.syncPurchases(receiptRefreshPolicy: receiptRefreshPolicy, + isRestore: isRestore, + initiationSource: initiationSource, + completion: completion) + } + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension PurchasesOrchestrator { + + private func syncDiagnosticsIfNeeded() async { + do { + try await diagnosticsSynchronizer?.syncDiagnosticsIfNeeded() + } catch { + Logger.error(Strings.diagnostics.could_not_synchronize_diagnostics(error: error)) + } + } + + private func setSK2DelegateAndStartListening() async { + await storeKit2TransactionListener.set(delegate: self) + if systemInfo.storeKitVersion == .storeKit2 { + await storeKit2TransactionListener.listenForTransactions() + } + } + + @available(iOS 16.4, macOS 14.4, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + @available(visionOS, unavailable) + private func setSK2PurchaseIntentDelegateAndStartListening() async { + await storeKit2TransactionListener.set(delegate: self) + if systemInfo.storeKitVersion == .storeKit2 { + await storeKit2TransactionListener.listenForTransactions() + } + } +} + +// MARK: - Win-Back Offer Fetching +@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +extension PurchasesOrchestrator { + func eligibleWinBackOffers( + forProduct product: StoreProduct + ) async throws -> [WinBackOffer] { + + // winBackOfferEligibilityCalculator is only nil when running in SK1 mode + guard let winBackOfferEligibilityCalculator = self.winBackOfferEligibilityCalculator, + self.systemInfo.storeKitVersion.isStoreKit2EnabledAndAvailable + else { + throw ErrorUtils.featureNotSupportedWithStoreKit1Error() + } + + return try await winBackOfferEligibilityCalculator.eligibleWinBackOffers(forProduct: product) + } +} + +// MARK: - Application Lifecycle +extension PurchasesOrchestrator { + func handleApplicationDidBecomeActive() { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *), + self.observerMode && self.systemInfo.storeKitVersion == .storeKit2 { + Task(priority: .utility) { + await self.storeKit2ObserverModePurchaseDetector?.detectUnobservedTransactions(delegate: self) + } + } + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension PurchasesOrchestrator: StoreKit2ObserverModePurchaseDetectorDelegate { + + func handleSK2ObserverModeTransaction(verifiedTransaction: StoreKit.Transaction, + jwsRepresentation: String) async throws { + try await self.storeKit2TransactionListener.handleSK2ObserverModeTransaction( + verifiedTransaction: verifiedTransaction, + jwsRepresentation: jwsRepresentation + ) + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +fileprivate extension DiagnosticsEvent.PurchaseResult { + + init?(purchaseResult: Product.PurchaseResult) { + switch purchaseResult { + case .success(.verified): + self = .verified + case .success(.unverified): + self = .unverified + case .userCancelled: + self = .userCancelled + case .pending: + self = .pending + @unknown default: + Logger.appleWarning(Strings.storeKit.skunknown_purchase_result(String(describing: purchaseResult))) + return nil + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/PurchasesType.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/PurchasesType.swift new file mode 100644 index 00000000..c8522956 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/PurchasesType.swift @@ -0,0 +1,1326 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PurchasesType.swift +// +// Created by Nacho Soto on 9/20/22. + +// There is a comment with a long URL that is causing the line length to be exceeded. +// swiftlint:disable line_length + +import Foundation +import StoreKit + +// swiftlint:disable file_length + +/// Interface for ``Purchases``. +@objc(RCPurchasesType) +public protocol PurchasesType: AnyObject { + + /** + * The ``appUserID`` used by ``Purchases``. + * If not passed on initialization this will be generated and cached by ``Purchases``. + */ + var appUserID: String { get } + + /** + * The three-letter code representing the country or region + * associated with the App Store storefront. + * - Note: This property uses the ISO 3166-1 Alpha-3 country code representation. + * + * #### Related articles + * - ``Purchases/getStorefront(completion:)`` + * - ``Purchases/getStorefront()`` + */ + var storeFrontCountryCode: String? { get } + + /** + * The ``appUserID`` used by ``Purchases``. + * If not passed on initialization this will be generated and cached by ``Purchases``. + */ + var isAnonymous: Bool { get } + + /** Controls if purchases should be made and transactions finished automatically by RevenueCat. + * ``PurchasesAreCompletedBy/revenueCat`` by default. + * - Warning: Setting this value to ``PurchasesAreCompletedBy/myApp`` + * will prevent the SDK from making purchases and finishing transactions. + * More information on finishing transactions manually [is available here](https://rev.cat/finish-transactions). + */ + var purchasesAreCompletedBy: PurchasesAreCompletedBy { get set } + + /** + * Delegate for ``Purchases`` instance. The delegate is responsible for handling promotional product purchases and + * changes to customer information. + * - Note: this is not thread-safe. + */ + var delegate: PurchasesDelegate? { get set } + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + /** + * Obtain the storefront currently used by the Apple account. This will use StoreKit 2 first, + * and if not possible, fallback to StoreKit 1. It will be `nil` if we can't obtain Apple's storefront. + * + * The `completion` block will be called with the latest Apple account storefront + * + * #### Related Articles + * - ``Purchases/storeFrontCountryCode`` + * - ``Purchases/getStorefront()`` + */ + func getStorefront(completion: @escaping GetStorefrontBlock) + + /** + * Obtain the storefront currently used by the Apple account. This will use StoreKit 2 first, + * and if not possible, fallback to StoreKit 1. It will be `nil` if we can't obtain Apple's storefront. + * + * #### Related Articles + * - ``Purchases/storeFrontCountryCode`` + * - ``Purchases/getStorefront(completion:)`` + */ + func getStorefront() async -> Storefront? + + /** + * This function will log in the current user with an ``appUserID``. + * + * - Parameter appUserID: The ``appUserID`` that should be linked to the current user. + * + * The `completion` block will be called with the latest ``CustomerInfo`` and a `Bool` specifying + * whether the user was created for the first time in the RevenueCat backend. + * + * RevenueCat provides a source of truth for a subscriber's status across different platforms. + * To do this, each subscriber has an App User ID that uniquely identifies them within your application. + * + * User identity is one of the most important components of many mobile applications, + * and it's extra important to make sure the subscription status RevenueCat is + * tracking gets associated with the correct user. + * + * The Purchases SDK allows you to specify your own user identifiers or use anonymous identifiers + * generated by RevenueCat. Some apps will use a combination + * of their own identifiers and RevenueCat anonymous Ids - that's okay! + * + * #### Related Articles + * - [Identifying Users](https://docs.revenuecat.com/docs/user-ids) + * - ``Purchases/logOut(completion:)`` + * - ``Purchases/isAnonymous`` + * - ``Purchases/appUserID`` + */ + func logIn( + _ appUserID: String, completion: @escaping (CustomerInfo?, Bool, PublicError?) -> Void) + + /** + * This function will log in the current user with an ``appUserID``. + * + * - Parameter appUserID: The ``appUserID`` that should be linked to the current user. + * - returns: A tuple of: the latest ``CustomerInfo`` and a `Bool` specifying + * whether the user was created for the first time in the RevenueCat backend. + * + * RevenueCat provides a source of truth for a subscriber's status across different platforms. + * To do this, each subscriber has an App User ID that uniquely identifies them within your application. + * + * User identity is one of the most important components of many mobile applications, + * and it's extra important to make sure the subscription status RevenueCat is + * tracking gets associated with the correct user. + * + * The Purchases SDK allows you to specify your own user identifiers or use anonymous identifiers + * generated by RevenueCat. Some apps will use a combination + * of their own identifiers and RevenueCat anonymous Ids - that's okay! + * + * #### Related Articles + * - [Identifying Users](https://docs.revenuecat.com/docs/user-ids) + * - ``Purchases/logOut()`` + * - ``Purchases/isAnonymous`` + * - ``Purchases/appUserID`` + */ + func logIn(_ appUserID: String) async throws -> (customerInfo: CustomerInfo, created: Bool) + + /** + * Logs out the ``Purchases`` client, clearing the saved ``appUserID``. + * + * This will generate a random user id and save it in the cache. + * If this method is called and the current user is anonymous, it will return an error. + * + * #### Related Articles + * - [Identifying Users](https://docs.revenuecat.com/docs/user-ids) + * - ``Purchases/logIn(_:)-arja`` + * - ``Purchases/isAnonymous`` + * - ``Purchases/appUserID`` + */ + func logOut(completion: ((CustomerInfo?, PublicError?) -> Void)?) + + /** + * Logs out the ``Purchases`` client, clearing the saved ``appUserID``. + * + * This will generate a random user id and save it in the cache. + * If this method is called and the current user is anonymous, it will return an error. + * + * #### Related Articles + * - [Identifying Users](https://docs.revenuecat.com/docs/user-ids) + * - ``Purchases/logIn(_:)-arja`` + * - ``Purchases/isAnonymous`` + * - ``Purchases/appUserID`` + */ + func logOut() async throws -> CustomerInfo + + /** + * Get latest available customer info. + * + * - Parameter completion: A completion block called when customer info is available and not stale. + * Called immediately if ``CustomerInfo`` is cached. Customer info can be nil if an error occurred. + */ + func getCustomerInfo(completion: @escaping ((CustomerInfo?, PublicError?) -> Void)) + + /** + * Get latest available customer info. + * + * - Parameter fetchPolicy: The behavior for what to do regarding caching. + * - Parameter completion: A completion block called when customer info is available and not stale. + */ + func getCustomerInfo( + fetchPolicy: CacheFetchPolicy, + completion: @escaping (CustomerInfo?, PublicError?) -> Void) + + /** + * Get latest available customer info. + * + * #### Related Symbols + * - ``Purchases/customerInfo(fetchPolicy:)`` + * - ``Purchases/customerInfoStream`` + */ + func customerInfo() async throws -> CustomerInfo + + /** + * Get latest available customer info. + * + * - Parameter fetchPolicy: The behavior for what to do regarding caching. + * + * #### Related Symbols + * - ``Purchases/customerInfoStream`` + */ + func customerInfo(fetchPolicy: CacheFetchPolicy) async throws -> CustomerInfo + + /** + * The currently cached ``CustomerInfo`` if one is available. + * This is synchronous, and therefore useful for contexts where an app needs a `CustomerInfo` + * right away without waiting for a callback, like a SwiftUI view. + * + * This allows initializing state to ensure that UI can be loaded from the very first frame. + */ + var cachedCustomerInfo: CustomerInfo? { get } + + #endif + + /** + * Fetch the configured ``Offerings`` for this user. + * + * ``Offerings`` allows you to configure your in-app products + * via RevenueCat and greatly simplifies management. + * + * ``Offerings`` will be fetched and cached on instantiation so that, by the time they are needed, + * your prices are loaded for your purchase flow. Time is money. + * + * - Parameter completion: A completion block called when offerings are available. + * Called immediately if offerings are cached. ``Offerings`` will be `nil` if an error occurred. + * + * #### Related Articles + * - [Displaying Products](https://docs.revenuecat.com/docs/displaying-products) + */ + func getOfferings(completion: @escaping ((Offerings?, PublicError?) -> Void)) + + /** + * Fetch the configured ``Offerings`` for this user. + * + * ``Offerings`` allows you to configure your in-app products + * via RevenueCat and greatly simplifies management. + * + * ``Offerings`` will be fetched and cached on instantiation so that, by the time they are needed, + * your prices are loaded for your purchase flow. Time is money. + * + * #### Related Articles + * - [Displaying Products](https://docs.revenuecat.com/docs/displaying-products) + */ + func offerings() async throws -> Offerings + + /** + * The currently cached ``Offerings`` if available. + * This is synchronous, and therefore useful for contexts where an app needs an instance of `Offerings` + * right away without waiting for a callback, like a SwiftUI view. + * + * This allows initializing state to ensure that UI can be loaded from the very first frame. + */ + var cachedOfferings: Offerings? { get } + + /** + * Fetches the ``StoreProduct``s for your IAPs for given `productIdentifiers`. + * + * Use this method if you aren't using ``Purchases/getOfferings(completion:)``. + * You should use ``Purchases/getOfferings(completion:)`` though. + * + * - Note: `completion` may be called without ``StoreProduct``s that you are expecting. This is usually caused by + * iTunesConnect configuration errors. Ensure your IAPs have the "Ready to Submit" status in iTunesConnect. + * Also ensure that you have an active developer program subscription and you have signed the latest paid + * application agreements. + * If you're having trouble, see: + * [App Store Connect In-App Purchase Configuration](https://rev.cat/how-to-configure-products) + * + * - Parameter productIdentifiers: A set of product identifiers for in-app purchases setup via + * [AppStoreConnect](https://appstoreconnect.apple.com/) + * This should be either hard coded in your application, from a file, or from a custom endpoint if you want + * to be able to deploy new IAPs without an app update. + * - Parameter completion: An `@escaping` callback that is called with the loaded products. + * If the fetch fails for any reason it will return an empty array. + */ + @objc(getProductsWithIdentifiers:completion:) + func getProducts(_ productIdentifiers: [String], completion: @escaping ([StoreProduct]) -> Void) + + /** + * Fetches the ``StoreProduct``s for your IAPs for given `productIdentifiers`. + * + * Use this method if you aren't using ``Purchases/getOfferings(completion:)``. + * You should use ``Purchases/getOfferings(completion:)`` though. + * + * - Note: The result might not contain the ``StoreProduct``s that you are expecting. This is usually caused by + * iTunesConnect configuration errors. Ensure your IAPs have the "Ready to Submit" status in iTunesConnect. + * Also ensure that you have an active developer program subscription and you have signed the latest paid + * application agreements. + * If you're having trouble, see: + * [App Store Connect In-App Purchase Configuration](https://rev.cat/how-to-configure-products) + * + * - Parameter productIdentifiers: A set of product identifiers for in-app purchases setup via + * [AppStoreConnect](https://appstoreconnect.apple.com/) + * This should be either hard coded in your application, from a file, or from a custom endpoint if you want + * to be able to deploy new IAPs without an app update. + */ + func products(_ productIdentifiers: [String]) async -> [StoreProduct] + + /** + * Initiates a purchase of a ``StoreProduct``. + * + * Use this function if you are not using the ``Offerings`` system to purchase a ``StoreProduct``. + * If you are using the ``Offerings`` system, use ``Purchases/purchase(package:completion:)`` instead. + * + * - Important: Call this method when a user has decided to purchase a product. + * Only call this in direct response to user input. + * + * From here ``Purchases`` will handle the purchase with `StoreKit` and call the ``PurchaseCompletedBlock``. + * + * - Note: You do not need to finish the transaction yourself in the completion callback, Purchases will + * handle this for you. + * + * - Parameter product: The ``StoreProduct`` the user intends to purchase. + * - Parameter completion: A completion block that is called when the purchase completes. + * + * If the purchase was successful there will be a ``StoreTransaction`` and a ``CustomerInfo``. + * + * If the purchase was not successful, there will be an `NSError`. + * + * If the user cancelled, `userCancelled` will be `true`. + */ + @objc(purchaseProduct:withCompletion:) + func purchase(product: StoreProduct, completion: @escaping PurchaseCompletedBlock) + + /** + * Initiates a purchase of a ``StoreProduct``. + * + * Use this function if you are not using the ``Offerings`` system to purchase a ``StoreProduct``. + * If you are using the ``Offerings`` system, use ``Purchases/purchase(package:completion:)`` instead. + * + * - Important: Call this method when a user has decided to purchase a product. + * Only call this in direct response to user input. + * + * From here ``Purchases`` will handle the purchase with `StoreKit` and return ``PurchaseResultData``. + * + * - Note: You do not need to finish the transaction yourself after this, ``Purchases`` will + * handle this for you. + * + * - Parameter product: The ``StoreProduct`` the user intends to purchase. + * + * - Throws: An error of type ``ErrorCode`` is thrown if a failure occurs while purchasing + * + * - Returns: A tuple with ``StoreTransaction`` and a ``CustomerInfo`` if the purchase was successful. + * If the user cancelled the purchase, `userCancelled` will be `true`. + */ + func purchase(product: StoreProduct) async throws -> PurchaseResultData + + /** + * Initiates a purchase of a ``Package``. + * + * - Important: Call this method when a user has decided to purchase a product. + * Only call this in direct response to user input. + + * From here ``Purchases`` will handle the purchase with `StoreKit` and call the ``PurchaseCompletedBlock``. + * + * - Note: You do not need to finish the transaction yourself in the completion callback, Purchases will + * handle this for you. + * + * - Parameter package: The ``Package`` the user intends to purchase + * - Parameter completion: A completion block that is called when the purchase completes. + * + * If the purchase was successful there will be a ``StoreTransaction`` and a ``CustomerInfo``. + * + * If the purchase was not successful, there will be an `NSError`. + * + * If the user cancelled, `userCancelled` will be `true`. + */ + @objc(purchasePackage:withCompletion:) + func purchase(package: Package, completion: @escaping PurchaseCompletedBlock) + + /** + * Initiates a purchase of a ``Package``. + * + * - Important: Call this method when a user has decided to purchase a product. + * Only call this in direct response to user input. + * + * From here ``Purchases`` will handle the purchase with `StoreKit` and return ``PurchaseResultData``. + * + * - Note: You do not need to finish the transaction yourself after this, Purchases will + * handle this for you. + * + * - Parameter package: The ``Package`` the user intends to purchase + * + * - Throws: An error of type ``ErrorCode`` is thrown if a failure occurs while purchasing + * + * - Returns: A tuple with ``StoreTransaction`` and a ``CustomerInfo`` if the purchase was successful. + * If the user cancelled the purchase, `userCancelled` will be `true`. + */ + func purchase(package: Package) async throws -> PurchaseResultData + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + /** + * Initiates a purchase. + * + * - Important: Call this method when a user has decided to purchase a product. + * Only call this in direct response to user input. + * + * From here ``Purchases`` will handle the purchase with `StoreKit` and call the ``PurchaseCompletedBlock``. + * + * - Note: You do not need to finish the transaction yourself in the completion callback, Purchases will + * handle this for you. + * + * - Parameter params: The ``PurchaseParams`` instance with the configuration options for this purchase. + * Check the ``PurchaseParams`` documentation for more information. + * + * If the purchase was successful there will be a ``StoreTransaction`` and a ``CustomerInfo``. + * + * If the purchase was not successful, there will be an `NSError`. + * + * If the user cancelled, `userCancelled` will be `true`. + */ + @objc(purchaseWithParams:completion:) + func purchase(_ params: PurchaseParams, completion: @escaping PurchaseCompletedBlock) + + /** + * Initiates a purchase. + * + * - Important: Call this method when a user has decided to purchase a product. + * Only call this in direct response to user input. + * + * From here ``Purchases`` will handle the purchase with `StoreKit` and return ``PurchaseResultData``. + * + * - Note: You do not need to finish the transaction yourself after this, ``Purchases`` will + * handle this for you. + * + * - Parameter params: The ``PurchaseParams`` instance with extra configuration options for this purchase. + * Check the ``PurchaseParams`` documentation for more information. + * + * - Throws: An error of type ``ErrorCode`` is thrown if a failure occurs while purchasing + * + * - Returns: A tuple with ``StoreTransaction`` and a ``CustomerInfo`` if the purchase was successful. + * If the user cancelled the purchase, `userCancelled` will be `true`. + */ + func purchase(_ params: PurchaseParams) async throws -> PurchaseResultData + + /** + * Invalidates the cache for customer information. + * + * Most apps will not need to use this method; invalidating the cache can leave your app in an invalid state. + * Refer to + * [Get User Information](https://www.revenuecat.com/docs/customers/customer-info#getting-subscription-status-via-the-sdk) + * for more information on using the cache properly. + * + * This is useful for cases where customer information might have been updated outside of the app, like if a + * promotional subscription is granted through the RevenueCat dashboard. + */ + func invalidateCustomerInfoCache() + + /** + * This method will post all purchases associated with the current App Store account to RevenueCat and become + * associated with the current ``appUserID``. If the receipt is being used by an existing user, the current + * ``appUserID`` will be aliased together with the ``appUserID`` of the existing user. + * Going forward, either ``appUserID`` will be able to reference the same user. + * + * You shouldn't use this method if you have your own account system. In that case "restoration" is provided + * by your app passing the same ``appUserID`` used to purchase originally. + * + * - Note: This may force your users to enter the App Store password so should only be performed on request of + * the user. Typically with a button in settings or near your purchase UI. Use + * ``Purchases/syncPurchases(completion:)`` if you need to restore transactions programmatically. + * + * - Warning: Receiving a ``CustomerInfo`` instead of an error does not imply that the user has any + * entitlements, simply that the process was successful. You must verify the ``CustomerInfo/entitlements`` + * to confirm that they are active. + */ + func restorePurchases(completion: ((CustomerInfo?, PublicError?) -> Void)?) + + /** + * This method will post all purchases associated with the current App Store account to RevenueCat and become + * associated with the current ``appUserID``. If the receipt is being used by an existing user, the current + * ``appUserID`` will be aliased together with the ``appUserID`` of the existing user. + * Going forward, either ``appUserID`` will be able to reference the same user. + * + * You shouldn't use this method if you have your own account system. In that case "restoration" is provided + * by your app passing the same ``appUserID`` used to purchase originally. + * + * - Note: This may force your users to enter the App Store password so should only be performed on request of + * the user. Typically with a button in settings or near your purchase UI. Use + * ``Purchases/syncPurchases(completion:)`` if you need to restore transactions programmatically. + * + * - Warning: Receiving a ``CustomerInfo`` instead of an error does not imply that the user has any + * entitlements, simply that the process was successful. You must verify the ``CustomerInfo/entitlements`` + * to confirm that they are active. + */ + func restorePurchases() async throws -> CustomerInfo + + /** + * This method will post all purchases associated with the current App Store account to RevenueCat and + * become associated with the current ``appUserID``. + * + * If the receipt is being used by an existing user, the current ``appUserID`` will be aliased together with + * the ``appUserID`` of the existing user. + * Going forward, either ``appUserID`` will be able to reference the same user. + * + * - Warning: This function should only be called if you're not calling any purchase method. + * + * - Note: This method will not trigger a login prompt from App Store. However, if the receipt currently + * on the device does not contain subscriptions, but the user has made subscription purchases, this method + * won't be able to restore them. Use ``Purchases/restorePurchases(completion:)`` to cover those cases. + */ + func syncPurchases(completion: ((CustomerInfo?, PublicError?) -> Void)?) + + /** + * This method will post all purchases associated with the current App Store account to RevenueCat and + * become associated with the current ``appUserID``. + * + * If the receipt is being used by an existing user, the current ``appUserID`` will be aliased together with + * the ``appUserID`` of the existing user. + * Going forward, either ``appUserID`` will be able to reference the same user. + * + * - Warning: This function should only be called if you're not calling any purchase method. + * + * - Note: This method will not trigger a login prompt from App Store. However, if the receipt currently + * on the device does not contain subscriptions, but the user has made subscription purchases, this method + * won't be able to restore them. Use ``Purchases/restorePurchases(completion:)`` to cover those cases. + */ + func syncPurchases() async throws -> CustomerInfo + + /** + * Initiates a purchase of a ``StoreProduct`` with a ``PromotionalOffer``. + * + * Use this function if you are not using the Offerings system to purchase a ``StoreProduct`` with an + * applied ``PromotionalOffer``. + * If you are using the Offerings system, use ``Purchases/purchase(package:promotionalOffer:completion:)`` instead. + * + * - Important: Call this method when a user has decided to purchase a product with an applied discount. + * Only call this in direct response to user input. + * + * From here ``Purchases`` will handle the purchase with `StoreKit` and call the ``PurchaseCompletedBlock``. + * + * - Note: You do not need to finish the transaction yourself in the completion callback, Purchases will handle + * this for you. + * + * - Parameter product: The ``StoreProduct`` the user intends to purchase. + * - Parameter promotionalOffer: The ``PromotionalOffer`` to apply to the purchase. + * - Parameter completion: A completion block that is called when the purchase completes. + * + * If the purchase was successful there will be a ``StoreTransaction`` and a ``CustomerInfo``. + * If the purchase was not successful, there will be an `NSError`. + * If the user cancelled, `userCancelled` will be `true`. + * + * #### Related Symbols + * - ``StoreProduct/discounts`` + * - ``StoreProduct/eligiblePromotionalOffers()`` + * - ``Purchases/promotionalOffer(forProductDiscount:product:)`` + */ + @objc(purchaseProduct:withPromotionalOffer:completion:) + func purchase( + product: StoreProduct, + promotionalOffer: PromotionalOffer, + completion: @escaping PurchaseCompletedBlock) + + /** + * Use this function if you are not using the Offerings system to purchase a ``StoreProduct`` with an + * applied ``PromotionalOffer``. + * If you are using the Offerings system, use ``Purchases/purchase(package:promotionalOffer:completion:)`` instead. + * + * Call this method when a user has decided to purchase a product with an applied discount. + * Only call this in direct response to user input. + * + * From here ``Purchases`` will handle the purchase with `StoreKit` and return ``PurchaseResultData``. + * + * - Note: You do not need to finish the transaction yourself after this, Purchases will handle + * this for you. + * + * - Parameter product: The ``StoreProduct`` the user intends to purchase + * - Parameter promotionalOffer: The ``PromotionalOffer`` to apply to the purchase + * + * - Throws: An error of type ``ErrorCode`` is thrown if a failure occurs while purchasing + * + * - Returns: A tuple with ``StoreTransaction`` and a ``CustomerInfo`` if the purchase was successful. + * If the user cancelled the purchase, `userCancelled` will be `true`. + */ + func purchase(product: StoreProduct, promotionalOffer: PromotionalOffer) async throws + -> PurchaseResultData + + /** + * Purchase the passed ``Package``. + * Call this method when a user has decided to purchase a product with an applied discount. Only call this in + * direct response to user input. From here ``Purchases`` will handle the purchase with `StoreKit` and call the + * ``PurchaseCompletedBlock``. + * + * - Note: You do not need to finish the transaction yourself in the completion callback, Purchases will handle + * this for you. + * + * - Parameter package: The ``Package`` the user intends to purchase + * - Parameter promotionalOffer: The ``PromotionalOffer`` to apply to the purchase + * - Parameter completion: A completion block that is called when the purchase completes. + * + * If the purchase was successful there will be a ``StoreTransaction`` and a ``CustomerInfo``. + * If the purchase was not successful, there will be an `NSError`. + * If the user cancelled, `userCancelled` will be `true`. + */ + @objc(purchasePackage:withPromotionalOffer:completion:) + func purchase( + package: Package, + promotionalOffer: PromotionalOffer, + completion: @escaping PurchaseCompletedBlock) + + /** + * Purchase the passed ``Package``. + * Call this method when a user has decided to purchase a product with an applied discount. Only call this in + * direct response to user input. From here ``Purchases`` will handle the purchase with `StoreKit` and return + * ``PurchaseResultData``. + * + * - Note: You do not need to finish the transaction yourself after this, Purchases will handle + * this for you. + * + * - Parameter package: The ``Package`` the user intends to purchase + * - Parameter promotionalOffer: The ``PromotionalOffer`` to apply to the purchase + * + * - Throws: An error of type ``ErrorCode`` is thrown if a failure occurs while purchasing + * + * - Returns: A tuple with ``StoreTransaction`` and a ``CustomerInfo`` if the purchase was successful. + * If the user cancelled the purchase, `userCancelled` will be `true`. + */ + func purchase(package: Package, promotionalOffer: PromotionalOffer) async throws + -> PurchaseResultData + + /** + * Computes whether or not a user is eligible for the introductory pricing period of a given product. + * You should use this method to determine whether or not you show the user the normal product price or + * the introductory price. This also applies to trials (trials are considered a type of introductory pricing). + * [iOS Introductory Offers](https://docs.revenuecat.com/docs/ios-subscription-offers). + * + * - Note: If you're looking to use Promotional Offers instead, + * use ``Purchases/getPromotionalOffer(forProductDiscount:product:completion:)``. + * + * - Note: Subscription groups are automatically collected for determining eligibility. If RevenueCat can't + * definitively compute the eligibility, most likely because of missing group information, it will return + * ``IntroEligibilityStatus/unknown``. The best course of action on unknown status is to display the non-intro + * pricing, to not create a misleading situation. To avoid this, make sure you are testing with the latest + * version of iOS so that the subscription group can be collected by the SDK. + * + * + * - Parameter productIdentifiers: Array of product identifiers for which you want to compute eligibility + * - Parameter receiveEligibility: A block that receives a dictionary of `product_id` -> ``IntroEligibility``. + * + * ### Related symbols + * - ``Purchases/checkTrialOrIntroDiscountEligibility(product:completion:)`` + */ + @objc(checkTrialOrIntroDiscountEligibility:completion:) + func checkTrialOrIntroDiscountEligibility( + productIdentifiers: [String], + completion receiveEligibility: @escaping ([String: IntroEligibility]) -> Void + ) + + /** + * Computes whether or not a user is eligible for the introductory pricing period of a given product. + * You should use this method to determine whether or not you show the user the normal product price or + * the introductory price. This also applies to trials (trials are considered a type of introductory pricing). + * [iOS Introductory Offers](https://docs.revenuecat.com/docs/ios-subscription-offers). + * + * - Note: If you're looking to use Promotional Offers instead, + * use ``Purchases/getPromotionalOffer(forProductDiscount:product:completion:)``. + * + * - Note: Subscription groups are automatically collected for determining eligibility. If RevenueCat can't + * definitively compute the eligibility, most likely because of missing group information, it will return + * ``IntroEligibilityStatus/unknown``. The best course of action on unknown status is to display the non-intro + * pricing, to not create a misleading situation. To avoid this, make sure you are testing with the latest + * version of iOS so that the subscription group can be collected by the SDK. + * + * - Parameter productIdentifiers: Array of product identifiers for which you want to compute eligibility + * + * ### Related symbols + * - ``Purchases/checkTrialOrIntroDiscountEligibility(product:)`` + */ + func checkTrialOrIntroDiscountEligibility(productIdentifiers: [String]) async -> [String: + IntroEligibility] + + /** + * Computes whether or not a user is eligible for the introductory pricing period of a given product. + * You should use this method to determine whether or not you show the user the normal product price or + * the introductory price. This also applies to trials (trials are considered a type of introductory pricing). + * [iOS Introductory Offers](https://docs.revenuecat.com/docs/ios-subscription-offers). + * + * - Note: If you're looking to use Promotional Offers instead, + * use ``Purchases/getPromotionalOffer(forProductDiscount:product:completion:)``. + * + * - Note: Subscription groups are automatically collected for determining eligibility. If RevenueCat can't + * definitively compute the eligibility, most likely because of missing group information, it will return + * ``IntroEligibilityStatus/unknown``. The best course of action on unknown status is to display the non-intro + * pricing, to not create a misleading situation. To avoid this, make sure you are testing with the latest + * version of iOS so that the subscription group can be collected by the SDK. + * + * + * - Parameter product: The ``StoreProduct`` for which you want to compute eligibility. + * - Parameter completion: A block that receives an ``IntroEligibilityStatus``. + * + * ### Related symbols + * - ``Purchases/checkTrialOrIntroDiscountEligibility(productIdentifiers:completion:)`` + */ + @objc(checkTrialOrIntroDiscountEligibilityForProduct:completion:) + func checkTrialOrIntroDiscountEligibility( + product: StoreProduct, + completion: @escaping (IntroEligibilityStatus) -> Void + ) + + /** + * Computes whether or not a user is eligible for the introductory pricing period of a given product. + * You should use this method to determine whether or not you show the user the normal product price or + * the introductory price. This also applies to trials (trials are considered a type of introductory pricing). + * [iOS Introductory Offers](https://docs.revenuecat.com/docs/ios-subscription-offers). + * + * - Note: If you're looking to use Promotional Offers instead, + * use ``Purchases/getPromotionalOffer(forProductDiscount:product:completion:)``. + * + * - Note: Subscription groups are automatically collected for determining eligibility. If RevenueCat can't + * definitively compute the eligibility, most likely because of missing group information, it will return + * ``IntroEligibilityStatus/unknown``. The best course of action on unknown status is to display the non-intro + * pricing, to not create a misleading situation. To avoid this, make sure you are testing with the latest + * version of iOS so that the subscription group can be collected by the SDK. + * + * + * - Parameter product: The ``StoreProduct`` for which you want to compute eligibility. + * + * ### Related symbols + * - ``Purchases/checkTrialOrIntroDiscountEligibility(productIdentifiers:)`` + */ + func checkTrialOrIntroDiscountEligibility(product: StoreProduct) async + -> IntroEligibilityStatus + + /** + * Use this method to fetch ``PromotionalOffer`` + * to use in ``Purchases/purchase(package:promotionalOffer:)`` + * or ``Purchases/purchase(product:promotionalOffer:)``. + * [iOS Promotional Offers](https://docs.revenuecat.com/docs/ios-subscription-offers#promotional-offers). + * - Note: If you're looking to use free trials or Introductory Offers instead, + * use ``Purchases/checkTrialOrIntroDiscountEligibility(productIdentifiers:completion:)``. + * + * - Parameter discount: The ``StoreProductDiscount`` to apply to the product. + * - Parameter product: The ``StoreProduct`` the user intends to purchase. + * - Parameter completion: A completion block that is called when the ``PromotionalOffer`` is returned. + * If it was not successful, there will be an `Error`. + */ + @objc(getPromotionalOfferForProductDiscount:withProduct:withCompletion:) + func getPromotionalOffer( + forProductDiscount discount: StoreProductDiscount, + product: StoreProduct, + completion: @escaping ((PromotionalOffer?, PublicError?) -> Void)) + + /** + * Use this method to find eligibility for this user for + * [iOS Promotional Offers](https://docs.revenuecat.com/docs/ios-subscription-offers#promotional-offers). + * - Note: If you're looking to use free trials or Introductory Offers instead, + * use ``Purchases/checkTrialOrIntroDiscountEligibility(productIdentifiers:completion:)``. + * + * - Parameter discount: The ``StoreProductDiscount`` to apply to the product. + * - Parameter product: The ``StoreProduct`` the user intends to purchase. + */ + func promotionalOffer( + forProductDiscount discount: StoreProductDiscount, + product: StoreProduct + ) async throws -> PromotionalOffer + + /// Finds the subset of ``StoreProduct/discounts`` that's eligible for the current user. + /// + /// - Parameter product: the product to filter discounts from. + /// - Note: if checking for eligibility for a `StoreProductDiscount` fails (for example, if network is down), + /// that discount will fail silently and be considered not eligible. + /// #### Related Symbols + /// - ``Purchases/promotionalOffer(forProductDiscount:product:)`` + /// - ``StoreProduct/eligiblePromotionalOffers()`` + /// - ``StoreProduct/discounts`` + func eligiblePromotionalOffers(forProduct product: StoreProduct) async -> [PromotionalOffer] + + /** + * Returns the win-back offers that the subscriber is eligible for on the provided product. + * + * - Parameter product: The product to check for eligible win-back offers. + * - Parameter completion: A completion block that is called with the eligible win-back + * offers for the provided product. + * - Important: Win-back offers are only supported when the SDK is running with StoreKit 2 enabled. + */ + @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) + func eligibleWinBackOffers( + forProduct product: StoreProduct, + completion: @escaping @Sendable ([WinBackOffer]?, PublicError?) -> Void + ) + + /** + * Returns the win-back offers that the subscriber is eligible for on the provided package. + * + * - Parameter package: The package to check for eligible win-back offers. + * - Parameter completion: A completion block that is called with the eligible win-back + * offers for the provided product. + * - Important: Win-back offers are only supported when the SDK is running with StoreKit 2 enabled. + */ + @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) + func eligibleWinBackOffers( + forPackage package: Package, + completion: @escaping @Sendable ([WinBackOffer]?, PublicError?) -> Void + ) + + #endif + + #if os(iOS) || VISION_OS + + /** + * Presents a refund request sheet in the current window scene for + * the latest transaction associated with the `productID` + * + * - Parameter productID: The `productID` to begin a refund request for. + * If the request was successful, there will be a ``RefundRequestStatus``. + * Keep in mind the status could be ``RefundRequestStatus/userCancelled`` + * + * - throws: If the request was unsuccessful, there will be an `Error` and `RefundRequestStatus.error`. + */ + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @objc(beginRefundRequestForProduct:completion:) + func beginRefundRequest(forProduct productID: String) async throws -> RefundRequestStatus + + /** + * Presents a refund request sheet in the current window scene for + * the latest transaction associated with the entitlement ID. + * + * - Parameter entitlementID: The entitlementID to begin a refund request for. + * - returns ``RefundRequestStatus``: The status of the refund request. + * Keep in mind the status could be ``RefundRequestStatus/userCancelled`` + * + * - throws: If the request was unsuccessful or the entitlement could not be found, an `Error` will be thrown. + */ + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @objc(beginRefundRequestForEntitlement:completion:) + func beginRefundRequest(forEntitlement entitlementID: String) async throws + -> RefundRequestStatus + + /** + * Presents a refund request sheet in the current window scene for + * the latest transaction associated with the active entitlement. + * + * - returns ``RefundRequestStatus``: The status of the refund request. + * Keep in mind the status could be ``RefundRequestStatus/userCancelled`` + * + *- throws: If the request was unsuccessful, no active entitlements could be found for the user, + * or multiple active entitlements were found for the user, an `Error` will be thrown. + * + *- important: This method should only be used if your user can only + * have a single active entitlement at a given time. If a user could have more than one entitlement at a time, + * use ``Purchases/beginRefundRequest(forEntitlement:)`` instead. + */ + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @objc(beginRefundRequestForActiveEntitlementWithCompletion:) + func beginRefundRequestForActiveEntitlement() async throws -> RefundRequestStatus + + #endif + + /** + * Displays a sheet that enables users to redeem subscription offer codes that you generated in App Store Connect. + * + * - Important: Even though the docs in `SKPaymentQueue.presentCodeRedemptionSheet` + * say that it's available on Catalyst 14.0, there is a note: + * "`This function doesn’t affect Mac apps built with Mac Catalyst.`" + * when, in fact, it crashes when called both from Catalyst and also when running as "Designed for iPad". + * This is why RevenueCat's SDK makes it unavailable in mac catalyst. + */ + @available(iOS 14.0, *) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @available(macOS, unavailable) + @available(macCatalyst, unavailable) + func presentCodeRedemptionSheet() + + #if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + /** + * Displays price consent sheet if needed. You only need to call this manually if you implement + * ``PurchasesDelegate/shouldShowPriceConsent`` and return false at some point. + * + * You may want to delay showing the sheet if it would interrupt your user’s interaction in your app. You can do + * this by implementing ``PurchasesDelegate/shouldShowPriceConsent``. + * + * In most cases, you don't _*typically*_ implement ``PurchasesDelegate/shouldShowPriceConsent``, therefore, + * you won't need to call this. + * + * ### Related Symbols + * - ``SKPaymentQueue/showPriceConsentIfNeeded()` + * + * ### Related Articles + * - [Apple Documentation](https://rev.cat/testing-promoted-in-app-purchases) + */ + @available(iOS 13.4, macCatalyst 13.4, *) + @objc func showPriceConsentIfNeeded() + #endif + + #if os(iOS) || os(macOS) || VISION_OS + + /** + * Use this function to open the manage subscriptions page. + * + * - Parameter completion: A completion block that will be called when the modal is opened, + * not when it's actually closed. This is because of an undocumented change in StoreKit's behavior + * between iOS 15.0 and 15.2, where 15.0 would return when the modal was closed, and 15.2 returns + * when the modal is opened. + * + * If the manage subscriptions page can't be opened, the ``CustomerInfo/managementURL`` in + * the ``CustomerInfo`` will be opened. If ``CustomerInfo/managementURL`` is not available, + * the App Store's subscription management section will be opened. + * + * The `completion` block will be called when the modal is opened, not when it's actually closed. + * This is because of an undocumented change in StoreKit's behavior between iOS 15.0 and 15.2, + * where 15.0 would return when the modal was closed, + * and 15.2 returns when the modal is opened. + */ + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @available(iOS 13.0, macOS 10.15, *) + @objc func showManageSubscriptions(completion: @escaping (PublicError?) -> Void) + + /** + * Use this function to open the manage subscriptions modal. + * + * - throws: an `Error` will be thrown if the current window scene couldn't be opened, + * or the ``CustomerInfo/managementURL`` couldn't be obtained. + * If the manage subscriptions page can't be opened, the ``CustomerInfo/managementURL`` in + * the ``CustomerInfo`` will be opened. If ``CustomerInfo/managementURL`` is not available, + * the App Store's subscription management section will be opened. + */ + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @available(iOS 13.0, macOS 10.15, *) + func showManageSubscriptions() async throws + + #endif + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + + /** + * ``Attribution`` object that is responsible for all explicit attribution APIs + * as well as subscriber attributes that RevenueCat offers. + * + * #### Example: + * + * ```swift + * Purchases.shared.attribution.setEmail(“nobody@example.com”) + * ``` + * + * #### Related Articles + * - [Subscriber Attribution](https://docs.revenuecat.com/docs/subscriber-attributes) + * - ``Attribution`` + */ + var attribution: Attribution { get } + + /** + * Syncs subscriber attributes and then fetches the configured offerings for this user. This method is intended to + * be called when using Targeting Rules with Custom Attributes. Any subscriber attributes should be set before + * calling this method to ensure the returned offerings are applied with the latest subscriber attributes. + * + * This method is rate limited to 5 calls per minute. It will log a warning and return offerings cache when reached. + * + * - Parameter completion: A completion block called when attributes are synced and offerings are available. + * Called immediately with cached offerings if rate limit reached. ``Offerings`` will be `nil` if an error occurred. + * + * #### Related Articles + * - [Targeting](https://docs.revenuecat.com/docs/targeting) + */ + @objc func syncAttributesAndOfferingsIfNeeded( + completion: @escaping (Offerings?, PublicError?) -> Void) + + /** + * Syncs subscriber attributes and then fetches the configured offerings for this user. This method is intended to + * be called when using Targeting Rules with Custom Attributes. Any subscriber attributes should be set before + * calling this method to ensure the returned offerings are applied with the latest subscriber attributes. + * + * This method is rate limited to 5 calls per minute. It will log a warning and return offerings cache when reached. + * + * #### Related Articles + * - [Targeting](https://docs.revenuecat.com/docs/targeting) + */ + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) + func syncAttributesAndOfferingsIfNeeded() async throws -> Offerings? + + /** + * Redeems a web purchase previously parsed from a deep link with ``Purchases/parseAsWebPurchaseRedemption(_:)``. + * + * - Parameter webPurchaseRedemption: WebPurchaseRedemption object previously parsed from + * a URL using ``Purchases/parseAsWebPurchaseRedemption(_:)`` + * - Parameter completion: The completion block to be called with the updated CustomerInfo + * on a successful redemption, or the error if not. + * - Seealso: ``Purchases/redeemWebPurchase(_:)`` + */ + @objc func redeemWebPurchase( + webPurchaseRedemption: WebPurchaseRedemption, + completion: @escaping (CustomerInfo?, PublicError?) -> Void + ) + + /** + * Fetches the virtual currencies for the current subscriber. + * + * - Parameter completion: The callback that is called when the request is complete with a ``VirtualCurrencies`` + * object containing the subscriber's virtual currencies. + * + * #### Related Articles + * - [Virtual Currencies](https://www.revenuecat.com/docs/offerings/virtual-currency) + */ + @objc + func getVirtualCurrencies( + completion: @escaping @Sendable (VirtualCurrencies?, PublicError?) -> Void + ) + + /** + * The currently cached ``VirtualCurrencies`` if one is available. + * This is synchronous, and therefore useful for contexts where an app needs a `VirtualCurrencies` + * right away without waiting for a callback, like a SwiftUI view. + * + * This allows initializing state to ensure that UI can be loaded from the very first frame. + */ + var cachedVirtualCurrencies: VirtualCurrencies? { get } + + /** + * Invalidates the cache for virtual currencies. + * + * This is useful for cases where a virtual currency's balance might have been updated + * outside of the app, like if you decreased a user's balance from the user spending a virtual currency, + * or if you increased the balance from your backend using the server APIs. + * + * #### Related Articles + * - [Virtual Currencies](https://www.revenuecat.com/docs/offerings/virtual-currency) + */ + @objc + func invalidateVirtualCurrenciesCache() + + // MARK: - Deprecated + + // swiftlint:disable missing_docs + + func setAttributes(_ attributes: [String: String]) + + @available(*, deprecated) + var allowSharingAppStoreAccount: Bool { get set } + @available(*, deprecated) + func setEmail(_ email: String?) + @available(*, deprecated) + func setPhoneNumber(_ phoneNumber: String?) + @available(*, deprecated) + func setDisplayName(_ displayName: String?) + @available(*, deprecated) + func setPushToken(_ pushToken: Data?) + @available(*, deprecated) + func setPushTokenString(_ pushToken: String?) + @available(*, deprecated) + func setAdjustID(_ adjustID: String?) + @available(*, deprecated) + func setAppsflyerID(_ appsflyerID: String?) + @available(*, deprecated) + func setFBAnonymousID(_ fbAnonymousID: String?) + @available(*, deprecated) + func setMparticleID(_ mparticleID: String?) + @available(*, deprecated) + func setOnesignalID(_ onesignalID: String?) + @available(*, deprecated) + func setMediaSource(_ mediaSource: String?) + @available(*, deprecated) + func setCampaign(_ campaign: String?) + @available(*, deprecated) + func setAdGroup(_ adGroup: String?) + @available(*, deprecated) + func setAd(_ value: String?) + @available(*, deprecated) + func setKeyword(_ keyword: String?) + @available(*, deprecated) + func setCreative(_ creative: String?) + @available(*, deprecated) + func setCleverTapID(_ cleverTapID: String?) + @available(*, deprecated) + func setMixpanelDistinctID(_ mixpanelDistinctID: String?) + @available(*, deprecated) + func setFirebaseAppInstanceID(_ firebaseAppInstanceID: String?) + @available(*, deprecated) + func collectDeviceIdentifiers() + @available(*, deprecated) + @objc(params:withCompletion:) + func purchaseWithParams( + _ params: PurchaseParams, completion: @escaping PurchaseCompletedBlock) + + // swiftlint:enable missing_docs + + #endif + + /** Whether transactions should be finished automatically. `true` by default. + * - Warning: Setting this value to `false` will prevent the SDK from finishing transactions. + * In this case, you *must* finish transactions in your app, otherwise they will remain in the queue and + * will turn up every time the app is opened. + * More information on finishing transactions manually [is available here](https://rev.cat/finish-transactions). + */ + @available(*, deprecated, message: "Use purchasesAreCompletedBy instead.") + var finishTransactions: Bool { get set } + +} + +/// Interface for ``Purchases``'s `Swift`-only methods. +public protocol PurchasesSwiftType: AnyObject { + + /// Returns an `AsyncStream` of ``CustomerInfo`` changes, starting from the last known value. + /// + /// #### Related Symbols + /// - ``PurchasesDelegate/purchases(_:receivedUpdated:)`` + /// - ``Purchases/customerInfo(fetchPolicy:)`` + /// + /// #### Example: + /// ```swift + /// for await customerInfo in Purchases.shared.customerInfoStream { + /// // this gets called whenever new CustomerInfo is available + /// let entitlements = customerInfo.entitlements + /// ... + /// } + /// ``` + /// + /// - Note: An alternative way of getting ``CustomerInfo`` updates + /// is using ``PurchasesDelegate/purchases(_:receivedUpdated:)``. + /// - Important: this method is not thread-safe. + var customerInfoStream: AsyncStream { get } + + #if os(iOS) || VISION_OS + + /** + * Presents a refund request sheet in the current window scene for + * the latest transaction associated with the `productID` + * + * - Parameter productID: The `productID` to begin a refund request for. + * - Parameter completion: A completion block that is called when the ``RefundRequestStatus`` is returned. + * Keep in mind the status could be ``RefundRequestStatus/userCancelled`` + * If the request was unsuccessful, no active entitlements could be found for the user, + * or multiple active entitlements were found for the user, an `Error` will be thrown. + */ + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func beginRefundRequest( + forProduct productID: String, + completion: @escaping (Result) -> Void + ) + + /** + * Presents a refund request sheet in the current window scene for + * the latest transaction associated with the entitlement ID. + * + * - Parameter entitlementID: The entitlementID to begin a refund request for. + * - Parameter completion: A completion block that is called when the ``RefundRequestStatus`` is returned. + * Keep in mind the status could be ``RefundRequestStatus/userCancelled`` + * If the request was unsuccessful, no active entitlements could be found for the user, + * or multiple active entitlements were found for the user, an `Error` will be thrown. + */ + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func beginRefundRequest( + forEntitlement entitlementID: String, + completion: @escaping (Result) -> Void + ) + + /** + * Presents a refund request sheet in the current window scene for + * the latest transaction associated with the active entitlement. + * + * - Parameter completion: A completion block that is called when the ``RefundRequestStatus`` is returned. + * Keep in mind the status could be ``RefundRequestStatus/userCancelled`` + * If the request was unsuccessful, no active entitlements could be found for the user, + * or multiple active entitlements were found for the user, an `Error` will be thrown. + * + * - Important: This method should only be used if your user can only + * have a single active entitlement at a given time. If a user could have more than one entitlement at a time, + * use ``beginRefundRequest(forEntitlement:completion:)`` instead. + */ + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func beginRefundRequestForActiveEntitlement( + completion: @escaping (Result) -> Void + ) + + #endif + + #if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + + /** + * Displays the specified store in-app message types to the user if there are any available to be shown. + * - Important: This should only be used if you disabled these messages from showing automatically + * during SDK configuration using ``Configuration/Builder/with(showStoreMessagesAutomatically:)`` + * ### Related Symbols + * - ``Configuration/Builder/with(showStoreMessagesAutomatically:)`` + */ + @available(iOS 16.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func showStoreMessages(for types: Set) async + + #endif + + /** + * Use this method only if you already have your own IAP implementation using StoreKit 2 and want to use + * RevenueCat's backend. If you are using StoreKit 1 for your implementation, you do not need this method. + * + * You only need to use this method with *new* purchases. Subscription updates are observed automatically. + * + * #### Example: + * + * ```swift + * // Fetch and purchase the product + * let product = try await StoreKit.Product.products(for: ["my_product_id"]).first + * guard let product = product else { return } + * let result = try await product.purchase() + * // Let RevenueCat handle the transaction result + * _ = try await Purchases.shared.recordPurchase(result) + * // Handle the result and finish the transaction + * switch result { + * case .success(let verification): + * switch verification { + * case .unverified(_, _): + * break + * case .verified(let transaction): + * // If the purchase was successful and verified, finish the transaction + * await transaction.finish() + * } + * case .userCancelled: + * break + * case .pending: + * break + * @unknown default: + * break + * } + * ``` + * + * - Warning: You need to finish the transaction yourself after calling this method. + * + * - Parameter purchaseResult: The `StoreKit.Product.PurchaseResult` of the product that was just purchased. + * + * - Throws: An error of type ``ErrorCode`` is thrown if a failure occurs while handling the purchase. + * + * - Returns: A ``StoreTransaction`` if there was a transacton found and handled for the provided product ID. + * + * - Important: This should only be used if you are processing transactions directly within your app, configuring + * the SDK by passing ``PurchasesAreCompletedBy/myApp`` to `purchasesAreCompletedBy`: in + * ``Configuration/Builder/with(purchasesAreCompletedBy:storeKitVersion:)`` + */ + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + func recordPurchase( + _ purchaseResult: StoreKit.Product.PurchaseResult + ) async throws -> StoreTransaction? + + /** + * Redeems a web purchase previously parsed from a deep link with ``Purchases/parseAsWebPurchaseRedemption(_:)`` + * + * - Parameter webPurchaseRedemption: Deep link previously parsed from a + * URL using ``Purchases/parseAsWebPurchaseRedemption(_:)`` + */ + func redeemWebPurchase( + _ webPurchaseRedemption: WebPurchaseRedemption + ) async -> WebPurchaseRedemptionResult + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + /** + * Returns the win-back offers that the subscriber is eligible for on the provided product. + * + * - Parameter product: The product to check for eligible win-back offers. + * - Returns: The win-back offers on the given product that a subscriber is eligible for. + * - Important: Win-back offers are only supported when the SDK is running with StoreKit 2 enabled. + */ + @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) + func eligibleWinBackOffers( + forProduct product: StoreProduct + ) async throws -> [WinBackOffer] + + /** + * Returns the win-back offers that the subscriber is eligible for on the provided package. + * + * - Parameter package: The package to check for eligible win-back offers. + * - Returns: The win-back offers on the given product that a subscriber is eligible for. + * - Important: Win-back offers are only supported when the SDK is running with StoreKit 2 enabled. + */ + @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) + func eligibleWinBackOffers( + forPackage package: Package + ) async throws -> [WinBackOffer] + + /** + * Fetches the virtual currencies for the current subscriber. + * + * - Returns: The ``VirtualCurrencies`` object containing the virtual currencies for the subscriber. + * + * #### Related Articles + * - [Virtual Currencies](https://www.revenuecat.com/docs/offerings/virtual-currency) + */ + func virtualCurrencies() async throws -> VirtualCurrencies + #endif +} + +// MARK: - + +/// Interface for ``Purchases``'s internal-only methods. +internal protocol InternalPurchasesType: AnyObject { + + /// Performs an unauthenticated request to the API to verify connectivity. + /// - Throws: `PublicError` if request failed. + func healthRequest(signatureVerification: Bool) async throws + + #if DEBUG + /// Requests an in-depth report of the SDK's configuration from the server. + /// - Throws: A `BackendError` if the request fails due to an invalid API key or connectivity issues. + /// - Returns: A health report containing all checks performed on the server and their status. + func healthReport() async -> PurchasesDiagnostics.SDKHealthReport + #endif + + func offerings(fetchPolicy: OfferingsManager.FetchPolicy) async throws -> Offerings + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func productEntitlementMapping() async throws -> ProductEntitlementMapping + + var responseVerificationMode: Signing.ResponseVerificationMode { get } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/TransactionMetadataSyncHelper.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/TransactionMetadataSyncHelper.swift new file mode 100644 index 00000000..57c05ef0 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/TransactionMetadataSyncHelper.swift @@ -0,0 +1,94 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// TransactionMetadataSyncHelper.swift +// +// Created by RevenueCat. + +import Foundation + +/// Helper class responsible for syncing remaining cached transaction metadata +/// that wasn't synced during normal transaction processing. +/// This handles edge cases where a transaction is not returned by the store anymore +/// but we still have metadata cached for it. +final class TransactionMetadataSyncHelper { + + private let customerInfoManager: CustomerInfoManager + private let attribution: Attribution + private let currentUserProvider: CurrentUserProvider + private let operationDispatcher: OperationDispatcher + private let transactionPoster: TransactionPosterType + + private let isSyncing: Atomic = .init(false) + + private var appUserID: String { self.currentUserProvider.currentAppUserID } + + init( + customerInfoManager: CustomerInfoManager, + attribution: Attribution, + currentUserProvider: CurrentUserProvider, + operationDispatcher: OperationDispatcher, + transactionPoster: TransactionPosterType + ) { + self.customerInfoManager = customerInfoManager + self.attribution = attribution + self.currentUserProvider = currentUserProvider + self.operationDispatcher = operationDispatcher + self.transactionPoster = transactionPoster + } + + /// Posts any remaining cached transaction metadata that wasn't synced during normal transaction processing. + /// This handles edge cases where a transaction is not returned by the store anymore but we still have + /// metadata cached for it. + func syncIfNeeded(allowSharingAppStoreAccount: Bool) { + #if DEBUG + let delay: JitterableDelay = ProcessInfo.isRunningRevenueCatTests ? .none : .default + #else + let delay: JitterableDelay = .default + #endif + self.operationDispatcher.dispatchOnWorkerThread(jitterableDelay: delay) { + Task { + await self.performSync(allowSharingAppStoreAccount: allowSharingAppStoreAccount) + } + } + } + + func performSync(allowSharingAppStoreAccount: Bool) async { + guard self.isSyncing.getAndSet(true) == false else { + Logger.debug(Strings.purchase.cached_transaction_metadata_sync_already_in_progress) + return + } + defer { self.isSyncing.value = false } + + let currentAppUserID = self.appUserID + let isRestore = allowSharingAppStoreAccount + + let resultsStream = self.transactionPoster.postRemainingCachedTransactionMetadata( + appUserID: currentAppUserID, + isRestore: isRestore + ) + + for await (transactionData, result) in resultsStream { + if let customerInfo = try? result.get() { + self.customerInfoManager.cache(customerInfo: customerInfo, appUserID: currentAppUserID) + } + self.attribution.markSyncedIfNeeded( + subscriberAttributes: transactionData.unsyncedAttributes, + adServicesToken: transactionData.aadAttributionToken, + appUserID: currentAppUserID, + error: result.error + ) + } + + Logger.debug(Strings.purchase.finished_posting_cached_metadata) + } + +} + +extension TransactionMetadataSyncHelper: Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/TransactionNotifications.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/TransactionNotifications.swift new file mode 100644 index 00000000..c8ced512 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/TransactionNotifications.swift @@ -0,0 +1,28 @@ +// +// TransactionNotifications.swift +// RevenueCat +// +// Created by Jacob Zivan Rakidzich on 10/10/25. +// + +import Combine +import Foundation + +extension NSNotification.Name { + /// A notification that states a purchase has completed + static let purchaseCompleted = Notification.Name("RevenueCat.PurchaseCompleted") +} + +extension NotificationCenter { + /// A publisher that wraps the `purchaseCompleted` notification that will allow us to propagate + /// those events for transactions that were not initiated directly by the Purchases SDK + /// (like promotional offers) + /// + /// - Important: This is not intended for public consumption and should be used with care + @_spi(Internal) public func purchaseCompletedPublisher() -> AnyPublisher { + self + .publisher(for: .purchaseCompleted) + .compactMap { $0.object as? PurchaseResultData } + .eraseToAnyPublisher() + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/TransactionPoster.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/TransactionPoster.swift new file mode 100644 index 00000000..4698137d --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/Purchases/TransactionPoster.swift @@ -0,0 +1,621 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// TransactionPoster.swift +// +// Created by Nacho Soto on 5/24/23. + +import Foundation + +// swiftlint:disable file_length + +/// Determines what triggered a receipt to be posted and whether it comes from a restore. +struct PostReceiptSource: Equatable { + + /// Determines what triggered a receipt to be posted + enum InitiationSource: CaseIterable { + + /// From a call to restore purchases + case restore + + /// From a purchase + case purchase + + /// From a transaction in the queue + case queue + + } + + let isRestore: Bool + let initiationSource: InitiationSource + +} + +/// Encapsulates data used when posting transactions to the backend. +struct PurchasedTransactionData { + + var presentedOfferingContext: PresentedOfferingContext? + var presentedPaywall: PaywallEvent? + var unsyncedAttributes: SubscriberAttribute.Dictionary? + var metadata: [String: String]? + var aadAttributionToken: String? + var storeCountry: String? + +} + +/// Result of posting a single cached transaction metadata entry. +/// Contains the transaction data (for syncing attributes) and the result of the receipt post. +typealias CachedTransactionMetadataPostResult = ( + transactionData: PurchasedTransactionData, + result: Result +) + +/// A type that can post receipts as a result of a purchased transaction. +protocol TransactionPosterType: AnyObject, Sendable { + + /// Starts a `PostReceiptDataOperation` for the transaction. + func handlePurchasedTransaction( + _ transaction: StoreTransactionType, + data: PurchasedTransactionData, + postReceiptSource: PostReceiptSource, + currentUserID: String, + completion: @escaping CustomerAPI.CustomerInfoResponseHandler + ) + + /// Finishes the transaction if not in observer mode. + /// - Note: `handlePurchasedTransaction` calls this automatically, + /// this is only required for failed transactions. + func finishTransactionIfNeeded( + _ transaction: StoreTransactionType, + completion: @escaping @Sendable @MainActor () -> Void + ) + + // swiftlint:disable function_parameter_count + func postReceiptFromSyncedSK2Transaction( + _ transaction: StoreTransactionType, + data: PurchasedTransactionData, + receipt: EncodedAppleReceipt, + postReceiptSource: PostReceiptSource, + appTransactionJWS: String?, + currentUserID: String, + completion: @escaping CustomerAPI.CustomerInfoResponseHandler + ) + + /// Posts any remaining cached transaction metadata that wasn't synced during normal transaction processing. + /// This handles edge cases where a transaction was cached but never successfully posted + /// (e.g., due to app crashes or network issues). + /// - Parameters: + /// - appUserID: The current app user ID to post the receipts for + /// - isRestore: Whether this is a restore operation + /// - Returns: An `AsyncStream` that yields a result for each cached metadata entry as it's processed. + /// Each element contains the transaction data and the result of posting. + /// The stream completes after all entries have been processed. + func postRemainingCachedTransactionMetadata( + appUserID: String, + isRestore: Bool + ) -> AsyncStream + +} + +final class TransactionPoster: TransactionPosterType { + + private let productsManager: ProductsManagerType + private let receiptFetcher: ReceiptFetcher + private let transactionFetcher: StoreKit2TransactionFetcherType + private let backend: Backend + private let paymentQueueWrapper: EitherPaymentQueueWrapper + private let systemInfo: SystemInfo + private let operationDispatcher: OperationDispatcher + private let localTransactionMetadataStore: LocalTransactionMetadataStoreType + + init( + productsManager: ProductsManagerType, + receiptFetcher: ReceiptFetcher, + transactionFetcher: StoreKit2TransactionFetcherType, + backend: Backend, + paymentQueueWrapper: EitherPaymentQueueWrapper, + systemInfo: SystemInfo, + operationDispatcher: OperationDispatcher, + localTransactionMetadataStore: LocalTransactionMetadataStoreType + ) { + self.productsManager = productsManager + self.receiptFetcher = receiptFetcher + self.transactionFetcher = transactionFetcher + self.backend = backend + self.paymentQueueWrapper = paymentQueueWrapper + self.systemInfo = systemInfo + self.operationDispatcher = operationDispatcher + self.localTransactionMetadataStore = localTransactionMetadataStore + } + + func handlePurchasedTransaction(_ transaction: StoreTransactionType, + data: PurchasedTransactionData, + postReceiptSource: PostReceiptSource, + currentUserID: String, + completion: @escaping CustomerAPI.CustomerInfoResponseHandler) { + Logger.debug(Strings.purchase.transaction_poster_handling_transaction( + transactionID: transaction.transactionIdentifier, + productID: transaction.productIdentifier, + transactionDate: transaction.purchaseDate, + offeringID: data.presentedOfferingContext?.offeringIdentifier, + placementID: data.presentedOfferingContext?.placementIdentifier, + paywallSessionID: data.presentedPaywall?.data.sessionIdentifier + )) + + guard let productIdentifier = transaction.productIdentifier.notEmpty else { + self.finishTransactionIfNeededFromReceiptPost(transaction: transaction, + result: .failure(.missingTransactionProductIdentifier()), + completion: completion) + return + } + + self.fetchEncodedReceipt(transaction: transaction) { result in + switch result { + case .success(let encodedReceipt): + self.product(with: productIdentifier) { product in + self.getAppTransactionJWSIfNeeded { appTransaction in + self.postReceipt(transaction: transaction, + purchasedTransactionData: data, + postReceiptSource: postReceiptSource, + receipt: encodedReceipt, + product: product, + appTransaction: appTransaction, + currentUserID: currentUserID) { result in + self.finishTransactionIfNeededFromReceiptPost(transaction: transaction, + result: result.map { ($0, product) }, + completion: completion) + } + } + } + case .failure(let error): + self.finishTransactionIfNeededFromReceiptPost(transaction: transaction, + result: .failure(error), + completion: completion) + } + } + } + + // swiftlint:disable function_parameter_count + func postReceiptFromSyncedSK2Transaction( + _ transaction: StoreTransactionType, + data: PurchasedTransactionData, + receipt: EncodedAppleReceipt, + postReceiptSource: PostReceiptSource, + appTransactionJWS: String?, + currentUserID: String, + completion: @escaping CustomerAPI.CustomerInfoResponseHandler + ) { + self.product(with: transaction.productIdentifier) { product in + self.postReceipt(transaction: transaction, + purchasedTransactionData: data, + postReceiptSource: postReceiptSource, + receipt: receipt, + product: product, + appTransaction: appTransactionJWS, + currentUserID: currentUserID, + completion: completion) + } + } + + func postRemainingCachedTransactionMetadata( + appUserID: String, + isRestore: Bool + ) -> AsyncStream { + return AsyncStream { continuation in + let metadataToSync = self.localTransactionMetadataStore.getAllStoredMetadata() + + guard !metadataToSync.isEmpty else { + Logger.verbose(Strings.purchase.no_cached_transaction_metadata_to_post) + continuation.finish() + return + } + + Logger.debug(Strings.purchase.posting_remaining_cached_metadata(count: metadataToSync.count)) + + self.getAppTransactionJWSIfNeeded { appTransaction in + self.postCachedMetadataSequentially( + metadataToSync: metadataToSync, + appUserID: appUserID, + isRestore: isRestore, + appTransaction: appTransaction, + continuation: continuation + ) + } + } + } + + func finishTransactionIfNeeded( + _ transaction: StoreTransactionType, + completion: @escaping @Sendable @MainActor () -> Void + ) { + @Sendable + func complete() { + self.operationDispatcher.dispatchOnMainActor(completion) + } + + guard self.finishTransactions else { + complete() + return + } + + Logger.purchase(Strings.purchase.finishing_transaction(transaction)) + + transaction.finish(self.paymentQueueWrapper.paymentQueueWrapperType, completion: complete) + } + + static func shouldFinish( + transaction: StoreTransactionType, + for product: StoreProductType?, + customerInfo: CustomerInfo + ) -> Bool { + // Don't finish transactions if CustomerInfo was computed offline + guard !customerInfo.isComputedOffline else { return false } + + // If we couldn't find the product, we can't determine if it's a consumable + guard let product = product else { return true } + + switch product.productCategory { + case .subscription: + // Note: this includes non-renewing subscriptions. Those are included in `.nonSubscriptions`, + // but we can't tell them apart using `product.productType` because that's unknown for SK1 products. + return true + + case .nonSubscription: + // Only finish consumables if the server actually processed it. + let shouldFinish = ( + !transaction.hasKnownTransactionIdentifier || + customerInfo.nonSubscriptions.contains { + $0.storeTransactionIdentifier == transaction.transactionIdentifier + } + ) + if !shouldFinish { + Logger.warn(Strings.purchase.finish_transaction_skipped_because_its_missing_in_non_subscriptions( + transaction, + customerInfo.nonSubscriptions + )) + } + + return shouldFinish + } + } + +} + +/// Async extension +extension TransactionPosterType { + + /// Starts a `PostReceiptDataOperation` for the transaction. + func handlePurchasedTransaction( + _ transaction: StoreTransaction, + data: PurchasedTransactionData, + postReceiptSource: PostReceiptSource, + currentUserID: String + ) async -> Result { + await Async.call { completion in + self.handlePurchasedTransaction( + transaction, + data: data, + postReceiptSource: postReceiptSource, + currentUserID: currentUserID, + completion: completion + ) + } + } + +} + +extension PostReceiptSource: Codable {} + +// MARK: - Implementation + +extension TransactionPoster { + + func finishTransactionIfNeededFromReceiptPost( + transaction: StoreTransactionType, + result: Result< + ( + info: CustomerInfo, + product: StoreProduct? + ), + BackendError + >, + completion: @escaping CustomerAPI.CustomerInfoResponseHandler + ) { + let customerInfoResult = result.map(\.info) + + self.operationDispatcher.dispatchOnMainActor { + switch result { + case let .success((customerInfo, product)): + if Self.shouldFinish( + transaction: transaction, + for: product, + customerInfo: customerInfo + ) { + self.finishTransactionIfNeeded(transaction) { + completion(customerInfoResult) + } + } else { + completion(customerInfoResult) + + } + + case let .failure(error): + if error.finishable { + self.finishTransactionIfNeeded(transaction) { + completion(customerInfoResult) + } + } else { + completion(customerInfoResult) + } + } + } + } + + // swiftlint:disable function_parameter_count function_body_length + private func postReceipt(transaction: StoreTransactionType, + purchasedTransactionData: PurchasedTransactionData, + postReceiptSource: PostReceiptSource, + receipt: EncodedAppleReceipt, + product: StoreProduct?, + appTransaction: String?, + currentUserID: String, + completion: @escaping CustomerAPI.CustomerInfoResponseHandler) { + let storedTransactionMetadata = self.localTransactionMetadataStore.getMetadata( + forTransactionId: transaction.transactionIdentifier + ) + let shouldStoreMetadata = storedTransactionMetadata == nil && ( + postReceiptSource.initiationSource == .purchase || + purchasedTransactionData.presentedOfferingContext != nil || + purchasedTransactionData.presentedPaywall != nil + ) + + let containsAttributionData = storedTransactionMetadata != nil || shouldStoreMetadata + + let effectiveProductData = storedTransactionMetadata?.productData ?? product.map { + ProductRequestData(with: $0, storeCountry: purchasedTransactionData.storeCountry) + } + let effectiveTransactionData = storedTransactionMetadata?.transactionData ?? purchasedTransactionData + let effectivePurchasesAreCompletedBy = storedTransactionMetadata?.originalPurchasesAreCompletedBy ?? + self.purchasesAreCompletedBy + + // sdkOriginated indicates whether this purchase was initiated by the SDK (stored metadata takes precedence): + // - true when the purchase was initiated via SDK's purchase() methods (initiationSource == .purchase) + // - false when the purchase was detected in the queue but triggered outside the SDK + let sdkOriginated = storedTransactionMetadata?.sdkOriginated ?? + (postReceiptSource.initiationSource == .purchase) + + if shouldStoreMetadata { + let metadataToStore = LocalTransactionMetadata( + transactionId: transaction.transactionIdentifier, + productData: effectiveProductData, + transactionData: effectiveTransactionData, + encodedAppleReceipt: receipt, + originalPurchasesAreCompletedBy: effectivePurchasesAreCompletedBy, + sdkOriginated: sdkOriginated + ) + self.localTransactionMetadataStore.storeMetadata(metadataToStore, + forTransactionId: transaction.transactionIdentifier) + } + + self.backend.post(receipt: receipt, + productData: effectiveProductData, + transactionData: effectiveTransactionData, + postReceiptSource: postReceiptSource, + observerMode: self.observerMode, + originalPurchaseCompletedBy: effectivePurchasesAreCompletedBy, + appTransaction: appTransaction, + associatedTransactionId: transaction.transactionIdentifier, + sdkOriginated: sdkOriginated, + appUserID: currentUserID, + containsAttributionData: containsAttributionData) { result in + if containsAttributionData { + switch result { + case let .success(customerInfo) where !customerInfo.isComputedOffline: + // Offline-computed CustomerInfo means server is down, so it didn't process the transaction yet + self.localTransactionMetadataStore + .removeMetadata(forTransactionId: transaction.transactionIdentifier) + case let .failure(error) where error.finishable: + self.localTransactionMetadataStore + .removeMetadata(forTransactionId: transaction.transactionIdentifier) + default: break + } + } + completion(result) + } + } + + func fetchEncodedReceipt(transaction: StoreTransactionType, + completion: @escaping (Result) -> Void) { + if systemInfo.isSimulatedStoreAPIKey { + let purchaseToken = transaction.jwsRepresentation ?? "" + completion(.success(.jws(purchaseToken))) + return + } + + if systemInfo.storeKitVersion.isStoreKit2EnabledAndAvailable, + let jwsRepresentation = transaction.jwsRepresentation { + if transaction.environment == .xcode, #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) { + _ = Task { + completion(.success( + .sk2receipt(await self.transactionFetcher.fetchReceipt(containing: transaction)) + )) + } + } else { + completion(.success(.jws(jwsRepresentation))) + } + } else { + self.receiptFetcher.receiptData( + refreshPolicy: self.refreshRequestPolicy(forProductIdentifier: transaction.productIdentifier) + ) { receiptData, receiptURL in + if let receiptData = receiptData, !receiptData.isEmpty { + completion(.success(.receipt(receiptData))) + } else { + completion(.failure(BackendError.missingReceiptFile(receiptURL))) + } + } + } + } + + func getAppTransactionJWSIfNeeded(_ completion: @escaping (String?) -> Void) { + if systemInfo.isSimulatedStoreAPIKey { + completion(nil) + } else { + self.transactionFetcher.appTransactionJWS(completion) + } + } + +} + +// MARK: - Cached Metadata Posting + +private extension TransactionPoster { + + /// Posts cached metadata entries sequentially, yielding each result to the continuation. + func postCachedMetadataSequentially( + metadataToSync: [LocalTransactionMetadata], + appUserID: String, + isRestore: Bool, + appTransaction: String?, + continuation: AsyncStream.Continuation + ) { + var remainingMetadata = metadataToSync + + func postNext() { + guard !remainingMetadata.isEmpty else { + continuation.finish() + return + } + + let metadata = remainingMetadata.removeFirst() + + self.postCachedMetadata( + metadata: metadata, + receipt: metadata.encodedAppleReceipt, + appUserID: appUserID, + isRestore: isRestore, + appTransaction: appTransaction + ) { transactionData, result in + continuation.yield((transactionData, result)) + // Continue posting regardless of success or failure + postNext() + } + } + + postNext() + } + + /// Posts a single cached metadata entry. + func postCachedMetadata( + metadata: LocalTransactionMetadata, + receipt: EncodedAppleReceipt, + appUserID: String, + isRestore: Bool, + appTransaction: String?, + completion: @escaping (PurchasedTransactionData, Result) -> Void + ) { + Logger.debug(Strings.purchase.posting_cached_metadata(transactionId: metadata.transactionId)) + + let transactionData = metadata.transactionData + // Set the source to indicate this is from unsynced purchases + let postReceiptSource = PostReceiptSource( + isRestore: isRestore, + initiationSource: .queue + ) + + self.backend.post( + receipt: receipt, + productData: metadata.productData, + transactionData: transactionData, + postReceiptSource: postReceiptSource, + observerMode: self.observerMode, + originalPurchaseCompletedBy: metadata.originalPurchasesAreCompletedBy, + appTransaction: appTransaction, + associatedTransactionId: metadata.transactionId, + appUserID: appUserID + ) { result in + // Clear metadata on success or finishable error + switch result { + case .success: + self.localTransactionMetadataStore.removeMetadata(forTransactionId: metadata.transactionId) + case let .failure(error) where error.finishable: + self.localTransactionMetadataStore.removeMetadata(forTransactionId: metadata.transactionId) + default: + break + } + completion(transactionData, result) + } + } + +} + +// MARK: - Properties + +private extension TransactionPoster { + + var observerMode: Bool { + self.systemInfo.observerMode + } + + var purchasesAreCompletedBy: PurchasesAreCompletedBy { + return self.observerMode ? .myApp : .revenueCat + } + + var finishTransactions: Bool { + self.systemInfo.finishTransactions + } + +} + +// MARK: - Receipt refreshing + +extension TransactionPoster { + + private func refreshRequestPolicy(forProductIdentifier productIdentifier: String) -> ReceiptRefreshPolicy { + if self.systemInfo.dangerousSettings.internalSettings.enableReceiptFetchRetry { + return .retryUntilProductIsFound(productIdentifier: productIdentifier, + maximumRetries: Self.receiptRetryCount, + sleepDuration: Self.receiptRetrySleepDuration) + } else { + // See https://github.com/RevenueCat/purchases-ios/pull/2245 and + // https://github.com/RevenueCat/purchases-ios/issues/2260 + // - Release or production builds: + // We don't _want_ to always refresh receipts to avoid throttling errors + // We don't _need_ to because the receipt will be refreshed by the backend using /verifyReceipt + // - Debug and sandbox builds (potentially using StoreKit config files): + // We need to always refresh the receipt because the backend does not use /verifyReceipt + // when it was generated locally with SK config files. + + #if DEBUG + return self.systemInfo.isSandbox + ? .always + : .onlyIfEmpty + #else + return .onlyIfEmpty + #endif + } + } + + static let receiptRetryCount: Int = 3 + static let receiptRetrySleepDuration: DispatchTimeInterval = .seconds(5) + +} + +// MARK: - Products + +private extension TransactionPoster { + + func product(with identifier: String, completion: @escaping (StoreProduct?) -> Void) { + self.productsManager.products(withIdentifiers: [identifier]) { products in + self.operationDispatcher.dispatchOnMainThread { + completion(products.value?.first) + } + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ReceiptFetcher.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ReceiptFetcher.swift new file mode 100644 index 00000000..e04a51db --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ReceiptFetcher.swift @@ -0,0 +1,223 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ReceiptFetcher.swift +// +// Created by Javier de Martín Gil on 8/7/21. +// + +import Foundation + +class ReceiptFetcher { + + private let requestFetcher: StoreKitRequestFetcher + private let receiptParser: PurchasesReceiptParser + private let fileReader: FileReader + + private let lastReceiptRefreshRequest: Atomic = nil + + let systemInfo: SystemInfo + + init( + requestFetcher: StoreKitRequestFetcher, + systemInfo: SystemInfo, + receiptParser: PurchasesReceiptParser = .default, + fileReader: FileReader = DefaultFileReader() + ) { + self.requestFetcher = requestFetcher + self.systemInfo = systemInfo + self.receiptParser = receiptParser + self.fileReader = fileReader + } + + func receiptData(refreshPolicy: ReceiptRefreshPolicy, completion: @escaping (Data?, URL?) -> Void) { + let receiptURL = self.receiptURL + + switch refreshPolicy { + case .always: + if self.shouldThrottleRefreshRequest() { + Logger.debug(Strings.receipt.throttling_force_refreshing_receipt) + + // If requested to refresh again within the throttle duration + // Use `ReceiptRefreshPolicy.onlyIfEmpty` so receipt is not refreshed if it's already loaded. + self.receiptData(refreshPolicy: .onlyIfEmpty, completion: completion) + } else { + Logger.debug(Strings.receipt.force_refreshing_receipt) + self.refreshReceipt(completion) + } + + case .onlyIfEmpty: + let receiptData = self.receiptData() + let isReceiptEmpty = receiptData?.isEmpty ?? true + + if isReceiptEmpty { + Logger.debug(Strings.receipt.refreshing_empty_receipt) + self.refreshReceipt(completion) + } else { + completion(receiptData, receiptURL) + } + + case let .retryUntilProductIsFound(productIdentifier, maximumRetries, sleepDuration): + Async.call(with: completion) { + await self.refreshReceipt(untilProductIsFound: productIdentifier, + maximumRetries: maximumRetries, + sleepDuration: sleepDuration) + } + + case .never: + completion(self.receiptData(), receiptURL) + } + } + + func receiptData(refreshPolicy: ReceiptRefreshPolicy) async -> Data? { + // Note: We're using UnsafeContinuation instead of Checked because + // of a crash in iOS 18.0 devices when CheckedContinuations are used. + // See: https://github.com/RevenueCat/purchases-ios/issues/4177 + return await withUnsafeContinuation { continuation in + self.receiptData(refreshPolicy: refreshPolicy) { result, _ in + continuation.resume(returning: result) + } + } + } + + private func shouldThrottleRefreshRequest() -> Bool { + guard let lastRefresh = self.lastReceiptRefreshRequest.value else { + return false + } + + let timeSinceLastRequest = DispatchTimeInterval(self.systemInfo.clock.now.timeIntervalSince(lastRefresh)) + return timeSinceLastRequest.nanoseconds < ReceiptRefreshPolicy.alwaysRefreshThrottleDuration.nanoseconds + } + +} + +extension ReceiptFetcher { + + var receiptURL: URL? { + guard var receiptURL = self.systemInfo.bundle.appStoreReceiptURL else { + Logger.debug(Strings.receipt.no_sandbox_receipt_restore) + return nil + } + + #if os(watchOS) + return self.watchOSReceiptURL(receiptURL) + #else + return receiptURL + #endif + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension ReceiptFetcher: @unchecked Sendable {} + +extension PurchasesReceiptParser { + + /// A default instance of ``PurchasesReceiptParser`` + @objc + public static let `default`: PurchasesReceiptParser = .init(logger: Logger()) + +} + +// MARK: - + +private extension ReceiptFetcher { + + /// Returns non-empty `Data` for the receipt, or `nil` if it was empty or it couldn't be loaded. + private func receiptData() -> Data? { + guard let receiptURL = self.receiptURL else { + return nil + } + + do { + let data = try self.fileReader.contents(of: receiptURL) + guard !data.isEmpty else { throw Error.loadedEmptyReceipt } + + Logger.debug(Strings.receipt.loaded_receipt(url: receiptURL)) + return data + } catch { + Logger.appleWarning(Strings.receipt.unable_to_load_receipt(error)) + return nil + } + } + + func refreshReceipt(_ completion: @escaping (Data, URL?) -> Void) { + self.lastReceiptRefreshRequest.value = self.systemInfo.clock.now + + self.requestFetcher.fetchReceiptData { + completion(self.receiptData() ?? Data(), self.receiptURL) + } + } + + /// `async` version of `refreshReceipt(_:)` + func refreshReceipt() async -> (Data, URL?) { + // Note: We're using UnsafeContinuation instead of Checked because + // of a crash in iOS 18.0 devices when CheckedContinuations are used. + // See: https://github.com/RevenueCat/purchases-ios/issues/4177 + await withUnsafeContinuation { continuation in + self.refreshReceipt { + continuation.resume(returning: ($0, $1)) + } + } + } + + @MainActor + private func refreshReceipt( + untilProductIsFound productIdentifier: String, + maximumRetries: Int, + sleepDuration: DispatchTimeInterval + ) async -> (Data, URL?) { + return await Async.retry(maximumRetries: maximumRetries, pollInterval: sleepDuration) { + let (data, receiptURL) = await self.refreshReceipt() + if !data.isEmpty { + do { + // Parse receipt in a background thread + let receipt = try await Task.detached { [currentData = data] in + try self.receiptParser.parse(from: currentData) + }.value + + if receipt.containsActivePurchase(forProductIdentifier: productIdentifier) { + return (shouldRetry: false, (data, receiptURL)) + } else { + Logger.appleWarning(Strings.receipt.local_receipt_missing_purchase( + receipt, + forProductIdentifier: productIdentifier + )) + } + } catch { + Logger.error(Strings.receipt.parse_receipt_locally_error(error: error)) + } + } + + Logger.debug(Strings.receipt.retrying_receipt_fetch_after(sleepDuration: sleepDuration.seconds)) + return (shouldRetry: true, (data, receiptURL)) + } + } + +} + +// MARK: - + +private extension ReceiptFetcher { + + private enum Error: Swift.Error, CustomNSError { + + case loadedEmptyReceipt + + var errorUserInfo: [String: Any] { + switch self { + case .loadedEmptyReceipt: + return [ NSLocalizedDescriptionKey: "Receipt was empty" ] + } + } + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ReceiptRefreshPolicy.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ReceiptRefreshPolicy.swift new file mode 100644 index 00000000..45f5e610 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/ReceiptRefreshPolicy.swift @@ -0,0 +1,39 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ReceiptRefreshPolicy.swift +// +// Created by Juanpe Catalán on 7/7/21. +// + +import Foundation + +/// Determines the behavior when fetching receipts with `ReceiptFetcher`. +enum ReceiptRefreshPolicy { + + case always + case onlyIfEmpty + case retryUntilProductIsFound(productIdentifier: String, + maximumRetries: Int, + sleepDuration: DispatchTimeInterval = .never) + case never + +} + +extension ReceiptRefreshPolicy { + + /// See `ReceiptFetcher`. + /// `ReceiptRefreshPolicy.always` won't refresh receipts faster than this interval + /// to avoid StoreKit errors: + /// "Finished refreshing receipt with error: Error Domain=ASDErrorDomain Code=603 "Request throttled" + static let alwaysRefreshThrottleDuration: DispatchTimeInterval = .seconds(2) + +} + +extension ReceiptRefreshPolicy: Equatable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/SimulatedStore/SimulatedStoreProduct.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/SimulatedStore/SimulatedStoreProduct.swift new file mode 100644 index 00000000..5e57943a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/SimulatedStore/SimulatedStoreProduct.swift @@ -0,0 +1,17 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SimulatedStoreProduct.swift +// +// Created by Antonio Pallares on 6/8/25. + +import Foundation + +internal typealias SimulatedStoreProduct = TestStoreProduct +internal typealias SimulatedStoreProductDiscount = TestStoreProductDiscount diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/SimulatedStore/SimulatedStoreProductsManager.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/SimulatedStore/SimulatedStoreProductsManager.swift new file mode 100644 index 00000000..b7322c60 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/SimulatedStore/SimulatedStoreProductsManager.swift @@ -0,0 +1,67 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SimulatedStoreProductsManager.swift +// +// Created by Antonio Pallares on 21/7/25. + +import Foundation + +/// Implementation of `ProductsManagerType` for the Simulated Store. +final class SimulatedStoreProductsManager: ProductsManagerType { + + let requestTimeout: TimeInterval + let backend: Backend + let deviceCache: DeviceCache + + init(backend: Backend, deviceCache: DeviceCache, requestTimeout: TimeInterval) { + self.requestTimeout = requestTimeout + self.backend = backend + self.deviceCache = deviceCache + } + + func products(withIdentifiers identifiers: Set, completion: @escaping Completion) { + guard !identifiers.isEmpty else { + completion(.success(Set())) + return + } + + let appUserID = self.deviceCache.cachedAppUserID ?? "" + backend.webBilling.getWebBillingProducts(appUserID: appUserID, productIds: identifiers) { result in + switch result { + case let .success(response): + do { + let products: [StoreProduct] = try response.productDetails.map { + try $0.convertToStoreProduct() + } + completion(.success(Set(products))) + } catch { + let purchasesError = ErrorUtils.purchasesError(withUntypedError: error) + completion(.failure(purchasesError)) + } + + case let .failure(backendError): + completion(.failure(backendError.asPurchasesError)) + } + } + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func sk2Products(withIdentifiers identifiers: Set, completion: @escaping SK2Completion) { + completion(.success(Set())) + } + + // This class does not implement caching. + // See `CachingProductsManager`. + func cache(_ product: any StoreProductType) { } + + // This class does not implement caching. + // See `CachingProductsManager`. + func clearCache() { } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/SimulatedStore/SimulatedStorePurchaseHandler.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/SimulatedStore/SimulatedStorePurchaseHandler.swift new file mode 100644 index 00000000..5b808686 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/SimulatedStore/SimulatedStorePurchaseHandler.swift @@ -0,0 +1,105 @@ +// +// SimulatedStorePurchaseHandler.swift +// RevenueCat +// +// Created by Antonio Pallares on 16/7/25. +// Copyright © 2025 RevenueCat, Inc. All rights reserved. +// + +import Foundation + +enum TestPurchaseResult { + case cancel + case failure(PurchasesError) + case success(StoreTransaction) +} + +protocol SimulatedStorePurchaseHandlerType: AnyObject, Sendable { + + @MainActor + func purchase(product: TestStoreProduct) async -> TestPurchaseResult + +} + +/// The object that handles purchases in the Simulated Store. +/// +/// This class is used to handle purchases when using a Simulated Store API key. +actor SimulatedStorePurchaseHandler: SimulatedStorePurchaseHandlerType { + + private let purchaseUI: SimulatedStorePurchaseUI + private let dateProvider: DateProvider + + private var currentPurchaseTask: Task? + private var purchaseInProgress: Bool { + return self.currentPurchaseTask != nil + } + + init(systemInfo: SystemInfo) { + self.purchaseUI = DefaultSimulatedStorePurchaseUI(systemInfo: systemInfo) + self.dateProvider = DateProvider() + } + + // For testing purposes + init(purchaseUI: SimulatedStorePurchaseUI, dateProvider: DateProvider) { + self.purchaseUI = purchaseUI + self.dateProvider = dateProvider + } + + func purchase(product: TestStoreProduct) async -> TestPurchaseResult { + guard !self.purchaseInProgress else { + return .failure(ErrorUtils.operationAlreadyInProgressError()) + } + + let newPurchaseTask = Task { [weak self] in + guard let self else { + return .failure(ErrorUtils.unknownError()) + } + + let result = await self.purchaseUI.presentPurchaseUI(for: product) + + let purchaseResult: TestPurchaseResult + switch result { + case .cancel: + purchaseResult = .cancel + case .error(let error): + purchaseResult = .failure(error) + case .simulateFailure: + purchaseResult = .failure(self.simulatedError) + case .simulateSuccess: + Logger.debug(Strings.purchase.simulating_purchase_success) + let transaction = await self.createStoreTransaction(product: product) + purchaseResult = .success(transaction) + } + return purchaseResult + } + + self.currentPurchaseTask = newPurchaseTask + let purchaseResult = await newPurchaseTask.value + self.currentPurchaseTask = nil + + return purchaseResult + } + + private func createStoreTransaction(product: TestStoreProduct) async -> StoreTransaction { + let purchaseDate = self.dateProvider.now() + let purchaseToken = "test_\(purchaseDate.millisecondsSince1970)_\(UUID().uuidString)" + let storefront = await Storefront.currentStorefront + let simulatedStoreTransaction = SimulatedStoreTransaction(productIdentifier: product.productIdentifier, + purchaseDate: purchaseDate, + transactionIdentifier: purchaseToken, + storefront: storefront, + jwsRepresentation: purchaseToken) + return StoreTransaction(simulatedStoreTransaction) + } + + nonisolated private var simulatedError: PurchasesError { + return ErrorUtils.testStoreSimulatedPurchaseError() + } + +} + +// MARK: - Purchase Alert Presentation + +private extension SimulatedStorePurchaseHandler { + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/SimulatedStore/SimulatedStorePurchaseUI.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/SimulatedStore/SimulatedStorePurchaseUI.swift new file mode 100644 index 00000000..19a1e792 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/SimulatedStore/SimulatedStorePurchaseUI.swift @@ -0,0 +1,379 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SimulatedStorePurchaseUI.swift +// +// Created by Antonio Pallares on 1/8/25. + +import Foundation +#if os(iOS) || os(tvOS) || VISION_OS || targetEnvironment(macCatalyst) +import UIKit +#elseif os(watchOS) +import UIKit +import WatchKit +#elseif os(macOS) +import AppKit +#endif + +enum SimulatedStorePurchaseUIResult: Sendable { + case cancel + case simulateFailure + case simulateSuccess + case error(PurchasesError) +} + +protocol SimulatedStorePurchaseUI: Sendable { + + /// Presents the purchase UI for the given product. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - Returns: A result indicating the selected outcome of the purchase UI interaction. + func presentPurchaseUI(for product: SimulatedStoreProduct) async -> SimulatedStorePurchaseUIResult + +} + +/// Contains the logic to present a system alert for the confirmation of Simulated Store products purchases. +struct DefaultSimulatedStorePurchaseUI: SimulatedStorePurchaseUI { + + private let systemInfo: SystemInfo + + init(systemInfo: SystemInfo) { + self.systemInfo = systemInfo + } + + func presentPurchaseUI(for product: SimulatedStoreProduct) async -> SimulatedStorePurchaseUIResult { + await Task { @MainActor in + return await withUnsafeContinuation { continuation in + + let completion: (SimulatedStorePurchaseUIResult) -> Void = { result in + continuation.resume(returning: result) + } + + let alert = Alert( + title: Self.purchaseAlertTitle, + message: product.purchaseAlertMessage, + actions: [ + .init(title: Self.purchaseActionTitle, + callback: { _ in completion(.simulateSuccess) }, + style: .default), + .init(title: Self.failureActionTitle, + callback: { _ in completion(.simulateFailure) }, + style: .destructive), + .init(title: Self.cancelActionTitle, + callback: { _ in completion(.cancel) }, + style: .cancel) + ], dismissCallback: { + completion(.cancel) + } + ) + + self.showAlert(alert) { (error: PurchasesError) in + completion(.error(error)) + } + } + }.value + } + + #if !DEBUG + + /// Calling this method will show an alert indicating that a Test Store API key + /// is being used in a release build, which is not supported. + func showTestKeyInReleaseAlert(redactedApiKey: String) async { + await Task { @MainActor in + return await withUnsafeContinuation { continuation in + + let completion: () -> Void = continuation.resume + + let alert = Alert( + title: TestKeyInReleaseAlert.title, + message: TestKeyInReleaseAlert.message(redactedApiKey: redactedApiKey), + actions: [ + .init(title: TestKeyInReleaseAlert.actionTitle, + callback: { _ in completion() }, + style: .cancel) + ], dismissCallback: { + completion() + } + ) + + self.showAlert(alert) { _ in + completion() + } + } + }.value + } + + #endif + +} + +#if os(iOS) || os(tvOS) || VISION_OS || targetEnvironment(macCatalyst) + +// MARK: - UIViewController Extensions + +private extension UIViewController { + + func topMostViewController() -> UIViewController { + if let presentedViewController = self.presentedViewController { + return presentedViewController.topMostViewController() + } + + if let navigationController = self as? UINavigationController { + return navigationController.visibleViewController?.topMostViewController() ?? navigationController + } + + if let tabBarController = self as? UITabBarController { + return tabBarController.selectedViewController?.topMostViewController() ?? tabBarController + } + + return self + } +} +#endif + +// MARK: - Purchase Alert Details + +private extension DefaultSimulatedStorePurchaseUI { + + static let purchaseAlertTitle = "Test Purchase" + static let purchaseActionTitle = "Test valid purchase" + static let cancelActionTitle = "Cancel" + static let failureActionTitle = "Test failed purchase" + +} + +private enum TestKeyInReleaseAlert { + + static let title = "Wrong API Key" + static func message(redactedApiKey: String) -> String { + return "This app is using a test API key: \(redactedApiKey)\n\n" + + "To prepare for release, update your RevenueCat settings to use a production key.\n\n" + + "For more info, visit the RevenueCat dashboard.\n\n" + + "The app will close now to protect the security of test purchases." + } + + static let actionTitle = "OK" +} + +private extension SimulatedStoreProduct { + + var purchaseAlertMessage: String { + var message = "This is a test purchase and should only be used during development. In production, " + + "use an Apple API key from RevenueCat.\n\n" + message += "Product ID: \(self.productIdentifier)\n" + message += "Title: \(self.localizedTitle)\n" + message += "Price: \(self.localizedPriceString)\n" + + if let subscriptionPeriod = self.subscriptionPeriod { + message += subscriptionPeriod.debugDescription + "\n" + } + + if !self.discounts.isEmpty { + message += "Offers:\n" + self.discounts.map { $0.testPurchaseDescription }.joined(separator: "\n") + } + + return message + } + +} + +private extension StoreProductDiscount { + + var testPurchaseDescription: String { + return "\(self.type.testPurchaseTitle): \(self.localizedPriceString) for " + + "\(self.numberOfPeriods * self.subscriptionPeriod.value) \(self.subscriptionPeriod.unit.debugDescription)(s)" + } +} + +private extension StoreProductDiscount.DiscountType { + + var testPurchaseTitle: String { + switch self { + case .introductory: + return "Intro" + case .promotional: + return "Promo" + case .winBack: + return "WinBack" + } + } +} + +// MARK: - Generic Alert Model + +private extension DefaultSimulatedStorePurchaseUI { + + struct Action { + + // swiftlint:disable:next nesting + enum Style { + case destructive + case cancel + case `default` + } + + let title: String + let callback: @MainActor (String) -> Void + let style: Style + } + + struct Alert { + let title: String + let message: String + + /// Only up to 3 actions are supported on macOS. + let actions: [Action] + let dismissCallback: @MainActor () -> Void + } + +} + +private extension DefaultSimulatedStorePurchaseUI { + + @MainActor + func showAlert(_ alert: Alert, onError: (PurchasesError) -> Void) { + + #if os(iOS) || os(tvOS) || VISION_OS || targetEnvironment(macCatalyst) + guard let viewController = self.findTopViewController() else { + Logger.warn(Strings.purchase.unable_to_find_root_view_controller_for_simulated_purchase) + onError(ErrorUtils.unknownError( + message: Strings.purchase.unable_to_find_root_view_controller_for_simulated_purchase.description + )) + return + } + + let alertController = UIAlertController(title: alert.title, + message: alert.message, + preferredStyle: .alert) + + alert.actions.forEach { action in + let alertAction = UIAlertAction(title: action.title, style: action.style.alertActionStyle) { _ in + action.callback(action.title) + } + alertController.addAction(alertAction) + } + + viewController.present(alertController, animated: true) + + #elseif os(watchOS) + + let actions: [WKAlertAction] = alert.actions.map { action in + WKAlertAction(title: action.title, style: action.style.alertActionStyle) { + action.callback(action.title) + } + + } + + WKInterfaceDevice.current().play(.click) + + let controller = WKExtension.shared().rootInterfaceController + controller?.presentAlert(withTitle: alert.title, + message: alert.message, + preferredStyle: .alert, + actions: actions) + + #elseif os(macOS) + + let nsAlert = NSAlert() + nsAlert.messageText = alert.title + nsAlert.informativeText = alert.message + nsAlert.alertStyle = .informational + + // Only up to 3 actions are supported on macOS. Keep a local array of the ones we actually show. + let displayedActions = Array(alert.actions.prefix(3)) + displayedActions.forEach { action in + nsAlert.addButton(withTitle: action.title) + } + + let response = nsAlert.runModal() + + // Map the modal response to the button index (0-based). + let indexMap: [NSApplication.ModalResponse: Int] = [ + .alertFirstButtonReturn: 0, + .alertSecondButtonReturn: 1, + .alertThirdButtonReturn: 2 + ] + let selectedIndex = indexMap[response] ?? 0 + let selectedAction = displayedActions[safe: selectedIndex] + selectedAction?.callback(selectedAction?.title ?? "") + + #endif + + } +} + +extension DefaultSimulatedStorePurchaseUI.Action.Style { + + #if os(iOS) || os(tvOS) || VISION_OS || targetEnvironment(macCatalyst) + + var alertActionStyle: UIAlertAction.Style { + switch self { + case .destructive: + return .destructive + case .cancel: + return .cancel + case .default: + return .default + } + } + + #elseif os(watchOS) + + var alertActionStyle: WKAlertActionStyle { + switch self { + case .destructive: + return .destructive + case .cancel: + return .cancel + case .default: + return .default + } + } + + #endif +} + +// MARK: - Helper + +#if os(iOS) || os(tvOS) || VISION_OS || targetEnvironment(macCatalyst) + +fileprivate extension DefaultSimulatedStorePurchaseUI { + + @MainActor + func findTopViewController() -> UIViewController? { + guard let application = self.systemInfo.sharedUIApplication else { + return nil + } + + let window: UIWindow? + + // Try to get the window from the scene first + if #available(macCatalyst 13.1, *), + let windowScene = application.currentWindowScene { + if #available(iOS 15.0, macCatalyst 15.0, tvOS 15.0, *) { + window = windowScene.keyWindow + } else { + window = windowScene.windows.first(where: { $0.isKeyWindow }) + } + } else { + if #available(iOS 15.0, macCatalyst 15.0, *) { + window = nil + } else { + // Fallback to legacy approach on OSs where UIApplication's `windows` property is not deprecated + window = application.windows.first(where: { $0.isKeyWindow }) + } + } + + return window?.rootViewController?.topMostViewController() + } + +} + +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/SimulatedStore/SimulatedStoreTransaction.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/SimulatedStore/SimulatedStoreTransaction.swift new file mode 100644 index 00000000..8136ecc1 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/SimulatedStore/SimulatedStoreTransaction.swift @@ -0,0 +1,33 @@ +// +// SimulatedStoreTransaction.swift +// RevenueCat +// +// Created by Antonio Pallares on 30/7/25. +// Copyright © 2025 RevenueCat, Inc. All rights reserved. +// + +import Foundation + +struct SimulatedStoreTransaction: StoreTransactionType, Equatable { + + let productIdentifier: String + let purchaseDate: Date + let transactionIdentifier: String + + var hasKnownPurchaseDate: Bool { return true } + var hasKnownTransactionIdentifier: Bool { return true } + var quantity: Int { return 1 } + + let storefront: Storefront? + + let jwsRepresentation: String? + + let environment: StoreEnvironment? = nil + let reason: TransactionReason? = nil + + func finish(_ wrapper: any PaymentQueueWrapperType, completion: @escaping @Sendable () -> Void) { + // no-op + completion() + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/SimulatedStore/WebBillingProduct+SimulatedStoreProduct.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/SimulatedStore/WebBillingProduct+SimulatedStoreProduct.swift new file mode 100644 index 00000000..f707ccfe --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/SimulatedStore/WebBillingProduct+SimulatedStoreProduct.swift @@ -0,0 +1,106 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// WebBillingProduct+SimulatedStoreProduct.swift +// +// Created by Antonio Pallares on 25/7/25. + +import Foundation + +extension WebBillingProductsResponse.Product { + + // Lazily instantiated + private static var _priceFormatterProvider: PriceFormatterProvider? + private static var priceFormatterProvider: PriceFormatterProvider { + if let priceFormatterProvider = self._priceFormatterProvider { + return priceFormatterProvider + } else { + let provider = PriceFormatterProvider() + self._priceFormatterProvider = provider + return provider + } + } + + func convertToStoreProduct(locale: Locale = .autoupdatingCurrent) throws -> StoreProduct { + guard let purchaseOption = self.purchaseOption else { + throw ErrorUtils.productNotAvailableForPurchaseError( + withMessage: "No purchase option found for product \(self.identifier)" + ) + } + + let price: WebBillingProductsResponse.Price + var period: SubscriptionPeriod? + let introDiscount: SimulatedStoreProductDiscount? = nil // Not supported in Simulated Store products for now + + if let basePrice = purchaseOption.basePrice { + price = basePrice + } else { + guard let basePhase = purchaseOption.base, + let basePrice = basePhase.price else { + throw ErrorUtils.productNotAvailableForPurchaseError( + withMessage: "No base price found for product \(self.identifier). " + + "Base price is required for test subscription products" + ) + } + + price = basePrice + if let periodDuration = basePhase.periodDuration { + period = SubscriptionPeriod.from(iso8601: periodDuration) + } + } + + let decimalPrice = Decimal(Double(price.amountMicros) / 1_000_000) + let localizedPriceString = formatPrice(decimalPrice, currencyCode: price.currency, locale: locale) + + let simulatedStoreProduct = SimulatedStoreProduct(localizedTitle: self.title, + price: decimalPrice, + currencyCode: price.currency, + localizedPriceString: localizedPriceString, + productIdentifier: self.identifier, + productType: self.productType.storeProductType, + localizedDescription: self.description ?? "", + subscriptionPeriod: period, + introductoryDiscount: introDiscount, + locale: locale) + return simulatedStoreProduct.toStoreProduct() + } + + private var purchaseOption: WebBillingProductsResponse.PurchaseOption? { + if let defaultPurchaseOptionId = self.defaultPurchaseOptionId, + let defaultPurchaseOption = self.purchaseOptions[defaultPurchaseOptionId] { + return defaultPurchaseOption + } else { + return self.purchaseOptions.first?.value + } + } + + private func formatPrice(_ price: Decimal, currencyCode: String, locale: Locale) -> String { + let formatter = Self.priceFormatterProvider.priceFormatterForWebProducts(withCurrencyCode: currencyCode, + locale: locale) + return formatter.string(from: price as NSDecimalNumber) ?? "" + } + +} + +private extension WebBillingProductsResponse.ProductType { + + var storeProductType: StoreProduct.ProductType { + switch self { + case .consumable: + return .consumable + case .nonConsumable: + return .nonConsumable + case .subscription: + return .autoRenewableSubscription + case .unknown: + return .autoRenewableSubscription + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit1/PaymentQueueWrapper.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit1/PaymentQueueWrapper.swift new file mode 100644 index 00000000..54462d38 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit1/PaymentQueueWrapper.swift @@ -0,0 +1,175 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaymentQueueWrapper.swift +// +// Created by Nacho Soto on 9/4/22. + +import Foundation +import StoreKit + +protocol PaymentQueueWrapperDelegate: AnyObject, Sendable { + + func paymentQueueWrapper(_ wrapper: PaymentQueueWrapper, + shouldAddStorePayment payment: SKPayment, + for product: SK1Product) -> Bool + + #if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + @available(iOS 13.4, macCatalyst 13.4, *) + var paymentQueueWrapperShouldShowPriceConsent: Bool { get } + #endif + +} + +/// A wrapper for `SKPaymentQueue` +@objc +protocol PaymentQueueWrapperType: AnyObject { + + func finishTransaction(_ transaction: SKPaymentTransaction, completion: @escaping () -> Void) + + #if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + @available(iOS 13.4, macCatalyst 13.4, *) + func showPriceConsentIfNeeded() + #endif + + @available(iOS 14.0, *) + @available(macOS, unavailable) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + @available(macCatalyst, unavailable) + func presentCodeRedemptionSheet() + +} + +/// The choice between SK1's `StoreKit1Wrapper` or `PaymentQueueWrapper` when SK2 is enabled. +typealias EitherPaymentQueueWrapper = Either + +// MARK: - + +/// Implementation of `PaymentQueueWrapperType` used when SK1 is not enabled. +class PaymentQueueWrapper: NSObject, PaymentQueueWrapperType { + + private let paymentQueue: SKPaymentQueue + + private lazy var purchaseIntentsAPIAvailable: Bool = { + // PurchaseIntents was introduced in macOS with macOS 14.4, which was first shipped with Xcode 15.3, + // which shipped with version 5.10 of the Swift compiler. We need to check for the Swift compiler version + // because the PurchaseIntents symbol isn't available on Xcode versions <15.3. + #if compiler(>=5.10) + if #available(iOS 16.4, macOS 14.4, *) { + return true + } else { + return false + } + #else + return false + #endif + }() + + weak var delegate: PaymentQueueWrapperDelegate? { + didSet { + if self.delegate != nil { + self.paymentQueue.delegate = self + + if !purchaseIntentsAPIAvailable { + // The PurchaseIntent documentation states that we shouldn't use both the PurchaseIntents API and + // `SKPaymentTransactionObserver/paymentQueue(queue:shouldAddStorePayment:for:) -> Bool` at the same + // time. So, we only observe the payment queue when using StoreKit 2 if the PurchaseIntents API + // is unavailable. See https://developer.apple.com/documentation/storekit/purchaseintent + // for more info. + // + // We don't need to check that SK2 is available and used since PaymentQueueWrapper itself + // is only used in SK2 mode. When running in SK1 mode, the StoreKit1Wrapper is used instead. + self.paymentQueue.add(self) + } + } else if self.delegate == nil, self.paymentQueue.delegate === self { + self.paymentQueue.delegate = nil + + if !purchaseIntentsAPIAvailable { + self.paymentQueue.remove(self) + } + } + } + } + + init(paymentQueue: SKPaymentQueue = .default()) { + self.paymentQueue = paymentQueue + + super.init() + } + + func finishTransaction(_ transaction: SKPaymentTransaction, completion: @escaping () -> Void) { + // See `StoreKit1Wrapper.finishTransaction(:completion:)`. + // Technically this is a race condition, because `SKPaymentQueue.finishTransaction` is asynchronous + // In practice this method won't be used, because this class is only used in SK2 mode, + // and those transactions are finished through `SK2StoreTransaction`. + + self.paymentQueue.finishTransaction(transaction) + completion() + } + + #if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + @available(iOS 13.4, macCatalyst 13.4, *) + func showPriceConsentIfNeeded() { + self.paymentQueue.showPriceConsentIfNeeded() + } + #endif + + #if (os(iOS) && !targetEnvironment(macCatalyst)) || VISION_OS + @available(iOS 14.0, *) + func presentCodeRedemptionSheet() { + self.paymentQueue.presentCodeRedemptionSheetIfAvailable() + } + #endif + +} + +extension PaymentQueueWrapper: SKPaymentQueueDelegate { + + #if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + @available(iOS 13.4, macCatalyst 13.4, *) + func paymentQueueShouldShowPriceConsent(_ paymentQueue: SKPaymentQueue) -> Bool { + return self.delegate?.paymentQueueWrapperShouldShowPriceConsent ?? true + } + #endif + +} + +extension PaymentQueueWrapper: SKPaymentTransactionObserver { + + func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { + // Ignored. Either `StoreKit1Wrapper` will handle this, or `StoreKit2TransactionListener` if `SK2` is enabled. + } + + #if !os(watchOS) + // Sent when a user initiated an in-app purchase from the App Store. + func paymentQueue(_ queue: SKPaymentQueue, + shouldAddStorePayment payment: SKPayment, + for product: SK1Product) -> Bool { + return self.delegate?.paymentQueueWrapper(self, + shouldAddStorePayment: payment, + for: product) ?? false + } + #endif + +} + +extension EitherPaymentQueueWrapper { + + var paymentQueueWrapperType: PaymentQueueWrapperType { + switch self { + case let .left(storeKit1Wrapper): return storeKit1Wrapper + case let .right(paymentQueueWrapper): return paymentQueueWrapper + } + } + + var sk1Wrapper: StoreKit1Wrapper? { return self.left } + var sk2Wrapper: PaymentQueueWrapper? { return self.right } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit1/ProductsFetcherSK1.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit1/ProductsFetcherSK1.swift new file mode 100644 index 00000000..aa6defb3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit1/ProductsFetcherSK1.swift @@ -0,0 +1,263 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ProductsFetcherSK1.swift +// +// Created by Andrés Boedo on 7/26/21. + +import Foundation +import StoreKit + +final class ProductsFetcherSK1: NSObject { + + typealias Callback = (Result, PurchasesError>) -> Void + + let requestTimeout: TimeInterval + private let productsRequestFactory: ProductsRequestFactory + + // Note: these 3 must be used only inside `queue` to be thread-safe. + private var cachedProductsByIdentifier: [String: SK1Product] = [:] + private var productsByRequests: [SKRequest: ProductRequest] = [:] + private var completionHandlers: [Set: [Callback]] = [:] + + private let queue = DispatchQueue(label: "ProductsFetcherSK1") + + private static let numberOfRetries: Int = 10 + + /// - Parameter requestTimeout: requests made by this class will return after whichever condition comes first: + /// - A success + /// - Retries up to ``Self.numberOfRetries`` + /// - Timeout specified by this parameter + init(productsRequestFactory: ProductsRequestFactory = ProductsRequestFactory(), + requestTimeout: TimeInterval) { + self.productsRequestFactory = productsRequestFactory + self.requestTimeout = requestTimeout + } + + // Note: this isn't thread-safe and must therefore be used inside of `queue` only. + @discardableResult + private func startRequest(forIdentifiers identifiers: Set, retriesLeft: Int) -> SKProductsRequest { + let request = self.productsRequestFactory.request(productIdentifiers: identifiers) + request.delegate = self + self.productsByRequests[request] = .init(identifiers, retriesLeft: retriesLeft) + request.start() + + return request + } + + func products(withIdentifiers identifiers: Set, + completion: @escaping (Result, PurchasesError>) -> Void) { + TimingUtil.measureAndLogIfTooSlow( + threshold: .productRequest, + message: Strings.storeKit.sk1_product_request_too_slow, + work: { completion in + self.sk1Products(withIdentifiers: identifiers) { skProducts in + completion(skProducts.map { Set($0.map(SK1StoreProduct.init)) }) + } + }, + result: completion + ) + } + + func products(withIdentifiers identifiers: Set) async throws -> Set { + return try await Async.call { completion in + self.products(withIdentifiers: identifiers, completion: completion) + } + } + + private func sk1Products(withIdentifiers identifiers: Set, + completion: @escaping Callback) { + guard identifiers.count > 0 else { + completion(.success([])) + return + } + + self.queue.async { [self] in + let productsAlreadyCached = self.cachedProductsByIdentifier.filter { key, _ in identifiers.contains(key) } + if productsAlreadyCached.count == identifiers.count { + let productsAlreadyCachedSet = Set(productsAlreadyCached.values) + Logger.debug(Strings.offering.products_already_cached(identifiers: identifiers)) + completion(.success(productsAlreadyCachedSet)) + return + } + + if let existingHandlers = self.completionHandlers[identifiers] { + Logger.debug(Strings.offering.found_existing_product_request(identifiers: identifiers)) + self.completionHandlers[identifiers] = existingHandlers + [completion] + return + } + + self.completionHandlers[identifiers] = [completion] + + let request = self.startRequest(forIdentifiers: identifiers, retriesLeft: Self.numberOfRetries) + self.scheduleCancellationInCaseOfTimeout(for: request) + } + } + +} + +private extension ProductsFetcherSK1 { + + struct ProductRequest { + let identifiers: Set + var retriesLeft: Int + + init(_ identifiers: Set, retriesLeft: Int) { + self.identifiers = identifiers + self.retriesLeft = retriesLeft + } + } + +} + +extension ProductsFetcherSK1: SKProductsRequestDelegate { + + func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { + self.queue.async { [self] in + Logger.rcSuccess(Strings.storeKit.store_product_request_received_response) + guard let productRequest = self.productsByRequests[request] else { + Logger.error("requested products not found for request: \(request)") + return + } + guard let completionBlocks = self.completionHandlers[productRequest.identifiers] else { + Logger.error("callback not found for failing request: \(request)") + self.productsByRequests.removeValue(forKey: request) + return + } + + self.completionHandlers.removeValue(forKey: productRequest.identifiers) + self.productsByRequests.removeValue(forKey: request) + + self.cacheProducts(response.products) + for completion in completionBlocks { + completion(.success(Set(response.products))) + } + } + } + + func requestDidFinish(_ request: SKRequest) { + Logger.rcSuccess(Strings.storeKit.store_product_request_finished) + self.cancelRequestToPreventTimeoutWarnings(request) + } + + func request(_ request: SKRequest, didFailWithError error: Error) { + defer { + self.cancelRequestToPreventTimeoutWarnings(request) + } + + self.queue.async { [self] in + Logger.appleError(Strings.storeKit.store_products_request_failed(error as NSError)) + + guard let productRequest = self.productsByRequests[request] else { + Logger.error(Strings.purchase.requested_products_not_found(request: request)) + return + } + + if productRequest.retriesLeft <= 0 { + guard let completionBlocks = self.completionHandlers[productRequest.identifiers] else { + Logger.error(Strings.purchase.callback_not_found_for_request(request: request)) + self.productsByRequests.removeValue(forKey: request) + return + } + + self.completionHandlers.removeValue(forKey: productRequest.identifiers) + self.productsByRequests.removeValue(forKey: request) + for completion in completionBlocks { + completion(.failure(ErrorUtils.purchasesError(withSKError: error))) + } + } else { + let delayInSeconds = Int((self.requestTimeout / 10).rounded()) + self.queue.asyncAfter(deadline: .now() + .seconds(delayInSeconds)) { [self] in + self.startRequest(forIdentifiers: productRequest.identifiers, + retriesLeft: productRequest.retriesLeft - 1) + } + } + } + } + + func cacheProduct(_ product: SK1Product) { + self.queue.async { + self.cachedProductsByIdentifier[product.productIdentifier] = product + } + } + + /// - Returns (via callback): The product identifiers that were removed, or empty if there were not + /// cached products. + func clearCache(completion: ((Set) -> Void)? = nil) { + self.queue.async { + let cachedProductIdentifiers = self.cachedProductsByIdentifier.keys + if !cachedProductIdentifiers.isEmpty { + Logger.debug(Strings.offering.product_cache_invalid_for_storefront_change) + self.cachedProductsByIdentifier.removeAll(keepingCapacity: false) + } + completion?(Set(cachedProductIdentifiers)) + } + } + +} + +private extension ProductsFetcherSK1 { + + func cacheProducts(_ products: [SK1Product]) { + self.queue.async { + let productsByIdentifier = products.dictionaryAllowingDuplicateKeys { + $0.productIdentifier + } + + self.cachedProductsByIdentifier += productsByIdentifier + } + } + + // Even though the request has finished, we've seen instances where + // the request seems to live on. So we manually call `cancel` to prevent warnings in runtime. + // https://github.com/RevenueCat/purchases-ios/issues/250 + // https://github.com/RevenueCat/purchases-ios/issues/391 + func cancelRequestToPreventTimeoutWarnings(_ request: SKRequest) { + request.cancel() + } + + // Even though there's a specific delegate method for when SKProductsRequest fails, + // there seem to be some situations in which SKProductsRequest hangs forever, + // without timing out and calling the delegate. + // So we schedule a cancellation just in case, and skip it if all goes as expected. + // More information: https://rev.cat/skproductsrequest-hangs + func scheduleCancellationInCaseOfTimeout(for request: SKProductsRequest) { + self.queue.asyncAfter(deadline: .now() + self.requestTimeout) { [weak self] in + guard let self = self, + let productRequest = self.productsByRequests[request] else { return } + + request.cancel() + + Logger.appleError(Strings.storeKit.skproductsrequest_timed_out( + after: Int(self.requestTimeout.rounded()) + )) + guard let completionBlocks = self.completionHandlers[productRequest.identifiers] else { + Logger.error("callback not found for failing request: \(request)") + return + } + + self.completionHandlers.removeValue(forKey: productRequest.identifiers) + self.productsByRequests.removeValue(forKey: request) + for completion in completionBlocks { + completion(.failure(ErrorUtils.productRequestTimedOutError())) + } + } + } + +} + +// @unchecked because: +// - It has mutable state, but it's made thread-safe through `queue`. +extension ProductsFetcherSK1: @unchecked Sendable {} + +#if hasFeature(RetroactiveAttribute) +// Conformance should be safe since it is only used as dictionary key +extension SKRequest: @unchecked @retroactive Sendable {} +extension SKProductsRequest: @unchecked @retroactive Sendable {} +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit1/StoreKit1Wrapper.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit1/StoreKit1Wrapper.swift new file mode 100644 index 00000000..e2a9f548 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit1/StoreKit1Wrapper.swift @@ -0,0 +1,304 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// RCStoreKit1Wrapper.swift +// +// Created by RevenueCat. +// + +import StoreKit + +protocol StoreKit1WrapperDelegate: AnyObject { + + func storeKit1Wrapper(_ storeKit1Wrapper: StoreKit1Wrapper, updatedTransaction transaction: SKPaymentTransaction) + + func storeKit1Wrapper(_ storeKit1Wrapper: StoreKit1Wrapper, removedTransaction transaction: SKPaymentTransaction) + + func storeKit1Wrapper(_ storeKit1Wrapper: StoreKit1Wrapper, + shouldAddStorePayment payment: SKPayment, + for product: SK1Product) -> Bool + + func storeKit1Wrapper(_ storeKit1Wrapper: StoreKit1Wrapper, + didRevokeEntitlementsForProductIdentifiers productIdentifiers: [String]) + + #if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + @available(iOS 13.4, macCatalyst 13.4, *) + var storeKit1WrapperShouldShowPriceConsent: Bool { get } + #endif + + func storeKit1WrapperDidChangeStorefront(_ storeKit1Wrapper: StoreKit1Wrapper) + +} + +class StoreKit1Wrapper: NSObject { + + @available(iOS 8.0, macOS 10.14, watchOS 6.2, macCatalyst 13.0, *) + static var simulatesAskToBuyInSandbox = false + + var currentStorefront: Storefront? { + return self.paymentQueue.storefront + .map(SK1Storefront.init) + .map(Storefront.from(storefront:)) + } + + /// - Note: this is not thread-safe + weak var delegate: StoreKit1WrapperDelegate? { + didSet { + if self.delegate != nil { + self.notifyDelegateOfExistingTransactionsIfNeeded() + + self.paymentQueue.add(self) + } else { + self.paymentQueue.remove(self) + } + } + } + + private let finishedTransactionCallbacks: Atomic<[SKPaymentTransaction: [() -> Void]]> = .init([:]) + + private let paymentQueue: SKPaymentQueue + private let operationDispatcher: OperationDispatcher + private let observerMode: Bool + private let sandboxEnvironmentDetector: SandboxEnvironmentDetector + private let diagnosticsTracker: DiagnosticsTrackerType? + + init(paymentQueue: SKPaymentQueue = .default(), + operationDispatcher: OperationDispatcher = .default, + observerMode: Bool, + sandboxEnvironmentDetector: SandboxEnvironmentDetector = BundleSandboxEnvironmentDetector.default, + diagnosticsTracker: DiagnosticsTrackerType?) { + self.paymentQueue = paymentQueue + self.operationDispatcher = operationDispatcher + self.observerMode = observerMode + self.sandboxEnvironmentDetector = sandboxEnvironmentDetector + self.diagnosticsTracker = diagnosticsTracker + + super.init() + + Logger.verbose(Strings.purchase.storekit1_wrapper_init(self)) + } + + deinit { + Logger.verbose(Strings.purchase.storekit1_wrapper_deinit(self)) + + self.paymentQueue.remove(self) + } + + func add(_ payment: SKPayment) { + Logger.debug(Strings.purchase.paymentqueue_adding_payment(self.paymentQueue, payment)) + + self.paymentQueue.add(payment) + } + + static func canMakePayments() -> Bool { + return SKPaymentQueue.canMakePayments() + } + + func payment(with product: SK1Product) -> SKMutablePayment { + let payment = SKMutablePayment(product: product) + payment.simulatesAskToBuyInSandbox = Self.simulatesAskToBuyInSandbox + + return payment + } + + func payment(with product: SK1Product, discount: SKPaymentDiscount?) -> SKMutablePayment { + let payment = self.payment(with: product) + payment.paymentDiscount = discount + return payment + } + + private func notifyDelegateOfExistingTransactionsIfNeeded() { + // Here be dragons. Explanation: + // When initializing the SDK after an app opens, `SKPaymentQueue` notifies its + // transaction observers of _existing_ transactions, so this method is normally not required. + // + // However: `BaseOfflineStoreKitIntegrationTests` simulates restarting apps. + // When it re-creates `Purchases` to do that, `StoreKit 1` doesn't know to re-notify + // its observers. This does so manually. + // This isn't required in StoreKit 2 because resubscribing to + // `StoreKit.Transaction.updates` does forward existing transactions. + + #if DEBUG + guard ProcessInfo.isRunningIntegrationTests, let delegate = self.delegate else { return } + + let transactions = self.paymentQueue.transactions + guard !transactions.isEmpty else { return } + + Logger.appleWarning( + Strings.storeKit.sk1_wrapper_notifying_delegate_of_existing_transactions(count: transactions.count) + ) + + for transaction in transactions { + delegate.storeKit1Wrapper(self, updatedTransaction: transaction) + } + #endif + } + +} + +extension StoreKit1Wrapper: PaymentQueueWrapperType { + + @objc + func finishTransaction(_ transaction: SKPaymentTransaction, completion: @escaping () -> Void) { + let existingCompletion: Bool = self.finishedTransactionCallbacks.modify { callbacks in + let existingCompletion = callbacks[transaction] != nil + + callbacks[transaction, default: []].append(completion) + + return existingCompletion + } + + if existingCompletion { + Logger.debug(Strings.storeKit.sk1_finish_transaction_called_with_existing_completion(transaction)) + } else { + self.paymentQueue.finishTransaction(transaction) + } + } + + #if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + @available(iOS 13.4, macCatalyst 13.4, *) + func showPriceConsentIfNeeded() { + self.paymentQueue.showPriceConsentIfNeeded() + } + #endif + + #if (os(iOS) && !targetEnvironment(macCatalyst)) || VISION_OS + @available(iOS 14.0, *) + func presentCodeRedemptionSheet() { + self.paymentQueue.presentCodeRedemptionSheetIfAvailable() + } + #endif + +} + +extension StoreKit1Wrapper: SKPaymentTransactionObserver { + + func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { + guard let delegate = self.delegate else { return } + + if transactions.count >= Self.highTransactionCountThreshold { + Logger.appleWarning(Strings.storeKit.sk1_payment_queue_too_many_transactions( + count: transactions.count, + isSandbox: self.sandboxEnvironmentDetector.isSandbox + )) + } + + self.trackTransactionQueueReceivedIfNeeded(transactions) + + self.operationDispatcher.dispatchOnWorkerThread { + for transaction in transactions { + Logger.debug(Strings.purchase.paymentqueue_updated_transaction(self, transaction)) + delegate.storeKit1Wrapper(self, updatedTransaction: transaction) + } + } + } + + // Sent when transactions are removed from the queue (via finishTransaction:). + func paymentQueue(_ queue: SKPaymentQueue, removedTransactions transactions: [SKPaymentTransaction]) { + guard let delegate = self.delegate else { return } + + self.operationDispatcher.dispatchOnWorkerThread { + for transaction in transactions { + Logger.debug(Strings.purchase.paymentqueue_removed_transaction(self, transaction)) + delegate.storeKit1Wrapper(self, removedTransaction: transaction) + + if let callbacks = self.finishedTransactionCallbacks.value.removeValue(forKey: transaction), + !callbacks.isEmpty { + callbacks.forEach { $0() } + } else { + Logger.debug(Strings.purchase.paymentqueue_removed_transaction_no_callbacks_found( + self, + transaction, + observerMode: self.observerMode + )) + } + } + } + } + + #if !os(watchOS) + // Sent when a user initiated an in-app purchase from the App Store. + func paymentQueue(_ queue: SKPaymentQueue, + shouldAddStorePayment payment: SKPayment, + for product: SK1Product) -> Bool { + return self.delegate?.storeKit1Wrapper(self, shouldAddStorePayment: payment, for: product) ?? false + } + #endif + + // Sent when access to a family shared subscription is revoked from a family member or canceled the subscription. + @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) + func paymentQueue(_ queue: SKPaymentQueue, + didRevokeEntitlementsForProductIdentifiers productIdentifiers: [String]) { + Logger.debug( + Strings.purchase.paymentqueue_revoked_entitlements_for_product_identifiers( + productIdentifiers: productIdentifiers + ) + ) + self.delegate?.storeKit1Wrapper(self, didRevokeEntitlementsForProductIdentifiers: productIdentifiers) + } + + // Sent when the storefront for the payment queue has changed. + func paymentQueueDidChangeStorefront(_ queue: SKPaymentQueue) { + self.delegate?.storeKit1WrapperDidChangeStorefront(self) + } + + /// Receiving this many or more will produce a warning. + private static let highTransactionCountThreshold: Int = 100 + + private func trackTransactionQueueReceivedIfNeeded(_ transactions: [SKPaymentTransaction]) { + guard #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *), + let diagnosticsTracker = self.diagnosticsTracker else { return } + + transactions.forEach { transaction in + diagnosticsTracker.trackAppleTransactionQueueReceived( + productId: transaction.payment.productIdentifier, + paymentDiscountId: transaction.payment.paymentDiscount?.identifier, + transactionState: transaction.transactionState.diagnosticsName, + storefront: self.currentStorefront?.countryCode, + errorMessage: transaction.error?.localizedDescription + ) + } + } + +} + +extension StoreKit1Wrapper: SKPaymentQueueDelegate { + + #if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + @available(iOS 13.4, macCatalyst 13.4, *) + func paymentQueueShouldShowPriceConsent(_ paymentQueue: SKPaymentQueue) -> Bool { + return self.delegate?.storeKit1WrapperShouldShowPriceConsent ?? true + } + #endif + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension StoreKit1Wrapper: @unchecked Sendable {} + +fileprivate extension SKPaymentTransactionState { + + var diagnosticsName: String { + switch self { + case .purchasing: + return "PURCHASING" + case .purchased: + return "PURCHASED" + case .failed: + return "FAILED" + case .restored: + return "RESTORED" + case .deferred: + return "DEFERRED" + @unknown default: + return "UNKNOWN (RAW VALUE: \(self.rawValue))" + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit1/StoreKitRequestFetcher.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit1/StoreKitRequestFetcher.swift new file mode 100644 index 00000000..8f98ca93 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit1/StoreKitRequestFetcher.swift @@ -0,0 +1,104 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StoreKitRequestFetcher.swift +// +// Created by Andrés Boedo on 6/29/21. +// + +import Foundation +import StoreKit + +class ReceiptRefreshRequestFactory { + + func receiptRefreshRequest() -> SKReceiptRefreshRequest { + return SKReceiptRefreshRequest() + } + +} + +class StoreKitRequestFetcher: NSObject { + + private let requestFactory: ReceiptRefreshRequestFactory + private var receiptRefreshRequest: SKRequest? + private var receiptRefreshCompletionHandlers: [@MainActor @Sendable () -> Void] + private let operationDispatcher: OperationDispatcher + + init(requestFactory: ReceiptRefreshRequestFactory = ReceiptRefreshRequestFactory(), + operationDispatcher: OperationDispatcher) { + self.requestFactory = requestFactory + self.operationDispatcher = operationDispatcher + self.receiptRefreshRequest = nil + self.receiptRefreshCompletionHandlers = [] + } + + func fetchReceiptData(_ completion: @MainActor @Sendable @escaping () -> Void) { + self.operationDispatcher.dispatchOnWorkerThread { + self.receiptRefreshCompletionHandlers.append(completion) + + if self.receiptRefreshRequest == nil { + Logger.debug(Strings.storeKit.sk_receipt_request_started) + + self.receiptRefreshRequest = self.requestFactory.receiptRefreshRequest() + self.receiptRefreshRequest?.delegate = self + self.receiptRefreshRequest?.start() + } + } + } + +} + +extension StoreKitRequestFetcher: SKRequestDelegate { + + func requestDidFinish(_ request: SKRequest) { + guard request is SKReceiptRefreshRequest else { return } + + Logger.debug(Strings.storeKit.sk_receipt_request_finished) + self.finishReceiptRequest(request) + request.cancel() + } + + func request(_ request: SKRequest, didFailWithError error: Error) { + guard request is SKReceiptRefreshRequest else { return } + + Logger.appleError(Strings.storeKit.skrequest_failed(error as NSError)) + self.finishReceiptRequest(request) + request.cancel() + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension ReceiptRefreshRequestFactory: @unchecked Sendable {} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +// - It has mutable state, but it's made thread-safe through `operationDispatcher`. +extension StoreKitRequestFetcher: @unchecked Sendable {} + +// MARK: - + +private extension StoreKitRequestFetcher { + + func finishReceiptRequest(_ request: SKRequest?) { + self.operationDispatcher.dispatchOnWorkerThread { + self.receiptRefreshRequest = nil + let completionHandlers = self.receiptRefreshCompletionHandlers + self.receiptRefreshCompletionHandlers = [] + + for handler in completionHandlers { + self.operationDispatcher.dispatchOnMainActor { + handler() + } + } + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/Observer Mode/StoreKit2ObserverModePurchaseDetector.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/Observer Mode/StoreKit2ObserverModePurchaseDetector.swift new file mode 100644 index 00000000..d667fe3f --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/Observer Mode/StoreKit2ObserverModePurchaseDetector.swift @@ -0,0 +1,125 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StoreKit2ObserverModePurchaseDetector.swift +// +// Created by Will Taylor on 5/1/24. + +import Foundation +import StoreKit + +/// A delegate protocol for handling verified transactions in observer mode. +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +// swiftlint:disable type_name +protocol StoreKit2ObserverModePurchaseDetectorDelegate: AnyObject, Sendable { + + /// Handles a verified transaction with its corresponding JWS representation. + /// - Parameters: + /// - verifiedTransaction: The verified transaction to be processed. + /// - jwsRepresentation: The JSON Web Signature representation of the transaction. + func handleSK2ObserverModeTransaction( + verifiedTransaction: StoreKit.Transaction, + jwsRepresentation: String + ) async throws +} + +/// Protocol describing an actor capable of detecting purchases from StoreKit 2. +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +protocol StoreKit2ObserverModePurchaseDetectorType: Actor { + func detectUnobservedTransactions( + delegate: StoreKit2ObserverModePurchaseDetectorDelegate + ) async +} + +/// Actor responsibile for detecting purchases from StoreKit2 that should be processed by observer mode. +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +actor StoreKit2ObserverModePurchaseDetector: StoreKit2ObserverModePurchaseDetectorType { + + private let deviceCache: DeviceCache + private let allTransactionsProvider: AllTransactionsProviderType + + init( + deviceCache: DeviceCache, + allTransactionsProvider: AllTransactionsProviderType + ) { + self.deviceCache = deviceCache + self.allTransactionsProvider = allTransactionsProvider + } + + /// Detects unobserved transactions and forwards them to the StoreKit2ObserverModeManagerDelegate + /// for processing. + func detectUnobservedTransactions( + delegate: StoreKit2ObserverModePurchaseDetectorDelegate + ) async { + + let allTransactions = await allTransactionsProvider.getAllTransactions() + + guard let mostRecentTransaction = await allTransactionsProvider.getMostRecentVerifiedTransaction( + from: allTransactions + ) else { return } + + let jwsRepresentation = mostRecentTransaction.jwsRepresentation + guard let transaction = mostRecentTransaction.verifiedTransaction else { return } + + let cachedSyncedSK2ObserverModeTransactionIDs = Set( + self.deviceCache.cachedSyncedSK2ObserverModeTransactionIDs() + ) + if cachedSyncedSK2ObserverModeTransactionIDs.contains(transaction.id) { return } + + let unsyncedTransactionIDs = allTransactions + .filter { + !cachedSyncedSK2ObserverModeTransactionIDs.contains($0.underlyingTransaction.id) + } + .map { $0.underlyingTransaction.id } + + do { + try await delegate.handleSK2ObserverModeTransaction( + verifiedTransaction: transaction, + jwsRepresentation: jwsRepresentation + ) + + deviceCache.registerNewSyncedSK2ObserverModeTransactionIDs(unsyncedTransactionIDs) + } catch { + Logger.error(Strings.purchase.sk2_observer_mode_error_processing_transaction(error)) + } + } +} + +/// A wrapper protocol that allows for abstracting out calls to an `AsyncSequence>`. +/// This will usually be `Transaction.all` in production but allows us to inject custom AsyncSequences for testing. +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +protocol AllTransactionsProviderType: Sendable { + func getAllTransactions() async -> [StoreKit.VerificationResult] + func getMostRecentVerifiedTransaction( + from transactions: [StoreKit.VerificationResult] + ) async -> StoreKit.VerificationResult? +} + +/// A concretete implementation of `AllTransactionsProviderType` that fetches +/// transactions from StoreKit's ``StoreKit/Transaction/all`` +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct SK2AllTransactionsProvider: AllTransactionsProviderType, Sendable { + func getAllTransactions() async -> [StoreKit.VerificationResult] { + return await StoreKit.Transaction.all.extractValues() + } + + func getMostRecentVerifiedTransaction( + from transactions: [StoreKit.VerificationResult] + ) async -> StoreKit.VerificationResult? { + let verifiedTransactions = transactions.filter { transaction in + return transaction.verifiedTransaction != nil + } + if verifiedTransactions.isEmpty { return nil } + guard let mostRecentTransaction = verifiedTransactions.max(by: { + $0.verifiedTransaction?.purchaseDate ?? .distantPast < $1.verifiedTransaction?.purchaseDate ?? .distantPast + }) else { return nil } + + return mostRecentTransaction + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/ProductsFetcherSK2.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/ProductsFetcherSK2.swift new file mode 100644 index 00000000..b6332251 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/ProductsFetcherSK2.swift @@ -0,0 +1,63 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ProductsManagerSK2.swift +// +// Created by Andrés Boedo on 7/23/21. + +import Foundation +import StoreKit + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +actor ProductsFetcherSK2 { + + enum Error: Swift.Error { + + case productsRequestError(innerError: Swift.Error) + + } + + /// - Throws: `ProductsFetcherSK2.Error` + func products(identifiers: Set) async throws -> Set { + do { + let storeKitProducts = try await TimingUtil.measureAndLogIfTooSlow( + threshold: .productRequest, + message: Strings.storeKit.sk2_product_request_too_slow + ) { + try await StoreKit.Product.products(for: identifiers) + } + + Logger.rcSuccess(Strings.storeKit.store_product_request_received_response) + return Set(storeKitProducts.map { SK2StoreProduct(sk2Product: $0) }) + } catch { + throw Error.productsRequestError(innerError: error) + } + } + +} + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +extension ProductsFetcherSK2.Error: CustomNSError { + + var errorUserInfo: [String: Any] { + switch self { + case let .productsRequestError(inner): + return [ + NSUnderlyingErrorKey: inner, + NSLocalizedDescriptionKey: self.localizedDescription + ] + } + } + + var localizedDescription: String { + switch self { + case let .productsRequestError(innerError): return "Products request error: \(innerError.localizedDescription)" + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/SK2AppTransaction.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/SK2AppTransaction.swift new file mode 100644 index 00000000..f60af69b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/SK2AppTransaction.swift @@ -0,0 +1,33 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SK2AppTransaction.swift +// +// Created by MarkVillacampa on 26/10/23. + +import StoreKit + +/// A wrapper for `StoreKit.AppTransaction`. +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +internal struct SK2AppTransaction { + + @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + init(appTransaction: AppTransaction) { + self.bundleId = appTransaction.bundleID + self.originalApplicationVersion = appTransaction.originalAppVersion + self.originalPurchaseDate = appTransaction.originalPurchaseDate + self.environment = .init(environment: appTransaction.environment) + } + + let bundleId: String + let originalApplicationVersion: String? + let originalPurchaseDate: Date? + let environment: StoreEnvironment? + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/SK2BeginRefundRequestHelper.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/SK2BeginRefundRequestHelper.swift new file mode 100644 index 00000000..d5c721d7 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/SK2BeginRefundRequestHelper.swift @@ -0,0 +1,157 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SK2BeginRefundRequestHelper.swift +// +// Created by Madeline Beyl on 10/21/21. + +#if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS +import Foundation +import StoreKit +import UIKit + +@available(iOS 15.0, *) +@available(watchOS, unavailable) +@available(tvOS, unavailable) +protocol SK2BeginRefundRequestHelperType: Sendable { + + func initiateSK2RefundRequest(transactionID: UInt64, windowScene: UIWindowScene) async -> + Result + + func verifyTransaction(productID: String) async throws -> UInt64 + +} + +/// Helper class responsible for calling into StoreKit2 and translating results/errors for consumption by RevenueCat. +@available(iOS 15.0, *) +@available(watchOS, unavailable) +@available(tvOS, unavailable) +final class SK2BeginRefundRequestHelper: SK2BeginRefundRequestHelperType { + + /* Checks with StoreKit2 that the given `productID` has an existing verified transaction, and maps the + * result for consumption by `BeginRefundRequestHelper`. + */ + func verifyTransaction(productID: String) async throws -> UInt64 { + let result = await StoreKit.Transaction.latest(for: productID) + guard let nonNilResult = result else { + let errorMessage = Strings.purchase.product_unpurchased_or_missing + Logger.error(errorMessage) + throw ErrorUtils.beginRefundRequestError(withMessage: errorMessage.description) + } + + switch nonNilResult { + case .unverified(_, let verificationError): + let message = Strings.purchase.transaction_unverified( + productID: productID, + errorMessage: verificationError.localizedDescription) + Logger.error(message) + throw ErrorUtils.beginRefundRequestError(withMessage: message.description) + case .verified(let transaction): return transaction.id + } + } + + /* + * Attempts to begin a refund request for the given transactionID and current windowScene with StoreKit2. + * If successful, passes result on as-is. If unsuccessful, calls `getErrorMessage` to add more + * descriptive details to the error. + * + * This function allows for us to mock the StoreKit2 response in unit tests. + */ + @MainActor + func initiateSK2RefundRequest(transactionID: UInt64, windowScene: UIWindowScene) async -> + Result { + do { + let sk2Status = try await StoreKit.Transaction.beginRefundRequest(for: transactionID, in: windowScene) + return .success(sk2Status) + } catch { + let message = getErrorMessage(from: error) + Logger.error(message) + return .failure(ErrorUtils.beginRefundRequestError(withMessage: message, error: error)) + } + } + +} + +@available(iOS 15.0, *) +@available(watchOS, unavailable) +@available(tvOS, unavailable) +extension SK2BeginRefundRequestHelperType { + + /// Calls `initiateSK2RefundRequest` and maps the result for consumption by `BeginRefundRequestHelper` + @MainActor + func initiateRefundRequest(transactionID: UInt64, windowScene: UIWindowScene) async throws -> RefundRequestStatus { + let sk2Result = await self.initiateSK2RefundRequest(transactionID: transactionID, windowScene: windowScene) + return try self.mapSk2Result(from: sk2Result) + } + +} + +@available(iOS 15.0, *) +@available(watchOS, unavailable) +@available(tvOS, unavailable) +private extension SK2BeginRefundRequestHelperType { + + func getErrorMessage(from sk2Error: Error?) -> String { + let details = sk2Error?.localizedDescription ?? "No extra info" + if let skError = sk2Error as? StoreKit.Transaction.RefundRequestError { + switch skError { + case .duplicateRequest: + return Strings.purchase.duplicate_refund_request(details: details).description + case .failed: + return Strings.purchase.failed_refund_request(details: details).description + @unknown default: + return Strings.purchase.unknown_refund_request_error_type(details: details).description + } + } else { + return Strings.purchase.unknown_refund_request_error(details: details).description + } + } + + /* + * - Parameter sk2Result: The Result returned from StoreKit2 + * - Returns The result expected by `BeginRefundRequestHelper`, converting from a StoreKit RefundRequestStatus + * to our `RefundRequestStatus` type and adding more descriptive error messages where needed. + */ + func mapSk2Result(from sk2Result: Result) throws -> + RCRefundRequestStatus { + switch sk2Result { + case .success(let sk2Status): + guard let rcStatus = RefundRequestStatus.from(sk2RefundRequestStatus: sk2Status) else { + let message = Strings.purchase.unknown_refund_request_status + Logger.error(message) + throw ErrorUtils.beginRefundRequestError( + withMessage: message.description) + } + return rcStatus + case .failure(let error): + Logger.error(error.localizedDescription) + throw error + } + } + +} + +@available(iOS 15.0, *) +@available(watchOS, unavailable) +@available(tvOS, unavailable) +private extension RefundRequestStatus { + + static func from(sk2RefundRequestStatus status: StoreKit.Transaction.RefundRequestStatus) -> RefundRequestStatus? { + switch status { + case .userCancelled: + return .userCancelled + case .success: + return .success + @unknown default: + return nil + } + } + +} +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/StoreKit2PromotionalOfferPurchaseOptions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/StoreKit2PromotionalOfferPurchaseOptions.swift new file mode 100644 index 00000000..f0f150ad --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/StoreKit2PromotionalOfferPurchaseOptions.swift @@ -0,0 +1,75 @@ +// +// StoreKit2PromotionalOfferPurchaseOptions.swift +// RevenueCat +// +// Created by Will Taylor on 12/4/25. +// Copyright © 2025 RevenueCat, Inc. All rights reserved. +// + +import Foundation + +// StoreKit2PromotionalOfferPurchaseOptions is only public when custom entitlement computation is enabled. +// However, certain internal classes (like PurchasesOrchestrator) need to access the type even when +// custom entitlement computation is disabled. Therefore, we use `internal` access control in that case +// to allow access to the type within the module. +// +// We should keep the two different type definitions and implementations in sync, and remove the internal copy +// if we ever make StoreKit2PromotionalOfferPurchaseOptions public unconditionally. + +#if ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + +/** + * ``StoreKit2PromotionalOfferPurchaseOptions`` can be used to apply promotional offers to StoreKit 2 purchases. + */ +@objc(StoreKit2PromotionalOfferPurchaseOptions) +public final class StoreKit2PromotionalOfferPurchaseOptions: NSObject, Sendable { + /// The id property of the subscription offer to apply. + @objc public let offerID: String + + /// The JWS signature used to validate a promotional offer. + @objc public let compactJWS: String + + /** + * Creates a new ``StoreKit2PromotionalOfferPurchaseOptions`` instance. + * - Parameters: + * - offerID: The id property of the subscription offer to apply. + * - compactJWS: The JWS signature used to validate a promotional offer. + */ + @objc public init( + offerID: String, + compactJWS: String + ) { + self.offerID = offerID + self.compactJWS = compactJWS + } +} + +#else + +/** + * ``StoreKit2PromotionalOfferPurchaseOptions`` can be used to apply promotional offers to StoreKit 2 purchases. + */ +@objc(StoreKit2PromotionalOfferPurchaseOptions) +internal final class StoreKit2PromotionalOfferPurchaseOptions: NSObject, Sendable { + /// The id property of the subscription offer to apply. + @objc let offerID: String + + /// The JWS signature used to validate a promotional offer. + @objc let compactJWS: String + + /** + * Creates a new ``StoreKit2PromotionalOfferPurchaseOptions`` instance. + * - Parameters: + * - offerID: The id property of the subscription offer to apply. + * - compactJWS: The JWS signature used to validate a promotional offer. + */ + @objc init( + offerID: String, + compactJWS: String + ) { + self.offerID = offerID + self.compactJWS = compactJWS + } +} + +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/StoreKit2PurchaseIntentListener.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/StoreKit2PurchaseIntentListener.swift new file mode 100644 index 00000000..85a769a7 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/StoreKit2PurchaseIntentListener.swift @@ -0,0 +1,202 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StoreKit2PurchaseIntentListener.swift +// +// Created by Will Taylor on 10/10/24. + +import StoreKit + +@available(iOS 16.4, macOS 14.4, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +protocol StoreKit2PurchaseIntentListenerDelegate: AnyObject, Sendable { + + func storeKit2PurchaseIntentListener( + _ listener: StoreKit2PurchaseIntentListenerType, + purchaseIntent: StorePurchaseIntent + ) async + +} + +@available(iOS 16.4, macOS 14.4, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +protocol StoreKit2PurchaseIntentListenerType: Sendable { + + func listenForPurchaseIntents() async + + func set(delegate: StoreKit2PurchaseIntentListenerDelegate) async +} + +/// Observes `StoreKit.PurchaseIntent.intents`, which receives purchase intents, which indicate that +/// subscriber customer initiated a purchase outside of the app, for the app to complete. +@available(iOS 16.4, macOS 14.4, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +actor StoreKit2PurchaseIntentListener: StoreKit2PurchaseIntentListenerType { + + private(set) var taskHandle: Task? + + private weak var delegate: StoreKit2PurchaseIntentListenerDelegate? + + // We can't directly store instances of `AsyncStream`, since that causes runtime crashes when + // loading this type in iOS <= 15, even with @available checks correctly in place. + // See https://openradar.appspot.com/radar?id=4970535809187840 / https://github.com/apple/swift/issues/58099 + private let _updates: Box>? + + var updates: AsyncStream? { + return self._updates?.value + } + + init(delegate: StoreKit2PurchaseIntentListenerDelegate? = nil) { + + #if compiler(>=5.10) && !os(tvOS) && !os(watchOS) && !os(visionOS) + let storePurchaseIntentSequence = StoreKit.PurchaseIntent.intents.map { purchaseIntent in + return StorePurchaseIntent(purchaseIntent: purchaseIntent) + }.toAsyncStream() + #else + let storePurchaseIntentSequence: AsyncStream? = nil + #endif + + self.init( + delegate: delegate, + updates: storePurchaseIntentSequence + ) + } + + /// Creates a listener with an `AsyncSequence` of `VerificationResult`s + /// By default `StoreKit.Transaction.updates` is used, but a custom one can be passed for testing. + init( + delegate: StoreKit2PurchaseIntentListenerDelegate? = nil, + updates: S? + ) where S.Element == StorePurchaseIntent { + self.delegate = delegate + if let updates { + self._updates = .init(updates.toAsyncStream()) + } else { + self._updates = nil + } + } + + func set(delegate: any StoreKit2PurchaseIntentListenerDelegate) async { + self.delegate = delegate + } + + func listenForPurchaseIntents() async { + Logger.debug(Strings.storeKit.sk2_observing_purchase_intents) + + self.taskHandle?.cancel() + self.taskHandle = Task(priority: .utility) { [weak self, updates = self.updates] in + if let updates { + for await purchaseIntent in updates { + guard let self = self else { + break + } + + // Important that handling purchase intents doesn't block the thread + Task.detached { + await self.delegate?.storeKit2PurchaseIntentListener(self, purchaseIntent: purchaseIntent) + } + } + } + } + } + + deinit { + self.taskHandle?.cancel() + self.taskHandle = nil + } +} + +@available(iOS 16.4, macOS 14.4, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +struct StorePurchaseIntent: Sendable, Equatable { + + #if compiler(>=5.10) && !os(tvOS) && !os(watchOS) && !os(visionOS) + init(purchaseIntent: (any StoreKit2PurchaseIntentType)?) { + self.purchaseIntent = purchaseIntent + } + #else + init() {} + #endif + + // PurchaseIntents became available on macOS starting in macOS 14.4, which isn't available + // until Xcode 15.3, which shipped with version 5.10 of the Swift compiler + #if compiler(>=5.10) && !os(tvOS) && !os(watchOS) && !os(visionOS) + @available(iOS 16.4, macOS 14.4, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + @available(visionOS, unavailable) + let purchaseIntent: (any StoreKit2PurchaseIntentType)? + #endif + + static func == (lhs: StorePurchaseIntent, rhs: StorePurchaseIntent) -> Bool { + // An explanation on why this implementation is this complicated is given in the + // comment in StoreKit2PurchaseIntentType's id property below + #if compiler(>=6.2) && !os(tvOS) && !os(watchOS) && !os(visionOS) + if #available(iOS 18.0, macOS 15.0, *) { + return lhs.purchaseIntent?.id == rhs.purchaseIntent?.id + } else { + return lhs.purchaseIntent?.product.id == rhs.purchaseIntent?.product.id + } + #elseif compiler(>=5.10) && !os(tvOS) && !os(watchOS) && !os(visionOS) + return lhs.purchaseIntent?.id == rhs.purchaseIntent?.id + #else + // purchaseIntent is not available in compiler < 5.10 + return true + #endif + } +} + +#if compiler(>=5.10) && !os(tvOS) && !os(watchOS) && !os(visionOS) + +@available(iOS 16.4, macOS 14.4, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +protocol StoreKit2PurchaseIntentType: Equatable, Sendable { + + // WARNING: **DO NOT** make this type conform to Identifiable!!! + // While StoreKit.PurchaseIntent conforms to Identifiable in iOS 18+, + // the conformance is not available in earlier versions of iOS, and this will + // cause a runtime crash when trying to typecast a StoreKit.PurchaseIntent + // to a StoreKit2PurchaseIntentType in iOS 16.4..<18.0. + // + // See https://github.com/RevenueCat/purchases-ios/pull/4964 + // and https://github.com/RevenueCat/purchases-ios/issues/4963 for more details. + + var product: StoreKit.Product { get } + + #if compiler(>=6.0) + @available(iOS 18.0, macOS 15.0, *) + var offer: StoreKit.Product.SubscriptionOffer? { get } + #endif + + // Xcode 26 changed the minimum versions where StoreKit.PurchaseIntent id property is available. + // In Xcode 16, it was available in iOS 16.4+, macOS 14.4+ + // In Xcode 26, it was available in iOS 18.0+, macOS 15.0+ + // That's why we need the following workaround: + #if compiler(>=6.2) + @available(iOS 18.0, macOS 15.0, *) + var id: StoreKit.Product.ID { get } + #else + var id: StoreKit.Product.ID { get } + #endif +} + +@available(iOS 16.4, macOS 14.4, *) +extension StoreKit.PurchaseIntent: StoreKit2PurchaseIntentType { } + +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/StoreKit2Receipt.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/StoreKit2Receipt.swift new file mode 100644 index 00000000..a00c15e1 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/StoreKit2Receipt.swift @@ -0,0 +1,115 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StoreKit2Receipt.swift +// +// Created by MarkVillacampa on 26/10/23. + +import StoreKit + +/// A type that resembles the structure of a StoreKit 1 receipt using StoreKit 2 data. +struct StoreKit2Receipt: Equatable { + + struct SubscriptionState: RawRepresentable, Equatable { + + let rawValue: Int + + init(rawValue: Int) { + self.rawValue = rawValue + } + + static let subscribed = Self(rawValue: 1) + static let expired = Self(rawValue: 2) + static let inBillingRetryPeriod = Self(rawValue: 3) + static let inGracePeriod = Self(rawValue: 4) + static let revoked = Self(rawValue: 5) + + } + + struct SubscriptionStatus: Equatable { + + /// The renewal state of the auto-renewable subscription. + let state: SubscriptionState + + /// JWS token of the renewal information. + let renewalInfoJWSToken: String + + /// JWS token of the latest transaction for the subscription group. + let transactionJWSToken: String + + } + + /// The server environment where the receipt was generated. + let environment: StoreEnvironment + + /// The current subscription status for each subscription group, including the renewal information. + let subscriptionStatusBySubscriptionGroupId: [String: [SubscriptionStatus]] + + /// The list of transaction JWS tokens purchased by the customer. + let transactions: [String] + + /// The bundle identifier of the app. + let bundleId: String + + /// The app version that the user originally purchased from the App Store. + let originalApplicationVersion: String? + + /// The date the user originally purchased the app from the App Store. + let originalPurchaseDate: Date? + +} + +// MARK: - + +extension StoreKit2Receipt.SubscriptionState: Codable {} + +extension StoreKit2Receipt.SubscriptionState { + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + static func from(state: StoreKit.Product.SubscriptionInfo.RenewalState) -> Self { + switch state { + case .subscribed: + return .subscribed + case .expired: + return .expired + case .inBillingRetryPeriod: + return .inBillingRetryPeriod + case .inGracePeriod: + return .inGracePeriod + case .revoked: + return .revoked + default: + return .init(rawValue: state.rawValue) + } + } + +} + +extension StoreKit2Receipt.SubscriptionStatus: Codable { + + private enum CodingKeys: String, CodingKey { + case state + case renewalInfoJWSToken = "renewalInfo" + case transactionJWSToken = "transaction" + } + +} + +extension StoreKit2Receipt: Codable { + + private enum CodingKeys: String, CodingKey { + case environment + case subscriptionStatusBySubscriptionGroupId = "subscriptionStatus" + case transactions + case bundleId + case originalApplicationVersion + case originalPurchaseDate + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/StoreKit2StorefrontListener.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/StoreKit2StorefrontListener.swift new file mode 100644 index 00000000..9a589606 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/StoreKit2StorefrontListener.swift @@ -0,0 +1,106 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StoreKit2StorefrontListener.swift +// +// Created by Juanpe Catalán on 5/5/22. + +import Foundation +import StoreKit + +protocol StoreKit2StorefrontListenerDelegate: AnyObject, Sendable { + + func storefrontValuesUpdated(with storefront: StorefrontType) + +} + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +class StoreKit2StorefrontListener { + + private static let lastKnownStorefrontKey = "com.revenuecat.userdefaults.lastKnownStorefrontKey" + + private(set) var taskHandle: Task? { + didSet { + if self.taskHandle != oldValue { + oldValue?.cancel() + } + } + } + + weak var delegate: StoreKit2StorefrontListenerDelegate? + private let updates: AsyncStream + private let userDefaults: SynchronizedUserDefaults + + convenience init(delegate: StoreKit2StorefrontListenerDelegate?, userDefaults: UserDefaults?) { + self.init( + delegate: delegate, + updates: StoreKit.Storefront.updates.map(Storefront.init(sk2Storefront:)), + userDefaults: userDefaults + ) + } + + /// Creates a listener with an `AsyncSequence` of `StorefrontType`s + /// By default `StoreKit.Storefront.updates` is used, but a custom one can be passed for testing. + init( + delegate: StoreKit2StorefrontListenerDelegate?, + updates: S, + userDefaults: UserDefaults? + ) where S.Element == StorefrontType { + self.delegate = delegate + self.updates = updates.toAsyncStream() + self.userDefaults = SynchronizedUserDefaults(userDefaults: userDefaults ?? UserDefaults.computeDefault()) + } + + func listenForStorefrontChanges() { + self.taskHandle = Task(priority: .utility) { [weak self, updates = self.updates] in + for await storefront in updates { + guard let delegate = self?.delegate else { break } + + // Only emit if this is an actual change from the last known storefront + if self?.shouldEmitStorefrontChange(storefront) == true { + + // Update the last known storefront + self?.updateLastKnownStorefront(storefront) + + OperationDispatcher.dispatchOnMainActor { + delegate.storefrontValuesUpdated(with: storefront) + } + } + } + } + } + + /// On macOS SK2 will emit a storefront update right away when subscribing to + /// updates, even when the storefront hasn't changed + /// by storing the last known storefront in UserDefaults we're ignoring this update + /// unless the storefront (identifier or country) has actually changed + private func shouldEmitStorefrontChange(_ storefront: StorefrontType) -> Bool { + let lastKnownStorefrontValue = self.userDefaults.read { + $0.string(forKey: Self.lastKnownStorefrontKey) + } + + return lastKnownStorefrontValue != Self.userDefaultsValue(for: storefront) + } + + private func updateLastKnownStorefront(_ storefront: StorefrontType) { + let value = Self.userDefaultsValue(for: storefront) + self.userDefaults.write { + $0.set(value, forKey: Self.lastKnownStorefrontKey) + } + } + + deinit { + self.taskHandle?.cancel() + self.taskHandle = nil + } + + private static func userDefaultsValue(for storefront: StorefrontType) -> String { + storefront.identifier + "." + storefront.countryCode + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/StoreKit2TransactionFetcher.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/StoreKit2TransactionFetcher.swift new file mode 100644 index 00000000..6bbb9c3c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/StoreKit2TransactionFetcher.swift @@ -0,0 +1,345 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StoreKit2TransactionFetcher.swift +// +// Created by Nacho Soto on 5/24/23. + +import Foundation +import StoreKit + +protocol StoreKit2TransactionFetcherType: Sendable { + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + var unfinishedVerifiedTransactions: [StoreTransaction] { get async } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func fetchReceipt(containing transaction: StoreTransactionType) async -> StoreKit2Receipt + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + var hasPendingConsumablePurchase: Bool { get async } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + var firstVerifiedAutoRenewableTransaction: StoreTransaction? { get async } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + var firstVerifiedTransaction: StoreTransaction? { get async } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + var oldestVerifiedTransaction: StoreTransaction? { get async } + + var appTransactionJWS: String? { get async } + + func appTransactionJWS(_ completionHandler: @escaping (String?) -> Void) + +} + +final class StoreKit2TransactionFetcher: StoreKit2TransactionFetcherType { + + private let diagnosticsTracker: DiagnosticsTrackerType? + + init(diagnosticsTracker: DiagnosticsTrackerType?) { + self.diagnosticsTracker = diagnosticsTracker + } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + var unfinishedVerifiedTransactions: [StoreTransaction] { + get async { + return await StoreKit.Transaction + .unfinished + .compactMap { $0.verifiedStoreTransaction } + .extractValues() + } + } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + var hasPendingConsumablePurchase: Bool { + get async { + return await StoreKit.Transaction + .unfinished + .compactMap { $0.verifiedTransaction } + .map(\.productType) + .map { StoreProduct.ProductType($0) } + .contains { $0.productCategory == .nonSubscription } + } + } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + var firstVerifiedAutoRenewableTransaction: StoreTransaction? { + get async { + await StoreKit.Transaction.all + .compactMap { $0.verifiedStoreTransaction } + .filter { $0.sk2Transaction?.productType == .autoRenewable } + .first { _ in true } + } + } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + var firstVerifiedTransaction: StoreTransaction? { + get async { + await StoreKit.Transaction.all + .compactMap { $0.verifiedStoreTransaction } + .first { _ in true } + } + } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + var oldestVerifiedTransaction: StoreTransaction? { + get async { + await StoreKit.Transaction.all + .compactMap { $0.verifiedStoreTransaction } + .extractValues() + // `Transaction.all` does not document iteration order. + // Although it appears to return newest-first in testing, we sort by `purchaseDate` here + // so `.first` is reliably the oldest transaction even if the order changes in a future OS version. + .sorted(by: { $0.purchaseDate < $1.purchaseDate }) + .first + } + } + + /// Returns an `StoreKit2Receipt` making sure `transaction` is contained in it. + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func fetchReceipt(containing transaction: StoreTransactionType) async -> StoreKit2Receipt { + async let transactions = verifiedTransactions(containing: transaction) + async let subscriptionStatuses = subscriptionStatusBySubscriptionGroupId + async let appTransaction = appTransaction + + return await .init( + environment: .xcode, + subscriptionStatusBySubscriptionGroupId: subscriptionStatuses.mapValues { statuses in + statuses.map { status in + return .init(state: .from(state: status.state), + renewalInfoJWSToken: status.renewalInfo.jwsRepresentation, + transactionJWSToken: status.transaction.jwsRepresentation) + } + }, + transactions: transactions.compactMap(\.jwsRepresentation), + bundleId: appTransaction?.bundleId ?? "", + originalApplicationVersion: appTransaction?.originalApplicationVersion, + originalPurchaseDate: appTransaction?.originalPurchaseDate + ) + } + + /// A computed property that retrieves the JWS (JSON Web Signature) representation + /// of the app transaction asynchronously. + /// + /// If the OS does not support AppTransaction (available in iOS16+), it returns `nil`. + /// + /// - Returns: A `String` containing the JWS representation of the app transaction, + /// or `nil` if the feature is unavailable on the current platform version. + var appTransactionJWS: String? { + get async { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + do { + return try await AppTransaction.shared.jwsRepresentation + } catch { + self.trackAppleAppTransactionError(error) + return nil + } + } else { + return nil + } + } + } + + /// Retrieves the JWS (JSON Web Signature) representation of the AppTransaction asynchronously and + /// passes it to the provided completion handler. + /// + /// If the OS does not support AppTransaction (available in iOS16+), it returns `nil` through the + /// completion handler. + /// + /// - Parameter completion: A closure that is called with the JWS representation of the app transaction, or `nil` + /// if the feature is unavailable on the current platform version. + /// - Parameter result: A `String?` containing the JWS representation of the app transaction, + /// or `nil` if unavailable. + func appTransactionJWS(_ completion: @escaping (String?) -> Void) { + Async.call(with: completion) { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + do { + return try await AppTransaction.shared.jwsRepresentation + } catch { + self.trackAppleAppTransactionError(error) + return nil + } + } else { + return nil + } + } + } +} + +// MARK: - + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +extension StoreKit.VerificationResult where SignedType == StoreKit.Transaction { + + var underlyingTransaction: StoreKit.Transaction { + switch self { + case let .unverified(transaction, _): return transaction + case let .verified(transaction): return transaction + } + } + + var verifiedTransaction: StoreKit.Transaction? { + switch self { + case let .verified(transaction): return transaction + case let .unverified(transaction, error): + Logger.warn(Strings.storeKit.sk2_unverified_transaction(identifier: String(transaction.id), error)) + return nil + } + } + + var verifiedStoreTransaction: StoreTransaction? { + switch self { + case let .verified(transaction): return StoreTransaction(sk2Transaction: transaction, + jwsRepresentation: self.jwsRepresentation) + case let .unverified(transaction, error): + Logger.warn(Strings.storeKit.sk2_unverified_transaction(identifier: String(transaction.id), error)) + return nil + } + } + +} + +// MARK: - Private + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +extension StoreKit.VerificationResult where SignedType == StoreKit.AppTransaction { + + var verifiedAppTransaction: SK2AppTransaction? { + switch self { + case let .verified(transaction): return .init(appTransaction: transaction) + case let .unverified(transaction, error): + Logger.warn( + Strings.storeKit.sk2_unverified_transaction(identifier: transaction.bundleID, error) + ) + return nil + } + } + +} + +extension StoreKit2TransactionFetcher { + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + private var verifiedTransactions: [StoreTransaction] { + get async { + return await StoreKit.Transaction.all + .compactMap { $0.verifiedStoreTransaction } + .extractValues() + } + } + + /// Returns an an array of all verified `StoreTransaction`s, making sure `transaction` is contained in it. + /// + /// When using SKTestSession / SK config files, a newly generated transaction may not appear immediately + /// in `Transaction.all`, so we need to retry and introduce a small synthetic delay to make sure it is.s + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + private func verifiedTransactions(containing transaction: StoreTransactionType) async -> [StoreTransaction] { + + return await Async.retry { + let verifiedTransactions = await verifiedTransactions + if verifiedTransactions.contains(where: { $0.id == transaction.transactionIdentifier }) { + return (shouldRetry: false, verifiedTransactions) + } else { + Logger.appleWarning( + Strings.storeKit.sk2_receipt_missing_purchase(transactionId: transaction.transactionIdentifier) + ) + return (shouldRetry: true, verifiedTransactions) + } + } + } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + private var subscriptionStatusBySubscriptionGroupId: [String: [Product.SubscriptionInfo.Status]] { + get async { + #if swift(>=5.9) + if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) { + return await StoreKit.Product.SubscriptionInfo.Status.all + .extractValues() + .reduce(into: [:]) {result, value in + result[value.groupID] = value.statuses + } + } + #endif + + // `StoreKit.Product.SubscriptionInfo.Status.all` is only available starting in iOS 17.0 + // For previous versions, we retrieve all the previously purchased transactions, + // and fetch the subscription status only once per subscription group. + var subscriptionGroups: Set = [] + for await transaction in StoreKit.Transaction.all { + if let verifiedTransaction = transaction.verifiedTransaction, + let subscriptionGroup = verifiedTransaction.subscriptionGroupID { + subscriptionGroups.insert(subscriptionGroup) + } + } + + let statusBySubscriptionGroup: [String: [Product.SubscriptionInfo.Status]] = await withTaskGroup( + of: Optional<(String, [Product.SubscriptionInfo.Status])>.self + ) { taskGroup in + for subscriptionGroup in subscriptionGroups { + taskGroup.addTask { + do { + let status = try await Product.SubscriptionInfo.status(for: subscriptionGroup) + return (subscriptionGroup, status) + } catch { + Logger.warn( + Strings.storeKit.sk2_error_fetching_subscription_status( + subscriptionGroupId: subscriptionGroup, error + ) + ) + return nil + } + } + } + + return await taskGroup.reduce( + into: [:] + ) { result, value in + if let value = value { + result[value.0] = value.1 + } + } + } + + return statusBySubscriptionGroup + } + } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + private var appTransaction: SK2AppTransaction? { + get async { + do { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + let transaction = try await StoreKit.AppTransaction.shared + return transaction.verifiedAppTransaction + } else { + Logger.warn(Strings.storeKit.sk2_app_transaction_unavailable) + return nil + } + } catch { + self.trackAppleAppTransactionError(error) + return nil + } + } + } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + private func trackAppleAppTransactionError(_ error: Error) { + let purchasesError = ErrorUtils.purchasesError(withStoreKitError: error) + let errorMessage: String = (purchasesError.userInfo[NSUnderlyingErrorKey] as? Error)?.localizedDescription + ?? purchasesError.localizedDescription + let errorCode = purchasesError.errorCode + let storeKitErrorDescription = StoreKitErrorUtils.extractStoreKitErrorDescription(from: error) + self.diagnosticsTracker?.trackAppleAppTransactionError(errorMessage: errorMessage, + errorCode: errorCode, + storeKitErrorDescription: storeKitErrorDescription) + Logger.warn(Strings.storeKit.sk2_error_fetching_app_transaction(error)) + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift new file mode 100644 index 00000000..1282cd0e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift @@ -0,0 +1,248 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StoreKit2TransactionListener.swift +// +// Created by Andrés Boedo on 31/8/21. + +import Foundation +import StoreKit + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +protocol StoreKit2TransactionListenerDelegate: AnyObject, Sendable { + + func storeKit2TransactionListener( + _ listener: StoreKit2TransactionListenerType, + updatedTransaction transaction: StoreTransactionType + ) async throws + +} + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +protocol StoreKit2TransactionListenerType: Sendable { + + func listenForTransactions() async + + func set(delegate: StoreKit2TransactionListenerDelegate) async + + /// - Throws: ``PurchasesError`` if purchase was not completed successfully + /// - Parameter fromTransactionUpdate: `true` only for transactions detected outside of a manual purchase flow. + func handle( + purchaseResult: StoreKit.Product.PurchaseResult, + fromTransactionUpdate: Bool + ) async throws -> StoreKit2TransactionListener.ResultData + + func handleSK2ObserverModeTransaction( + verifiedTransaction: StoreKit.Transaction, + jwsRepresentation: String + ) async throws +} + +/// Observes `StoreKit.Transaction.updates`, which receives: +/// - Updates from outside `Product.purchase()`, like renewals and purchases made on other devices +/// - Purchases from SwiftUI's paywalls. +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +actor StoreKit2TransactionListener: StoreKit2TransactionListenerType { + + /// Result of handling a `Product.PurchaseResult` + enum ResultData { + case userCancelled + case successfulVerifiedTransaction(StoreTransaction) + } + typealias TransactionResult = StoreKit.VerificationResult + + private(set) var taskHandle: Task? + + private weak var delegate: StoreKit2TransactionListenerDelegate? + + private let diagnosticsTracker: DiagnosticsTrackerType? + + // We can't directly store instances of `AsyncStream`, since that causes runtime crashes when + // loading this type in iOS <= 15, even with @available checks correctly in place. + // See https://openradar.appspot.com/radar?id=4970535809187840 / https://github.com/apple/swift/issues/58099 + private let _updates: Box> + + var updates: AsyncStream { + return self._updates.value + } + + init(delegate: StoreKit2TransactionListenerDelegate? = nil, diagnosticsTracker: DiagnosticsTrackerType?) { + self.init(delegate: delegate, diagnosticsTracker: diagnosticsTracker, updates: StoreKit.Transaction.updates) + } + + /// Creates a listener with an `AsyncSequence` of `VerificationResult`s + /// By default `StoreKit.Transaction.updates` is used, but a custom one can be passed for testing. + init( + delegate: StoreKit2TransactionListenerDelegate? = nil, + diagnosticsTracker: DiagnosticsTrackerType?, + updates: S + ) where S.Element == TransactionResult { + self.delegate = delegate + self.diagnosticsTracker = diagnosticsTracker + self._updates = .init(updates.toAsyncStream()) + } + + func set(delegate: StoreKit2TransactionListenerDelegate) { + self.delegate = delegate + } + + func listenForTransactions() { + Logger.debug(Strings.storeKit.sk2_observing_transaction_updates) + + self.taskHandle?.cancel() + self.taskHandle = Task(priority: .utility) { [weak self, updates = self.updates] in + for await result in updates { + guard let self = self else { break } + + // Important that handling transactions doesn't block this + // to allow all potential `PostReceiptOperations` to begin + // and get de-duped if they share the same cache key. + Task.detached { + do { + _ = try await self.handle(transactionResult: result, fromTransactionUpdate: true) + } catch { + Logger.error(error.localizedDescription) + } + } + } + } + } + + deinit { + self.taskHandle?.cancel() + self.taskHandle = nil + } + + func handle( + purchaseResult: StoreKit.Product.PurchaseResult, + fromTransactionUpdate: Bool = false + ) async throws -> ResultData { + switch purchaseResult { + case let .success(verificationResult): + let transaction = try await self.handle(transactionResult: verificationResult, + fromTransactionUpdate: fromTransactionUpdate) + return .successfulVerifiedTransaction(transaction) + case .pending: + throw ErrorUtils.paymentDeferredError() + case .userCancelled: + return .userCancelled + @unknown default: + throw ErrorUtils.storeProblemError( + withMessage: Strings.purchase.unknown_purchase_result(result: String(describing: purchaseResult)) + .description + ) + } + } + +} + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +private extension StoreKit2TransactionListener { + + /// - Throws: ``ErrorCode`` if the transaction fails to verify. + /// - Parameter fromTransactionUpdate: `true` only for transactions detected outside of a manual purchase flow. + func handle( + transactionResult: TransactionResult, + fromTransactionUpdate: Bool + ) async throws -> StoreTransaction { + switch transactionResult { + case let .unverified(unverifiedTransaction, verificationError): + throw ErrorUtils.storeProblemError( + withMessage: Strings.purchase.transaction_unverified( + productID: unverifiedTransaction.productID, + errorMessage: verificationError.localizedDescription + ).description, + error: verificationError + ) + + case let .verified(verifiedTransaction): + let transaction = StoreTransaction(sk2Transaction: verifiedTransaction, + jwsRepresentation: transactionResult.jwsRepresentation) + if fromTransactionUpdate, let delegate = self.delegate { + Logger.debug(Strings.purchase.sk2_transactions_update_received_transaction( + productID: verifiedTransaction.productID + )) + + self.trackTransactionUpdateReceivedIfNeeded(transaction: transaction, + sk2Transaction: verifiedTransaction) + + try await delegate.storeKit2TransactionListener( + self, + updatedTransaction: transaction + ) + } + + return transaction + } + } +} + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +extension StoreKit2TransactionListener { + + func handleSK2ObserverModeTransaction( + verifiedTransaction: StoreKit.Transaction, + jwsRepresentation: String + ) async throws { + let transaction = StoreTransaction(sk2Transaction: verifiedTransaction, + jwsRepresentation: jwsRepresentation) + if let delegate = self.delegate { + Logger.debug(Strings.purchase.sk2_transactions_update_received_transaction( + productID: verifiedTransaction.productID + )) + + self.trackTransactionUpdateReceivedIfNeeded(transaction: transaction, + sk2Transaction: verifiedTransaction) + try await delegate.storeKit2TransactionListener( + self, + updatedTransaction: transaction + ) + } + } +} + +// MARK: - Diagnostics + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +private extension StoreKit2TransactionListener { + + func trackTransactionUpdateReceivedIfNeeded(transaction: StoreTransaction, sk2Transaction: SK2Transaction) { + guard let diagnosticsTracker = self.diagnosticsTracker else { + return + } + + var reason: String? + var currency: String? + var price: Float? + #if compiler(>=6.0) + if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) { + reason = sk2Transaction.reason.rawValue + } + + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + currency = sk2Transaction.currency?.identifier + } + + price = sk2Transaction.price.map { ($0 as NSDecimalNumber).floatValue } + #endif + + diagnosticsTracker.trackAppleTransactionUpdateReceived( + transactionId: sk2Transaction.id, + environment: transaction.environment?.rawValue, + storefront: transaction.storefront?.countryCode, + productId: transaction.productIdentifier, + purchaseDate: transaction.purchaseDate, + expirationDate: sk2Transaction.expirationDate, + price: price, + currency: currency, + reason: reason + ) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/Win-Back Offers/WinBackOfferEligibilityCalculator.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/Win-Back Offers/WinBackOfferEligibilityCalculator.swift new file mode 100644 index 00000000..d2a02bb8 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/Win-Back Offers/WinBackOfferEligibilityCalculator.swift @@ -0,0 +1,138 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SK2WinBackOfferEligibilityCalculator.swift +// +// Created by Will Taylor on 10/31/24. + +import StoreKit + +@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +final class WinBackOfferEligibilityCalculator: Sendable { + + // MARK: - Properties + private let systemInfo: SystemInfo + + // MARK: - Initialization + + /// Creates an instance of `SK2WinBackOfferEligibilityCalculator`. + /// + /// - Parameter systemInfo: An instance of `SystemInfo` providing information about the system environment. + init( + systemInfo: SystemInfo + ) { + self.systemInfo = systemInfo + } +} + +// MARK: - WinBackOfferEligibilityCalculator Conformance +@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +extension WinBackOfferEligibilityCalculator: WinBackOfferEligibilityCalculatorType { + + func eligibleWinBackOffers(forProduct product: StoreProduct) async throws -> [WinBackOffer] { + return try await self.calculateEligibleWinBackOffers(forProduct: product) + } + +} + +// MARK: - Implementation +@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +extension WinBackOfferEligibilityCalculator { + + @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) + private func calculateEligibleWinBackOffers( + forProduct product: StoreProduct + ) async throws -> [WinBackOffer] { + + #if compiler(>=6.0) + guard self.systemInfo.storeKitVersion.isStoreKit2EnabledAndAvailable else { + throw ErrorUtils.featureNotSupportedWithStoreKit1Error() + } + + let eligibleWinBackOfferIDs: [String] = await self.calculateEligibleWinBackOfferIDs(forProduct: product) + guard !eligibleWinBackOfferIDs.isEmpty else { return [] } + + guard let allWinBackOffersForThisProduct: [ + Product.SubscriptionOffer + ] = product.sk2Product?.subscription?.winBackOffers else { + // StoreKit.Product.SubscriptionInfo is nil if the product is not a subscription + return [] + } + + let winbackOffersByID = allWinBackOffersForThisProduct + .reduce(into: [String: Product.SubscriptionOffer]() + ) { dict, offer in + if let id = offer.id { + dict[id] = offer + } + } + + let eligibleWinBackOffers: [WinBackOffer] = eligibleWinBackOfferIDs + // Convert the eligible offer IDs to StoreProductDiscounts for us to use + .compactMap { winbackOfferID in + guard let winbackOffer = winbackOffersByID[winbackOfferID] else { + return nil + } + return StoreProductDiscount(sk2Discount: winbackOffer, currencyCode: product.currencyCode) + } + // Convert the StoreProductDiscounts to WinBackOffer objects + .map { WinBackOffer(discount: $0) } + + return eligibleWinBackOffers + #else + return [] + #endif + } + + @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) + private func calculateEligibleWinBackOfferIDs(forProduct product: StoreProduct) async -> [String] { + #if compiler(>=6.0) + guard let statuses = try? await product.sk2Product?.subscription?.status, !statuses.isEmpty else { + // If StoreKit.Product.subscription is nil, then the product isn't a subscription + // If statuses is empty, the subscriber was never subscribed to a product in the subscription group. + return [] + } + + // It's okay for us to only check the first matching status since you can only be subscribed to a product once. + // Thus, there can be at most 1 renewalInfo that is not a family shared one. + // See https://developer.apple.com/videos/play/wwdc2024/10110/ for an example. + guard let purchaseSubscriptionStatus = statuses.first(where: { + $0.transaction.unsafePayloadValue.ownershipType == .purchased + }) else { + return [] + } + + guard let renewalInfo = purchaseSubscriptionStatus.verifiedRenewalInfo else { + return [] + } + + // StoreKit sorts eligibleWinBackOfferIDs by the "best" win-back offer first. + return renewalInfo.eligibleWinBackOfferIDs + #else + return [] + #endif + } +} + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +extension StoreKit.Product.SubscriptionInfo.Status { + var verifiedRenewalInfo: StoreKit.Product.SubscriptionInfo.RenewalInfo? { + switch self.renewalInfo { + case .unverified: + Logger.warn( + Strings.storeKit.sk2_unverified_renewal_info( + productIdentifier: String(self.transaction.underlyingTransaction.productID) + ) + ) + return nil + case .verified(let status): + return status + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/Win-Back Offers/WinBackOfferEligibilityCalculatorType.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/Win-Back Offers/WinBackOfferEligibilityCalculatorType.swift new file mode 100644 index 00000000..b3ea1d96 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKit2/Win-Back Offers/WinBackOfferEligibilityCalculatorType.swift @@ -0,0 +1,35 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// WinBackOfferEligibilityCalculatorType.swift +// +// Created by Will Taylor on 10/31/24. + +import Foundation + +/// A protocol defining functions to calculate eligible win-back offers for a given product. +/// +/// Implementations of this protocol can be used to determine eligibility for win-back offers, which are designed to +/// re-engage users who may have previously canceled or lapsed in their subscriptions. +/// - Availability: iOS 18.0+, macOS 15.0+, tvOS 18.0+, watchOS 11.0+, visionOS 2.0+ +protocol WinBackOfferEligibilityCalculatorType { + + /// Determines the eligible win-back offers for a specified product. + /// + /// - Parameter product: The `StoreProduct` instance representing the product to check for + /// win-back offer eligibility. + /// - Returns: An array of eligible `WinBackOffer` objects. + /// + /// This async variant provides a convenient way to work with eligibility checks in async contexts. + /// + @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) + func eligibleWinBackOffers( + forProduct product: StoreProduct + ) async throws -> [WinBackOffer] +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/EncodedAppleReceipt.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/EncodedAppleReceipt.swift new file mode 100644 index 00000000..64b73ff6 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/EncodedAppleReceipt.swift @@ -0,0 +1,49 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// EncodedAppleReceipt.swift +// +// Created by Mark Villacampa on 6/10/23. + +import Foundation + +/// Represents an `AppleReceipt` that's been encoded +/// in a suitable representation for the RevenueCat backend. +enum EncodedAppleReceipt: Equatable { + + case jws(String) + case receipt(Data) + case sk2receipt(StoreKit2Receipt) + case empty + +} + +extension EncodedAppleReceipt { + + func serialized() -> String? { + switch self { + case .jws(let jws): + return jws + case .receipt(let data): + return data.base64EncodedString() + case .sk2receipt(let receipt): + do { + return try receipt.prettyPrintedData.base64EncodedString() + } catch { + Logger.warn(Strings.storeKit.sk2_error_encoding_receipt(error)) + return "" + } + case .empty: + return nil + } + } + +} + +extension EncodedAppleReceipt: Codable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/ProductType.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/ProductType.swift new file mode 100644 index 00000000..d9074cb3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/ProductType.swift @@ -0,0 +1,93 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ProductType.swift +// +// Created by Nacho Soto on 2/15/22. + +import StoreKit + +extension StoreProduct { + + /// The category of a product, whether a subscription or a one-time purchase. + /// + /// ### Related Symbols + /// - ``StoreProduct/ProductType-swift.enum`` + @objc(RCStoreProductCategory) + public enum ProductCategory: Int { + + /// A non-renewable or auto-renewable subscription. + case subscription + + /// A consumable or non-consumable in-app purchase. + case nonSubscription + + } + + /// The type of product, equivalent to StoreKit's `Product.ProductType`. + /// + /// ### Related Symbols + /// - ``StoreProduct/ProductCategory-swift.enum`` + @objc(RCStoreProductType) + public enum ProductType: Int { + + /// A consumable in-app purchase. + case consumable + + /// A non-consumable in-app purchase. + case nonConsumable + + /// A non-renewing subscription. + case nonRenewableSubscription + + /// An auto-renewable subscription. + case autoRenewableSubscription + + } + +} + +extension StoreProduct.ProductType { + + var productCategory: StoreProduct.ProductCategory { + switch self { + case .consumable: return .nonSubscription + case .nonConsumable: return .nonSubscription + case .nonRenewableSubscription: return .subscription + case .autoRenewableSubscription: return .subscription + } + } + + /// Used as a placeholder when the type of product cannot be determined. + /// This value is considered undefined behavior. + static let defaultType: Self = .nonConsumable + +} + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +extension StoreProduct.ProductType { + + init(_ type: SK2Product.ProductType) { + switch type { + case .consumable: self = .consumable + case .nonConsumable: self = .nonConsumable + case .nonRenewable: self = .nonRenewableSubscription + case .autoRenewable: self = .autoRenewableSubscription + + default: + Logger.warn(Strings.storeKit.sk2_unknown_product_type(String(describing: type))) + + self = .defaultType + } + } + +} + +extension StoreProduct.ProductCategory: Sendable {} +extension StoreProduct.ProductType: Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/PromotionalOffer.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/PromotionalOffer.swift new file mode 100644 index 00000000..cfecff98 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/PromotionalOffer.swift @@ -0,0 +1,156 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PromotionalOffer.swift +// +// Created by Josh Holtz on 1/18/22. + +import Foundation +import StoreKit + +/// Represents a ``StoreProductDiscount`` that has been validated and +/// is ready to be used for a purchase. +/// +/// #### Related Symbols +/// - ``Purchases/promotionalOffer(forProductDiscount:product:)`` +/// - ``Purchases/getPromotionalOffer(forProductDiscount:product:completion:)`` +/// - ``StoreProduct/eligiblePromotionalOffers()`` +/// - ``Purchases/eligiblePromotionalOffers(forProduct:)`` +/// - ``Purchases/purchase(package:promotionalOffer:)`` +/// - ``Purchases/purchase(package:promotionalOffer:completion:)`` +/// - ``Purchases/purchase(product:promotionalOffer:)`` +/// - ``Purchases/purchase(product:promotionalOffer:completion:)`` +@objc(RCPromotionalOffer) +public final class PromotionalOffer: NSObject { + + /// The ``StoreProductDiscount`` in this offer. + @objc public let discount: StoreProductDiscount + /// The ``PromotionalOffer/SignedData-swift.class`` provides information about the ``PromotionalOffer``'s signature. + @objc public let signedData: SignedData + + init(discount: StoreProductDiscountType, signedData: SignedData) { + self.discount = StoreProductDiscount.from(discount: discount) + self.signedData = signedData + } + +} + +extension PromotionalOffer: Sendable {} + +// MARK: - SignedData + +@objc public extension PromotionalOffer { + + /// Contains the details of a promotional offer discount that you want to apply to a payment. + @objc(RCPromotionalOfferSignedData) + final class SignedData: NSObject { + + /// The subscription offer identifier. + @objc public let identifier: String + /// The key identifier of the subscription key. + @objc public let keyIdentifier: String + /// The nonce used in the signature. + @objc public let nonce: UUID + /// The cryptographic signature of the offer parameters, generated on RevenueCat's server. + @objc public let signature: String + /// The UNIX time, in milliseconds, when the signature was generated. + @objc public let timestamp: Int + + init(identifier: String, keyIdentifier: String, nonce: UUID, signature: String, timestamp: Int) { + self.identifier = identifier + self.keyIdentifier = keyIdentifier + self.nonce = nonce + self.signature = signature + self.timestamp = timestamp + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? Self else { return false } + + return self == other + } + + // swiftlint:disable:next missing_docs nsobject_prefer_isequal + public static func == ( + lhs: PromotionalOffer.SignedData, + rhs: PromotionalOffer.SignedData + ) -> Bool { + return (lhs.identifier == rhs.identifier && + lhs.keyIdentifier == rhs.keyIdentifier && + lhs.nonce == rhs.nonce && + lhs.signature == rhs.signature && + lhs.timestamp == rhs.timestamp) + } + + } + +} + +extension PromotionalOffer.SignedData: Sendable {} + +extension PromotionalOffer.SignedData { + + enum Error: Swift.Error { + + /// The signature generated by the backend could not be decoded. + case failedToDecodeSignature(String) + + } + + convenience init(sk1PaymentDiscount discount: SKPaymentDiscount) { + self.init(identifier: discount.identifier, + keyIdentifier: discount.keyIdentifier, + nonce: discount.nonce, + signature: discount.signature, + timestamp: discount.timestamp.intValue) + } + + var sk1PromotionalOffer: SKPaymentDiscount { + return SKPaymentDiscount(identifier: self.identifier, + keyIdentifier: self.keyIdentifier, + nonce: self.nonce, + signature: self.signature, + timestamp: self.timestamp as NSNumber) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + var sk2PurchaseOption: Product.PurchaseOption { + get throws { + let signature: Data + + if let decoded = Data(base64Encoded: self.signature) { + signature = decoded + } else { + throw Error.failedToDecodeSignature(self.signature) + } + + return .promotionalOffer( + offerID: self.identifier, + keyID: self.keyIdentifier, + nonce: self.nonce, + signature: signature, + timestamp: self.timestamp + ) + } + } + +} + +// MARK: - + +extension PromotionalOffer.SignedData.Error: LocalizedError { + + var errorDescription: String? { + switch self { + case let .failedToDecodeSignature(signature): + return "The signature generated by RevenueCat could not be decoded: \(signature)" + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK1StoreProduct.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK1StoreProduct.swift new file mode 100644 index 00000000..be66d14c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK1StoreProduct.swift @@ -0,0 +1,94 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SK1StoreProduct.swift +// +// Created by Nacho Soto on 12/20/21. + +import StoreKit + +internal struct SK1StoreProduct: StoreProductType { + + init(sk1Product: SK1Product) { + self.underlyingSK1Product = sk1Product + } + + let underlyingSK1Product: SK1Product + private let priceFormatterProvider: PriceFormatterProvider = .init() + + var productCategory: StoreProduct.ProductCategory { + guard #available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) else { + return .nonSubscription + } + + return self.subscriptionPeriod == nil + ? .nonSubscription + : .subscription + } + + var productType: StoreProduct.ProductType { + Logger.debug(Strings.storeKit.sk1_no_known_product_type) + + return .defaultType + } + + var localizedDescription: String { return underlyingSK1Product.localizedDescription } + + var currencyCode: String? { return underlyingSK1Product.priceLocale.rc_currencyCode } + + var price: Decimal { return underlyingSK1Product.price as Decimal } + + var localizedPriceString: String { + return self.priceFormatter?.string(from: underlyingSK1Product.price) ?? "" + } + + var productIdentifier: String { return underlyingSK1Product.productIdentifier } + + @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) + var isFamilyShareable: Bool { underlyingSK1Product.isFamilyShareable } + + var localizedTitle: String { underlyingSK1Product.localizedTitle } + + var subscriptionGroupIdentifier: String? { underlyingSK1Product.subscriptionGroupIdentifier } + + var priceFormatter: NumberFormatter? { + return self.priceFormatterProvider.priceFormatterForSK1(with: self.underlyingSK1Product.priceLocale) + } + + var subscriptionPeriod: SubscriptionPeriod? { + guard let skSubscriptionPeriod = underlyingSK1Product.subscriptionPeriod, + skSubscriptionPeriod.numberOfUnits > 0 else { + return nil + } + return SubscriptionPeriod.from(sk1SubscriptionPeriod: skSubscriptionPeriod) + } + + var introductoryDiscount: StoreProductDiscount? { + return self.underlyingSK1Product.introductoryPrice + .flatMap(StoreProductDiscount.init) + } + + var discounts: [StoreProductDiscount] { + return self.underlyingSK1Product.discounts + .compactMap(StoreProductDiscount.init) + } + +} + +extension SK1StoreProduct: Hashable { + + static func == (lhs: SK1StoreProduct, rhs: SK1StoreProduct) -> Bool { + return lhs.underlyingSK1Product == rhs.underlyingSK1Product + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.underlyingSK1Product) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK1StoreProductDiscount.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK1StoreProductDiscount.swift new file mode 100644 index 00000000..11cab705 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK1StoreProductDiscount.swift @@ -0,0 +1,79 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SK1StoreProductDiscount.swift +// +// Created by Nacho Soto on 1/17/22. + +import StoreKit + +@available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) +internal struct SK1StoreProductDiscount: StoreProductDiscountType { + + init?(sk1Discount: SK1ProductDiscount) { + guard let paymentMode = StoreProductDiscount.PaymentMode(skProductDiscountPaymentMode: sk1Discount.paymentMode), + let subscriptionPeriod = SubscriptionPeriod.from(sk1SubscriptionPeriod: sk1Discount.subscriptionPeriod), + let type = StoreProductDiscount.DiscountType.from(sk1Discount: sk1Discount) + else { return nil } + + self.underlyingSK1Discount = sk1Discount + + self.offerIdentifier = sk1Discount.identifier + self.currencyCode = sk1Discount.optionalLocale?.rc_currencyCode + self.price = sk1Discount.price as Decimal + self.paymentMode = paymentMode + self.subscriptionPeriod = subscriptionPeriod + self.numberOfPeriods = sk1Discount.numberOfPeriods + self.type = type + } + + let underlyingSK1Discount: SK1ProductDiscount + + let offerIdentifier: String? + let currencyCode: String? + let price: Decimal + let paymentMode: StoreProductDiscount.PaymentMode + let subscriptionPeriod: SubscriptionPeriod + let numberOfPeriods: Int + let type: StoreProductDiscount.DiscountType + + var localizedPriceString: String { + return self.priceFormatter.string(from: self.underlyingSK1Discount.price) ?? "" + } + + private let priceFormatterProvider: PriceFormatterProvider = .init() + + private var priceFormatter: NumberFormatter { + return self.priceFormatterProvider.priceFormatterForSK1( + with: self.underlyingSK1Discount.optionalLocale ?? .current + ) + } + +} + +// MARK: - Private + +private extension StoreProductDiscount.PaymentMode { + + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + init?(skProductDiscountPaymentMode paymentMode: SKProductDiscount.PaymentMode) { + switch paymentMode { + case .payUpFront: + self = .payUpFront + case .payAsYouGo: + self = .payAsYouGo + case .freeTrial: + self = .freeTrial + @unknown default: + Logger.appleWarning(Strings.storeKit.skunknown_payment_mode(String(paymentMode.rawValue))) + return nil + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK1StoreTransaction.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK1StoreTransaction.swift new file mode 100644 index 00000000..aaf4664c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK1StoreTransaction.swift @@ -0,0 +1,108 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SK1StoreTransaction.swift +// +// Created by Nacho Soto on 1/4/22. + +import StoreKit + +internal struct SK1StoreTransaction: StoreTransactionType { + + init(sk1Transaction: SK1Transaction) { + self.underlyingSK1Transaction = sk1Transaction + + self.productIdentifier = sk1Transaction.productIdentifier ?? "" + self.purchaseDate = sk1Transaction.purchaseDate + self.transactionIdentifier = sk1Transaction.transactionID + self.quantity = sk1Transaction.quantity + } + + let underlyingSK1Transaction: SK1Transaction + + let productIdentifier: String + let purchaseDate: Date + let transactionIdentifier: String + let quantity: Int + + var storefront: Storefront? { + // This is only available on StoreKit 2 transactions. + return nil + } + + internal var jwsRepresentation: String? { + // This is only available on StoreKit 2 transactions. + return nil + } + + internal var environment: StoreEnvironment? { + // This is only available on StoreKit 2 transactions. + return nil + } + + var reason: TransactionReason? { + // StoreKit 1 does not expose a transaction reason. + return nil + } + + var hasKnownPurchaseDate: Bool { + return self.underlyingSK1Transaction.transactionDate != nil + } + + func finish(_ wrapper: PaymentQueueWrapperType, completion: @escaping @Sendable () -> Void) { + wrapper.finishTransaction(self.underlyingSK1Transaction, completion: completion) + } + + var hasKnownTransactionIdentifier: Bool { + return self.underlyingSK1Transaction.transactionIdentifier != nil + } + +} + +extension SKPaymentTransaction { + + var productIdentifier: String? { + guard let payment = self.paymentIfPresent else { return nil } + + guard let productIdentifier = payment.productIdentifier as String?, + !productIdentifier.isEmpty else { + Logger.verbose(Strings.purchase.skpayment_missing_product_identifier) + return nil + } + + return productIdentifier + } + + fileprivate var purchaseDate: Date { + guard let date = self.transactionDate else { + Logger.verbose(Strings.purchase.sktransaction_missing_transaction_date(self.transactionState)) + + return Date(timeIntervalSince1970: 0) + } + + return date + } + + fileprivate var transactionID: String { + guard let identifier = self.transactionIdentifier else { + Logger.verbose(Strings.purchase.sktransaction_missing_transaction_identifier) + + return UUID().uuidString + } + + return identifier + } + + fileprivate var quantity: Int { + // Note: multi-quantity purchases aren't supported. + // Defaulting to `1` if `self.payment` is `nil` (which shouldn't happen) as a reasonable default. + return self.paymentIfPresent?.quantity ?? 1 + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK1Storefront.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK1Storefront.swift new file mode 100644 index 00000000..40ec0b10 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK1Storefront.swift @@ -0,0 +1,31 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SK1Storefront.swift +// +// Created by Nacho Soto on 4/13/22. + +import StoreKit + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, macCatalyst 13.1, *) +internal struct SK1Storefront: StorefrontType { + + init(_ sk1Storefront: SKStorefront) { + self.underlyingSK1Storefront = sk1Storefront + + self.identifier = sk1Storefront.identifier + self.countryCode = sk1Storefront.countryCode + } + + let underlyingSK1Storefront: SKStorefront + + let identifier: String + let countryCode: String + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK2StoreProduct.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK2StoreProduct.swift new file mode 100644 index 00000000..3d37e33a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK2StoreProduct.swift @@ -0,0 +1,135 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SK2StoreProduct.swift +// +// Created by Nacho Soto on 12/20/21. + +import StoreKit + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +internal struct SK2StoreProduct: StoreProductType { + + init(sk2Product: SK2Product) { + self._underlyingSK2Product = .init(sk2Product) + } + + // We can't directly store instances of StoreKit.Product, since that causes + // linking issues in iOS < 15, even with @available checks correctly in place. + // See https://openradar.appspot.com/radar?id=4970535809187840 / https://github.com/apple/swift/issues/58099 + // Those bugs are fixed, but still cause crashes on iOS 12: https://github.com/RevenueCat/purchases-unity/issues/278 + private let _underlyingSK2Product: Box + var underlyingSK2Product: SK2Product { self._underlyingSK2Product.value } + + private let priceFormatterProvider: PriceFormatterProvider = .init() + + var productCategory: StoreProduct.ProductCategory { + return self.productType.productCategory + } + + var productType: StoreProduct.ProductType { + return .init(self.underlyingSK2Product.type) + } + + var localizedDescription: String { underlyingSK2Product.description } + + var currencyCode: String? { self._currencyCodeAndLocale.code } + + var price: Decimal { underlyingSK2Product.price } + + var localizedPriceString: String { underlyingSK2Product.displayPrice } + + var productIdentifier: String { underlyingSK2Product.id } + + var isFamilyShareable: Bool { underlyingSK2Product.isFamilyShareable } + + var localizedTitle: String { underlyingSK2Product.displayName } + + var priceFormatter: NumberFormatter? { + let (currencyCode, locale) = self._currencyCodeAndLocale + + guard let currencyCode else { + Logger.appleError("Can't initialize priceFormatter for SK2 product! Could not find the currency code") + return nil + } + + return self.priceFormatterProvider.priceFormatterForSK2( + withCurrencyCode: currencyCode, + locale: locale ?? .autoupdatingCurrent + ) + } + + var subscriptionGroupIdentifier: String? { + underlyingSK2Product.subscription?.subscriptionGroupID + } + + var subscriptionPeriod: SubscriptionPeriod? { + guard let skSubscriptionPeriod = underlyingSK2Product.subscription?.subscriptionPeriod else { + return nil + } + return SubscriptionPeriod.from(sk2SubscriptionPeriod: skSubscriptionPeriod) + } + + var introductoryDiscount: StoreProductDiscount? { + self.underlyingSK2Product.subscription?.introductoryOffer + .flatMap { StoreProductDiscount(sk2Discount: $0, currencyCode: self.currencyCode) } + } + + var discounts: [StoreProductDiscount] { + (self.underlyingSK2Product.subscription?.promotionalOffers ?? []) + .compactMap { StoreProductDiscount(sk2Discount: $0, currencyCode: self.currencyCode) } + } + +} + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +private extension SK2StoreProduct { + + // swiftlint:disable:next identifier_name + var _currencyCodeAndLocale: (code: String?, locale: Locale?) { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + let format = self.currencyFormat + return (format.currencyCode, format.locale) + } else { + // note: if we ever need more information from the jsonRepresentation object, we + // should use Codable or another decoding method to clean up this code. + let attributes = jsonDict["attributes"] as? [String: Any] + let offers = attributes?["offers"] as? [[String: Any]] + return ( + code: offers?.first?["currencyCode"] as? String, + locale: nil // Not available inside of `jsonRepresentation` + ) + } + } + + private var jsonDict: [String: Any] { + let decoded = try? JSONSerialization.jsonObject(with: self.underlyingSK2Product.jsonRepresentation, options: []) + return decoded as? [String: Any] ?? [:] + } + + // This is marked as `@_backDeploy` but for some reason only returns a non-empty string on iOS 16+. + @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + private var currencyFormat: Decimal.FormatStyle.Currency { + return self.underlyingSK2Product.priceFormatStyle + } + +} + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +extension SK2StoreProduct: Hashable { + + static func == (lhs: SK2StoreProduct, rhs: SK2StoreProduct) -> Bool { + return lhs.underlyingSK2Product == rhs.underlyingSK2Product + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.underlyingSK2Product) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK2StoreProductDiscount.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK2StoreProductDiscount.swift new file mode 100644 index 00000000..d7b17509 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK2StoreProductDiscount.swift @@ -0,0 +1,68 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SK2StoreProductDiscount.swift +// +// Created by Nacho Soto on 1/17/22. + +import StoreKit + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +internal struct SK2StoreProductDiscount: StoreProductDiscountType { + + init?(sk2Discount: SK2ProductDiscount, currencyCode: String?) { + guard let paymentMode = StoreProductDiscount.PaymentMode(subscriptionOfferPaymentMode: sk2Discount.paymentMode), + let subscriptionPeriod = SubscriptionPeriod.from(sk2SubscriptionPeriod: sk2Discount.period), + let type = StoreProductDiscount.DiscountType.from(sk2Discount: sk2Discount) + else { return nil } + + self.underlyingSK2Discount = sk2Discount + + self.offerIdentifier = sk2Discount.id + self.currencyCode = currencyCode + self.price = sk2Discount.price + self.paymentMode = paymentMode + self.subscriptionPeriod = subscriptionPeriod + self.numberOfPeriods = sk2Discount.periodCount + self.type = type + } + + let underlyingSK2Discount: SK2ProductDiscount + + let offerIdentifier: String? + let currencyCode: String? + let price: Decimal + let paymentMode: StoreProductDiscount.PaymentMode + let subscriptionPeriod: SubscriptionPeriod + let numberOfPeriods: Int + let type: StoreProductDiscount.DiscountType + + var localizedPriceString: String { underlyingSK2Discount.displayPrice } +} + +// MARK: - Private + +private extension StoreProductDiscount.PaymentMode { + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + init?(subscriptionOfferPaymentMode paymentMode: Product.SubscriptionOffer.PaymentMode) { + switch paymentMode { + case .payUpFront: + self = .payUpFront + case .payAsYouGo: + self = .payAsYouGo + case .freeTrial: + self = .freeTrial + default: + Logger.appleWarning(Strings.storeKit.skunknown_payment_mode(String(paymentMode.rawValue))) + return nil + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK2StoreTransaction.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK2StoreTransaction.swift new file mode 100644 index 00000000..0d60b98f --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK2StoreTransaction.swift @@ -0,0 +1,67 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SK2StoreTransaction.swift +// +// Created by Nacho Soto on 1/4/22. + +import StoreKit + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +internal struct SK2StoreTransaction: StoreTransactionType { + + /// - Parameter environmentOverride: Overrides the environment from the StoreKit 2 transaction. + /// Used to override the default `Xcode` environment when running tests. + init(sk2Transaction: SK2Transaction, + jwsRepresentation: String, + environmentOverride: StoreEnvironment? = nil) { + self.underlyingSK2Transaction = sk2Transaction + + self.productIdentifier = sk2Transaction.productID + self.purchaseDate = sk2Transaction.purchaseDate + self.transactionIdentifier = String(sk2Transaction.id) + self.quantity = sk2Transaction.purchasedQuantity + self.jwsRepresentation = jwsRepresentation + self.environment = environmentOverride ?? .init(sk2Transaction: sk2Transaction) + + #if swift(>=5.9) + if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) { + self.storefront = .init(sk2Storefront: sk2Transaction.storefront) + self.reason = TransactionReason(sk2TransactionReason: sk2Transaction.reason) + } else { + self.storefront = nil + self.reason = nil + } + #else + self.storefront = nil + self.reason = nil + #endif + } + + let underlyingSK2Transaction: SK2Transaction + + let productIdentifier: String + let purchaseDate: Date + let transactionIdentifier: String + let quantity: Int + let storefront: Storefront? + let jwsRepresentation: String? + var environment: StoreEnvironment? + let reason: TransactionReason? + + var hasKnownPurchaseDate: Bool { return true } + var hasKnownTransactionIdentifier: Bool { return true } + + func finish(_ wrapper: PaymentQueueWrapperType, completion: @escaping @Sendable () -> Void) { + Async.call(with: completion) { + await self.underlyingSK2Transaction.finish() + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK2Storefront.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK2Storefront.swift new file mode 100644 index 00000000..278b1cf1 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SK2Storefront.swift @@ -0,0 +1,31 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SK2Storefront.swift +// +// Created by Nacho Soto on 4/13/22. + +import StoreKit + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +internal struct SK2Storefront: StorefrontType { + + init(_ sk2Storefront: StoreKit.Storefront) { + self.underlyingSK2Storefront = sk2Storefront + + self.identifier = sk2Storefront.id + self.countryCode = sk2Storefront.countryCode + } + + let underlyingSK2Storefront: StoreKit.Storefront + + let identifier: String + let countryCode: String + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/StoreEnvironment.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/StoreEnvironment.swift new file mode 100644 index 00000000..9a45e256 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/StoreEnvironment.swift @@ -0,0 +1,77 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StoreEnvironment.swift +// +// Created by MarkVillacampa on 26/10/23. +// + +import Foundation +import StoreKit + +/// A wrapper for `StoreKit.AppStore.Environment`. +enum StoreEnvironment: String { + + case production + case sandbox + case xcode + +} + +extension StoreEnvironment: Equatable, Codable {} + +// MARK: - Private + +extension StoreEnvironment { + + @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + init?(environment: StoreKit.AppStore.Environment) { + switch environment { + case .production: + self = .production + case .sandbox: + self = .sandbox + case .xcode: + self = .xcode + default: + Logger.appleWarning(Strings.storeKit.sk2_unknown_environment(environment.rawValue)) + return nil + } + } + + init?(environment: String) { + switch environment { + case "Production": + self = .production + case "Sandbox": + self = .sandbox + case "Xcode": + self = .xcode + default: + Logger.appleWarning(Strings.storeKit.sk2_unknown_environment(environment)) + return nil + } + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + init?(sk2Transaction: SK2Transaction) { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + self.init(environment: sk2Transaction.environment) + } else { + #if VISION_OS + self.init(environment: sk2Transaction.environment) + #else + self.init( + environment: sk2Transaction.environmentStringRepresentation + ) + #endif + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/StoreKitWorkarounds.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/StoreKitWorkarounds.swift new file mode 100644 index 00000000..58ca47e5 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/StoreKitWorkarounds.swift @@ -0,0 +1,143 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StoreKitWorkarounds.swift +// +// Created by Nacho Soto on 4/27/22. + +import StoreKit + +@available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) +extension SK1ProductDiscount { + + // See https://github.com/RevenueCat/purchases-ios/issues/1521 + // Despite `SKProductDiscount.priceLocale` being non-optional, StoreKit might return `nil` `NSLocale`s. + // This works around that to make sure the SDK doesn't crash when bridging to `Locale`. + var optionalLocale: Locale? { + guard let locale = self.priceLocale as NSLocale? else { + Logger.appleWarning(Strings.storeKit.sk1_discount_missing_locale) + return nil + } + + return locale as Locale + } + +} + +extension SKPaymentTransaction { + + /// Considering issue https://github.com/RevenueCat/purchases-ios/issues/279, sometimes `payment` + /// and `productIdentifier` can be `nil`, in this case, they must be treated as nullable. + /// Due to that an optional reference is created so that the compiler would allow us to check for nullability. + var paymentIfPresent: SKPayment? { + guard let payment = self.payment as SKPayment? else { + Logger.verbose(Strings.purchase.skpayment_missing_from_skpaymenttransaction) + return nil + } + + return payment + } + +} + +extension SKPayment { + + /// Attempts to find a non-nil `productIdentifier`. + /// + /// Although `SKPayment.productIdentifier` is supposed to be non-nil, we've seen instances where this is not true. + /// To handle this case, we cast `productIdentifier` to `Optional` in order to check nullability. + func extractProductIdentifier(fileName: String = #fileID, + functionName: String = #function, + line: UInt = #line) -> String? { + guard let result = self.productIdentifier as String?, + !result.trimmingWhitespacesAndNewLines.isEmpty else { + Logger.appleWarning(Strings.purchase.payment_identifier_nil, + fileName: fileName, functionName: functionName, line: line) + return nil + } + + return result + } + +} + +extension SubscriptionPeriod { + + /// This function simplifies large numbers of days into months and large numbers + /// of months into years if there are no leftover units after the conversion. + /// + /// Occasionally, StoreKit seems to send back a value 7 days for a 7day trial + /// instead of a value of 1 week for a trial of 7 days in length. + /// Source: https://github.com/RevenueCat/react-native-purchases/issues/348 + internal func normalized() -> SubscriptionPeriod { + switch unit { + case .day: + if value.isMultiple(of: 7) { + let numberOfWeeks = value / 7 + return .init(value: numberOfWeeks, unit: .week) + } + case .month: + if value.isMultiple(of: 12) { + let numberOfYears = value / 12 + return .init(value: numberOfYears, unit: .year) + } + case .week, .year: + break + } + + return self + } +} + +extension ReceiptFetcher { + + func watchOSReceiptURL(_ receiptURL: URL) -> URL? { + // as of watchOS 6.2.8, there's a bug where the receipt is stored in the sandbox receipt location, + // but the appStoreReceiptURL method returns the URL for the production receipt. + // This code replaces "sandboxReceipt" with "receipt" as the last component of the receiptURL so that we get the + // correct receipt. + // This has been filed as radar FB7699277. More info in https://github.com/RevenueCat/purchases-ios/issues/207 + + let firstOSVersionWithoutBug: OperatingSystemVersion = OperatingSystemVersion(majorVersion: 7, + minorVersion: 0, + patchVersion: 0) + let isBelowFirstOSVersionWithoutBug = !self.systemInfo.isOperatingSystemAtLeast(firstOSVersionWithoutBug) + + if isBelowFirstOSVersionWithoutBug && self.systemInfo.isSandbox { + let receiptURLFolder: URL = receiptURL.deletingLastPathComponent() + let productionReceiptURL: URL = receiptURLFolder.appendingPathComponent("receipt") + return productionReceiptURL + } else { + return receiptURL + } + } + +} + +extension SKPaymentQueue { + + @available(iOS 14.0, *) + @available(macOS, unavailable) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + @available(macCatalyst, unavailable) + func presentCodeRedemptionSheetIfAvailable() { + // Even though the docs in `SKPaymentQueue.presentCodeRedemptionSheet` + // say that it's available on Catalyst 14.0, there is a note: + // This function doesn’t affect Mac apps built with Mac Catalyst. + // It crashes when called both from Catalyst and also when running as "Designed for iPad". + if self.responds(to: #selector(SKPaymentQueue.presentCodeRedemptionSheet)) { + Logger.debug(Strings.purchase.presenting_code_redemption_sheet) + self.presentCodeRedemptionSheet() + } else { + Logger.appleError(Strings.purchase.unable_to_present_redemption_sheet) + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/StoreProduct.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/StoreProduct.swift new file mode 100644 index 00000000..c5e7cacf --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/StoreProduct.swift @@ -0,0 +1,370 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// opensource.org/licenses/MIT +// +// StoreProduct.swift +// +// Created by Andrés Boedo on 7/16/21. +// + +import Foundation +import StoreKit + +/// TypeAlias to StoreKit 1's Product type, called `StoreKit/SKProduct` +public typealias SK1Product = SKProduct + +/// TypeAlias to StoreKit 2's Product type, called `StoreKit.Product` +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +public typealias SK2Product = StoreKit.Product + +// It's an @objc wrapper of a `StoreProductType`. Swift-only code can use the protocol directly. +/// Type that provides access to all of `StoreKit`'s product type's properties. +@objc(RCStoreProduct) public final class StoreProduct: NSObject, StoreProductType { + + let product: StoreProductType + + /// Designated initializer. + /// - SeeAlso: ``StoreProduct.from(product:)`` to wrap an instance of `StoreProduct` + private init(_ product: StoreProductType) { + self.product = product + + super.init() + + if self.localizedTitle.isEmpty { + Logger.warn(Strings.offering.product_details_empty_title(productIdentifier: self.productIdentifier)) + } + } + + /// Creates an instance from any `StoreProductType`. + /// If `product` is already a wrapped `StoreProduct` then this returns it instead. + static func from(product: StoreProductType) -> StoreProduct { + return product as? StoreProduct + ?? StoreProduct(product) + } + + public override func isEqual(_ object: Any?) -> Bool { + return self.productIdentifier == (object as? StoreProductType)?.productIdentifier + } + + public override var hash: Int { + var hasher = Hasher() + hasher.combine(self.productIdentifier) + + return hasher.finalize() + } + + // Note: this class inherits its docs from `StoreProductType` + // swiftlint:disable missing_docs + + @objc public var productType: ProductType { self.product.productType } + + @objc public var productCategory: ProductCategory { self.product.productCategory } + + @objc public var localizedDescription: String { self.product.localizedDescription } + + @objc public var localizedTitle: String { self.product.localizedTitle } + + @objc public var currencyCode: String? { self.product.currencyCode } + + // See also `priceDecimalNumber` for Objective-C + public var price: Decimal { self.product.price } + + @objc public var localizedPriceString: String { self.product.localizedPriceString} + + @objc public var productIdentifier: String { self.product.productIdentifier } + + @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) + @objc public var isFamilyShareable: Bool { self.product.isFamilyShareable } + + @objc public var subscriptionGroupIdentifier: String? { self.product.subscriptionGroupIdentifier} + + @objc public var priceFormatter: NumberFormatter? { self.product.priceFormatter } + + @objc public var subscriptionPeriod: SubscriptionPeriod? { self.product.subscriptionPeriod } + + @objc public var introductoryDiscount: StoreProductDiscount? { self.product.introductoryDiscount } + + @objc public var discounts: [StoreProductDiscount] { self.product.discounts } + + // switflint:enable missing_docs +} + +/// Type that provides access to all of `StoreKit`'s product type's properties. +internal protocol StoreProductType: Sendable { + + /// The category of this product, whether a subscription or a one-time purchase. + + /// ### Related Symbols: + /// - ``StoreProduct/productType-swift.property`` + var productCategory: StoreProduct.ProductCategory { get } + + /// The type of product. + /// - Important: `StoreProduct`s backing SK1 products cannot determine the type. + /// + /// ### Related Symbols: + /// - ``StoreProduct/productCategory-swift.property`` + var productType: StoreProduct.ProductType { get } + + /// A description of the product. + /// - Note: The description's language is determined by the storefront that the user's device is connected to, + /// not the preferred language set on the device. + var localizedDescription: String { get } + + /// The name of the product. + /// - Note: The title's language is determined by the storefront that the user's device is connected to, + /// not the preferred language set on the device. + var localizedTitle: String { get } + + /// The currency of the product's price. + var currencyCode: String? { get } + + /// The decimal representation of the cost of the product, in local currency. + /// For a string representation of the price to display to customers, use ``localizedPriceString``. + /// + /// #### Related Symbols + /// - ``StoreProduct/pricePerWeek`` + /// - ``StoreProduct/pricePerMonth`` + /// - ``StoreProduct/pricePerYear`` + var price: Decimal { get } + + /// The price of this product using ``StoreProduct/priceFormatter``. + var localizedPriceString: String { get } + + /// The string that identifies the product to the Apple App Store. + var productIdentifier: String { get } + + /// A Boolean value that indicates whether the product is available for family sharing in App Store Connect. + /// Check the value of `isFamilyShareable` to learn whether an in-app purchase is sharable with the family group. + /// + /// When displaying in-app purchases in your app, indicate whether the product includes Family Sharing + /// to help customers make a selection that best fits their needs. + /// + /// Configure your in-app purchases to allow Family Sharing in App Store Connect. + /// For more information about setting up Family Sharing, see Turn-on Family Sharing for in-app purchases. + /// + /// #### Related Articles + /// - https://support.apple.com/en-us/HT201079 + @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) + var isFamilyShareable: Bool { get } + + /// The identifier of the subscription group to which the subscription belongs. + /// All auto-renewable subscriptions must be a part of a group. + /// You create the group identifiers in App Store Connect. + /// This property is `nil` if the product is not an auto-renewable subscription. + @available(iOS 12.0, macCatalyst 13.0, tvOS 12.0, macOS 10.14, watchOS 6.2, *) + var subscriptionGroupIdentifier: String? { get } + + /// Provides a `NumberFormatter`, useful for formatting the price for displaying. + /// - Note: This creates a new formatter for every product, which can be slow. + /// - Note: This will only be `nil` for StoreKit 2 backed products before iOS 16 + /// if the currency code could not be determined. In every other instance, it will never be `nil`. + var priceFormatter: NumberFormatter? { get } + + /// The period details for products that are subscriptions. + /// - Returns: `nil` if the product is not a subscription. + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + var subscriptionPeriod: SubscriptionPeriod? { get } + + /// The object containing introductory price information for the product. + /// If you've set up introductory prices in App Store Connect, the introductory price property will be populated. + /// This property is `nil` if the product has no introductory price. + /// + /// Before displaying UI that offers the introductory price, + /// you must first determine if the user is eligible to receive it. + /// #### Related Symbols + /// - ``Purchases/checkTrialOrIntroDiscountEligibility(productIdentifiers:)`` to determine eligibility. + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + var introductoryDiscount: StoreProductDiscount? { get } + + /// An array of subscription offers available for the auto-renewable subscription. + /// - Note: the current user may or may not be eligible for some of these. + /// #### Related Symbols + /// - ``Purchases/promotionalOffer(forProductDiscount:product:)`` + /// - ``Purchases/getPromotionalOffer(forProductDiscount:product:completion:)`` + /// - ``Purchases/eligiblePromotionalOffers(forProduct:)`` + /// - ``StoreProduct/eligiblePromotionalOffers()`` + @available(iOS 12.2, macOS 10.14.4, tvOS 12.2, watchOS 6.2, *) + var discounts: [StoreProductDiscount] { get } + +} + +public extension StoreProduct { + + /// The decimal representation of the cost of the product, in local currency. + /// For a string representation of the price to display to customers, use ``localizedPriceString``. + /// - Note: this is meant for Objective-C. For Swift, use ``price`` instead. + /// + /// #### Related Symbols + /// - ``pricePerWeek`` + /// - ``pricePerMonth`` + /// - ``pricePerYear`` + @objc(price) var priceDecimalNumber: NSDecimalNumber { + return self.price as NSDecimalNumber + } + + /// Calculates the price of this subscription product per day. + /// - Returns: `nil` if the product is not a subscription. + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + @objc var pricePerDay: NSDecimalNumber? { + return self.subscriptionPeriod?.pricePerDay(withTotalPrice: self.price) as NSDecimalNumber? + } + + /// Calculates the price of this subscription product per week. + /// - Returns: `nil` if the product is not a subscription. + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + @objc var pricePerWeek: NSDecimalNumber? { + return self.subscriptionPeriod?.pricePerWeek(withTotalPrice: self.price) as NSDecimalNumber? + } + + /// Calculates the price of this subscription product per month. + /// - Returns: `nil` if the product is not a subscription. + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + @objc var pricePerMonth: NSDecimalNumber? { + return self.subscriptionPeriod?.pricePerMonth(withTotalPrice: self.price) as NSDecimalNumber? + } + + /// Calculates the price of this subscription product per year. + /// - Returns: `nil` if the product is not a subscription. + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + @objc var pricePerYear: NSDecimalNumber? { + return self.subscriptionPeriod?.pricePerYear(withTotalPrice: self.price) as NSDecimalNumber? + } + + /// The price of the `introductoryPrice` formatted using ``priceFormatter``. + /// - Returns: `nil` if there is no `introductoryPrice`. + @objc var localizedIntroductoryPriceString: String? { + guard #available(iOS 12.2, macOS 10.14.4, tvOS 12.2, watchOS 6.2, *) else { return nil } + return self.formattedString(for: self.introductoryDiscount?.priceDecimalNumber) + } + + /// The formatted price per week using ``StoreProduct/priceFormatter``. + /// ### Related Symbols + /// - ``pricePerWeek`` + /// - ``localizedPricePerMonth`` + /// - ``localizedPricePerYear`` + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + @objc var localizedPricePerDay: String? { + return self.formattedString(for: self.pricePerDay) + } + + /// The formatted price per week using ``StoreProduct/priceFormatter``. + /// ### Related Symbols + /// - ``pricePerWeek`` + /// - ``localizedPricePerMonth`` + /// - ``localizedPricePerYear`` + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + @objc var localizedPricePerWeek: String? { + return self.formattedString(for: self.pricePerWeek) + } + + /// The formatted price per month using ``StoreProduct/priceFormatter``. + /// ### Related Symbols + /// - ``pricePerMonth`` + /// - ``localizedPricePerWeek`` + /// - ``localizedPricePerYear`` + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + @objc var localizedPricePerMonth: String? { + return self.formattedString(for: self.pricePerMonth) + } + + /// The formatted price per year using ``StoreProduct/priceFormatter``. + /// ### Related Symbols + /// - ``pricePerYear`` + /// - ``localizedPricePerWeek`` + /// - ``localizedPricePerMonth`` + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + @objc var localizedPricePerYear: String? { + return self.formattedString(for: self.pricePerYear) + } + +} + +#if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION +public extension StoreProduct { + /// Finds the subset of ``discounts`` that's eligible for the current user. + /// - Note: if checking for eligibility for a `StoreProductDiscount` fails (for example, if network is down), + /// that discount will fail silently and be considered not eligible. + /// - Warning: this method implicitly relies on ``Purchases`` already being initialized. + /// #### Related Symbols + /// - ``discounts`` + func eligiblePromotionalOffers() async -> [PromotionalOffer] { + return await Purchases.shared.eligiblePromotionalOffers(forProduct: self) + } +} +#endif + +// MARK: - Wrapper constructors / getters + +extension StoreProduct { + + @objc + public convenience init(sk1Product: SK1Product) { + self.init(SK1StoreProduct(sk1Product: sk1Product)) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + public convenience init(sk2Product: SK2Product) { + self.init(SK2StoreProduct(sk2Product: sk2Product)) + } + + /// Returns the `SKProduct` if this `StoreProduct` represents a `StoreKit.SKProduct`. + @objc public var sk1Product: SK1Product? { + return (self.product as? SK1StoreProduct)?.underlyingSK1Product + } + + /// Returns the `Product` if this `StoreProduct` represents a `StoreKit.Product`. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + public var sk2Product: SK2Product? { + return (self.product as? SK2StoreProduct)?.underlyingSK2Product + } + + /// Returns the `TestStoreProduct` if this `StoreProduct` represents a `TestStoreProduct`. + internal var testStoreProduct: TestStoreProduct? { + return self.product as? TestStoreProduct + } + +} + +// MARK: - Renames + +// @available annotations to help users migrating from `SKProduct` to `StoreProduct` +public extension StoreProduct { + + /// The object containing introductory price information for the product. + @available(iOS, introduced: 11.2, unavailable, + renamed: "introductoryDiscount", message: "Use StoreProductDiscount instead") + @available(tvOS, introduced: 11.2, unavailable, + renamed: "introductoryDiscount", message: "Use StoreProductDiscount instead") + @available(watchOS, introduced: 6.2, unavailable, + renamed: "introductoryDiscount", message: "Use StoreProductDiscount instead") + @available(macOS, introduced: 10.13.2, unavailable, + renamed: "introductoryDiscount", message: "Use StoreProductDiscount instead") + @objc var introductoryPrice: SKProductDiscount? { fatalError() } + + /// The locale used to format the price of the product. + @available(iOS, unavailable, message: "Use localizedPriceString instead") + @available(tvOS, unavailable, message: "Use localizedPriceString instead") + @available(watchOS, unavailable, message: "Use localizedPriceString instead") + @available(macOS, unavailable, message: "Use localizedPriceString instead") + @objc var priceLocale: Locale { fatalError() } + +} + +private extension StoreProduct { + + func formattedString(for price: NSDecimalNumber?) -> String? { + guard let formatter = self.priceFormatter, + let price = price + else { + return nil + } + + return formatter.string(from: price as NSDecimalNumber) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/StoreProductDiscount.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/StoreProductDiscount.swift new file mode 100644 index 00000000..01542112 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/StoreProductDiscount.swift @@ -0,0 +1,351 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StoreProductDiscount.swift +// +// Created by Joshua Liebowitz on 7/2/21. +// + +import Foundation +import StoreKit + +/// TypeAlias to StoreKit 1's Discount type, called `SKProductDiscount` +@available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) +public typealias SK1ProductDiscount = SKProductDiscount + +/// TypeAlias to StoreKit 2's Discount type, called `StoreKit.Product.SubscriptionOffer` +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +public typealias SK2ProductDiscount = StoreKit.Product.SubscriptionOffer + +/// Type that wraps `StoreKit.Product.SubscriptionOffer` and `SKProductDiscount` +/// and provides access to their properties. +/// Information about a subscription offer that you configured in App Store Connect. +@objc(RCStoreProductDiscount) +public final class StoreProductDiscount: NSObject { + + /// The payment mode for a `StoreProductDiscount` + /// Indicates how the product discount price is charged. + @objc(RCPaymentMode) + public enum PaymentMode: Int { + + /// Price is charged one or more times + case payAsYouGo = 0 + /// Price is charged once in advance + case payUpFront = 1 + /// No initial charge + case freeTrial = 2 + + } + + /// The discount type for a `StoreProductDiscount` + /// Wraps `SKProductDiscount.Type` if this `StoreProductDiscount` represents a `SKProductDiscount`. + /// Wraps `Product.SubscriptionOffer.OfferType` if this `StoreProductDiscount` represents + /// a `Product.SubscriptionOffer`. + @objc(RCDiscountType) + public enum DiscountType: Int { + + /// Introductory offer + case introductory = 0 + /// Promotional offer for subscriptions + case promotional = 1 + /// Win-back offers + case winBack = 2 + } + + private let discount: StoreProductDiscountType + + init(_ discount: StoreProductDiscountType) { + self.discount = discount + + super.init() + } + + // Note: this class inherits its docs from `StoreProductDiscountType` + // swiftlint:disable missing_docs + + @objc public var offerIdentifier: String? { self.discount.offerIdentifier } + @objc public var currencyCode: String? { self.discount.currencyCode } + // See also `priceDecimalNumber` for Objective-C + public var price: Decimal { self.discount.price } + @objc public var localizedPriceString: String { self.discount.localizedPriceString } + @objc public var paymentMode: PaymentMode { self.discount.paymentMode } + @objc public var subscriptionPeriod: SubscriptionPeriod { self.discount.subscriptionPeriod } + @objc public var numberOfPeriods: Int { self.discount.numberOfPeriods } + @objc public var type: DiscountType { self.discount.type } + + // swiftlint:enable missing_docs + + /// Creates an instance from any `StoreProductDiscountType`. + /// If `discount` is already a wrapped `StoreProductDiscount` then this returns it instead. + static func from(discount: StoreProductDiscountType) -> StoreProductDiscount { + return discount as? StoreProductDiscount + ?? StoreProductDiscount(discount) + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? StoreProductDiscountType else { return false } + + return Data(discount: self) == Data(discount: other) + } + + public override var hash: Int { + return Data(discount: self).hashValue + } + + public override var description: String { + return """ + <\(String(describing: StoreProductDiscount.self)): + offerIdentifier: \(self.offerIdentifier ?? "") + currencyCode: \(self.currencyCode ?? "") + price: \(self.price) + localizedPriceString: \(self.localizedPriceString) + paymentMode: \(self.paymentMode) + subscriptionPeriod: \(self.subscriptionPeriod) + numberOfPeriods: \(self.numberOfPeriods) + type: \(self.type) + > + """ + } + +} + +@_spi(Internal) extension StoreProductDiscount: StoreProductDiscountType { } + +extension StoreProductDiscount { + // swiftlint:disable:next missing_docs + @_spi(Internal) public func promotionalOffer(withSignedDataIdentifier identifier: String, + keyIdentifier: String, + nonce: UUID, + signature: String, + timestamp: Int) -> PromotionalOffer { + let signedData = PromotionalOffer.SignedData(identifier: identifier, + keyIdentifier: keyIdentifier, + nonce: nonce, + signature: signature, + timestamp: timestamp) + return PromotionalOffer(discount: self, signedData: signedData) + } +} + +extension StoreProductDiscount: Sendable {} +extension StoreProductDiscount.PaymentMode: Sendable {} +extension StoreProductDiscount.DiscountType: Sendable {} + +public extension StoreProductDiscount { + + /// The discount price of the product in the local currency. + /// - Note: this is meant for Objective-C. For Swift, use ``price`` instead. + @objc(price) var priceDecimalNumber: NSDecimalNumber { + return self.price as NSDecimalNumber + } + +} + +extension StoreProductDiscount { + + /// Used to represent `StoreProductDiscount/id`. Not for public use. + public struct Data: Hashable { + private var offerIdentifier: String? + private var currencyCode: String? + private var price: Decimal + private var localizedPriceString: String + private var paymentMode: StoreProductDiscount.PaymentMode + private var subscriptionPeriod: SubscriptionPeriod + private var numberOfPeriods: Int + private var type: StoreProductDiscount.DiscountType + + fileprivate init(discount: StoreProductDiscountType) { + self.offerIdentifier = discount.offerIdentifier + self.currencyCode = discount.currencyCode + self.price = discount.price + self.localizedPriceString = discount.localizedPriceString + self.paymentMode = discount.paymentMode + self.subscriptionPeriod = discount.subscriptionPeriod + self.numberOfPeriods = discount.numberOfPeriods + self.type = discount.type + } + } + +} + +/// The details of an introductory offer or a promotional offer for an auto-renewable subscription. +@_spi(Internal) public protocol StoreProductDiscountType: Sendable { + + // Note: this is only `nil` for SK1 products. + // It can become `String` once it's not longer supported. + /// A string used to uniquely identify a discount offer for a product. + var offerIdentifier: String? { get } + + /// The currency of the product's price. + var currencyCode: String? { get } + + /// The discount price of the product in the local currency. + var price: Decimal { get } + + /// The price of this product discount formatted for locale. + var localizedPriceString: String { get } + + /// The payment mode for this product discount. + var paymentMode: StoreProductDiscount.PaymentMode { get } + + /// The period for the product discount. + var subscriptionPeriod: SubscriptionPeriod { get } + + /// The number of periods the product discount is available. + /// This is `1` for ``StoreProductDiscount/PaymentMode-swift.enum/payUpFront`` + /// and ``StoreProductDiscount/PaymentMode-swift.enum/freeTrial``, but can be + /// more than 1 for ``StoreProductDiscount/PaymentMode-swift.enum/payAsYouGo``. + /// + /// - Note: + /// A product discount may be available for one or more periods. + /// The period, defined in `subscriptionPeriod`, is a set number of days, weeks, months, or years. + /// The total length of time that a product discount is available is calculated by + /// multiplying the `numberOfPeriods` by the period. + /// Note that the discount period is independent of the product subscription period. + var numberOfPeriods: Int { get } + + /// The type of product discount. + var type: StoreProductDiscount.DiscountType { get } + +} + +// MARK: - Wrapper constructors / getters + +extension StoreProductDiscount { + + internal convenience init?(sk1Discount: SK1ProductDiscount) { + guard let discount = SK1StoreProductDiscount(sk1Discount: sk1Discount) else { return nil } + + self.init(discount) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + internal convenience init?(sk2Discount: SK2ProductDiscount, currencyCode: String?) { + guard let discount = SK2StoreProductDiscount(sk2Discount: sk2Discount, + currencyCode: currencyCode) else { return nil } + + self.init(discount) + } + + /// Returns the `SK1ProductDiscount` if this `StoreProductDiscount` represents a `SKProductDiscount`. + @objc public var sk1Discount: SK1ProductDiscount? { + return (self.discount as? SK1StoreProductDiscount)?.underlyingSK1Discount + } + + /// Returns the `SK2ProductDiscount` if this `StoreProductDiscount` represents a `Product.SubscriptionOffer`. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + public var sk2Discount: SK2ProductDiscount? { + return (self.discount as? SK2StoreProductDiscount)?.underlyingSK2Discount + } + +} + +// MARK: - Encodable + +extension StoreProductDiscount: Encodable { + + private enum CodingKeys: String, CodingKey { + + case offerIdentifier + case price + case paymentMode + + } + + // swiftlint:disable:next missing_docs + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(self.offerIdentifier, forKey: .offerIdentifier) + // Note: price is encoded as `String` (using `NSDecimalNumber.description`) + // to preserve precision and avoid values like "1.89999999" + try container.encode((self.price as NSDecimalNumber).description, forKey: .price) + try container.encode(self.paymentMode, forKey: .paymentMode) + } + +} + +extension StoreProductDiscount.DiscountType { + + static func from(sk1Discount: SK1ProductDiscount) -> Self? { + switch sk1Discount.type { + case .introductory: + return .introductory + case .subscription: + return .promotional + @unknown default: + return nil + } + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + static func from(sk2Discount: SK2ProductDiscount) -> Self? { + switch sk2Discount.type { + case SK2ProductDiscount.OfferType.introductory: + return .introductory + case SK2ProductDiscount.OfferType.promotional: + return .promotional + default: + + // winBack discount type was added in iOS 18.0, but it's not recognized by Xcode versions <16.0. + #if compiler(>=6.0) + if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *), + case .winBack = sk2Discount.type { + return .winBack + } + #endif + + Logger.warn(Strings.storeKit.unknown_sk2_product_discount_type(rawValue: sk2Discount.type.rawValue)) + return nil + } + } + +} + +extension StoreProductDiscount.PaymentMode: Codable {} +extension StoreProductDiscount.DiscountType: Codable {} + +extension StoreProductDiscount: Identifiable { + + /// The stable identity of the entity associated with this instance. + public var id: Data { return Data(discount: self) } + +} + +public extension StoreProductDiscount { + + /// Calculates the approximate price of this subscription product per day. + /// - Returns: `nil` if the product is not a subscription. + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + @objc var pricePerDay: NSDecimalNumber? { + return self.subscriptionPeriod.pricePerDay(withTotalPrice: self.price) as NSDecimalNumber? + } + + /// Calculates the approximate price of this subscription product per week. + /// - Returns: `nil` if the product is not a subscription. + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + @objc var pricePerWeek: NSDecimalNumber? { + return self.subscriptionPeriod.pricePerWeek(withTotalPrice: self.price) as NSDecimalNumber? + } + + /// Calculates the approximate price of this subscription product per month. + /// - Returns: `nil` if the product is not a subscription. + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + @objc var pricePerMonth: NSDecimalNumber? { + return self.subscriptionPeriod.pricePerMonth(withTotalPrice: self.price) as NSDecimalNumber? + } + + /// Calculates the approximate price of this subscription product per year. + /// - Returns: `nil` if the product is not a subscription. + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + @objc var pricePerYear: NSDecimalNumber? { + return self.subscriptionPeriod.pricePerYear(withTotalPrice: self.price) as NSDecimalNumber? + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/StoreTransaction.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/StoreTransaction.swift new file mode 100644 index 00000000..16de8213 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/StoreTransaction.swift @@ -0,0 +1,211 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StoreTransaction.swift +// +// Created by Andrés Boedo on 2/12/21. + +import Foundation +import StoreKit + +/// TypeAlias to StoreKit 1's Transaction type, called `StoreKit.SKPaymentTransaction` +public typealias SK1Transaction = SKPaymentTransaction + +/// TypeAlias to StoreKit 2's Transaction type, called `StoreKit.Transaction` +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +public typealias SK2Transaction = StoreKit.Transaction + +/// Abstract class that provides access to properties of a transaction. +/// ``StoreTransaction``s can represent transactions from StoreKit 1, StoreKit 2 or +/// transactions made from other places, like Stripe, Google Play or Amazon Store. +@objc(RCStoreTransaction) public final class StoreTransaction: NSObject, StoreTransactionType { + + private let transaction: StoreTransactionType + + init(_ transaction: StoreTransactionType) { + self.transaction = transaction + + super.init() + } + + // Note: docs are inherited through `StoreTransactionType` + // swiftlint:disable missing_docs + + @objc public var productIdentifier: String { self.transaction.productIdentifier } + @objc public var purchaseDate: Date { self.transaction.purchaseDate } + @objc public var transactionIdentifier: String { self.transaction.transactionIdentifier } + @objc public var quantity: Int { self.transaction.quantity } + @objc public var storefront: Storefront? { self.transaction.storefront } + @objc internal var jwsRepresentation: String? { self.transaction.jwsRepresentation } + internal var environment: StoreEnvironment? { self.transaction.environment } + internal var reason: TransactionReason? { self.transaction.reason } + + var hasKnownPurchaseDate: Bool { return self.transaction.hasKnownPurchaseDate } + var hasKnownTransactionIdentifier: Bool { self.transaction.hasKnownTransactionIdentifier } + + func finish(_ wrapper: PaymentQueueWrapperType, completion: @escaping @Sendable () -> Void) { + self.transaction.finish(wrapper, completion: completion) + } + + // swiftlint:enable missing_docs + + /// Creates an instance from any `StoreTransactionType`. + /// If `transaction` is already a wrapped `StoreTransaction` then this returns it instead. + static func from(transaction: StoreTransactionType) -> StoreTransaction { + return transaction as? StoreTransaction + ?? StoreTransaction(transaction) + } + + public override func isEqual(_ object: Any?) -> Bool { + self.transactionIdentifier == (object as? StoreTransactionType)?.transactionIdentifier + } + + public override var hash: Int { + var hasher = Hasher() + hasher.combine(self.transactionIdentifier) + + return hasher.finalize() + } + + public override var description: String { + return """ + <\(String(describing: StoreTransaction.self)): + identifier="\(self.transactionIdentifier)" + product="\(self.productIdentifier)" + date="\(self.purchaseDate)" + quantity=\(self.quantity) + > + """ + } + +} + +/// Information that represents the customer’s purchase of a product. +internal protocol StoreTransactionType: Sendable { + + /// The product identifier. + var productIdentifier: String { get } + + /// The date that App Store charged the user’s account for a purchased or restored product, + /// or for a subscription purchase or renewal after a lapse. + var purchaseDate: Date { get } + + /// Whether the underlying transaction has a non-nil purchase date. + /// See `SK1StoreTransaction/purchaseDate`` + var hasKnownPurchaseDate: Bool { get } + + /// The unique identifier for the transaction. + var transactionIdentifier: String { get } + + /// Whether the underlying transaction has a known transaction identifier. + /// See `SKPaymentTransaction.transactionID`. + var hasKnownTransactionIdentifier: Bool { get } + + /// The number of consumable products purchased. + /// - Note: multi-quantity purchases aren't currently supported. + var quantity: Int { get } + + /// The App Store storefront associated with the transaction. + /// - Note: this is only available for StoreKit 2 transactions starting with iOS 17. + var storefront: Storefront? { get } + + /// The raw JWS repesentation of the transaction. + /// - Note: this is only available for StoreKit 2 transactions. + var jwsRepresentation: String? { get } + + /// The server environment where the receipt was generated. + /// - Note: this is only available for StoreKit 2 transactions. + var environment: StoreEnvironment? { get } + + /// The reason for the transaction, if known. + /// - Note: this is only available for StoreKit 2 transactions starting with iOS 17. + var reason: TransactionReason? { get } + + /// Indicates to the App Store that the app delivered the purchased content + /// or enabled the service to finish the transaction. + func finish(_ wrapper: PaymentQueueWrapperType, completion: @escaping @Sendable () -> Void) + +} + +// MARK: - Wrapper constructors / getters + +extension StoreTransaction { + + /// Creates a `StoreTransaction` instance for testing purposes. + /// - Parameters: + /// - productIdentifier: The product identifier. + /// - purchaseDate: The date that the user's account was charged for a purchased or restored product. + /// - transactionIdentifier: The unique identifier for the transaction. + /// - quantity: The number of consumable products purchased. Defaults to 1. + /// - storefront: The App Store storefront associated with the transaction. Defaults to `nil`. + public convenience init( + productIdentifier: String, + purchaseDate: Date, + transactionIdentifier: String, + quantity: Int = 1, + storefront: Storefront? = nil + ) { + self.init( + TestStoreTransaction( + productIdentifier: productIdentifier, + purchaseDate: purchaseDate, + transactionIdentifier: transactionIdentifier, + quantity: quantity, + storefront: storefront + ) + ) + } + + internal convenience init(sk1Transaction: SK1Transaction) { + self.init(SK1StoreTransaction(sk1Transaction: sk1Transaction)) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + internal convenience init(sk2Transaction: SK2Transaction, + jwsRepresentation: String, + environmentOverride: StoreEnvironment? = nil) { + self.init(SK2StoreTransaction(sk2Transaction: sk2Transaction, + jwsRepresentation: jwsRepresentation, + environmentOverride: environmentOverride)) + } + + /// Returns the `SKPaymentTransaction` if this `StoreTransaction` represents a `SKPaymentTransaction`. + @objc public var sk1Transaction: SK1Transaction? { + return (self.transaction as? SK1StoreTransaction)?.underlyingSK1Transaction + } + + /// Returns the `StoreKit.Transaction` if this `StoreTransaction` represents a `StoreKit.Transaction`. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + public var sk2Transaction: SK2Transaction? { + return (self.transaction as? SK2StoreTransaction)?.underlyingSK2Transaction + } + + internal var simulatedStoreTransaction: SimulatedStoreTransaction? { + return self.transaction as? SimulatedStoreTransaction + } + +} + +extension StoreTransactionType { + + /// - Returns: the `Storefront` associated to this transaction, or `Storefront.currentStorefront` if not available. + var storefrontOrCurrent: Storefront? { + get async { + return await self.storefront ??? (await Storefront.currentStorefront) + } + } + +} + +extension StoreTransaction: Identifiable { + + /// The stable identity of the entity associated with this instance. + public var id: String { return self.transactionIdentifier } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/Storefront.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/Storefront.swift new file mode 100644 index 00000000..6bab6bbe --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/Storefront.swift @@ -0,0 +1,162 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Storefront.swift +// +// Created by Nacho Soto on 4/13/22. + +import Foundation +import StoreKit + +/// An object containing the location and unique identifier of an Apple App Store storefront. +/// +/// - Note: Don't save the storefront information with your user information; storefront information can change +/// at any time. Always get the storefront identifier immediately before you display product information or availability +/// to the user in your app. Storefront information may not be used to develop or enhance a user profile, +/// or track customers for advertising or marketing purposes. +@objc(RCStorefront) +public final class Storefront: NSObject, StorefrontType { + + private let storefront: StorefrontType + + init(_ storefront: StorefrontType) { + self.storefront = storefront + + super.init() + } + + // Note: this class inherits its docs from `StorefrontType` + // swiftlint:disable missing_docs + + @objc public var countryCode: String { self.storefront.countryCode } + @objc public var identifier: String { self.storefront.identifier } + + // swiftlint:enable missing_docs + + /// A locale containing the region information but no language code representing + /// the region for the App Store storefront. + @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + @_spi(Experimental) @objc public var locale: Locale { + Locale(components: .init( + languageCode: nil, + script: nil, + languageRegion: .init(self.storefront.countryCode) + )) + } + + // MARK: - + + /// Creates an instance from any `StorefrontType`. + /// If `storefront` is already a wrapped `Storefront` then this returns it instead. + static func from(storefront: StorefrontType) -> Storefront { + return storefront as? Storefront + ?? Storefront(storefront) + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? StorefrontType else { return false } + + return self.identifier == other.identifier + } + + public override var hash: Int { + return self.identifier.hashValue + } + + public override var description: String { + return """ + <\(String(describing: Storefront.self)): + identifier=\(self.identifier), + countryCode=\(countryCode) + > + """ + } + +} + +extension Storefront: Sendable {} + +public extension Storefront { + + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, macCatalyst 13.1, *) + private static var currentStorefrontType: StorefrontType? { + get async { + if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { + let sk2Storefront = await StoreKit.Storefront.current + return sk2Storefront.map(SK2Storefront.init) + } else { + return Self.sk1CurrentStorefrontType + } + } + } + + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, macCatalyst 13.1, *) + internal static var sk1CurrentStorefrontType: StorefrontType? { + return SKPaymentQueue.default().storefront.map(SK1Storefront.init) + } + + /// The current App Store storefront for the device. + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, macCatalyst 13.1, *) + static var currentStorefront: Storefront? { + get async { + return await self.currentStorefrontType.map(Storefront.from(storefront: )) + } + } + + /// The current App Store storefront for the device obtained from StoreKit 1 only. + @available(swift, obsoleted: 0.0.1, renamed: "currentStorefront") + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, macCatalyst 13.1, *) + @objc static var sk1CurrentStorefront: Storefront? { + return self.sk1CurrentStorefrontType.map(Storefront.from(storefront: )) + } + +} + +// MARK: - + +/// A type containing the location and unique identifier of an Apple App Store storefront. +internal protocol StorefrontType: Sendable { + + /// The three-letter code representing the country or region + /// associated with the App Store storefront. + /// - Note: This property uses the ISO 3166-1 Alpha-3 country code representation. + var countryCode: String { get } + + /// A value defined by Apple that uniquely identifies an App Store storefront. + var identifier: String { get } + +} + +// MARK: - Wrapper constructors / getters + +extension Storefront { + + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, macCatalyst 13.1, *) + internal convenience init(sk1Storefront: SKStorefront) { + self.init(SK1Storefront(sk1Storefront)) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + internal convenience init(sk2Storefront: StoreKit.Storefront) { + self.init(SK2Storefront(sk2Storefront)) + } + + /// Returns the `SKStorefront` if this `Storefront` represents an `SKStorefront`. + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, macCatalyst 13.1, *) + @objc public var sk1Storefront: SKStorefront? { + return (self.storefront as? SK1Storefront)?.underlyingSK1Storefront + } + + /// Returns the `StoreKit.Storefront` if this `Storefront` represents a `StoreKit.Storefront`. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + public var sk2Storefront: StoreKit.Storefront? { + return (self.storefront as? SK2Storefront)?.underlyingSK2Storefront + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/StorefrontProvider.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/StorefrontProvider.swift new file mode 100644 index 00000000..030facde --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/StorefrontProvider.swift @@ -0,0 +1,35 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StorefrontProvider.swift +// +// Created by Nacho Soto on 11/17/23. + +import Foundation + +/// A type that can determine the current `Storefront`. +protocol StorefrontProviderType { + + var currentStorefront: StorefrontType? { get } + +} + +/// Main ``StorefrontProviderType`` implementation. +/// Relies on StoreKit 1 because StoreKit 2's implementation would be `async`. +final class DefaultStorefrontProvider: StorefrontProviderType { + + var currentStorefront: StorefrontType? { + if #available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, macCatalyst 13.1, *) { + return Storefront.sk1CurrentStorefrontType + } else { + return nil + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SubscriptionPeriod.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SubscriptionPeriod.swift new file mode 100644 index 00000000..e490d397 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/SubscriptionPeriod.swift @@ -0,0 +1,275 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SubscriptionPeriod.swift +// +// Created by Andrés Boedo on 3/12/21. + +import Foundation +import StoreKit + +/// The duration of time between subscription renewals. +/// Use the value and the unit together to determine the subscription period. +/// For example, if the unit is `.month`, and the value is `3`, the subscription period is three months. +@objc(RCSubscriptionPeriod) +public final class SubscriptionPeriod: NSObject { + + /// The number of period units. + @objc public let value: Int + /// The increment of time that a subscription period is specified in. + @objc public let unit: Unit + + /// Creates a new ``SubscriptionPeriod`` with the given value and unit. + public init(value: Int, unit: Unit) { + assert(value > 0, "Invalid value: \(value)") + + self.value = value + self.unit = unit + } + + /// Units of time used to describe subscription periods. + @objc(RCSubscriptionPeriodUnit) + public enum Unit: Int { + + /// A subscription period unit of a day. + case day = 0 + /// A subscription period unit of a week. + case week = 1 + /// A subscription period unit of a month. + case month = 2 + /// A subscription period unit of a year. + case year = 3 + + } + + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + static func from(sk1SubscriptionPeriod: SKProductSubscriptionPeriod) -> SubscriptionPeriod? { + guard let unit = SubscriptionPeriod.Unit.from(sk1PeriodUnit: sk1SubscriptionPeriod.unit) else { + return nil + } + + return .init(value: sk1SubscriptionPeriod.numberOfUnits, unit: unit) + .normalized() + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8, *) + static func from(sk2SubscriptionPeriod: StoreKit.Product.SubscriptionPeriod) -> SubscriptionPeriod? { + guard let unit = SubscriptionPeriod.Unit.from(sk2PeriodUnit: sk2SubscriptionPeriod.unit) else { + return nil + } + + return .init(value: sk2SubscriptionPeriod.value, unit: unit) + .normalized() + } + + static func from(iso8601: String) -> SubscriptionPeriod? { + guard let isoDuration = ISODurationFormatter.parse(from: iso8601) else { + return nil + } + // Look for the smaller Unit > 0 + if isoDuration.days > 0 { + return SubscriptionPeriod(value: isoDuration.days, unit: .day) + } else if isoDuration.weeks > 0 { + return SubscriptionPeriod(value: isoDuration.weeks, unit: .week) + } else if isoDuration.months > 0 { + return SubscriptionPeriod(value: isoDuration.months, unit: .month) + } else if isoDuration.years > 0 { + return SubscriptionPeriod(value: isoDuration.years, unit: .year) + } else { + return nil + } + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? SubscriptionPeriod else { return false } + + return self.value == other.value && self.unit == other.unit + } + + public override var hash: Int { + var hasher = Hasher() + hasher.combine(self.value) + hasher.combine(self.unit) + + return hasher.finalize() + } + +} + +// MARK: - Renames + +// @available annotations to help users migrating from `SKProductSubscriptionPeriod` to `SubscriptionPeriod` +public extension SubscriptionPeriod { + + /// The number of units per subscription period + @available(iOS, unavailable, renamed: "value") + @available(tvOS, unavailable, renamed: "value") + @available(watchOS, unavailable, renamed: "value") + @available(macOS, unavailable, renamed: "value") + @objc var numberOfUnits: Int { fatalError() } + +} + +extension SubscriptionPeriod.Unit: Sendable {} +extension SubscriptionPeriod: Sendable {} + +public extension SubscriptionPeriod { + + /// The length of the period convert to another unit + func numberOfUnitsAs(unit: Unit) -> Decimal { + switch unit { + case .day: + return Decimal(self.value) * self.unitsPerDay + case .week: + return Decimal(self.value) * self.unitsPerWeek + case .month: + return Decimal(self.value) * self.unitsPerMonth + case .year: + return Decimal(self.value) * self.unitsPerYear + } + } + +} + +extension SubscriptionPeriod { + + func pricePerDay(withTotalPrice price: Decimal) -> Decimal { + return self.pricePerPeriod(for: self.unitsPerDay, totalPrice: price) + } + + func pricePerWeek(withTotalPrice price: Decimal) -> Decimal { + return self.pricePerPeriod(for: self.unitsPerWeek, totalPrice: price) + } + + func pricePerMonth(withTotalPrice price: Decimal) -> Decimal { + return self.pricePerPeriod(for: self.unitsPerMonth, totalPrice: price) + } + + func pricePerYear(withTotalPrice price: Decimal) -> Decimal { + return self.pricePerPeriod(for: self.unitsPerYear, totalPrice: price) + } + + private var unitsPerDay: Decimal { + switch self.unit { + case .day: return 1 + case .week: return Constants.daysPerWeek + case .month: return Constants.daysPerMonth + case .year: return Constants.daysPerYear + } + } + + private var unitsPerWeek: Decimal { + switch self.unit { + case .day: return 1 / Constants.daysPerWeek + case .week: return 1 + case .month: return Constants.weeksPerMonth + case .year: return Constants.weeksPerYear + } + } + + private var unitsPerMonth: Decimal { + switch self.unit { + case .day: return 1 / Constants.daysPerMonth + case .week: return 1 / Constants.weeksPerMonth + case .month: return 1 + case .year: return Constants.monthsPerYear + } + } + + private var unitsPerYear: Decimal { + switch self.unit { + case .day: return 1 / Constants.daysPerYear + case .week: return 1 / Constants.weeksPerYear + case .month: return 1 / Constants.monthsPerYear + case .year: return 1 + } + } + + private func pricePerPeriod(for units: Decimal, totalPrice: Decimal) -> Decimal { + let periods: Decimal = units * Decimal(self.value) + + return (totalPrice as NSDecimalNumber) + .dividing(by: periods as NSDecimalNumber, + withBehavior: Self.roundingBehavior) as Decimal + } + + private static let roundingBehavior = NSDecimalNumberHandler( + roundingMode: .down, + scale: 2, + raiseOnExactness: false, + raiseOnOverflow: false, + raiseOnUnderflow: false, + raiseOnDivideByZero: false + ) + +} + +private extension SubscriptionPeriod { + + enum Constants { + static let daysPerWeek: Decimal = 7 + static let daysPerMonth: Decimal = 30 + static let daysPerYear: Decimal = 365 + static let weeksPerMonth: Decimal = daysPerYear / monthsPerYear / daysPerWeek + static let weeksPerYear: Decimal = daysPerYear / daysPerWeek + static let monthsPerYear: Decimal = 12 + } + +} + +extension SubscriptionPeriod.Unit: CustomDebugStringConvertible { + + // swiftlint:disable missing_docs + public var debugDescription: String { + switch self { + case .day: return "day" + case .week: return "week" + case .month: return "month" + case .year: return "year" + } + } + +} + +extension SubscriptionPeriod { + public override var debugDescription: String { + return "SubscriptionPeriod: \(self.value) \(self.unit)" + } +} + +fileprivate extension SubscriptionPeriod.Unit { + + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + static func from(sk1PeriodUnit: SK1Product.PeriodUnit) -> Self? { + switch sk1PeriodUnit { + case .day: return .day + case .week: return .week + case .month: return .month + case .year: return .year + @unknown default: return nil + } + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8, *) + static func from(sk2PeriodUnit: StoreKit.Product.SubscriptionPeriod.Unit) -> Self? { + switch sk2PeriodUnit { + case .day: return .day + case .week: return .week + case .month: return .month + case .year: return .year + @unknown default: return nil + } + } + +} + +// MARK: - Encodable + +extension SubscriptionPeriod.Unit: Codable { } +extension SubscriptionPeriod: Codable { } diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/Test Data/TestStoreProduct.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/Test Data/TestStoreProduct.swift new file mode 100644 index 00000000..ee5fb77e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/Test Data/TestStoreProduct.swift @@ -0,0 +1,173 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// TestStoreProduct.swift +// +// Created by Nacho Soto on 6/23/23. + +import Foundation + +/// A type that contains the necessary data to create a ``StoreProduct``. +/// This can be used to create mock data for tests or SwiftUI previews. +/// +/// Example: +/// ```swift +/// let product = TestStoreProduct( +/// localizedTitle: "PRO monthly", +/// price: 3.99, +/// localizedPriceString: "$3.99", +/// productIdentifier: "com.revenuecat.product", +/// productType: .autoRenewableSubscription, +/// localizedDescription: "Description", +/// subscriptionGroupIdentifier: "group", +/// subscriptionPeriod: .init(value: 1, unit: .month) +/// ) +/// +/// let offering = Offering( +/// identifier: "offering", +/// serverDescription: "Main offering", +/// metadata: [:], +/// availablePackages: [ +/// .init( +/// identifier: "monthly", +/// packageType: .monthly, +/// storeProduct: product.toStoreProduct(), +/// offeringIdentifier: offering +/// ), +/// ] +/// ) +/// ``` +public struct TestStoreProduct { + + // Note: this class inherits its docs from `StoreProductType` + // swiftlint:disable missing_docs + + public var localizedTitle: String + public var price: Decimal + public var currencyCode: String? + public var localizedPriceString: String + public var localizedPricePerDay: String? + public var localizedPricePerWeek: String? + public var localizedPricePerMonth: String? + public var localizedPricePerYear: String? + public var productIdentifier: String + public var productType: StoreProduct.ProductType + public var localizedDescription: String + public var subscriptionGroupIdentifier: String? + public var subscriptionPeriod: SubscriptionPeriod? + public var isFamilyShareable: Bool + public var introductoryDiscount: StoreProductDiscount? + public var discounts: [StoreProductDiscount] + public var locale: Locale + + // swiftlint:disable:next line_length + @available(*, deprecated, message: "Use init(localizedTitle:price:currencyCode:localizedPriceString:productIdentifier:productType:localizedDescription:subscriptionGroupIdentifier:subscriptionPeriod:isFamilyShareable:introductoryDiscount:discounts:locale:) instead") + public init( + localizedTitle: String, + price: Decimal, + localizedPriceString: String, + productIdentifier: String, + productType: StoreProduct.ProductType, + localizedDescription: String, + subscriptionGroupIdentifier: String? = nil, + subscriptionPeriod: SubscriptionPeriod? = nil, + isFamilyShareable: Bool = false, + introductoryDiscount: TestStoreProductDiscount? = nil, + discounts: [TestStoreProductDiscount] = [], + locale: Locale = .current + ) { + self.localizedTitle = localizedTitle + self.price = price + self.currencyCode = locale.rc_currencyCode + self.localizedPriceString = localizedPriceString + self.productIdentifier = productIdentifier + self.productType = productType + self.localizedDescription = localizedDescription + self.subscriptionGroupIdentifier = subscriptionGroupIdentifier + self.subscriptionPeriod = subscriptionPeriod + self.isFamilyShareable = isFamilyShareable + self.introductoryDiscount = introductoryDiscount?.toStoreProductDiscount() + self.discounts = discounts.map { $0.toStoreProductDiscount() } + self.locale = locale + } + + /// Creates a new ``TestStoreProduct``. + /// + /// - Parameters: + /// - localizedTitle: The localized title of the product + /// - price: The price of the product + /// - currencyCode: The currency code (e.g., "USD", "EUR"). + /// - localizedPriceString: The localized price string (e.g., "$3.99") + /// - productIdentifier: The product identifier + /// - productType: The type of product + /// - localizedDescription: The localized description + /// - subscriptionGroupIdentifier: Optional subscription group identifier + /// - subscriptionPeriod: Optional subscription period + /// - isFamilyShareable: Whether the product is family shareable + /// - introductoryDiscount: Optional introductory discount + /// - discounts: Array of discounts + /// - locale: The locale that should be used when formatting prices. + /// It is important that this matches with the price strings passed (e.g. localizedPriceString) + public init( + localizedTitle: String, + price: Decimal, + currencyCode: String, + localizedPriceString: String, + productIdentifier: String, + productType: StoreProduct.ProductType, + localizedDescription: String, + subscriptionGroupIdentifier: String? = nil, + subscriptionPeriod: SubscriptionPeriod? = nil, + isFamilyShareable: Bool = false, + introductoryDiscount: TestStoreProductDiscount? = nil, + discounts: [TestStoreProductDiscount] = [], + locale: Locale + ) { + self.localizedTitle = localizedTitle + self.price = price + self.currencyCode = currencyCode + self.localizedPriceString = localizedPriceString + self.productIdentifier = productIdentifier + self.productType = productType + self.localizedDescription = localizedDescription + self.subscriptionGroupIdentifier = subscriptionGroupIdentifier + self.subscriptionPeriod = subscriptionPeriod + self.isFamilyShareable = isFamilyShareable + self.introductoryDiscount = introductoryDiscount?.toStoreProductDiscount() + self.discounts = discounts.map { $0.toStoreProductDiscount() } + self.locale = locale + } + + // swiftlint:enable missing_docs + + private let priceFormatterProvider: PriceFormatterProvider = .init() + +} + +// Ensure consistency with the internal type +extension TestStoreProduct: StoreProductType { + + internal var productCategory: StoreProduct.ProductCategory { return self.productType.productCategory } + + internal var priceFormatter: NumberFormatter? { + return self.currencyCode.map { + self.priceFormatterProvider.priceFormatterForSK2(withCurrencyCode: $0, locale: self.locale) + } + } + +} + +extension TestStoreProduct { + + /// Convert it into a ``StoreProduct``. + public func toStoreProduct() -> StoreProduct { + return .from(product: self) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/Test Data/TestStoreProductDiscount.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/Test Data/TestStoreProductDiscount.swift new file mode 100644 index 00000000..5e83eecb --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/Test Data/TestStoreProductDiscount.swift @@ -0,0 +1,70 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// TestStoreProductDiscount.swift +// +// Created by Nacho Soto on 6/26/23. + +import Foundation + +/// A type that contains the necessary data to create a ``StoreProduct``. +public struct TestStoreProductDiscount { + + // Note: this class inherits its docs from `StoreProductDiscountType` + // swiftlint:disable missing_docs + + public var identifier: String + public var price: Decimal + public var localizedPriceString: String + public var paymentMode: StoreProductDiscount.PaymentMode + public var subscriptionPeriod: SubscriptionPeriod + public var numberOfPeriods: Int + public var type: StoreProductDiscount.DiscountType + + public init( + identifier: String, + price: Decimal, + localizedPriceString: String, + paymentMode: StoreProductDiscount.PaymentMode, + subscriptionPeriod: SubscriptionPeriod, + numberOfPeriods: Int, + type: StoreProductDiscount.DiscountType + ) { + self.identifier = identifier + self.price = price + self.localizedPriceString = localizedPriceString + self.paymentMode = paymentMode + self.subscriptionPeriod = subscriptionPeriod + self.numberOfPeriods = numberOfPeriods + self.type = type + } + +} + +@_spi(Internal) extension TestStoreProductDiscount: StoreProductDiscountType { + + @_spi(Internal) public var offerIdentifier: String? { + return self.identifier + } + + @_spi(Internal) public var currencyCode: String? { + // Test currency defaults to current locale + return Locale.current.rc_currencyCode + } + +} + +extension TestStoreProductDiscount { + + /// Convert it into a ``StoreProductDiscount``. + public func toStoreProductDiscount() -> StoreProductDiscount { + return .from(discount: self) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/TestStoreTransaction.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/TestStoreTransaction.swift new file mode 100644 index 00000000..e5dc4f37 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/TestStoreTransaction.swift @@ -0,0 +1,32 @@ +// +// TestStoreTransaction.swift +// RevenueCat +// +// Copyright © 2026 RevenueCat, Inc. All rights reserved. +// + +import Foundation + +/// Internal struct used to create `StoreTransaction` instances for testing purposes. +/// This allows developers to mock `StoreTransaction` objects in unit tests. +struct TestStoreTransaction: StoreTransactionType { + + let productIdentifier: String + let purchaseDate: Date + let transactionIdentifier: String + let quantity: Int + let storefront: Storefront? + + var hasKnownPurchaseDate: Bool { return true } + var hasKnownTransactionIdentifier: Bool { return true } + + let jwsRepresentation: String? = nil + let environment: StoreEnvironment? = nil + let reason: TransactionReason? = nil + + func finish(_ wrapper: any PaymentQueueWrapperType, completion: @escaping @Sendable () -> Void) { + // no-op + completion() + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/TransactionReason.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/TransactionReason.swift new file mode 100644 index 00000000..5ed990a8 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/TransactionReason.swift @@ -0,0 +1,58 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// TransactionReason.swift + +import Foundation +import StoreKit + +/// Indicates the reason for a transaction. +/// +/// This mirrors StoreKit 2's `Transaction.Reason` (available on iOS 17+, macOS 14+, tvOS 17+, watchOS 10+). +/// +/// When the reason cannot be determined, the property is `nil`. This happens for: +/// - All StoreKit 1 transactions (SK1 does not expose a transaction reason). +/// - StoreKit 2 transactions on iOS 16 and earlier (the `reason` property is not available). +enum TransactionReason: String { + + /// The customer initiated the transaction, such as a purchase or a subscription offer redemption. + case purchase + + /// The App Store server initiated the transaction, such as an auto-renewable subscription renewal. + case renewal + +} + +extension TransactionReason: Equatable, Sendable {} + +// MARK: - StoreKit 2 + +#if swift(>=5.9) +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +extension TransactionReason { + + /// Creates a ``TransactionReason`` from a StoreKit 2 `Transaction.Reason`. + /// + /// Returns `nil` for unrecognized reasons. + init?(sk2TransactionReason reason: SK2Transaction.Reason) { + switch reason { + case .purchase: + self = .purchase + case .renewal: + self = .renewal + default: + Logger.appleWarning( + Strings.storeKit.sk2_unknown_transaction_reason(String(describing: reason)) + ) + return nil + } + } + +} +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/WinBackOffer.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/WinBackOffer.swift new file mode 100644 index 00000000..4dff40ec --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/StoreKitAbstractions/WinBackOffer.swift @@ -0,0 +1,26 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// WinBackOffer.swift +// +// Created by Will Taylor on 10/29/24. + +import Foundation + +/// Represents an Apple win-back offer. +@objc(RCWinBackOffer) +public final class WinBackOffer: NSObject, Sendable { + + /// The ``StoreProductDiscount`` in this offer. + @objc public let discount: StoreProductDiscount + + init(discount: StoreProductDiscountType) { + self.discount = StoreProductDiscount.from(discount: discount) + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/TransactionsFactory.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/TransactionsFactory.swift new file mode 100644 index 00000000..ebf47d81 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/TransactionsFactory.swift @@ -0,0 +1,31 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// TransactionsFactory.swift +// +// Created by RevenueCat. +// + +import Foundation + +enum TransactionsFactory { + + static func nonSubscriptionTransactions( + withSubscriptionsData subscriptionsData: [String: [CustomerInfoResponse.Transaction]] + ) -> [NonSubscriptionTransaction] { + subscriptionsData + .flatMap { (productID, transactions) -> [NonSubscriptionTransaction] in + transactions + .lazy + .compactMap { NonSubscriptionTransaction(with: $0, productID: productID) } + } + .sorted { $0.purchaseDate < $1.purchaseDate } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/TransactionsManager.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/TransactionsManager.swift new file mode 100644 index 00000000..63855d7b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/TransactionsManager.swift @@ -0,0 +1,34 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// TransactionsManager.swift +// +// Created by Juanpe Catalán on 9/12/21. + +import StoreKit + +class TransactionsManager { + + private let receiptParser: PurchasesReceiptParser + + init(receiptParser: PurchasesReceiptParser) { + self.receiptParser = receiptParser + } + + func customerHasTransactions(receiptData: Data) -> Bool { + // Note: even though SK2's implementation (using `StoreKit.Transaction.all`) might be more accurate + // we need to check what will be reflected in the posted receipt. + return self.receiptParser.receiptHasTransactions(receiptData: receiptData) + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension TransactionsManager: @unchecked Sendable {} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/TrialOrIntroPriceEligibilityChecker.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/TrialOrIntroPriceEligibilityChecker.swift new file mode 100644 index 00000000..1746d8c3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Purchasing/TrialOrIntroPriceEligibilityChecker.swift @@ -0,0 +1,363 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// IntroTrialOrIntroductoryPriceEligibilityChecker.swift +// +// Created by César de la Vega on 8/31/21. + +import Foundation +import StoreKit + +typealias ReceiveIntroEligibilityBlock = ([String: IntroEligibility]) -> Void + +/// A type that can determine `IntroEligibility` for products. +protocol TrialOrIntroPriceEligibilityCheckerType: Sendable { + + func checkEligibility(productIdentifiers: Set, completion: @escaping ReceiveIntroEligibilityBlock) +} + +class TrialOrIntroPriceEligibilityChecker: TrialOrIntroPriceEligibilityCheckerType { + + private var appUserID: String { self.currentUserProvider.currentAppUserID } + + private let systemInfo: SystemInfo + private let receiptFetcher: ReceiptFetcher + private let introEligibilityCalculator: IntroEligibilityCalculator + private let backend: Backend + private let currentUserProvider: CurrentUserProvider + private let operationDispatcher: OperationDispatcher + private let productsManager: ProductsManagerType + private let diagnosticsTracker: DiagnosticsTrackerType? + private let dateProvider: DateProvider + + init( + systemInfo: SystemInfo, + receiptFetcher: ReceiptFetcher, + introEligibilityCalculator: IntroEligibilityCalculator, + backend: Backend, + currentUserProvider: CurrentUserProvider, + operationDispatcher: OperationDispatcher, + productsManager: ProductsManagerType, + diagnosticsTracker: DiagnosticsTrackerType?, + dateProvider: DateProvider = DateProvider() + ) { + self.systemInfo = systemInfo + self.receiptFetcher = receiptFetcher + self.introEligibilityCalculator = introEligibilityCalculator + self.backend = backend + self.currentUserProvider = currentUserProvider + self.operationDispatcher = operationDispatcher + self.productsManager = productsManager + self.diagnosticsTracker = diagnosticsTracker + self.dateProvider = dateProvider + } + + // swiftlint:disable:next function_body_length + func checkEligibility(productIdentifiers: Set, + completion: @escaping ReceiveIntroEligibilityBlock) { + guard !self.systemInfo.dangerousSettings.uiPreviewMode else { + // No check eligibility request should happen in UI preview mode. + // Thus, the eligibility status for all product identifiers are set to `.unknown` + let result = productIdentifiers.reduce(into: [:]) { resultDict, productId in + resultDict[productId] = IntroEligibility(eligibilityStatus: IntroEligibilityStatus.unknown) + } + completion(result) + return + } + + guard !productIdentifiers.isEmpty else { + Logger.warn(Strings.eligibility.check_eligibility_no_identifiers) + completion([:]) + return + } + + guard !self.systemInfo.isSimulatedStoreAPIKey else { + // For now, all products in the Simulated Store are ineligible for trial or intro discount + let result = productIdentifiers.reduce(into: [:]) { resultDict, productId in + resultDict[productId] = IntroEligibility(eligibilityStatus: IntroEligibilityStatus.ineligible) + } + completion(result) + return + } + + let startTime = self.dateProvider.now() + + // Extracting and wrapping the completion block from the async call + // to avoid having to mark ReceiveIntroEligibilityBlock as @Sendable + // up to the public API thus making a breaking change. + let completionBlock: ([String: IntroEligibility], Error?, StoreKitVersion) -> Void = + { [weak self] (result, error, storeKitVersion) in + self?.trackTrialOrIntroEligibilityRequestIfNeeded(startTime: startTime, + requestedProductIds: productIdentifiers, + result: result, + error: error, + storeKitVersion: storeKitVersion) + self?.operationDispatcher.dispatchOnMainActor { + completion(result) + } + } + + if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *), + self.systemInfo.storeKitVersion.isStoreKit2EnabledAndAvailable { + Async.call(with: completionBlock) { + let result: [String: IntroEligibility] + let checkError: Error? + do { + result = try await self.sk2CheckEligibility(productIdentifiers) + checkError = nil + } catch { + Logger.appleError(Strings.eligibility.unable_to_get_intro_eligibility_for_user(error: error)) + + result = productIdentifiers.reduce(into: [:]) { resultDict, productId in + resultDict[productId] = IntroEligibility(eligibilityStatus: IntroEligibilityStatus.unknown) + } + checkError = error + } + return (result, checkError, .storeKit2) + } + } else { + self.sk1CheckEligibility(productIdentifiers) { eligibility, error in + completionBlock(eligibility, error, .storeKit1) + } + } + } + + func sk1CheckEligibility(_ productIdentifiers: Set, + completion: @escaping ([String: IntroEligibility], Error?) -> Void) { + // We don't want to refresh receipts because it will likely prompt the user for their credentials, + // and intro eligibility is triggered programmatically. + self.receiptFetcher.receiptData(refreshPolicy: .never) { data, _ in + if let data = data { + self.sk1CheckEligibility(with: data, + productIdentifiers: productIdentifiers) { eligibility, error in + self.operationDispatcher.dispatchOnMainActor { + completion(eligibility, error) + } + } + } else { + self.getIntroEligibility(with: data ?? Data(), + productIdentifiers: productIdentifiers) { eligibility, error in + self.operationDispatcher.dispatchOnMainActor { + completion(eligibility, error) + } + } + } + } + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func sk2CheckEligibility(_ productIdentifiers: Set) async throws -> [String: IntroEligibility] { + var introDictionary: [String: IntroEligibility] = productIdentifiers.dictionaryWithValues { _ in + .init(eligibilityStatus: .unknown) + } + + let products = try await self.productsManager.sk2Products(withIdentifiers: productIdentifiers) + for sk2StoreProduct in products { + let sk2Product = sk2StoreProduct.underlyingSK2Product + + let eligibilityStatus: IntroEligibilityStatus + + if let subscription = sk2Product.subscription, subscription.introductoryOffer != nil { + let isEligible = await TimingUtil.measureAndLogIfTooSlow( + threshold: .introEligibility, + message: Strings.eligibility.sk2_intro_eligibility_too_slow.description) { + return await subscription.isEligibleForIntroOffer + } + eligibilityStatus = isEligible ? .eligible : .ineligible + } else { + eligibilityStatus = .noIntroOfferExists + } + + introDictionary[sk2StoreProduct.productIdentifier] = .init(eligibilityStatus: eligibilityStatus) + } + + return introDictionary + } + +} + +/// Default overload implementation that takes a single `StoreProductType`. +extension TrialOrIntroPriceEligibilityCheckerType { + + func checkEligibility(product: StoreProductType, completion: @escaping (IntroEligibilityStatus) -> Void) { + self.checkEligibility(productIdentifiers: [product.productIdentifier]) { eligibility in + completion(eligibility[product.productIdentifier]?.status ?? .unknown) + } + } + +} + +// MARK: - Implementations + +private extension TrialOrIntroPriceEligibilityChecker { + + func sk1CheckEligibility(with receiptData: Data, + productIdentifiers: Set, + completion: @escaping ([String: IntroEligibility], Error?) -> Void) { + introEligibilityCalculator + .checkEligibility(with: receiptData, + productIdentifiers: productIdentifiers) { result in + switch result { + case .failure(let localCheckError): + Logger.error(Strings.receipt.parse_receipt_locally_error(error: localCheckError)) + self.getIntroEligibility(with: receiptData, + productIdentifiers: productIdentifiers) { eligibility, backendError in + completion(eligibility, backendError ?? localCheckError) + } + case .success(let receivedEligibility): + let convertedEligibility = receivedEligibility.mapValues(IntroEligibility.init) + + self.operationDispatcher.dispatchOnMainThread { + completion(convertedEligibility, nil) + } + } + } + } + + func getIntroEligibility(with receiptData: Data, + productIdentifiers: Set, + completion: @escaping ([String: IntroEligibility], BackendError?) -> Void) { + if #available(iOS 11.2, macOS 10.13.2, macCatalyst 13.0, tvOS 11.2, watchOS 6.2, *) { + // Products that don't have an introductory discount don't need to be sent to the backend + // Step 1: Filter out products without introductory discount and give .noIntroOfferExists status + // Step 2: Send products without eligibility status to backend + // Step 3: Merge results from step 1 and step 2 + self.productsWithKnownIntroEligibilityStatus(productIdentifiers: productIdentifiers) { onDeviceResults in + let nilProductIdentifiers = productIdentifiers.filter { productIdentifier in + return onDeviceResults[productIdentifier] == nil + } + + self.getIntroEligibilityFromBackend( + with: receiptData, + productIdentifiers: nilProductIdentifiers + ) { backendResults, error in + let results = onDeviceResults + backendResults + completion(results, error) + } + } + } else { + self.getIntroEligibilityFromBackend(with: receiptData, + productIdentifiers: productIdentifiers, + completion: completion) + } + } + +} + +extension TrialOrIntroPriceEligibilityChecker { + + @available(iOS 11.2, macOS 10.13.2, macCatalyst 13.0, tvOS 11.2, watchOS 6.2, *) + func productsWithKnownIntroEligibilityStatus(productIdentifiers: Set, + completion: @escaping ReceiveIntroEligibilityBlock) { + self.productsManager.products(withIdentifiers: productIdentifiers) { products in + let eligibility: [(String, IntroEligibility)] = Array(products.value ?? []) + .filter { $0.introductoryDiscount == nil } + .map { ($0.productIdentifier, IntroEligibility(eligibilityStatus: .noIntroOfferExists)) } + + let productIdsToIntroEligibleStatus = Dictionary(uniqueKeysWithValues: eligibility) + completion(productIdsToIntroEligibleStatus) + } + } + + func getIntroEligibilityFromBackend(with receiptData: Data, + productIdentifiers: Set, + completion: @escaping ([String: IntroEligibility], BackendError?) -> Void) { + if productIdentifiers.isEmpty { + completion([:], nil) + return + } + + self.backend.offerings.getIntroEligibility(appUserID: self.appUserID, + receiptData: receiptData, + productIdentifiers: productIdentifiers) { backendResult, error in + let result: [String: IntroEligibility] = { + if let error = error { + Logger.error(Strings.eligibility.unable_to_get_intro_eligibility_for_user(error: error)) + return productIdentifiers + .dictionaryWithValues { _ in IntroEligibility(eligibilityStatus: .unknown) } + } else { + return backendResult + } + }() + + self.operationDispatcher.dispatchOnMainThread { + completion(result, error) + } + } + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension TrialOrIntroPriceEligibilityChecker: @unchecked Sendable {} + +// MARK: - Diagnostics + +private extension TrialOrIntroPriceEligibilityChecker { + + func trackTrialOrIntroEligibilityRequestIfNeeded(startTime: Date, + requestedProductIds: Set, + result: [String: IntroEligibility], + error: Error?, + storeKitVersion: StoreKitVersion) { + guard #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *), + let diagnosticsTracker = self.diagnosticsTracker else { + return + } + + var unknownCount, ineligibleCount, eligibleCount, noIntroOfferCount: Int? + if !result.isEmpty { + (unknownCount, ineligibleCount, eligibleCount, noIntroOfferCount) = result.reduce(into: (0, 0, 0, 0)) { + switch $1.value.status { + case .unknown: + $0.0 += 1 + case .ineligible: + $0.1 += 1 + case .eligible: + $0.2 += 1 + case .noIntroOfferExists: + $0.3 += 1 + } + } + } + + let errorCode: Int? + let errorMessage: String? + switch error { + case let purchasesError as PurchasesError: + errorCode = purchasesError.errorCode + errorMessage = purchasesError.localizedDescription + case let purchasesErrorConvertible as PurchasesErrorConvertible: + let purchasesError = purchasesErrorConvertible.asPurchasesError + errorCode = purchasesError.errorCode + errorMessage = purchasesError.localizedDescription + case let receiptParserError as PurchasesReceiptParser.Error: + errorCode = ErrorCode.invalidReceiptError.rawValue + errorMessage = receiptParserError.errorDescription ?? receiptParserError.localizedDescription + case let otherError: + errorCode = otherError != nil ? ErrorCode.unknownError.rawValue : nil + errorMessage = otherError?.localizedDescription + } + + let responseTime = self.dateProvider.now().timeIntervalSince(startTime) + + diagnosticsTracker.trackAppleTrialOrIntroEligibilityRequest(storeKitVersion: storeKitVersion, + requestedProductIds: requestedProductIds, + eligibilityUnknownCount: unknownCount, + eligibilityIneligibleCount: ineligibleCount, + eligibilityEligibleCount: eligibleCount, + eligibilityNoIntroOfferCount: noIntroOfferCount, + errorMessage: errorMessage, + errorCode: errorCode, + storefront: self.systemInfo.storefront?.countryCode, + responseTime: responseTime) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Security/FakeSigning.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Security/FakeSigning.swift new file mode 100644 index 00000000..48ea76b2 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Security/FakeSigning.swift @@ -0,0 +1,34 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// FakeSigning.swift +// +// Created by Nacho Soto on 6/13/23. + +import Foundation + +#if DEBUG + +/// A `SigningType` implementation that always fails, used for testing. +/// - Seealso: `InternalDangerousSettingsType.forceSignatureFailures` +final class FakeSigning: SigningType { + + func verify( + signature: String, + with parameters: Signing.SignatureParameters, + publicKey: Signing.PublicKey + ) -> Bool { + return false + } + + static let `default`: FakeSigning = .init() + +} + +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Security/HTTPRequest+Signing.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Security/HTTPRequest+Signing.swift new file mode 100644 index 00000000..82652074 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Security/HTTPRequest+Signing.swift @@ -0,0 +1,88 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// HTTPRequest+Signing.swift +// +// Created by Nacho Soto on 11/16/23. + +import CryptoKit +import Foundation + +extension HTTPRequest { + + static func headerParametersForSignatureHeader( + headers: Headers, + path: HTTPRequestPath + ) -> String? { + guard path.needsNonceForSigning else { + // Static signatures cannot sign header parameters + return nil + } + + if let hash = Self.postParameterHash(headers) { + return Self.signatureHashHeader(keys: Self.headersToSign.map(\.rawValue), + hash: hash) + } else { + return nil + } + } + + /// - Returns: `nil` if none of the requested headers are found + private static func postParameterHash(_ headers: Headers) -> String? { + let values = Self.headersToSign.compactMap { headers[$0.rawValue] } + + guard !values.isEmpty else { return nil } + + return Self.signingParameterHash(values) + } + +} + +extension HTTPRequest { + + static func signatureHashHeader( + keys: [String], + hash: String + ) -> String { + return [ + keys.joined(separator: ","), + postParameterHashingAlgorithmName, + hash + ].joined(separator: ":") + } + + static func signingParameterHash(_ values: [String]) -> String { + var sha256 = SHA256() + + for (index, value) in values.enumerated() { + if index > 0 { + sha256.update(data: fieldSeparator) + } + + sha256.update(data: value.asData) + } + + return sha256.toString() + } + +} + +extension HTTPRequest { + + /// Ordered list of header keys that will be included in the signature. + static let headersToSign: [HTTPClient.RequestHeader] = [ + .sandbox + ] + +} + +// MARK: - Private + +private let postParameterHashingAlgorithmName = "sha256" +private let fieldSeparator = Data(bytes: [0x00], count: 1) diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Security/HTTPRequestBody+Signing.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Security/HTTPRequestBody+Signing.swift new file mode 100644 index 00000000..607c0de7 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Security/HTTPRequestBody+Signing.swift @@ -0,0 +1,41 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// HTTPRequestBody+Signing.swift +// +// Created by Nacho Soto on 7/6/23. + +import Foundation + +extension HTTPRequestBody { + + var postParameterHeader: String? { + let keys = self.keysToSign + guard !keys.isEmpty else { + return nil + } + + return HTTPRequest.signatureHashHeader(keys: keys, hash: self.postParameterHash) + } + + private var postParameterHash: String { + let nonNilValues = self.contentForSignature.compactMap { $0.value } + return HTTPRequest.signingParameterHash(nonNilValues) + } + +} + +private extension HTTPRequestBody { + + /// - Returns: an ordered list of keys that will be included in the signature. + var keysToSign: [String] { + return self.contentForSignature.map(\.key) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Security/Signing+ResponseVerification.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Security/Signing+ResponseVerification.swift new file mode 100644 index 00000000..1b983982 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Security/Signing+ResponseVerification.swift @@ -0,0 +1,107 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Signing+ResponseVerification.swift +// +// Created by Nacho Soto on 2/8/23. + +import Foundation + +extension HTTPResponse where Body == Data? { + + // swiftlint:disable:next function_parameter_count + func verify( + signing: SigningType, + request: HTTPRequest, + requestHeaders: HTTPRequest.Headers, + publicKey: Signing.PublicKey?, + isLoadShedderResponse: Bool, + isFallbackUrlResponse: Bool + ) -> VerifiedHTTPResponse { + let verificationResult = Self.verificationResult( + body: self.body, + statusCode: self.httpStatusCode, + requestHeaders: requestHeaders, + responseHeaders: self.responseHeaders, + requestDate: self.requestDate, + request: request, + publicKey: publicKey, + signing: signing, + isFallbackUrlResponse: isFallbackUrlResponse + ) + + #if DEBUG + if verificationResult == .failed, ProcessInfo.isRunningRevenueCatTests { + Logger.warn(Strings.signing.invalid_signature_data( + request, + self.body, + self.responseHeaders, + self.httpStatusCode + )) + } + #endif + + return self.verified(with: verificationResult, + isLoadShedderResponse: isLoadShedderResponse, + isFallbackUrlResponse: isFallbackUrlResponse) + } + + // swiftlint:disable:next function_parameter_count + private static func verificationResult( + body: Data?, + statusCode: HTTPStatusCode, + requestHeaders: HTTPClient.RequestHeaders, + responseHeaders: HTTPClient.ResponseHeaders, + requestDate: Date?, + request: HTTPRequest, + publicKey: Signing.PublicKey?, + signing: SigningType, + isFallbackUrlResponse: Bool + ) -> VerificationResult { + guard let publicKey = publicKey, statusCode.isSuccessfulResponse else { + return .notRequested + } + + guard let signature = HTTPResponse.value( + forCaseInsensitiveHeaderField: .signature, + in: responseHeaders + ) else { + if request.path.supportsSignatureVerification { + Logger.warn(Strings.signing.signature_was_requested_but_not_provided(request)) + return .failed + } else { + return .notRequested + } + } + + guard let requestDate = requestDate else { + Logger.warn(Strings.signing.request_date_missing_from_headers(request)) + + return .failed + } + + if signing.verify(signature: signature, + with: .init( + path: request.path, + message: body, + requestHeaders: requestHeaders, + requestBody: request.requestBody, + nonce: request.nonce, + etag: HTTPResponse.value(forCaseInsensitiveHeaderField: .eTag, in: responseHeaders), + requestDate: requestDate.millisecondsSince1970, + useFallbackPath: isFallbackUrlResponse + ), + publicKey: publicKey) { + return .verified + } else { + return .failed + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Security/Signing.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Security/Signing.swift new file mode 100644 index 00000000..72657afa --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Security/Signing.swift @@ -0,0 +1,400 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Signing.swift +// +// Created by Nacho Soto on 1/13/23. + +import CryptoKit +import Foundation + +/// A type that can verify signatures. +protocol SigningType { + + func verify( + signature: String, + with parameters: Signing.SignatureParameters, + publicKey: Signing.PublicKey + ) -> Bool + +} + +/// Utilities for handling signature verification. +final class Signing: SigningType { + + /// An object that represents a cryptographic key. + typealias PublicKey = SigningPublicKey + + /// Parameters used for signature creation / verification. + struct SignatureParameters { + + var path: HTTPRequestPath + var message: Data? + var requestHeaders: HTTPRequest.Headers + var requestBody: HTTPRequestBody? + var nonce: Data? + var etag: String? + var requestDate: UInt64 + var useFallbackPath: Bool + + } + + private let apiKey: String + private let clock: ClockType + + init(apiKey: String, clock: ClockType = Clock.default) { + self.apiKey = apiKey + self.clock = clock + } + + /// Parses the binary `key` and returns a `PublicKey` + static func loadPublicKey() -> PublicKey { + func fail(_ error: CustomStringConvertible) -> Never { + // This would crash the SDK, but the key is known at compile time + // so if it's encoded incorrectly we would know during tests + fatalError(error.description) + } + + guard let key = Data(base64Encoded: Self.publicKey) else { + fail(Strings.signing.invalid_public_key(Self.publicKey)) + } + + do { + return try Self.createPublicKey(with: key) + } catch { + fail(Strings.signing.invalid_public_key(error.localizedDescription)) + } + } + + func verify( + signature: String, + with parameters: SignatureParameters, + publicKey: Signing.PublicKey + ) -> Bool { + guard let signature = Data(base64Encoded: signature) else { + Logger.warn(Strings.signing.signature_not_base64(signature)) + return false + } + + guard signature.count == SignatureComponent.totalSize else { + Logger.warn(Strings.signing.signature_invalid_size(signature)) + return false + } + + guard let intermediatePublicKey = Self.extractAndVerifyIntermediateKey( + from: signature, + publicKey: publicKey, + clock: self.clock + ) else { + return false + } + + let salt = signature.component(.salt) + let payload = signature.component(.payload) + let messageToVerify = parameters.signature(salt: salt, apiKey: self.apiKey) + + #if DEBUG + Logger.verbose(Strings.signing.verifying_signature( + signature: signature, + publicKey: intermediatePublicKey.rawRepresentation, + parameters: parameters, + salt: salt, + payload: payload, + message: messageToVerify + )) + #endif + + let isValid = intermediatePublicKey.isValidSignature(payload, for: messageToVerify) + + if isValid { + Logger.verbose(Strings.signing.signature_passed_verification) + } else { + Logger.warn(Strings.signing.signature_failed_verification) + } + + return isValid + } + + static func verificationMode( + with setting: Configuration.EntitlementVerificationMode + ) -> ResponseVerificationMode { + switch setting { + case .disabled: return .disabled + case .informational: return .informational(Self.loadPublicKey()) + case .enforced: return .enforced(Self.loadPublicKey()) + } + } + + /// - Returns: `ResponseVerificationMode.enforced` + /// This is useful while ``Configuration.EntitlementVerificationMode`` is unavailable. + static func enforcedVerificationMode() -> ResponseVerificationMode { + return .enforced(Self.loadPublicKey()) + } + + // MARK: - + + /// The actual algorithm used to verify signatures. + fileprivate typealias Algorithm = Curve25519.Signing.PublicKey + + private static let publicKey = "UC1upXWg5QVmyOSwozp755xLqquBKjjU+di6U8QhMlM=" + +} + +extension Signing { + + /// Verification level with a loaded `PublicKey` + /// - Seealso: ``Configuration/EntitlementVerificationMode`` + enum ResponseVerificationMode { + + case disabled + case informational(PublicKey) + case enforced(PublicKey) + + static let `default`: Self = .informational(Signing.loadPublicKey()) + + var publicKey: PublicKey? { + switch self { + case .disabled: return nil + case let .informational(key): return key + case let .enforced(key): return key + } + } + + var isEnabled: Bool { + switch self { + case .disabled: return false + case .informational, .enforced: return true + } + } + + var isEnforced: Bool { + switch self { + case .disabled, .informational: return false + case .enforced: return true + } + } + + } + +} + +/// A type representing a public key that can be used to validate signatures +/// The current type used is `CryptoKit.Curve25519.Signing.PublicKey` +protocol SigningPublicKey { + + func isValidSignature(_ signature: Data, for data: Data) -> Bool + var rawRepresentation: Data { get } + +} + +extension Signing.Algorithm: SigningPublicKey {} + +// MARK: - Internal implementation (visible for tests) + +extension Signing { + + enum SignatureComponent: CaseIterable, Comparable { + + case intermediatePublicKey + case intermediateKeyExpiration + case intermediateKeySignature + case salt + case payload + + var size: Int { + switch self { + case .intermediatePublicKey: return 32 + case .intermediateKeyExpiration: return 4 + case .intermediateKeySignature: return 64 + case .salt: return 16 + case .payload: return 64 + } + } + + static let totalSize: Int = Self.allCases.map(\.size).sum() + + /// Number of bytes where the component begins + fileprivate var offset: Int { + // swiftlint:disable:next force_unwrapping + return Self.offsets[self]! + } + + fileprivate static let offsets: [SignatureComponent: Int] = Set(Self.allCases) + .dictionaryWithValues { component in + Self.allCases + .prefix(while: { $0 != component }) + .map(\.size) + .sum() + } + } + +} + +extension Signing.SignatureParameters { + + init( + path: HTTPRequest.Path, + message: Data? = nil, + requestHeaders: HTTPRequest.Headers = [:], + requestBody: HTTPRequestBody? = nil, + nonce: Data? = nil, + etag: String? = nil, + requestDate: UInt64, + useFallbackPath: Bool = false + ) { + self.path = path + self.message = message + self.requestHeaders = requestHeaders + self.requestBody = requestBody + self.nonce = nonce + self.etag = etag + self.requestDate = requestDate + self.useFallbackPath = useFallbackPath + } + + func signature(salt: Data, apiKey: String) -> Data { + let apiKey = self.path.authenticated ? apiKey : "" + return salt + apiKey.asData + self.asData + } + + var asData: Data { + let nonce: Data = self.nonce ?? .init() + let relativePath: String + if useFallbackPath, let fallbackRelativePath = self.path.fallbackRelativePath { + relativePath = fallbackRelativePath + } else { + relativePath = self.path.relativePath + } + let path: Data = relativePath.asData + let postParameterHash: Data = self.requestBody?.postParameterHeader?.asData ?? .init() + let headerParametersHash: Data = HTTPRequest.headerParametersForSignatureHeader( + headers: self.requestHeaders, + path: self.path + )? + .asData ?? .init() + let requestDate: Data = String(self.requestDate).asData + let etag: Data = (self.etag ?? "").asData + let message: Data = self.message ?? .init() + + return (nonce + path + postParameterHash + headerParametersHash + requestDate + etag + message) + } + +} + +extension Signing.SignatureParameters: CustomDebugStringConvertible { + + var debugDescription: String { + return """ + SignatureParameters(" + + path: '\(self.path.relativePath)' + message: '\(self.messageString.trimmingWhitespacesAndNewLines)' + headerParametersHash: '\(HTTPRequest.headerParametersForSignatureHeader( + headers: self.requestHeaders, + path: self.path + ) ?? "")' + headers: '\(self.requestHeaders)' + postParameterHeader: '\(self.requestBody?.postParameterHeader ?? "")' + nonce: '\(self.nonce?.base64EncodedString() ?? "")' + etag: '\(self.etag ?? "")' + requestDate: \(self.requestDate) + ) + """ + } + + private var messageString: String { + return self.message.flatMap { String(data: $0, encoding: .utf8) } ?? "" + } + +} + +// MARK: - Private + +private final class BundleToken: NSObject {} + +private extension Signing { + + static func createPublicKey(with data: Data) throws -> PublicKey { + return try Algorithm(rawRepresentation: data) + } + + static func extractAndVerifyIntermediateKey( + from signature: Data, + publicKey: Signing.PublicKey, + clock: ClockType + ) -> Signing.PublicKey? { + let intermediatePublicKey = signature.component(.intermediatePublicKey) + let intermediateKeyExpiration = signature.component(.intermediateKeyExpiration) + let intermediateKeySignature = signature.component(.intermediateKeySignature) + + guard publicKey.isValidSignature(intermediateKeySignature, + for: intermediateKeyExpiration + intermediatePublicKey) else { + Logger.warn(Strings.signing.intermediate_key_failed_verification(signature: intermediateKeySignature)) + return nil + } + + guard let expirationDate = Self.extractAndVerifyIntermediateKeyExpiration(intermediateKeyExpiration, + clock) else { + return nil + } + + Logger.verbose(Strings.signing.intermediate_key_creating(expiration: expirationDate, + data: intermediatePublicKey)) + + do { + return try Self.createPublicKey(with: intermediatePublicKey) + } catch { + Logger.error(Strings.signing.intermediate_key_failed_creation(error)) + return nil + } + } + + /// - Returns: `nil` if the key is expired or has an invalid expiration date. + private static func extractAndVerifyIntermediateKeyExpiration( + _ expirationData: Data, + _ clock: ClockType + ) -> Date? { + let daysSince1970 = UInt32(littleEndian32Bits: expirationData) + + guard daysSince1970 > 0 else { + Logger.warn(Strings.signing.intermediate_key_invalid(expirationData)) + return nil + } + + let expirationDate = Date(daysSince1970: daysSince1970) + guard expirationDate.timeIntervalSince(clock.now) >= 0 else { + Logger.warn(Strings.signing.intermediate_key_expired(expirationDate, expirationData)) + return nil + } + + return expirationDate + } + +} + +// MARK: - Extensions + +private extension Data { + + /// Extracts `Signing.SignatureComponent` from the receiver. + func component(_ component: Signing.SignatureComponent) -> Data { + let offset = component.offset + let size = component.size + + return self.subdata(in: offset ..< offset + size) + } + +} + +private extension Date { + + init(daysSince1970: UInt32) { + self.init(timeIntervalSince1970: DispatchTimeInterval.days(Int(daysSince1970)).seconds) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Security/VerificationResult.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Security/VerificationResult.swift new file mode 100644 index 00000000..78d68cc4 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Security/VerificationResult.swift @@ -0,0 +1,121 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// EntitlementVerification.swift +// +// Created by Nacho Soto on 2/10/23. + +import Foundation + +/// The result of data verification process. +/// +/// This is accomplished by preventing MiTM attacks between the SDK and the RevenueCat server. +/// With verification enabled, the SDK ensures that the response created by the server was not +/// modified by a third-party, and the entitlements received are exactly what was sent. +/// +/// - Note: Entitlements are only verified if enabled using +/// ``Configuration/Builder/with(entitlementVerificationMode:)``, which is disabled by default. +/// +/// ### Example: +/// ```swift +/// let purchases = Purchases.configure( +/// with: Configuration +/// .builder(withAPIKey: "") +/// .with(entitlementVerificationMode: .informational) +/// ) +/// +/// let customerInfo = try await purchases.customerInfo() +/// if !customerInfo.entitlements.verification.isVerified { +/// print("Entitlements could not be verified") +/// } +/// ``` +/// +/// ### Related Articles +/// - [Documentation](https://rev.cat/trusted-entitlements) +/// +/// ### Related Symbols +/// - ``Configuration/EntitlementVerificationMode`` +/// - ``Configuration/Builder/with(entitlementVerificationMode:)`` +/// - ``EntitlementInfos/verification`` +@objc(RCVerificationResult) +public enum VerificationResult: Int { + + /// No verification was done. + /// + /// This can happen due to: + /// - Verification is not enabled in ``Configuration`` + case notRequested = 0 + + /// Entitlements were verified with our server. + case verified = 1 + + /// Entitlements were created and verified on device through `StoreKit 2`. + case verifiedOnDevice = 3 + + /// Entitlement verification failed, possibly due to a MiTM attack. + /// ### Related Symbols + /// - ``ErrorCode/signatureVerificationFailed`` + case failed = 2 + +} + +extension VerificationResult: Sendable, Codable {} + +extension VerificationResult { + + /// Whether the result is ``VerificationResult/verified`` or ``VerificationResult/verifiedOnDevice``. + public var isVerified: Bool { + switch self { + case .verified, .verifiedOnDevice: + return true + case .notRequested, .failed: + return false + } + } + +} + +extension VerificationResult: DefaultValueProvider { + + static let defaultValue: Self = .notRequested + +} + +extension VerificationResult: CustomDebugStringConvertible { + + // swiftlint:disable:next missing_docs + public var debugDescription: String { + let prefix = "\(type(of: self))" + + switch self { + case .notRequested: return "\(prefix).notRequested" + case .verified: return "\(prefix).verified" + case .verifiedOnDevice: return "\(prefix).verifiedOnDevice" + case .failed: return "\(prefix).failed" + } + } + +} + +extension VerificationResult { + + var name: String { + switch self { + case .notRequested: + return "NOT_REQUESTED" + case .verified: + return "VERIFIED" + case .verifiedOnDevice: + return "VERIFIED_ON_DEVICE" + case .failed: + return "FAILED" + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/SubscriberAttributes/AttributionDataMigrator.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/SubscriberAttributes/AttributionDataMigrator.swift new file mode 100644 index 00000000..4e2233ff --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/SubscriberAttributes/AttributionDataMigrator.swift @@ -0,0 +1,164 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// AttributionDataMigrator.swift +// +// Created by César de la Vega on 6/16/21. +// + +import Foundation + +class AttributionDataMigrator { + + func convertToSubscriberAttributes(attributionData: [String: Any], network: Int) -> [String: String] { + let network = AttributionNetwork(rawValue: network) + let attributionData = attributionData.removingNSNullValues() + var convertedAttribution: [String: String] = [:] + if let value = attributionData[AttributionKey.idfa.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.idfa.key] = value + } + if let value = attributionData[AttributionKey.idfv.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.idfv.key] = value + } + if let value = attributionData[AttributionKey.ip.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.ip.key] = value + } + if let value = attributionData[AttributionKey.gpsAdId.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.gpsAdId.key] = value + } + + let networkSpecificSubscriberAttributes = convertNetworkSpecificSubscriberAttributes( + for: network, + attributionData: attributionData + ) + + return convertedAttribution.merging(networkSpecificSubscriberAttributes) + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension AttributionDataMigrator: @unchecked Sendable {} + +private extension AttributionDataMigrator { + + // This implementation follows the backend mapping of the attribution data to subscriber attributes + func convertNetworkSpecificSubscriberAttributes(for network: AttributionNetwork?, + attributionData: [String: Any]) -> [String: String] { + let networkSpecificSubscriberAttributes: [String: String] + switch network { + case .adjust: + networkSpecificSubscriberAttributes = convertAdjustAttribution(attributionData) + case .appsFlyer: + networkSpecificSubscriberAttributes = convertAppsFlyerAttribution(attributionData) + case .branch: + networkSpecificSubscriberAttributes = convertBranchAttribution(attributionData) + case .tenjin, + .facebook: + networkSpecificSubscriberAttributes = [:] + case .mParticle: + networkSpecificSubscriberAttributes = convertMParticleAttribution(attributionData) + case .none, .appleSearchAds, .adServices: + // Apple Search Ads & AdServices use standard attribution system + networkSpecificSubscriberAttributes = [:] + } + + return networkSpecificSubscriberAttributes + } + + func convertMParticleAttribution(_ data: [String: Any]) -> [String: String] { + var convertedAttribution: [String: String] = [:] + if let value = data[AttributionKey.MParticle.id.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.mpParticleID.key] = value + } + if let value = data[AttributionKey.networkID.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.mpParticleID.key] = value + } + return convertedAttribution + } + + func convertBranchAttribution(_ data: [String: Any]) -> [String: String] { + var convertedAttribution: [String: String] = [:] + if let value = data[AttributionKey.Branch.channel.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.mediaSource.key] = value + } + if let value = data[AttributionKey.Branch.campaign.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.campaign.key] = value + } + return convertedAttribution + } + + // swiftlint:disable:next cyclomatic_complexity + func convertAppsFlyerAttribution(_ data: [String: Any]) -> [String: String] { + var fixedData = data + if let innerDataObject = fixedData[AttributionKey.AppsFlyer.dataKey.rawValue] as? [String: String?] { + if fixedData[AttributionKey.AppsFlyer.statusKey.rawValue] != nil { + for (key, value) in innerDataObject { + fixedData[key] = value + } + } + } + + var convertedAttribution: [String: String] = [:] + + if let value = fixedData[AttributionKey.networkID.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.appsFlyerID.key] = value + } + if let value = fixedData[AttributionKey.AppsFlyer.id.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.appsFlyerID.key] = value + } + if let value = fixedData[AttributionKey.AppsFlyer.mediaSource.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.mediaSource.key] = value + } + if let value = fixedData[AttributionKey.AppsFlyer.channel.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.mediaSource.key] = value + } + if let value = fixedData[AttributionKey.AppsFlyer.campaign.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.campaign.key] = value + } + if let value = fixedData[AttributionKey.AppsFlyer.adSet.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.adGroup.key] = value + } + if let value = fixedData[AttributionKey.AppsFlyer.adGroup.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.ad.key] = value + } + if let value = fixedData[AttributionKey.AppsFlyer.ad.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.ad.key] = value + } + if let value = fixedData[AttributionKey.AppsFlyer.adKeywords.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.keyword.key] = value + } + if let value = fixedData[AttributionKey.AppsFlyer.adId.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.creative.key] = value + } + + return convertedAttribution + } + + func convertAdjustAttribution(_ data: [String: Any]) -> [String: String] { + var convertedAttribution: [String: String] = [:] + if let value = data[AttributionKey.Adjust.id.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.adjustID.key] = value + } + if let value = data[AttributionKey.Adjust.network.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.mediaSource.key] = value + } + if let value = data[AttributionKey.Adjust.campaign.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.campaign.key] = value + } + if let value = data[AttributionKey.Adjust.adGroup.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.adGroup.key] = value + } + if let value = data[AttributionKey.Adjust.creative.rawValue] as? String { + convertedAttribution[ReservedSubscriberAttribute.creative.key] = value + } + return convertedAttribution + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/SubscriberAttributes/AttributionKey.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/SubscriberAttributes/AttributionKey.swift new file mode 100644 index 00000000..860021d3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/SubscriberAttributes/AttributionKey.swift @@ -0,0 +1,63 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// AttributionKey.swift +// + +import Foundation + +// swiftlint:disable identifier_name +enum AttributionKey: String { + + case idfa = "rc_idfa", + idfv = "rc_idfv", + ip = "rc_ip_address", + gpsAdId = "rc_gps_adid", + networkID = "rc_attribution_network_id" + + enum Adjust: String { + + case id = "adid", + network = "network", + campaign = "campaign", + adGroup = "adgroup", + creative = "creative" + + } + + enum AppsFlyer: String { + + case id = "rc_appsflyer_id", + campaign = "campaign", + channel = "af_channel", + mediaSource = "media_source", + adSet = "adset", + ad = "af_ad", + adGroup = "adgroup", + adKeywords = "af_keywords", + adId = "ad_id", + dataKey = "data", + statusKey = "status" + + } + + enum Branch: String { + + case campaign, + channel + + } + + enum MParticle: String { + + case id = "mpid" + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/SubscriberAttributes/ReservedSubscriberAttributes.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/SubscriberAttributes/ReservedSubscriberAttributes.swift new file mode 100644 index 00000000..f3f2e666 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/SubscriberAttributes/ReservedSubscriberAttributes.swift @@ -0,0 +1,62 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ReservedSubscriberAttributes.swift +// +// Created by César de la Vega on 6/17/21. +// + +import Foundation + +// swiftlint:disable identifier_name +enum ReservedSubscriberAttribute: String { + + var key: String { rawValue } + + case email = "$email" + case phoneNumber = "$phoneNumber" + case displayName = "$displayName" + case pushToken = "$apnsTokens" + + case idfa = "$idfa" + case idfv = "$idfv" + case gpsAdId = "$gpsAdId" + case consentStatus = "$attConsentStatus" + + case ip = "$ip" + case deviceVersion = "$deviceVersion" + + case adjustID = "$adjustId" + case appsFlyerID = "$appsflyerId" + case fBAnonID = "$fbAnonId" + case mpParticleID = "$mparticleId" + case oneSignalID = "$onesignalId" + case oneSignalUserID = "$onesignalUserId" + case airshipChannelID = "$airshipChannelId" + case cleverTapID = "$clevertapId" + case airbridgeDeviceID = "$airbridgeDeviceId" + case kochavaDeviceID = "$kochavaDeviceId" + case mixpanelDistinctID = "$mixpanelDistinctId" + case firebaseAppInstanceID = "$firebaseAppInstanceId" + case tenjinAnalyticsInstallationID = "$tenjinId" + case postHogUserID = "$posthogUserId" + case amplitudeUserID = "$amplitudeUserId" + case amplitudeDeviceID = "$amplitudeDeviceId" + case solarEngineDistinctId = "$solarEngineDistinctId" + case solarEngineAccountId = "$solarEngineAccountId" + case solarEngineVisitorId = "$solarEngineVisitorId" + + case mediaSource = "$mediaSource" + case campaign = "$campaign" + case adGroup = "$adGroup" + case ad = "$ad" + case keyword = "$keyword" + case creative = "$creative" + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/SubscriberAttributes/SubscriberAttribute.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/SubscriberAttributes/SubscriberAttribute.swift new file mode 100644 index 00000000..21307b24 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/SubscriberAttributes/SubscriberAttribute.swift @@ -0,0 +1,173 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SubscriberAttribute.swift +// +// Created by Joshua Liebowitz on 7/1/21. +// + +import Foundation + +struct SubscriberAttribute { + + let setTime: Date + let key: String + let value: String + var isSynced: Bool + + /// Whether the `setTime` should be ignored when generating the `individualizedCacheKeyPart`. + /// + /// If `true`, two attributes with the same `key` and `value` but different `setTime` will be treated as + /// identical (e.g. to avoid duplicate Post Receipt requests when only `updated_at_ms` differs). + let ignoreTimeInCacheIdentity: Bool + + init( + withKey key: String, + value: String?, + isSynced: Bool, + setTime: Date, + ignoreTimeInCacheIdentity: Bool = false + ) { + self.key = key + self.value = value ?? "" + self.isSynced = isSynced + self.setTime = setTime + self.ignoreTimeInCacheIdentity = ignoreTimeInCacheIdentity + } + + init( + withKey key: String, + value: String?, + dateProvider: DateProvider = DateProvider(), + ignoreTimeInCacheIdentity: Bool = false + ) { + self.init( + withKey: key, + value: value, + isSynced: false, + setTime: dateProvider.now(), + ignoreTimeInCacheIdentity: ignoreTimeInCacheIdentity + ) + } + + init( + attribute: ReservedSubscriberAttribute, + value: String?, + dateProvider: DateProvider = DateProvider(), + ignoreTimeInCacheIdentity: Bool = false + ) { + self.init( + withKey: attribute.rawValue, + value: value, + dateProvider: dateProvider, + ignoreTimeInCacheIdentity: ignoreTimeInCacheIdentity + ) + } + +} + +extension SubscriberAttribute { + + init?(dictionary: [String: Any]) { + guard let key = dictionary[Key.key.rawValue] as? String, + let isSynced = (dictionary[Key.isSynced.rawValue] as? NSNumber)?.boolValue, + let setTime = dictionary[Key.setTime.rawValue] as? Date else { + return nil + } + + let value = dictionary[Key.value.rawValue] as? String + let ignoreTimeInCacheIdentity = (dictionary[Key.ignoreTimeInCacheIdentity.rawValue] as? NSNumber)?.boolValue + ?? false + + self.init( + withKey: key, + value: value, + isSynced: isSynced, + setTime: setTime, + ignoreTimeInCacheIdentity: ignoreTimeInCacheIdentity + ) + } + + func asDictionary() -> [String: NSObject] { + return [Key.key.rawValue: self.key as NSString, + Key.value.rawValue: self.value as NSString, + Key.isSynced.rawValue: NSNumber(value: self.isSynced), + Key.setTime.rawValue: self.setTime as NSDate, + Key.ignoreTimeInCacheIdentity.rawValue: NSNumber(value: self.ignoreTimeInCacheIdentity)] + } + + func asBackendDictionary() -> [String: Any] { + return [BackendKey.value.rawValue: self.value, + BackendKey.timestamp.rawValue: self.setTime.millisecondsSince1970] + } + + var individualizedCacheKeyPart: String { + return "[SubscriberAttribute] key: \(self.key) value: \(self.value)" + + (ignoreTimeInCacheIdentity ? "" : " setTime: \(self.setTime)") + } + +} + +extension SubscriberAttribute: Equatable {} +extension SubscriberAttribute: Codable {} + +extension SubscriberAttribute: CustomStringConvertible { + + var description: String { + return "[SubscriberAttribute] key: \(self.key) value: \(self.value) setTime: \(self.setTime)" + } + +} + +extension SubscriberAttribute { + + typealias Dictionary = [String: SubscriberAttribute] + +} + +extension SubscriberAttribute { + + static func map(subscriberAttributes: SubscriberAttribute.Dictionary) -> [String: [String: Any]] { + return subscriberAttributes.mapValues { $0.asBackendDictionary() } + } +} + +extension SubscriberAttribute.Dictionary { + + var individualizedCacheKeyPart: String { + // Sort keys to ensure deterministic ordering for cache key computation. + self.keys.sorted().compactMap { key in + guard let attribute = self[key] else { return nil } + return "\(key):\(attribute.individualizedCacheKeyPart)" + }.joined(separator: ",") + } +} + +// MARK: - Private + +extension SubscriberAttribute { + + private enum Key: String { + + case key + case value + case isSynced + case setTime + case ignoreTimeInCacheIdentity + + } + + private enum BackendKey: String { + + case value = "value" + case timestamp = "updated_at_ms" + + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/SubscriberAttributes/SubscriberAttributesManager.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/SubscriberAttributes/SubscriberAttributesManager.swift new file mode 100644 index 00000000..05439def --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/SubscriberAttributes/SubscriberAttributesManager.swift @@ -0,0 +1,409 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SubscriberAttributesManager.swift +// +// Created by Madeline Beyl on 8/10/21. + +import Foundation + +// swiftlint:disable file_length + +class SubscriberAttributesManager { + + private let backend: Backend + private let deviceCache: DeviceCache + private let operationDispatcher: OperationDispatcher + private let attributionFetcher: AttributionFetcher + private let attributionDataMigrator: AttributionDataMigrator + private let automaticDeviceIdentifierCollectionEnabled: Bool + private let lock = Lock() + + weak var delegate: SubscriberAttributesManagerDelegate? + + init(backend: Backend, + deviceCache: DeviceCache, + operationDispatcher: OperationDispatcher, + attributionFetcher: AttributionFetcher, + attributionDataMigrator: AttributionDataMigrator, + automaticDeviceIdentifierCollectionEnabled: Bool = true) { + self.backend = backend + self.deviceCache = deviceCache + self.operationDispatcher = operationDispatcher + self.attributionFetcher = attributionFetcher + self.attributionDataMigrator = attributionDataMigrator + self.automaticDeviceIdentifierCollectionEnabled = automaticDeviceIdentifierCollectionEnabled + } + + func setAttributes(_ attributes: [String: String], appUserID: String) { + Logger.debug(Strings.attribution.setting_attributes(attributes: Array(attributes.keys))) + for (key, value) in attributes { + setAttribute(key: key, value: value, appUserID: appUserID) + } + } + + func setEmail(_ email: String?, appUserID: String) { + setReservedAttribute(.email, value: email, appUserID: appUserID) + } + + func setPhoneNumber(_ phoneNumber: String?, appUserID: String) { + setReservedAttribute(.phoneNumber, value: phoneNumber, appUserID: appUserID) + } + + func setDisplayName(_ displayName: String?, appUserID: String) { + setReservedAttribute(.displayName, value: displayName, appUserID: appUserID) + } + + func setPushToken(_ pushToken: Data?, appUserID: String) { + let pushTokenString = pushToken?.asString + setPushTokenString(pushTokenString, appUserID: appUserID) + } + + func setPushTokenString(_ pushTokenString: String?, appUserID: String) { + setReservedAttribute(.pushToken, value: pushTokenString, appUserID: appUserID) + } + + func setAdjustID(_ adjustID: String?, appUserID: String) { + setAttributionID(adjustID, forNetworkID: .adjustID, appUserID: appUserID) + } + + func setAppsflyerID(_ appsflyerID: String?, appUserID: String) { + setAttributionID(appsflyerID, forNetworkID: .appsFlyerID, appUserID: appUserID) + } + + func setFBAnonymousID(_ fBAnonymousID: String?, appUserID: String) { + setAttributionID(fBAnonymousID, forNetworkID: .fBAnonID, appUserID: appUserID) + } + + func setMparticleID(_ mparticleID: String?, appUserID: String) { + setAttributionID(mparticleID, forNetworkID: .mpParticleID, appUserID: appUserID) + } + + func setOnesignalID(_ onesignalID: String?, appUserID: String) { + setReservedAttribute(.oneSignalID, value: onesignalID, appUserID: appUserID) + } + + func setOnesignalUserID(_ onesignalUserID: String?, appUserID: String) { + setReservedAttribute(.oneSignalUserID, value: onesignalUserID, appUserID: appUserID) + } + + func setAirshipChannelID(_ airshipChannelID: String?, appUserID: String) { + setReservedAttribute(.airshipChannelID, value: airshipChannelID, appUserID: appUserID) + } + + func setCleverTapID(_ cleverTapID: String?, appUserID: String) { + setReservedAttribute(.cleverTapID, value: cleverTapID, appUserID: appUserID) + } + + func setAirbridgeDeviceID(_ airbridgeDeviceID: String?, appUserID: String) { + setAttributionID(airbridgeDeviceID, forNetworkID: .airbridgeDeviceID, appUserID: appUserID) + } + + func setKochavaDeviceID(_ kochavaDeviceID: String?, appUserID: String) { + setAttributionID(kochavaDeviceID, forNetworkID: .kochavaDeviceID, appUserID: appUserID) + } + + func setSolarEngineDistinctId(_ solarEngineDistinctId: String?, appUserID: String) { + setAttributionID(solarEngineDistinctId, forNetworkID: .solarEngineDistinctId, appUserID: appUserID) + } + + func setSolarEngineAccountId(_ solarEngineAccountId: String?, appUserID: String) { + setAttributionID(solarEngineAccountId, forNetworkID: .solarEngineAccountId, appUserID: appUserID) + } + + func setSolarEngineVisitorId(_ solarEngineVisitorId: String?, appUserID: String) { + setAttributionID(solarEngineVisitorId, forNetworkID: .solarEngineVisitorId, appUserID: appUserID) + } + + func setMixpanelDistinctID(_ mixpanelDistinctID: String?, appUserID: String) { + setReservedAttribute(.mixpanelDistinctID, value: mixpanelDistinctID, appUserID: appUserID) + } + + func setFirebaseAppInstanceID(_ firebaseAppInstanceID: String?, appUserID: String) { + setReservedAttribute(.firebaseAppInstanceID, value: firebaseAppInstanceID, appUserID: appUserID) + } + + func setTenjinAnalyticsInstallationID(_ tenjinAnalyticsInstallationID: String?, appUserID: String) { + setReservedAttribute(.tenjinAnalyticsInstallationID, value: tenjinAnalyticsInstallationID, appUserID: appUserID) + } + + func setPostHogUserID(_ postHogUserID: String?, appUserID: String) { + setReservedAttribute(.postHogUserID, value: postHogUserID, appUserID: appUserID) + } + + func setAmplitudeUserID(_ amplitudeUserID: String?, appUserID: String) { + setReservedAttribute(.amplitudeUserID, value: amplitudeUserID, appUserID: appUserID) + } + + func setAmplitudeDeviceID(_ amplitudeDeviceID: String?, appUserID: String) { + setReservedAttribute(.amplitudeDeviceID, value: amplitudeDeviceID, appUserID: appUserID) + } + + func setMediaSource(_ mediaSource: String?, appUserID: String) { + setReservedAttribute(.mediaSource, value: mediaSource, appUserID: appUserID) + } + + func setCampaign(_ campaign: String?, appUserID: String) { + setReservedAttribute(.campaign, value: campaign, appUserID: appUserID) + } + + func setAdGroup(_ adGroup: String?, appUserID: String) { + setReservedAttribute(.adGroup, value: adGroup, appUserID: appUserID) + } + + // swiftlint:disable:next identifier_name + func setAd(_ ad: String?, appUserID: String) { + setReservedAttribute(.ad, value: ad, appUserID: appUserID) + } + + func setKeyword(_ keyword: String?, appUserID: String) { + setReservedAttribute(.keyword, value: keyword, appUserID: appUserID) + } + + func setCreative(_ creative: String?, appUserID: String) { + setReservedAttribute(.creative, value: creative, appUserID: appUserID) + } + + func setAppsFlyerConversionData(_ data: [AnyHashable: Any]?, appUserID: String) { + guard let data = data else { + return + } + + let mediaSource = stringValueForPrimitive(from: data, forKey: "media_source") ?? ( + stringValueForPrimitive(from: data, forKey: "af_status")?.caseInsensitiveCompare("Organic") == .orderedSame + ? "Organic" : nil + ) + if let mediaSource = mediaSource { + setMediaSource(mediaSource, appUserID: appUserID) + } + + if let campaign = stringValueForPrimitive(from: data, forKey: "campaign") { + setCampaign(campaign, appUserID: appUserID) + } + + if let adGroup = stringValueForPrimitive(from: data, forKey: "adgroup") + ?? stringValueForPrimitive(from: data, forKey: "adset") { + setAdGroup(adGroup, appUserID: appUserID) + } + + // swiftlint:disable:next identifier_name + if let ad = stringValueForPrimitive(from: data, forKey: "af_ad") + ?? stringValueForPrimitive(from: data, forKey: "ad_id") { + setAd(ad, appUserID: appUserID) + } + + if let keyword = stringValueForPrimitive(from: data, forKey: "af_keywords") + ?? stringValueForPrimitive(from: data, forKey: "keyword") { + setKeyword(keyword, appUserID: appUserID) + } + + if let creative = stringValueForPrimitive(from: data, forKey: "creative") + ?? stringValueForPrimitive(from: data, forKey: "af_creative") { + setCreative(creative, appUserID: appUserID) + } + } + + func collectDeviceIdentifiers(forAppUserID appUserID: String) { + let identifierForAdvertisers = attributionFetcher.identifierForAdvertisers + let identifierForVendor = attributionFetcher.identifierForVendor + + setReservedAttribute(.idfa, value: identifierForAdvertisers, appUserID: appUserID) + setReservedAttribute(.idfv, value: identifierForVendor, appUserID: appUserID) + setReservedAttribute(.ip, value: "true", appUserID: appUserID) + setReservedAttribute(.deviceVersion, value: "true", appUserID: appUserID) + } + + /// - Parameter syncedAttribute: will be called for every attribute that is updated + /// - Parameter completion: will be called once all attributes have completed syncing + /// - Returns: the number of attributes that will be synced + @discardableResult + func syncAttributesForAllUsers(currentAppUserID: String, + syncedAttribute: (@Sendable (PurchasesError?) -> Void)? = nil, + completion: (@Sendable () -> Void)? = nil) -> Int { + let unsyncedAttributesForAllUsers = unsyncedAttributesByKeyForAllUsers() + let total = unsyncedAttributesForAllUsers.count + + operationDispatcher.dispatchOnWorkerThread { + let completed: Atomic = .init(0) + + for (syncingAppUserID, attributes) in unsyncedAttributesForAllUsers { + self.syncAttributes(attributes: attributes, appUserID: syncingAppUserID) { error in + self.handleAttributesSynced(syncingAppUserId: syncingAppUserID, + currentAppUserId: currentAppUserID, + error: error) + + syncedAttribute?(error?.asPurchasesError) + self.delegate?.subscriberAttributesManager( + self, + didFinishSyncingAttributes: attributes, + forUserID: syncingAppUserID + ) + + let completedSoFar: Int = completed.modify { $0 += 1; return $0 } + + if completedSoFar == total { + completion?() + } + } + } + + if total == 0 { + completion?() + } + } + + return total + } + + func handleAttributesSynced(syncingAppUserId: String, currentAppUserId: String, error: BackendError?) { + if error == nil { + Logger.rcSuccess(Strings.attribution.attributes_sync_success(appUserID: syncingAppUserId)) + if syncingAppUserId != currentAppUserId { + deviceCache.deleteAttributesIfSynced(appUserID: syncingAppUserId) + } + } else { + let receivedNSError = error as NSError? + Logger.error(Strings.attribution.attributes_sync_error(error: receivedNSError)) + } + } + + func unsyncedAttributesByKey(appUserID: String) -> SubscriberAttribute.Dictionary { + let unsyncedAttributes = deviceCache.unsyncedAttributesByKey(appUserID: appUserID) + Logger.debug(Strings.attribution.unsynced_attributes_count(unsyncedAttributesCount: unsyncedAttributes.count, + appUserID: appUserID)) + if !unsyncedAttributes.isEmpty { + Logger.debug(Strings.attribution.unsynced_attributes(unsyncedAttributes: unsyncedAttributes)) + } + + return unsyncedAttributes + } + + func unsyncedAttributesByKeyForAllUsers() -> [String: SubscriberAttribute.Dictionary] { + return deviceCache.unsyncedAttributesForAllUsers() + } + + func markAttributesAsSynced(_ attributesToSync: SubscriberAttribute.Dictionary?, appUserID: String) { + guard let attributesToSync = attributesToSync, + !attributesToSync.isEmpty else { + return + } + + Logger.info(Strings.attribution.marking_attributes_synced(appUserID: appUserID, attributes: attributesToSync)) + + self.lock.perform { + var unsyncedAttributes = self.unsyncedAttributesByKey(appUserID: appUserID) + + for (key, attribute) in attributesToSync where unsyncedAttributes[key]?.value == attribute.value { + unsyncedAttributes[key]?.isSynced = true + } + + self.deviceCache.store(subscriberAttributesByKey: unsyncedAttributes, appUserID: appUserID) + } + } + + func setAttributes(fromAttributionData attributionData: [String: Any], + network: AttributionNetwork, + appUserID: String) { + let convertedAttribution = attributionDataMigrator.convertToSubscriberAttributes( + attributionData: attributionData, + network: network.rawValue) + setAttributes(convertedAttribution, appUserID: appUserID) + } + +} + +extension SubscriberAttributesManager: AttributeSyncing { + + func syncSubscriberAttributes(currentAppUserID: String, completion: @Sendable @escaping () -> Void) { + self.syncAttributesForAllUsers(currentAppUserID: currentAppUserID, + syncedAttribute: nil, + completion: completion) + } + +} + +private extension SubscriberAttributesManager { + + func stringValueForPrimitive(from data: [AnyHashable: Any], forKey key: String) -> String? { + guard let value = data[key as AnyHashable] else { return nil } + if let stringValue = value as? String { + return stringValue.isEmpty ? nil : stringValue + } + if let boolValue = value as? Bool { return String(boolValue) } + if let number = value as? NSNumber { + return number.stringValue + } + return nil + } + + func storeAttributeLocallyIfNeeded(key: String, value: String?, appUserID: String) { + let currentValue = currentValueForAttribute(key: key, appUserID: appUserID) + if currentValue == nil || currentValue != (value ?? "") { + storeAttributeLocally(key: key, value: value ?? "", appUserID: appUserID) + } + } + + func setReservedAttribute(_ reservedAttribute: ReservedSubscriberAttribute, value: String?, appUserID: String) { + Logger.debug(Strings.attribution.setting_reserved_attribute(reservedAttribute)) + setAttribute(key: reservedAttribute.key, value: value, appUserID: appUserID) + } + + func setAttribute(key: String, value: String?, appUserID: String) { + storeAttributeLocallyIfNeeded(key: key, value: value, appUserID: appUserID) + } + + func syncAttributes(attributes: SubscriberAttribute.Dictionary, + appUserID: String, + completion: @escaping (BackendError?) -> Void) { + backend.post(subscriberAttributes: attributes, appUserID: appUserID) { error in + let didBackendReceiveValues = error?.successfullySynced ?? true + + if didBackendReceiveValues { + self.markAttributesAsSynced(attributes, appUserID: appUserID) + } + completion(error) + } + } + + func storeAttributeLocally(key: String, value: String, appUserID: String) { + let subscriberAttribute = SubscriberAttribute(withKey: key, value: value) + Logger.debug(Strings.attribution.attribute_set_locally(attribute: subscriberAttribute.description)) + deviceCache.store(subscriberAttribute: subscriberAttribute, appUserID: appUserID) + } + + func currentValueForAttribute(key: String, appUserID: String) -> String? { + let attribute = deviceCache.subscriberAttribute(attributeKey: key, appUserID: appUserID) + return attribute?.value + } + + func setAttributionID(_ attributionID: String?, + forNetworkID networkID: ReservedSubscriberAttribute, + appUserID: String) { + if automaticDeviceIdentifierCollectionEnabled { + collectDeviceIdentifiers(forAppUserID: appUserID) + } + setReservedAttribute(networkID, value: attributionID, appUserID: appUserID) + } + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +// - It has a mutable `delegate` because it needs to be, as `weak`. +extension SubscriberAttributesManager: @unchecked Sendable {} + +// MARK: - + +protocol SubscriberAttributesManagerDelegate: AnyObject, Sendable { + + func subscriberAttributesManager(_ manager: SubscriberAttributesManager, + didFinishSyncingAttributes attributes: SubscriberAttribute.Dictionary, + forUserID userID: String) + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/BeginRefundRequestHelper.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/BeginRefundRequestHelper.swift new file mode 100644 index 00000000..1004d8c8 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/BeginRefundRequestHelper.swift @@ -0,0 +1,168 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// BeginRefundRequestHelper.swift +// +// Created by Madeline Beyl on 10/13/21. + +import Foundation + +/** + * Helper class responsible for handling any non-store-specific code involved in beginning a refund request. + * Delegates store-specific operations to `SK2BeginRefundRequestHelper`. + */ +class BeginRefundRequestHelper { + + private let systemInfo: SystemInfo + private let customerInfoManager: CustomerInfoManager + private let currentUserProvider: CurrentUserProvider + +#if os(iOS) || VISION_OS + + private var _sk2Helper: Any? + + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + var sk2Helper: SK2BeginRefundRequestHelperType { + get { + // swiftlint:disable:next force_cast force_unwrapping + return self._sk2Helper! as! SK2BeginRefundRequestHelperType + } + + set { + self._sk2Helper = newValue + } + } + +#endif + + init(systemInfo: SystemInfo, customerInfoManager: CustomerInfoManager, currentUserProvider: CurrentUserProvider) { + self.systemInfo = systemInfo + self.customerInfoManager = customerInfoManager + self.currentUserProvider = currentUserProvider + + #if os(iOS) || VISION_OS + if #available(iOS 15, *) { + self._sk2Helper = SK2BeginRefundRequestHelper() + } else { + self._sk2Helper = nil + } + #endif + } + +#if os(iOS) || VISION_OS + /* + * Entry point for beginning the refund request. Handles getting the current windowScene and verifying the + * transaction before calling into `SK2BeginRefundRequestHelper`'s `initiateRefundRequest`. + */ + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @MainActor + func beginRefundRequest(forProduct productID: String) async throws -> RefundRequestStatus { + let windowScene = try self.systemInfo.currentWindowScene + + let transactionID = try await self.sk2Helper.verifyTransaction(productID: productID) + return try await self.sk2Helper.initiateRefundRequest(transactionID: transactionID, + windowScene: windowScene) + } + + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func beginRefundRequest(forEntitlement entitlementID: String) async throws -> RefundRequestStatus { + let entitlement = try await getEntitlement(entitlementID: entitlementID) + return try await self.beginRefundRequest(forProduct: entitlement.productIdentifier) + } + + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func beginRefundRequestForActiveEntitlement() async throws -> RefundRequestStatus { + let activeEntitlement = try await getEntitlement(entitlementID: nil) + return try await self.beginRefundRequest(forProduct: activeEntitlement.productIdentifier) + } +#endif +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +// - It has mutable `_sk2Helper` which is necessary due to the availability annotations. +extension BeginRefundRequestHelper: @unchecked Sendable {} + +// MARK: - Private + +#if os(iOS) || VISION_OS +@available(iOS 15.0, *) +@available(macOS, unavailable) +@available(watchOS, unavailable) +@available(tvOS, unavailable) +private extension BeginRefundRequestHelper { + + /* + * Gets entitlement with the given `entitlementID` from customerInfo, or the active entitlement + * if no ID passed in. + */ + func getEntitlement(entitlementID: String?) async throws -> EntitlementInfo { + let customerInfo: CustomerInfo + + do { + customerInfo = try await self.customerInfoManager.customerInfo( + appUserID: self.currentUserProvider.currentAppUserID, + fetchPolicy: .cachedOrFetched + ) + } catch { + let message = Strings.purchase.begin_refund_customer_info_error(entitlementID: entitlementID) + .description + throw ErrorUtils.beginRefundRequestError(withMessage: message, error: error) + } + + if let entitlementID = entitlementID { + guard let entitlement = customerInfo.entitlements[entitlementID] else { + let message = Strings.purchase + .begin_refund_no_entitlement_found(entitlementID: entitlementID) + .description + throw ErrorUtils.beginRefundRequestError(withMessage: message) + } + + return entitlement + } + + guard customerInfo.entitlements.active.count < 2 else { + let message = Strings.purchase.begin_refund_multiple_active_entitlements.description + throw ErrorUtils.beginRefundRequestError(withMessage: message) + } + + guard let activeEntitlement = customerInfo.entitlements.active.first?.value else { + let message = Strings.purchase.begin_refund_no_active_entitlement.description + throw ErrorUtils.beginRefundRequestError(withMessage: message) + } + + return activeEntitlement + } + +} +#endif + +/// Status codes for refund requests. +@objc(RCRefundRequestStatus) public enum RefundRequestStatus: Int, Sendable { + + /// User canceled submission of the refund request. + @objc(RCRefundRequestUserCancelled) case userCancelled = 0 + /// Apple has received the refund request. + @objc(RCRefundRequestSuccess) case success + /// There was an error with the request. See message for more details. + @objc(RCRefundRequestError) case error + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/DebugContentViews.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/DebugContentViews.swift new file mode 100644 index 00000000..102c7526 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/DebugContentViews.swift @@ -0,0 +1,636 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DebugViewContent.swift +// +// Created by Nacho Soto on 5/30/23. + +#if DEBUG && (os(iOS) || os(macOS) || VISION_OS) + +import StoreKit +import SwiftUI + +#if os(macOS) +import AppKit +#endif + +// swiftlint:disable file_length + +@available(iOS 16.0, macOS 13.0, *) +struct DebugSwiftUIRootView: View { + + @StateObject + private var model = DebugViewModel() + + @Environment(\.presentationMode) + private var presentationMode + + var body: some View { + NavigationStack(path: self.$model.navigationPath) { + DebugSummaryView(model: self.model) + .navigationDestination(for: DebugViewPath.self) { path in + switch path { + case let .offering(offering): + DebugOfferingView(offering: offering) + + case let .offeringMetadata(offering): + DebugOfferingMetadataView(offering: offering) + + case let .package(package): + DebugPackageView(package: package) + + case let .paywall(paywall): + DebugPaywallJSONView(paywall: paywall) + + case .statusDetails: + DebugStatusDetailsView(errors: model.errorsToExpandOn) + } + } + .background( + Rectangle() + .foregroundStyle(Material.thinMaterial) + .edgesIgnoringSafeArea(.all) + ) + #if os(macOS) || targetEnvironment(macCatalyst) + .toolbar { + ToolbarItem(placement: .automatic) { + Button { + self.presentationMode.wrappedValue.dismiss() + } label: { + Label("Close", systemImage: "xmark") + } + } + } + #endif + } + .task { + await self.model.load() + } + } + + static let cornerRadius: CGFloat = 24 + +} + +private enum DebugViewPath: Hashable { + + case offering(Offering) + case offeringMetadata(Offering) + case package(Package) + case paywall(PaywallData) + case statusDetails + +} + +@available(iOS 16.0, macOS 13.0, *) +internal struct DebugSummaryView: View { + + @ObservedObject + var model: DebugViewModel + + var body: some View { + List { + self.diagnosticsSection + + self.openAppButton + + self.configurationSection + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + self.customerInfoSection + #endif + + self.offeringsSection + } + #if os(iOS) + .listStyle(.insetGrouped) + #endif + .scrollContentBackground(.hidden) + .navigationTitle("RevenueCat Debug") + } + + private var statusLabel: some View { + LabeledContent("Status") { + HStack { + Text(self.model.diagnosticsStatus) + self.model.diagnosticsIcon + } + } + } + + private var diagnosticsSection: some View { + Section { + if !self.model.errorsToExpandOn.isEmpty { + NavigationLink(value: DebugViewPath.statusDetails) { + statusLabel + } + } else { + statusLabel + } + + } header: { + Text("Diagnostics") + } footer: { + if let diagnosticsFooter = self.model.diagnosticsExplainer { + Text(diagnosticsFooter) + } + } + } + + private var openAppButton: some View { + Group { + if let url = model.diagnosticsActionURL, + let title = model.diagnosticsActionTitle { + Link(destination: url) { + Label(title, systemImage: "arrow.up.forward") + } + } + } + + } + + private var configurationSection: some View { + Section("Configuration") { + switch self.model.configuration { + case .loading: + Text("Loading...") + + case let .loaded(config): + Group { + LabeledContent("SDK version", value: config.sdkVersion) + LabeledContent("Observer mode", value: config.observerMode.description) + LabeledContent("Sandbox", value: config.sandbox.description) + LabeledContent("StoreKit 2", value: config.storeKit2Enabled ? "on" : "off") + LabeledContent("Locale", value: config.locale.display) + LabeledContent("Offline Customer Info", + value: config.offlineCustomerInfoSupport ? "enabled" : "disabled") + LabeledContent("Entitlement Verification Mode", value: config.verificationMode) + LabeledContent("Receipt URL", value: config.receiptURL?.relativeString ?? "") + #if os(macOS) + .contextMenu { + Button { + if let url = config.receiptURL { + NSWorkspace.shared.selectFile( + nil, + inFileViewerRootedAtPath: url.deletingLastPathComponent().path + ) + } + } label: { + Text("Show in Finder") + } + } + #endif + LabeledContent("Receipt status", value: config.receiptStatus) + + ShareLink(item: AnyEncodable(config), preview: .init("Configuration")) { + Label("Share", systemImage: "square.and.arrow.up") + } + } + .textSelection(.enabled) + .frame(maxWidth: .infinity) + } + } + } + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + private var customerInfoSection: some View { + Section("Customer Info") { + switch self.model.customerInfo { + case .loading: + Text("Loading...") + + case let .loaded(info): + LabeledContent("User ID", value: self.model.currentAppUserID ?? "") + LabeledContent("Original User ID", value: info.originalAppUserId) + LabeledContent("Entitlements", value: info.entitlementDescription) + + if let latestExpiration = info.latestExpirationDate { + LabeledContent("Latest Expiration Date", + value: latestExpiration.formatted(date: .abbreviated, + time: .omitted)) + } + + case let .failed(error): + Text("Error loading customer info: \(error.localizedDescription)") + } + } + } + #endif + + @ViewBuilder + private var offeringsSection: some View { + Section("Offerings") { + switch self.model.offerings { + case .loading: + Text("Loading...") + + case let .loaded(offerings): + ForEach(Array(offerings.all.values)) { offering in + NavigationLink(value: DebugViewPath.offering(offering)) { + VStack { + LabeledContent( + offering.identifier, + value: "\(offering.availablePackages.count) package(s)" + ) + } + } + } + + case let .failed(error): + Text("Error loading offerings: \(error.localizedDescription)") + } + } + } + +} + +@available(iOS 16.0, macOS 13.0, *) +private struct DebugOfferingView: View { + + @State private var showingSubscriptionSheet = false + @State private var showingStoreSheet = false + + var offering: Offering + + var body: some View { + if #available(iOS 17.0, macOS 14.0, tvOS 17.0, *) { + content + #if swift(>=5.9) + .onInAppPurchaseCompletion { _, _ in + self.showingSubscriptionSheet = false + self.showingStoreSheet = false + } + #endif + } else { + content + } + } + + private var content: some View { + List { + Section("Data") { + LabeledContent("Identifier", value: self.offering.id) + LabeledContent("Description", value: self.offering.serverDescription) + + if !self.offering.metadata.isEmpty { + NavigationLink(value: DebugViewPath.offeringMetadata(self.offering)) { + Text("Metadata") + } + } else { + LabeledContent("Metadata", value: "{}") + } + + if let paywall = self.offering.paywall { + NavigationLink(value: DebugViewPath.paywall(paywall)) { + Text("RevenueCatUI paywall") + } + } else { + LabeledContent("RevenueCatUI", value: "No paywall") + } + } + + Section("Packages") { + ForEach(self.offering.availablePackages) { package in + NavigationLink(value: DebugViewPath.package(package)) { + Text(package.identifier) + } + } + } + + if #available(iOS 17.0, macOS 14.0, tvOS 17.0, *) { + Section("Paywalls") { + Button { + self.showingSubscriptionSheet = true + } label: { + Text("Display SubscriptionStoreView") + } + + Button { + self.showingStoreSheet = true + } label: { + Text("Display StoreView") + } + } + } + } + .navigationTitle("Offering") + #if swift(>=5.9) + .sheet(isPresented: self.$showingSubscriptionSheet) { + if #available(iOS 17.0, macOS 14.0, tvOS 17.0, *) { + self.subscriptionStoreView + } + } + .sheet(isPresented: self.$showingStoreSheet) { + if #available(iOS 17.0, macOS 14.0, tvOS 17.0, *) { + StoreView.forOffering(self.offering) + } + } + #endif + } + + #if swift(>=5.9) + @available(iOS 17.0, macOS 14.0, tvOS 17.0, *) + private var subscriptionStoreView: some View { + SubscriptionStoreView.forOffering(self.offering) { + VStack { + VStack { + Text("🐈") + Text("RevenueCat Demo Paywall") + } + .font(.title) + + Text(self.offering.getMetadataValue(for: "title", default: "Premium Access")) + .font(.title2) + .foregroundStyle(.primary) + + Text(self.offering.getMetadataValue(for: "subtitle", + default: "Unlimited access to premium content.")) + .foregroundStyle(.secondary) + .font(.subheadline) + } + #if !VISION_OS + .containerBackground(for: .subscriptionStoreFullHeight) { + Rectangle() + .edgesIgnoringSafeArea(.all) + .foregroundStyle(Color.blue.gradient.quaternary) + } + #endif + } + .backgroundStyle(.clear) + .subscriptionStoreButtonLabel(.multiline) + .subscriptionStorePickerItemBackground(.thickMaterial) + } + #endif + +} + +@available(iOS 16.0, macOS 13.0, *) +private struct DebugOfferingMetadataView: View { + + var offering: Offering + + var body: some View { + DebugJSONView(value: AnyEncodable(self.offering.metadata)) + .navigationTitle("Offering Metadata") + } + +} + +@available(iOS 16.0, macOS 13.0, *) +private struct DebugPackageView: View { + + var package: Package + + @State private var error: NSError? { + didSet { + if self.error != nil { + self.displayError = true + } + } + } + + @State private var displayError: Bool = false + @State private var purchasing: Bool = false + + var body: some View { + List { + Section("Data") { + LabeledContent("Identifier", value: self.package.identifier) + LabeledContent("Price", value: self.package.localizedPriceString) + LabeledContent("Product", value: self.package.storeProduct.productIdentifier) + LabeledContent("Type", value: self.package.packageType.description ?? "") + } + + Section("Purchasing") { + Button { + _ = Task { @MainActor in + do { + self.purchasing = true + try await self.purchase() + } catch { + self.error = error as NSError + } + + self.purchasing = false + } + } label: { + Text("Purchase with RevenueCat") + } + + #if swift(>=5.9) + if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) { + ProductView(id: self.package.storeProduct.productIdentifier) + .productViewStyle(ProductStyle()) + } + #endif + } + .disabled(self.purchasing) + } + .navigationTitle("Package") + .alert( + "Error", + isPresented: self.$displayError, + presenting: self.error + ) { error in + Text(error.localizedDescription) + } + } + + @MainActor + private func purchase() async throws { + _ = try await Purchases.shared.purchase(package: self.package) + } + +} + +@available(iOS 16.0, macOS 13.0, *) +private struct DebugStatusDetailsView: View { + let errors: [PurchasesDiagnostics.SDKHealthError] + + var body: some View { + List { + ForEach(errors, id: \.errorCode) { error in + switch error { + case let .invalidProducts(products): + Section("Product Validation Issues") { + ForEach(products, id: \.identifier) { product in + if product.status != .valid { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 8) { + product.status.icon.imageScale(.small) + Text(product.identifier) + } + Text(product.description) + .foregroundStyle(.secondary) + .font(.caption) + } + } + } + } + case .noOfferings: + Section("Offering Validation Issues") { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.yellow) + Text("No offerings configured") + } + Text( + """ + While offerings are not mandatory, they are the way to 'offer' products \ + to your customers on your paywalls. + """ + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } + case let .offeringConfiguration(offerings): + Section("Offering Validation Issues") { + ForEach(offerings, id: \.identifier) { offering in + if offering.status != .passed { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 8) { + offering.status.icon.imageScale(.small) + Text(offering.identifier) + } + Text( + offering.packages.isEmpty ? + "Offerings must have at least one package" : + """ + Products \(offering.packages.map { "'\($0.productIdentifier)'" }.formatted()) \ + in the offering have issues, check the products list above for more details. + """ + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + default: EmptyView() + } + } + } + } +} + +@available(iOS 16.0, macOS 13.0, *) +private struct DebugPaywallJSONView: View { + + let paywall: PaywallData + + var body: some View { + DebugJSONView(value: AnyEncodable(self.paywall)) + .navigationTitle("RevenueCatUI Paywall") + } + +} + +@available(iOS 16.0, macOS 13.0, *) +private struct DebugJSONView: View { + + let value: Value + + var body: some View { + ScrollView(.vertical) { + Text(self.json) + .multilineTextAlignment(.leading) + .font(.caption) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .toolbar { + ToolbarItem(placement: .primaryAction) { + ShareLink(item: self.value, preview: .init("JSON")) { + Label("Export", systemImage: "square.and.arrow.up") + } + } + } + } + + private var json: String { + return (try? self.value.prettyPrintedJSON) ?? "{}" + } + +} + +// MARK: - Locale + +private extension Locale { + + var display: String { + return "\(self.identifier) (\(self.rc_currencyCode ?? "unknown"))" + } + +} + +// MARK: - Transferable + +@available(iOS 16.0, macOS 13.0, *) +extension AnyEncodable: Transferable { + + static var transferRepresentation: some TransferRepresentation { + return CodableRepresentation( + for: Self.self, + contentType: .plainText, + encoder: JSONEncoder.prettyPrinted, + decoder: JSONDecoder.default + ) + } + +} + +#if swift(>=5.9) +@available(iOS 17.0, macOS 14.0, *) +private struct ProductStyle: ProductViewStyle { + + func makeBody(configuration: ProductViewStyleConfiguration) -> some View { + switch configuration.state { + case .loading: + ProgressView() + .progressViewStyle(.circular) + + case .success: + Button { + configuration.purchase() + } label: { + Text("Purchase with StoreKit") + } + + default: + ProductView(configuration) + } + } + +} +#endif + +#if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + +private extension CustomerInfo { + + var entitlementDescription: String { + let total = self.entitlements.all.count + let active = self.entitlements.active.values + + let activeList = active.isEmpty + ? "" + : ": \(active.map(\.identifier).joined(separator: ", "))" + + return "\(total) total, \(active.count) active\(activeList)" + } + +} + +#endif + +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/DebugView.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/DebugView.swift new file mode 100644 index 00000000..53dc72f3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/DebugView.swift @@ -0,0 +1,85 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DebugView.swift +// +// Created by Nacho Soto on 5/30/23. + +#if DEBUG && (os(iOS) || os(macOS) || VISION_OS) + +import SwiftUI + +@available(iOS 16.0, macOS 13.0, *) +public extension View { + + /// Adds a bottom sheet overlay to the current view which allows debugging the current SDK setup. + /// + /// Usage: + /// ```swift + /// var body: some View { + /// YourViewContent() + /// .debugRevenueCatOverlay() + /// } + /// ``` + /// + /// - Note: This will present the overlay automatically on launch. + /// To manage the presentation manually, use `debugRevenueCatOverlay(isPresented:)` + func debugRevenueCatOverlay() -> some View { + return self.debugRevenueCatOverlay(isPresented: .constant(true)) + } + + /// Adds a bottom sheet overlay to the current view which allows debugging the current SDK setup. + /// + /// Usage: + /// ```swift + /// @State private var debugOverlayVisible: Bool = false + /// + /// var body: some View { + /// YourViewContent() + /// .debugRevenueCatOverlay(isPresented: self.debugOverlayVisible) + /// + /// Button { + /// self.debugOverlayVisible.toggle() + /// } label: { + /// Text("RevenueCat Debug view") + /// } + /// } + /// ``` + func debugRevenueCatOverlay(isPresented: Binding) -> some View { + self.bottomSheet( + presentationDetents: self.detents, + isPresented: isPresented, + largestUndimmedIdentifier: .fraction(0.6), + cornerRadius: DebugSwiftUIRootView.cornerRadius, + content: { + DebugSwiftUIRootView() + #if os(macOS) + .frame(width: 500, height: 600) + #elseif VISION_OS + .frame(width: 600, height: 700) + #endif + } + ) + } + + private var detents: Set { + #if VISION_OS + return [.large] + #else + return [ + .fraction(0.2), + .fraction(0.6), + .large + ] + #endif + } + +} + +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/DebugViewController.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/DebugViewController.swift new file mode 100644 index 00000000..1a77648b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/DebugViewController.swift @@ -0,0 +1,88 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DebugViewController.swift +// +// Created by Nacho Soto on 6/12/23. + +import Foundation + +#if DEBUG && os(iOS) + +import SwiftUI +import UIKit + +/// A view controller which allows debugging the current SDK setup. +/// +/// You can present this yourself, or use `UIViewController.presentDebugRevenueCatOverlay` +/// for a default presentation using `UISheetPresentationController`. +/// +/// - Seealso: `View.debugRevenueCatOverlay` for `SwiftUI`. +@available(iOS 16.0, *) +@objc(RCDebugViewController) +public final class DebugViewController: UIViewController { + + private lazy var hostingController: UIHostingController = { + .init(rootView: .init()) + }() + + // swiftlint:disable:next missing_docs + public init() { + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func loadView() { + super.loadView() + + self.addChild(self.hostingController) + self.view.addSubview(self.hostingController.view) + self.hostingController.didMove(toParent: self) + } + + public override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + + self.hostingController.view.frame = self.view.bounds + } + +} + +@available(iOS 16.0, *) +extension UIViewController { + + /// Presents a bottom sheet overlay on top of the current view controller + /// which allows debugging the current SDK setup. + /// + /// - Seealso: `DebugViewController`. + /// - Seealso: `View.debugRevenueCatOverlay` for `SwiftUI`. + @objc(rc_presentDebugRevenueCatOverlayAnimated:) + public func presentDebugRevenueCatOverlay(animated: Bool = true) { + let controller = DebugViewController() + + if let sheet = controller.sheetPresentationController { + sheet.detents = [ + .custom(resolver: { context in context.maximumDetentValue * 0.2 }), + .medium(), + .large() + ] + sheet.largestUndimmedDetentIdentifier = .medium + sheet.preferredCornerRadius = DebugSwiftUIRootView.cornerRadius + sheet.prefersGrabberVisible = true + } + + self.present(controller, animated: animated) + } + +} + +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/DebugViewModel.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/DebugViewModel.swift new file mode 100644 index 00000000..e3bd8e00 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/DebugViewModel.swift @@ -0,0 +1,250 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DebugViewModel.swift +// +// Created by Nacho Soto on 5/30/23. + +import Foundation + +#if DEBUG && (os(iOS) || os(macOS) || VISION_OS) + +import SwiftUI + +@MainActor +@available(iOS 16.0, macOS 13.0, *) +final class DebugViewModel: ObservableObject { + + struct Configuration: Codable { + + var sdkVersion: String = SystemInfo.frameworkVersion + var observerMode: Bool + var sandbox: Bool + var storeKit2Enabled: Bool + var locale: Locale + var offlineCustomerInfoSupport: Bool + var verificationMode: String + var receiptURL: URL? + + } + + var configuration: LoadingState = .loading + + @Published + var diagnosticsResult: LoadingState = .loading + @Published + var offerings: LoadingState = .loading + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + @Published + var customerInfo: LoadingState = .loading + @Published + var currentAppUserID: String? + #endif + + // We can't directly store instances of `NavigationPath`, since that causes runtime crashes when + // loading this type in iOS <= 15, even with @available checks correctly in place. + // See https://openradar.appspot.com/radar?id=4970535809187840 / https://github.com/apple/swift/issues/58099 + @Published + private var _navigationPath: Any = NavigationPath() + + var navigationPath: NavigationPath { + // swiftlint:disable:next force_cast + get { return self._navigationPath as! NavigationPath } + set { self._navigationPath = newValue } + } + + @MainActor + func load() async { + self.configuration = .loaded(.create()) + #if DEBUG && !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + self.diagnosticsResult = .loaded(await PurchasesDiagnostics.default.healthReport()) + #endif + self.offerings = await .create { try await Purchases.shared.offerings() } + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + self.customerInfo = await .create { try await Purchases.shared.customerInfo() } + self.currentAppUserID = Purchases.shared.appUserID + + for await info in Purchases.shared.customerInfoStream { + self.customerInfo = .loaded(info) + } + #endif + } + +} + +@available(iOS 16.0, macOS 13.0, *) +extension DebugViewModel { + + var diagnosticsStatus: String { + switch self.diagnosticsResult { + case .loading: return "Loading..." + case let .loaded(healthReport): + switch healthReport.status { + case .healthy: return "Configuration OK" + case .unhealthy: return "Invalid Configuration" + } + } + } + + var diagnosticsExplainer: String? { + switch self.diagnosticsResult { + case let .loaded(healthReport): + switch healthReport.status { + case let .healthy(warnings): + return warnings.count > 0 ? """ + Your RevenueCat configuration is valid, however we encountered some potential issues \ + during validation. Feel free to ignore them if your configuration works as expected. + """ + : nil + case .unhealthy(let error): return error.localizedDescription + } + default: return nil + } + } + + var diagnosticsActionURL: URL? { + switch self.diagnosticsResult { + case let .loaded(healthReport): + guard let appId = healthReport.appId, let projectId = healthReport.projectId else { + return nil + } + switch healthReport.status { + case .healthy: return nil + case .unhealthy(let error): + switch error { + case .offeringConfiguration: + return URL(string: "https://app.revenuecat.com/projects/\(projectId)/offerings") + case .invalidBundleId: + return URL(string: "https://app.revenuecat.com/projects/\(projectId)/apps/\(appId)") + case .invalidProducts: + return URL(string: "https://app.revenuecat.com/projects/\(projectId)/products") + default: return nil + } + } + case .loading, .failed: return nil + } + } + + var diagnosticsActionTitle: String? { + switch self.diagnosticsResult { + case .loading, .failed: return nil + case let .loaded(healthReport): + switch healthReport.status { + case .healthy: return nil + case .unhealthy(let error): + switch error { + case .offeringConfiguration: + return "Open Offerings" + case .invalidBundleId: + return "Open App Configuration" + case .invalidProducts: + return "Open Products" + default: return nil + } + } + } + } + + @ViewBuilder + var diagnosticsIcon: some View { + switch self.diagnosticsResult { + case .loading: + Image(systemName: "gear.circle") + .foregroundColor(.gray) + case let .loaded(healthReport): + healthReport.status.icon + } + } + + var errorsToExpandOn: [PurchasesDiagnostics.SDKHealthError] { + switch self.diagnosticsResult { + case .loading: return [] + case let .loaded(healthReport): + switch healthReport.status { + case let .healthy(warnings): return warnings + case let .unhealthy(error): + switch error { + case .invalidProducts, .offeringConfiguration: return [error] + default: return [] + } + } + } + } +} + +@available(iOS 16.0, macOS 13.0, *) +extension DebugViewModel.Configuration { + + var receiptStatus: String { + switch self.receiptURL { + case .none: + return "no URL" + case let .some(url): + if FileManager.default.fileExists(atPath: url.relativePath) { + return "present" + } else { + return "missing" + } + } + } + +} + +// MARK: - + +enum LoadingState { + + case loading + case loaded(Value) + case failed(Error) + +} + +extension LoadingState where Error == NSError { + + static func create(_ loader: @Sendable () async throws -> Value) async -> Self { + do { + return .loaded(try await loader()) + } catch { + return .failed(error as NSError) + } + } + +} + +@available(iOS 16.0, macOS 13.0, *) +private extension DebugViewModel.Configuration { + + static func create(with purchases: Purchases = .shared) -> Self { + return .init( + observerMode: purchases.observerMode, + sandbox: purchases.isSandbox, + storeKit2Enabled: purchases.isStoreKit2EnabledAndAvailable, + locale: .autoupdatingCurrent, + offlineCustomerInfoSupport: purchases.offlineCustomerInfoEnabled, + verificationMode: purchases.responseVerificationMode.display, + receiptURL: purchases.receiptURL + ) + } + +} + +private extension Signing.ResponseVerificationMode { + + var display: String { + switch self { + case .disabled: return "disabled" + case .informational: return "informational" + case .enforced: return "enforced" + } + } + +} + +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/DebugViewSheetPresentation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/DebugViewSheetPresentation.swift new file mode 100644 index 00000000..f6d5cec5 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/DebugViewSheetPresentation.swift @@ -0,0 +1,48 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// DebugViewSheetPresentation.swift +// +// Created by Nacho Soto on 5/30/23. + +#if DEBUG && (os(iOS) || os(macOS) || VISION_OS) + +import SwiftUI + +@available(iOS 16.0, macOS 13.0, *) +extension View { + + @ViewBuilder + func bottomSheet( + presentationDetents: Set, + isPresented: Binding, + largestUndimmedIdentifier: PresentationDetent = .large, + cornerRadius: CGFloat, + interactiveDismissDisabled: Bool = false, + @ViewBuilder content: @escaping () -> some View + ) -> some View { + self + .sheet(isPresented: isPresented) { + let result = content() + .presentationDetents(presentationDetents) + .presentationDragIndicator(.automatic) + .interactiveDismissDisabled(interactiveDismissDisabled) + + if #available(iOS 16.4, macOS 13.3, tvOS 16.4, watchOS 9.4, *) { + result + .presentationCornerRadius(cornerRadius) + .presentationBackgroundInteraction(.enabled(upThrough: largestUndimmedIdentifier)) + } else { + result + } + } + } +} + +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/ProductStatus+Icon.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/ProductStatus+Icon.swift new file mode 100644 index 00000000..fb5dc438 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/ProductStatus+Icon.swift @@ -0,0 +1,36 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ProductStatus+Icon.swift +// +// Created by Pol Piella on 4/10/25. + +#if DEBUG +import SwiftUI + +@available(iOS 16.0, macOS 13.0, *) +extension PurchasesDiagnostics.ProductStatus { + var icon: some View { + switch self { + case .valid: + return Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + case .couldNotCheck, .unknown: + return Image(systemName: "questionmark.circle.fill") + .foregroundColor(.gray) + case .notFound: + return Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + case .actionInProgress, .needsAction: + return Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.yellow) + } + } +} +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/SDKHealthCheckStatus+Icon.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/SDKHealthCheckStatus+Icon.swift new file mode 100644 index 00000000..76ebbe20 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/SDKHealthCheckStatus+Icon.swift @@ -0,0 +1,33 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SDKHealthCheckStatus+Icon.swift +// +// Created by Pol Piella on 4/10/25. + +#if DEBUG +import SwiftUI + +@available(iOS 16.0, macOS 13.0, *) +extension PurchasesDiagnostics.SDKHealthCheckStatus { + var icon: some View { + switch self { + case .passed: + return Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + case .failed: + return Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + case .warning: + return Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.yellow) + } + } +} +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/SDKHealthStatus+Icon.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/SDKHealthStatus+Icon.swift new file mode 100644 index 00000000..17be41b1 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/DebugUI/SDKHealthStatus+Icon.swift @@ -0,0 +1,33 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SDKHealthStatus+Icon.swift +// +// Created by Pol Piella on 4/10/25. + +#if DEBUG +import SwiftUI + +@available(iOS 16.0, macOS 13.0, *) +extension PurchasesDiagnostics.SDKHealthStatus { + var icon: some View { + switch self { + case let .healthy(warnings): + return Image(systemName: warnings.count > 0 + ? "checkmark.circle.badge.questionmark.fill" + : "checkmark.circle.fill") + .foregroundColor(.green) + case .unhealthy: + return Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + } + + } +} +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/FrameworkDisambiguation.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/FrameworkDisambiguation.swift new file mode 100644 index 00000000..e6e848f3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/FrameworkDisambiguation.swift @@ -0,0 +1,29 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// FrameworkDisambiguation.swift +// +// Created by Nacho Soto on 4/25/23.w + +/** + Purpose: this file is needed because several parts of the SDK need to explicitily reference a type or value + from the `RevenueCat` target. However, we expose 2 variants of the framework from SPM: + `RevenueCat` and `RevenueCat_CustomEntitlementComputation` (see `Package.swift`). + Because of that, we can't simply do `RevenueCat.ErrorCode` for example, since the other variant + would need `RevenueCat_CustomEntitlementComputation.ErrorCode`. + + To handle that, this exposes those types explicitly so they work regardless of the name of the framework. + */ + +typealias RCRefundRequestStatus = RefundRequestStatus +typealias RCErrorCode = ErrorCode +typealias RCOffering = Offering +typealias RCStorefront = Storefront + +let RCDefaultLogHandler = defaultLogHandler diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/HealthReport+Validate.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/HealthReport+Validate.swift new file mode 100644 index 00000000..6934df44 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/HealthReport+Validate.swift @@ -0,0 +1,194 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// HealthReport+Validate.swift +// +// Created by Pol Piella on 4/10/25. + +#if DEBUG +extension HealthReport { + func validate() -> PurchasesDiagnostics.SDKHealthReport { + guard let firstFailedCheck = self.checks.first(where: { $0.status == .failed }) else { + let warnings = self.checks.filter { $0.status == .warning }.map { error(from: $0) } + let products: [PurchasesDiagnostics.ProductDiagnosticsPayload] = { + guard case let .products(payload) = self + .checks + .first(where: { $0.name == .products })?.details else { + return [] + } + + let productPayloads = payload.products.map(createProductPayload(from:)) + + return productPayloads + }() + + let offerings: [PurchasesDiagnostics.OfferingDiagnosticsPayload] = { + guard case let .offeringsProducts(payload) = self + .checks + .first(where: { $0.name == .offeringsProducts })?.details else { + return [] + } + + let offeringPayloads = payload.offerings.map { offeringCheck in + let status = self.convertOfferingStatus(offeringCheck.status) + return PurchasesDiagnostics.OfferingDiagnosticsPayload( + identifier: offeringCheck.identifier, + packages: offeringCheck.packages.map(createPackagePayload(from:)), + status: status + ) + } + + return offeringPayloads + }() + + return .init( + status: .healthy(warnings: warnings), + projectId: self.projectId, + appId: self.appId, + products: products, + offerings: offerings + ) + } + + return .init( + status: .unhealthy(error(from: firstFailedCheck)), + projectId: self.projectId, + appId: self.appId + ) + } + + func error(from check: HealthCheck) -> PurchasesDiagnostics.SDKHealthError { + switch check.name { + case .apiKey: return .invalidAPIKey + case .bundleId: return createBundleIdError(from: check) + case .products: return createProductsError(from: check) + case .offerings: return .noOfferings + case .offeringsProducts: return createOfferingsError(from: check) + } + } + + private func createBundleIdError(from check: HealthCheck) -> PurchasesDiagnostics.SDKHealthError { + guard case let .bundleId(payload) = check.details else { + return .invalidBundleId(nil) + } + return .invalidBundleId(.init(appBundleId: payload.appBundleId, sdkBundleId: payload.sdkBundleId)) + } + + private func createProductsError(from check: HealthCheck) -> PurchasesDiagnostics.SDKHealthError { + guard case let .products(payload) = check.details else { + return .invalidProducts([]) + } + + return .invalidProducts(payload.products.map(createProductPayload)) + } + + private func createOfferingsError(from check: HealthCheck) -> PurchasesDiagnostics.SDKHealthError { + guard case let .offeringsProducts(payload) = check.details else { + return .offeringConfiguration([]) + } + + let offeringPayloads = payload.offerings.map { offeringCheck in + let status = self.convertOfferingStatus(offeringCheck.status) + return PurchasesDiagnostics.OfferingDiagnosticsPayload( + identifier: offeringCheck.identifier, + packages: offeringCheck.packages.map { self.createPackagePayload(from: $0) }, + status: status + ) + } + + return .offeringConfiguration(offeringPayloads) + } + + private func convertOfferingStatus(_ status: HealthCheckStatus) -> PurchasesDiagnostics.SDKHealthCheckStatus { + switch status { + case .passed: return .passed + case .failed: return .failed + case .warning: return .warning + default: return .failed + } + } + + private func createProductPayload( + from productReport: ProductHealthReport + ) -> PurchasesDiagnostics.ProductDiagnosticsPayload { + .init( + identifier: productReport.identifier, + title: productReport.title, + status: status(from: productReport.status), + description: description( + from: productReport.status, + identifier: productReport.identifier, + description: productReport.description + ) + ) + } + + private func createPackagePayload(from packageReport: PackageHealthReport) + -> PurchasesDiagnostics.OfferingPackageDiagnosticsPayload { + .init( + identifier: packageReport.identifier, + title: packageReport.title, + status: status(from: packageReport.status), + description: description( + from: packageReport.status, + identifier: packageReport.productIdentifier, + description: packageReport.description + ), + productIdentifier: packageReport.productIdentifier, + productTitle: packageReport.productTitle + ) + } + + private func description( + from status: ProductStatus, + identifier: String, + description: String + ) -> String { + switch status { + case .valid: + return "Available for production purchases." + case .couldNotCheck: + return description + case .notFound: + return """ + Product not found in App Store Connect. You need to create a product with identifier: \ + '\(identifier)' in App Store Connect to use it for production purchases. + """ + case .actionInProgress: + return """ + Some process is ongoing and needs to be completed before using this product in production purchases, \ + by Apple (state: \(description)). \ + You can still make test purchases with the RevenueCat SDK, but you will need to \ + wait for the state to change before you can make production purchases. + """ + case .needsAction: + return """ + This product's status (\(description)) requires you to take action in App Store Connect \ + before using it in production purchases. + """ + case .unknown: + return """ + We could not check the status of your product using the App Store Connect API. \ + Please check the app's credentials in the dashboard and try again. + """ + } + } + + private func status(from productCheckStatus: ProductStatus) -> PurchasesDiagnostics.ProductStatus { + switch productCheckStatus { + case .valid: return .valid + case .couldNotCheck: return .couldNotCheck + case .notFound: return .notFound + case .actionInProgress: return .actionInProgress + case .needsAction: return .needsAction + case .unknown: return .unknown + } + } +} +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/ManageSubscriptionsHelper.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/ManageSubscriptionsHelper.swift new file mode 100644 index 00000000..b9654b6e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/ManageSubscriptionsHelper.swift @@ -0,0 +1,155 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ManageSubscriptionsHelper.swift +// +// Created by Andrés Boedo on 16/8/21. + +import StoreKit + +class ManageSubscriptionsHelper { + + private let systemInfo: SystemInfo + private let customerInfoManager: CustomerInfoManager + private let currentUserProvider: CurrentUserProvider + + init(systemInfo: SystemInfo, + customerInfoManager: CustomerInfoManager, + currentUserProvider: CurrentUserProvider) { + self.systemInfo = systemInfo + self.customerInfoManager = customerInfoManager + self.currentUserProvider = currentUserProvider + } + +#if os(iOS) || os(macOS) || VISION_OS + + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func showManageSubscriptions(completion: @escaping (Result) -> Void) { + let currentAppUserID = self.currentUserProvider.currentAppUserID + self.customerInfoManager.customerInfo(appUserID: currentAppUserID, + fetchPolicy: .cachedOrFetched) { @Sendable result in + let result: Result = result + .mapError { error in + let message = Strings.failed_to_get_management_url_error_unknown(error: error) + return ErrorUtils.customerInfoError(withMessage: message.description, error: error) + } + .flatMap { customerInfo in + guard let managementURL = customerInfo.managementURL else { + Logger.debug(Strings.management_url_nil_opening_default) + + return .success(SystemInfo.appleSubscriptionsURL) + } + + return .success(managementURL) + } + + switch result { + case let .success(url): + if SystemInfo.isAppleSubscription(managementURL: url) { + self.showAppleManageSubscriptions(managementURL: url, completion: completion) + } else { + self.openURL(url, completion: completion) + } + + case let .failure(error): + completion(.failure(error)) + } + } + } + +#endif + +} + +// @unchecked because: +// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. +extension ManageSubscriptionsHelper: @unchecked Sendable {} + +// MARK: - Private + +@available(watchOS, unavailable) +@available(tvOS, unavailable) +private extension ManageSubscriptionsHelper { + + func showAppleManageSubscriptions(managementURL: URL, + completion: @escaping (Result) -> Void) { +#if os(iOS) && !targetEnvironment(macCatalyst) || VISION_OS + if #available(iOS 15.0, *), + // showManageSubscriptions doesn't work on iOS apps running on Apple Silicon + // https://developer.apple.com/documentation/storekit/appstore/3803198-showmanagesubscriptions# + !ProcessInfo().isiOSAppOnMac { + Async.call(with: completion) { + return await self.showSK2ManageSubscriptions() + } + return + } +#endif + openURL(managementURL, completion: completion) + } + + func openURL(_ url: URL, completion: @escaping (Result) -> Void) { +#if os(iOS) || VISION_OS + openURLIfNotAppExtension(url: url) +#elseif os(macOS) + NSWorkspace.shared.open(url) +#endif + completion(.success(())) + } + +#if os(iOS) || VISION_OS + // we can't directly reference UIApplication.shared in case this SDK is embedded into an app extension. + // so we ensure that it's not running in an app extension and use selectors to call UIApplication methods. + func openURLIfNotAppExtension(url: URL) { + guard !systemInfo.isAppExtension, + let application = systemInfo.sharedUIApplication else { return } + + // NSInvocation is needed because the method takes three arguments + // and performSelector works for up to 2 + typealias ClosureType = @convention(c) (AnyObject, Selector, NSURL, NSDictionary?, Any?) -> Void + + let selector: Selector = NSSelectorFromString("openURL:options:completionHandler:") + let methodIMP: IMP! = application.method(for: selector) + let openURLMethod = unsafeBitCast(methodIMP, to: ClosureType.self) + openURLMethod(application, selector, url as NSURL, nil, nil) + } + + @MainActor + @available(iOS 15.0, *) + @available(macOS, unavailable) + func showSK2ManageSubscriptions() async -> Result { + let windowScene: UIWindowScene + + do { + windowScene = try self.systemInfo.currentWindowScene + } catch { + return .failure(ErrorUtils.purchasesError(withUntypedError: error)) + } + +#if os(iOS) || VISION_OS + // Note: we're ignoring the result of AppStore.showManageSubscriptions(in:) because as of + // iOS 15.2, it only returns after the sheet is dismissed, which isn't desired. + _ = Task { + do { + try await AppStore.showManageSubscriptions(in: windowScene) + Logger.info(Strings.susbscription_management_sheet_dismissed) + } catch { + let message = Strings.error_from_appstore_show_manage_subscription(error: error) + Logger.appleError(message) + } + } + + return .success(()) +#else + fatalError(Strings.manageSubscription.show_manage_subscriptions_called_in_unsupported_platform.description) +#endif + } +#endif + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/PaymentAuthorizationProvider.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/PaymentAuthorizationProvider.swift new file mode 100644 index 00000000..c1c1c6da --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/PaymentAuthorizationProvider.swift @@ -0,0 +1,31 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaymentAuthorizationProvider.swift +// +// Created by Pol Piella Abadia on 23/06/2025. + +import Foundation +import StoreKit + +struct PaymentAuthorizationProvider { + var isAuthorized: () -> Bool +} + +extension PaymentAuthorizationProvider { + static let storeKit = PaymentAuthorizationProvider( + isAuthorized: { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) { + return AppStore.canMakePayments + } else { + return SKPaymentQueue.canMakePayments() + } + } + ) +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/PaywallExtensions.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/PaywallExtensions.swift new file mode 100644 index 00000000..ab348f25 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/PaywallExtensions.swift @@ -0,0 +1,175 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallExtensions.swift +// +// Created by Nacho Soto on 6/6/23. + +import StoreKit +import SwiftUI + +#if swift(>=5.9) + +// MARK: - StoreView + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +extension StoreView { + + /// Creates a view to load a collection of products from the App Store, and merchandise them using an + /// icon and custom placeholder icon. + /// When the user purchases products through this paywall, the `RevenueCat` SDK will handle + /// the result automatically. All you need to do is to dismiss the paywall. + /// + /// - Warning: In order to use StoreKit paywalls you must configure the `RevenueCat` SDK + /// in SK2 mode using ``Configuration/Builder/with(storeKitVersion:)``. + public static func forOffering( + _ offering: Offering, + prefersPromotionalIcon: Bool = false, + @ViewBuilder icon: @escaping (Product) -> Icon, + @ViewBuilder placeholderIcon: () -> PlaceholderIcon + ) -> some View { + return self + .init( + ids: offering.allProductIdentifiers, + prefersPromotionalIcon: prefersPromotionalIcon, + icon: icon, + placeholderIcon: placeholderIcon + ) + .handlePurchases(offering) + } + +} + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +extension StoreView where Icon == EmptyView, PlaceholderIcon == EmptyView { + + /// Creates a view to load a collection of products from the App Store, and merchandise them. + /// When the user purchases products through this paywall, the `RevenueCat` SDK will handle + /// the result automatically. All you need to do is to dismiss the paywall. + /// + /// - Warning: In order to use StoreKit paywalls you must configure the `RevenueCat` SDK + /// in SK2 mode using ``Configuration/Builder/with(storeKitVersion:)``. + public static func forOffering( + _ offering: Offering, + prefersPromotionalIcon: Bool = false + ) -> some View { + return self + .init( + ids: offering.allProductIdentifiers, + prefersPromotionalIcon: prefersPromotionalIcon + ) + .handlePurchases(offering) + } +} + +// MARK: - SubscriptionStoreView + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +extension SubscriptionStoreView { + + /// Creates a ``SubscriptionStoreView`` from an ``Offering`` + /// with custom marketing content. + /// When the user purchases products through this paywall, the `RevenueCat` SDK will handle + /// the result automatically. All you need to do is to dismiss the paywall. + /// + /// - Warning: In order to use StoreKit paywalls you must configure the `RevenueCat` SDK + /// in SK2 mode using ``Configuration/Builder/with(storeKitVersion:)``. + public static func forOffering( + _ offering: Offering, + @ViewBuilder marketingContent: () -> (Content) + ) -> some View { + return self + .init( + productIDs: offering.subscriptionProductIdentifiers, + marketingContent: marketingContent + ) + .handlePurchases(offering) + } + +} + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +extension SubscriptionStoreView where Content == AutomaticSubscriptionStoreMarketingContent { + + /// Creates a ``SubscriptionStoreView`` from an ``Offering`` + /// that doesn't take a custom view to use for marketing content. + /// + /// - Warning: In order to use StoreKit paywalls you must configure the `RevenueCat` SDK + /// in SK2 mode using ``Configuration/Builder/with(storeKitVersion:)``. + public static func forOffering(_ offering: Offering) -> some View { + return self + .init(productIDs: offering.subscriptionProductIdentifiers) + .handlePurchases(offering) + } + +} + +// MARK: - Private + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +private extension View { + + func handlePurchases(_ offering: Offering) -> some View { + return self + .onInAppPurchaseStart { product in + guard Purchases.isConfigured else { return } + + if !Purchases.shared.isStoreKit2EnabledAndAvailable { + Logger.appleWarning(Strings.configure.sk2_required_for_swiftui_paywalls) + } + + // Find offering context from a matching package. + // Packages could have the same product but each product has the same offering + // context so that is okay if that happens. + let offeringContext = offering.availablePackages.first { + $0.storeProduct.productIdentifier == product.id + }?.presentedOfferingContext + + Purchases.shared.cachePresentedOfferingContext( + offeringContext ?? .init( + offeringIdentifier: offering.identifier + ), + productIdentifier: product.id + ) + } + } + +} + +private extension Offering { + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + var subscriptionProductIdentifiers: [String] { + return self.products + .filter { $0.productCategory == .subscription } + // Filter out products with no subscription periods (non-subscriptions) + .compactMap { product in + product.subscriptionPeriod.map { period in + ( + product: product, + period: period + ) + } + } + // Sort by subscription period + .sorted(using: KeyPathComparator(\.period.unit.rawValue, order: .forward)) + .map(\.product.productIdentifier) + } + + var allProductIdentifiers: [String] { + return self.products.map(\.productIdentifier) + } + + private var products: some Sequence { + return self.availablePackages.lazy.map(\.storeProduct) + } + +} + +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/PurchasesDiagnostics.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/PurchasesDiagnostics.swift new file mode 100644 index 00000000..2383c66a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/PurchasesDiagnostics.swift @@ -0,0 +1,349 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PurchasesDiagnostics.swift +// +// Created by Nacho Soto on 9/21/22. + +import Foundation +import StoreKit + +/// `PurchasesDiagnostics` allows you to ensure that the SDK is set up correctly by diagnosing configuration errors. +/// To run the test, simply call ``PurchasesDiagnostics/testSDKHealth()``. +/// +/// #### Example: +/// ```swift +/// let diagnostics = PurchasesDiagnostics.default +/// do { +/// try await diagnostics.testSDKHealth() +/// } catch { +/// print("Diagnostics failed: \(error.localizedDescription)") +/// } +/// ``` +@objc(RCPurchasesDiagnostics) +public final class PurchasesDiagnostics: NSObject, Sendable { + + typealias SDK = PurchasesType & InternalPurchasesType & Sendable + + private let purchases: SDK + + init(purchases: SDK) { + self.purchases = purchases + } + + /// Default instance of `PurchasesDiagnostics`. + /// Note: you must call ``Purchases/configure(with:)-6oipy`` before using this. + @objc + public static let `default`: PurchasesDiagnostics = .init(purchases: Purchases.shared) +} + +#if DEBUG +extension PurchasesDiagnostics { + /// Enum representing the status of a product in the store + public enum ProductStatus: Sendable { + /// Product is configured correctly in App Store Connect + case valid + /// There was a problem checking the product state in App Store Connect + case couldNotCheck + /// The product does not exist in App Store Connect + case notFound + /// The product is in a state that requires action from Apple or the Developer before being ready for production + case actionInProgress + /// The product is in a state that requires action from the developer before being ready for production + case needsAction + /// The product state could not be determined + case unknown + } + + /// Additional information behind a configuration issue for a Product + public struct ProductDiagnosticsPayload: Sendable { + /// Product identifier that must match the product in App Store Connect + public let identifier: String + /// Title of the product as it appears on the RevenueCat website + public let title: String? + /// Status of the RevenueCat product derived from the App Store Connect product + public let status: ProductStatus + /// Explainer of the product status + public let description: String + } + + /// Additional information behind a configuration issue for the app's Bundle Id + public struct InvalidBundleIdErrorPayload: Equatable { + /// Bundle ID for the RevenueCat app + public let appBundleId: String + /// Bundle ID detected from the app at runtime by the RevenueCat SDK + public let sdkBundleId: String + } + + /// Health status for a specific validation check in the SDK's Health Report + public enum SDKHealthCheckStatus: Sendable { + /// SDK Health Check is valid + case passed + /// SDK Health Check is not valid + case failed + /// SDK Health Check is valid, but yielded some warnings + case warning + } + + /// Additional information behind a configuration issue for a specific offering + public struct OfferingDiagnosticsPayload: Sendable { + /// Offering identifier as set up in the RevenueCat website + public let identifier: String + /// Extra information for each of the packages in the offering + public let packages: [OfferingPackageDiagnosticsPayload] + /// Status of the offering health check + public let status: SDKHealthCheckStatus + } + + /// Additional information about a specific package in an offering that has a configuration issue. + public struct OfferingPackageDiagnosticsPayload: Sendable { + /// The identifier of the package as configured in the RevenueCat website. + public let identifier: String + /// The display name of the package, if available. + public let title: String? + /// The current configuration status of the underlying product in App Store Connect. + public let status: ProductStatus + /// A human-readable explanation of the product's configuration status. + public let description: String + /// The product identifier associated with this package. + public let productIdentifier: String + /// The reference name of the product from App Store Connect, if available. + public let productTitle: String? + } + + /// An error that represents a problem in the SDK's configuration + public enum SDKHealthError: Swift.Error { + /// API key is invalid + case invalidAPIKey + + /// There are no offerings in project + case noOfferings + + /// Offerings are not configured correctly + case offeringConfiguration([OfferingDiagnosticsPayload]) + + /// App bundle ID does not match the one set in the dashboard + case invalidBundleId(InvalidBundleIdErrorPayload?) + + /// One or more products are not configured correctly + case invalidProducts([ProductDiagnosticsPayload]) + + /// The person is not authorized to make In-App Purchases + case notAuthorizedToMakePayments + + /// Any other not identifier error. You can check the undelying error for details. + case unknown(Swift.Error) + } + + /// A report that encapsulates the result of the SDK configuration health check. + /// Use this to programmatically inspect the SDK's health status after calling `healthReport()`. + public struct SDKHealthReport: Sendable { + /// The overall status of the SDK's health. + public let status: SDKHealthStatus + /// The RevenueCat project identifier associated with the current SDK configuration, if available. + public let projectId: String? + /// The RevenueCat app identifier associated with the current SDK configuration, if available. + public let appId: String? + /// The report for each of your app's products set up in the RevenueCat website + public let products: [ProductDiagnosticsPayload] + /// The report for each of your app's offerings set up in the RevenueCat website + public let offerings: [OfferingDiagnosticsPayload] + + init( + status: SDKHealthStatus, + projectId: String? = nil, + appId: String? = nil, + products: [ProductDiagnosticsPayload] = [], + offerings: [OfferingDiagnosticsPayload] = [] + ) { + self.status = status + self.projectId = projectId + self.appId = appId + self.products = products + self.offerings = offerings + } + } + + /// Status of the SDK Health report + public enum SDKHealthStatus: Sendable { + /// SDK configuration is valid but might have some non-blocking issues + case healthy(warnings: [PurchasesDiagnostics.SDKHealthError]) + /// SDK configuration is not valid and has issues that must be resolved + case unhealthy(PurchasesDiagnostics.SDKHealthError) + } +} +#endif + +extension PurchasesDiagnostics { + + /// An error that represents a failing step in ``PurchasesDiagnostics`` + public enum Error: Swift.Error { + + /// Connection to the API failed + case failedConnectingToAPI(Swift.Error) + + /// API key is invalid + case invalidAPIKey + + /// Fetching offerings failed due to the underlying error + case failedFetchingOfferings(Swift.Error) + + /// Failure performing a signed request + case failedMakingSignedRequest(Swift.Error) + + /// Any other not identifier error. You can check the undelying error for details. + case unknown(Swift.Error) + + } + +} + +extension PurchasesDiagnostics { + + /// Checks if the SDK is configured correctly. + /// - Important: This method is intended solely for debugging configuration issues with the SDK implementation. + /// It should not be invoked in production builds. + /// - Throws: ``PurchasesDiagnostics/Error`` if any step fails + @available(*, deprecated, message: """ + Use the `PurchasesDiagnostics.shared.checkSDKHealth()` method instead. + """) + @objc(testSDKHealthWithCompletion:) + public func testSDKHealth() async throws { + do { + try await self.unauthenticatedRequest() + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + try await self.authenticatedRequest() + #endif + try await self.offeringsRequest() + try await self.signatureVerification() + } catch let error as Error { + throw error + } catch let error { + // Catch every other error to ensure that we only throw `Error`s from here. + throw Error.unknown(error) + } + } + + #if DEBUG + /// Performs a full SDK configuration health check and throws an error if the configuration is not valid. + /// - Important: This method can not be invoked in production builds. + /// - Throws: ``SDKHealthError`` indicating the specific configuration issue that needs to be solved. + public func checkSDKHealth() async throws { + switch await self.healthReport().status { + case let .unhealthy(error): throw error + default: break + } + } + + /// Performs a full SDK configuration health check and returns its status. + /// - Important: This method is intended solely for debugging configuration issues with the SDK implementation. + /// It should not be invoked in production builds. + /// - Returns: The result of the SDK configuration health check. + public func healthReport() async -> SDKHealthReport { + await purchases.healthReport() + } + #endif +} + +// MARK: - Private + +private extension PurchasesDiagnostics { + + /// Makes a request to the backend, to verify connectivity, firewalls, or anything blocking network traffic. + func unauthenticatedRequest() async throws { + do { + try await self.purchases.healthRequest(signatureVerification: false) + } catch { + throw Error.failedConnectingToAPI(error) + } + } + + #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + func authenticatedRequest() async throws { + do { + _ = try await self.purchases.customerInfo() + } catch let error as ErrorCode { + throw self.convert(error) + } catch { + throw Error.unknown(error) + } + } + #endif + + func offeringsRequest() async throws { + do { + _ = try await self.purchases.offerings(fetchPolicy: .failIfProductsAreMissing) + } catch { + throw Error.failedFetchingOfferings(error) + } + } + + func signatureVerification() async throws { + guard self.purchases.responseVerificationMode.isEnabled else { return } + + do { + try await self.purchases.healthRequest(signatureVerification: true) + } catch { + throw Error.failedMakingSignedRequest(error) + } + } + + func convert(_ error: ErrorCode) -> Error { + switch error { + case .unknownError: + return Error.unknown(error) + + case .offlineConnectionError: + return Error.failedConnectingToAPI(error) + + case .invalidCredentialsError: + return Error.invalidAPIKey + + case .signatureVerificationFailed: + return Error.failedMakingSignedRequest(error) + + default: + return Error.unknown(error) + } + } + +} + +extension PurchasesDiagnostics.Error: CustomNSError { + + // swiftlint:disable:next missing_docs + public var errorUserInfo: [String: Any] { + return [ + NSUnderlyingErrorKey: self.underlyingError as NSError? ?? NSNull(), + NSLocalizedDescriptionKey: self.localizedDescription + ] + } + + var localizedDescription: String { + switch self { + case let .unknown(error): return "Unknown error: \(error.localizedDescription)" + case let .failedConnectingToAPI(error): return "Error connecting to API: \(error.localizedDescription)" + case let .failedFetchingOfferings(error): return "Failed fetching offerings: \(error.localizedDescription)" + case let .failedMakingSignedRequest(error): return "Failed making signed request: \(error.localizedDescription)" + case .invalidAPIKey: return "API key is not valid" + } + } + + private var underlyingError: Swift.Error? { + switch self { + case let .unknown(error): return error + case let .failedConnectingToAPI(error): return error + case let .failedFetchingOfferings(error): return error + case let .failedMakingSignedRequest(error): return error + case .invalidAPIKey: + return nil + } + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/SDKHealthError+CustomNSError.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/SDKHealthError+CustomNSError.swift new file mode 100644 index 00000000..afaeb65b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/SDKHealthError+CustomNSError.swift @@ -0,0 +1,133 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SDKHealthError+CustomNSError.swift +// +// Created by Pol Piella Abadia on 25/04/2025. + +import Foundation + +#if DEBUG +extension PurchasesDiagnostics.SDKHealthError: CustomNSError { + + // swiftlint:disable:next missing_docs + public var errorUserInfo: [String: Any] { + return [ + NSUnderlyingErrorKey: self.underlyingError as NSError? ?? NSNull(), + NSLocalizedDescriptionKey: self.localizedDescription + ] + } + + var localizedDescription: String { + switch self { + case .notAuthorizedToMakePayments: + return """ + This device is not authorized to make purchases. This can happen if Content & Privacy Restrictions are \ + enabled in Screen Time, or if the device has a mobile device management (MDM) profile that prevents \ + purchases. Please check your Screen Time settings or contact your device administrator, or try again \ + from a different device. + """ + + case let .unknown(error): + return """ + We encountered an unknown error that prevented the operation from completing. This is likely a \ + temporary issue. Please try again in a few moments and, if the problem persists, contact support \ + with this error: \(error.localizedDescription). + """ + + case .invalidAPIKey: + return """ + Your API key is not valid or has been revoked. This prevents your app from accessing RevenueCat \ + services and among other things, retrieving products. Please verify your API key in the RevenueCat \ + website and update your app's configuration. + """ + + case .noOfferings: + return """ + Your app doesn't have any offerings configured in RevenueCat. This means users can't see available \ + product options through offerings. If you plan on using offerings to show products to your users, \ + please configure them in the RevenueCat website. + """ + + case let .offeringConfiguration(payload): + guard let offendingOffering = payload.first(where: { $0.status == .failed }) else { + let offeringsWithWarnings = payload.filter { $0.status == .warning } + let offeringDescription = offeringsWithWarnings.isEmpty ? + "Some offerings" : + "The offerings \(offeringsWithWarnings.map { "'\($0.identifier)'" }.joined(separator: ", "))" + return """ + \(offeringDescription) have configuration issues that may prevent users from seeing product \ + options or making purchases. + """ + } + + let offeringIdentifier = offendingOffering.identifier + let offendingPackageCount = offendingOffering.packages.filter { $0.status != .valid }.count + + if offendingOffering.packages.isEmpty { + return """ + Offering '\(offeringIdentifier)' has no packages configured, so users won't see any product \ + options. Please add packages to this offering in the RevenueCat website. + """ + } else { + return """ + Offering '\(offeringIdentifier)' uses \(offendingPackageCount) products that are not approved \ + in App Store Connect yet. While such products may work while testing, users won't be able to \ + make purchases in production. Please ensure all products are approved and available in App Store \ + Connect. + """ + } + + case let .invalidBundleId(payload): + guard let payload else { + return """ + Your app's Bundle ID doesn't match the one configured in RevenueCat. This will cause the SDK \ + to not show any products and won't allow users to make purchases. Please update your Bundle ID \ + in either your app or the RevenueCat website to match. + """ + } + let sdkBundleId = payload.sdkBundleId + let appBundleId = payload.appBundleId + return """ + Your app's Bundle ID '\(sdkBundleId)' doesn't match the RevenueCat configuration '\(appBundleId)'. \ + This will cause the SDK to not show any products and won't allow users to make purchases. Please \ + update your Bundle ID in either your app or the RevenueCat website to match. + """ + + case let .invalidProducts(products): + if products.isEmpty { + return """ + Your app doesn't have any products set up, so users can't make any purchases. Please create \ + and configure products in the RevenueCat website. + """ + } else { + return """ + Your products are configured in RevenueCat but aren't approved in App Store Connect yet. This \ + prevents users from making purchases in production. Please ensure all products are approved and \ + available for sale in App Store Connect. + """ + } + } + } + + private var underlyingError: Swift.Error? { + switch self { + case let .unknown(error): return error + case .invalidAPIKey, + .offeringConfiguration, + .noOfferings, + .invalidBundleId, + .invalidProducts, + .notAuthorizedToMakePayments: + return nil + } + } + +} +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/SDKHealthManager.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/SDKHealthManager.swift new file mode 100644 index 00000000..5b99b22e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/SDKHealthManager.swift @@ -0,0 +1,214 @@ +import Foundation + +final class SDKHealthManager: Sendable { + private let backend: Backend + private let identityManager: IdentityManager + private let paymentAuthorizationProvider: PaymentAuthorizationProvider + + init( + backend: Backend, + identityManager: IdentityManager, + paymentAuthorizationProvider: PaymentAuthorizationProvider = .storeKit + ) { + self.backend = backend + self.identityManager = identityManager + self.paymentAuthorizationProvider = paymentAuthorizationProvider + } + + #if DEBUG + func healthReport() async -> PurchasesDiagnostics.SDKHealthReport { + do { + if !paymentAuthorizationProvider.isAuthorized() { + return .init(status: .unhealthy(.notAuthorizedToMakePayments)) + } + let appUserID = self.identityManager.currentAppUserID + return try await self.backend.healthReportRequest(appUserID: appUserID).validate() + } catch let error as BackendError { + if case .networkError(let networkError) = error, + case .errorResponse(let response, _, _) = networkError, response.code == .invalidAPIKey { + return .init(status: .unhealthy(.invalidAPIKey)) + } + return .init(status: .unhealthy(.unknown(error))) + } catch { + return .init(status: .unhealthy(.unknown(error))) + } + } + #endif + + #if DEBUG && !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION + func logSDKHealthReportOutcome() async { + let report = await healthReport() + switch report.status { + case let .unhealthy(error): + switch error { + case .unknown: break + default: Logger.error(HealthReportLogMessage.unhealthy(error: error, report: report)) + } + case let .healthy(warnings): + if warnings.isEmpty { + Logger.info(HealthReportLogMessage.healthy(report: report)) + } else { + Logger.warn(HealthReportLogMessage.healthyWithWarnings(warnings: warnings, report: report)) + } + } + } + #endif +} + +#if DEBUG && !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION +private enum HealthReportLogMessage: LogMessage { + case unhealthy(error: PurchasesDiagnostics.SDKHealthError, report: PurchasesDiagnostics.SDKHealthReport) + case healthy(report: PurchasesDiagnostics.SDKHealthReport) + case healthyWithWarnings( + warnings: [PurchasesDiagnostics.SDKHealthError], + report: PurchasesDiagnostics.SDKHealthReport + ) + + var description: String { + switch self { + case let .unhealthy(error, report): + return buildUnhealthyMessage(error: error, report: report) + case let .healthy(report): + return buildHealthyMessage(report: report) + case let .healthyWithWarnings(warnings, report): + return buildHealthyWithWarningsMessage(warnings: warnings, report: report) + } + } + + var category: String { "health_report" } + + private func buildUnhealthyMessage( + error: PurchasesDiagnostics.SDKHealthError, + report: PurchasesDiagnostics.SDKHealthReport + ) -> String { + var message = "RevenueCat SDK Configuration is not valid\n" + message += "\n\(error.localizedDescription)\n" + + let actionURL: String? = { + guard let projectId = report.projectId, let appId = report.appId else { return nil } + + switch error { + case .invalidBundleId: + return "https://app.revenuecat.com/projects/\(projectId)/apps/\(appId)" + case .offeringConfiguration, .noOfferings: + return "https://app.revenuecat.com/projects/\(projectId)/product-catalog/offerings" + case .invalidProducts: + return "https://app.revenuecat.com/projects/\(projectId)/product-catalog/products" + default: return nil + } + }() + + if let actionURL { + message += "\nPlease visit the RevenueCat website to resolve the issue: \(actionURL)\n" + } + + message += buildProductsSection(report: report) + message += buildOfferingsSection(report: report) + + return message + } + + private func buildHealthyMessage(report: PurchasesDiagnostics.SDKHealthReport) -> String { + var message = "✅ RevenueCat SDK is configured correctly\n" + + message += buildProductsSection(report: report) + message += buildOfferingsSection(report: report) + + return message + } + + private func buildHealthyWithWarningsMessage( + warnings: [PurchasesDiagnostics.SDKHealthError], + report: PurchasesDiagnostics.SDKHealthReport + ) -> String { + if report.products.allSatisfy({ $0.status == .couldNotCheck }) { + var message = """ + We could not validate your SDK's configuration and check your product statuses in App Store Connect.\n + """ + + if let description = report.products.first?.description { + message += "\nError: \(description)\n" + } + + message += """ + \nIf you want to check if your SDK is configured correctly, please check your App Store Connect \ + credentials in RevenueCat, make sure your App Store Connect App exists and try again: + """ + + if let projectId = report.projectId, let appId = report.appId { + let url = "https://app.revenuecat.com/projects/\(projectId)/apps/\(appId)#scroll=app-store-connect-api" + message += "\n\n\(url)" + } + + return message + } + + var message = "RevenueCat SDK is configured correctly, but contains some issues you might want to address\n" + + message += "\nWarnings:\n" + for warning in warnings { + message += " • \(warning.localizedDescription)\n" + } + + message += buildProductsSection(report: report) + message += buildOfferingsSection(report: report) + + return message + } + + private func buildProductsSection(report: PurchasesDiagnostics.SDKHealthReport) -> String { + let productsWithIssues = report.products.filter { $0.status != .valid } + guard !productsWithIssues.isEmpty else { return "" } + + var section = "\nProduct Issues:\n" + for product in productsWithIssues { + let statusIcon = productStatusIcon(product.status) + section += " \(statusIcon) \(product.identifier)" + if let title = product.title { + section += " (\(title))" + } + section += ": \(product.description)\n" + } + + return section + } + + private func buildOfferingsSection(report: PurchasesDiagnostics.SDKHealthReport) -> String { + let offeringsWithIssues = report.offerings.filter { $0.status != .passed } + guard !offeringsWithIssues.isEmpty else { return "" } + + var section = "\nOffering Issues:\n" + for offering in offeringsWithIssues { + let statusIcon = offeringStatusIcon(offering.status) + section += " \(statusIcon) \(offering.identifier)\n" + let packagesWithIssues = offering.packages.filter { $0.status != .valid } + for package in packagesWithIssues { + let packageStatusIcon = productStatusIcon(package.status) + let packageInfo = "\(packageStatusIcon) \(package.identifier) (\(package.productIdentifier))" + section += " \(packageInfo): \(package.description)\n" + } + } + + return section + } + + private func productStatusIcon(_ status: PurchasesDiagnostics.ProductStatus) -> String { + switch status { + case .valid: return "✅" + case .couldNotCheck: return "❓" + case .notFound: return "❌" + case .actionInProgress: return "⏳" + case .needsAction: return "⚠️" + case .unknown: return "❓" + } + } + + private func offeringStatusIcon(_ status: PurchasesDiagnostics.SDKHealthCheckStatus) -> String { + switch status { + case .passed: return "✅" + case .failed: return "❌" + case .warning: return "⚠️" + } + } +} +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/StoreMessageType.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/StoreMessageType.swift new file mode 100644 index 00000000..283ad831 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/StoreMessageType.swift @@ -0,0 +1,70 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StoreMessageType.swift +// +// Created by Antonio Rico Diez on 27/9/23. + +import StoreKit + +/// Type of messages available in StoreKit +/// +/// #### Related Symbols +/// - ``Purchases/showStoreMessages(for:)`` +@objc(RCStoreMessageType) public enum StoreMessageType: Int, CaseIterable, Sendable { + + /// Message shown when there are billing issues in a subscription + case billingIssue = 0 + + /// Message shown when there is a price increase in a subscription that requires consent + case priceIncreaseConsent + + /// Generic Store messages + case generic + + /// Message shown when a subscriber is eligible to redeem a win-back offer that you've + /// configured in App Store Connect. More information can be found + /// [here](https://developer.apple.com/documentation/storekit/message/reason/4418230-winbackoffer). + case winBackOffer +} + +#if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + +@available(iOS 16.0, *) +@available(macOS, unavailable) +@available(watchOS, unavailable) +@available(tvOS, unavailable) +extension Message.Reason { + + var messageType: StoreMessageType? { + switch self { + case .priceIncreaseConsent: return .priceIncreaseConsent + case .generic: return .generic + default: + // winBackOffer message reason was added in iOS 18.0, but it's not recognized by xcode versions <16.0. + #if compiler(>=6.0) + if #available(iOS 18.0, visionOS 2.0, *), case .winBackOffer = self { + return .winBackOffer + } + #endif + + // billingIssue message reason was added in iOS 16.4, but it's not recognized by older xcode versions. + // https://developer.apple.com/documentation/xcode-release-notes/xcode-14_3-release-notes + if #available(iOS 16.4, *), case .billingIssue = self { + return .billingIssue + } + + Logger.error("Unrecognized Message.Reason: \(self)") + return nil + } + } + +} + +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/StoreMessagesHelper.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/StoreMessagesHelper.swift new file mode 100644 index 00000000..487652b8 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/StoreMessagesHelper.swift @@ -0,0 +1,131 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// StoreMessagesHelper.swift +// +// Created by Antonio Rico Diez on 27/9/23. + +import StoreKit + +protocol StoreMessagesHelperType { + + #if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + + @available(iOS 16.0, tvOS 16.0, macOS 12.0, watchOS 8.0, *) + func deferMessagesIfNeeded() async throws + + @available(iOS 16.0, tvOS 16.0, macOS 12.0, watchOS 8.0, *) + func showStoreMessages(types: Set) async + + #endif + +} + +@available(iOS 16.0, tvOS 16.0, macOS 12.0, watchOS 8.0, *) +actor StoreMessagesHelper: StoreMessagesHelperType { + + private let systemInfo: SystemInfo + private let showStoreMessagesAutomatically: Bool + private let storeMessagesProvider: StoreMessagesProviderType + + private var deferredMessages: [StoreMessage] = [] + + init(systemInfo: SystemInfo, + showStoreMessagesAutomatically: Bool, + storeMessagesProvider: StoreMessagesProviderType = StoreMessagesProvider()) { + self.systemInfo = systemInfo + self.showStoreMessagesAutomatically = showStoreMessagesAutomatically + self.storeMessagesProvider = storeMessagesProvider + } + + #if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + + func deferMessagesIfNeeded() async throws { + guard !self.showStoreMessagesAutomatically else { + return + } + + for try await message in self.storeMessagesProvider.messages { + self.deferredMessages.append(message) + } + } + + func showStoreMessages(types: Set) async { + var displayedMessages: [StoreMessage] = [] + for message in self.deferredMessages { + if let messageType = message.reason.messageType, types.contains(messageType) { + do { + try await message.display(in: self.systemInfo.currentWindowScene) + displayedMessages.append(message) + } catch { + Logger.error(Strings.storeKit.error_displaying_store_message(error)) + } + } + } + + for message in displayedMessages { + self.deferredMessages.removeAll(where: { $0.hashValue == message.hashValue }) + } + } + + #endif +} + +@available(iOS 16.0, tvOS 16.0, macOS 12.0, watchOS 8.0, *) +extension StoreMessagesHelper: Sendable {} + +protocol StoreMessagesProviderType: Sendable { + + #if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + + @available(iOS 16.0, *) + var messages: AsyncStream { get } + + #endif +} + +/// Abstraction over `StoreKit.Message`. +protocol StoreMessage: Sendable { + + #if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + + @available(iOS 16.0, *) + var reason: Message.Reason { get } + + @available(iOS 16.0, *) + // swiftlint:disable:next legacy_hashing + var hashValue: Int { get } + + @available(iOS 16.0, *) + @MainActor + func display(in scene: UIWindowScene) throws + + #endif +} + +#if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + +@available(iOS 16.0, *) +extension StoreKit.Message: StoreMessage {} + +#endif + +private final class StoreMessagesProvider: StoreMessagesProviderType { + + #if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS + + @available(iOS 16.0, *) + var messages: AsyncStream { + return Message.messages + .map { $0 as StoreMessage } + .toAsyncStream() + } + + #endif +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/SwiftVersionCheck.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/SwiftVersionCheck.swift new file mode 100644 index 00000000..9992254d --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Support/SwiftVersionCheck.swift @@ -0,0 +1,19 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SwiftVersionCheck.swift +// +// Created by Nacho Soto on 1/18/23. + +import Foundation + +#if swift(<5.8) +// See https://xcodereleases.com and https://swiftversion.net +#error("RevenueCat requires Xcode 14.3.1 with Swift 5.8 to compile.") +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Virtual Currencies/VirtualCurrencies.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Virtual Currencies/VirtualCurrencies.swift new file mode 100644 index 00000000..3f049b0d --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Virtual Currencies/VirtualCurrencies.swift @@ -0,0 +1,56 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// VirtualCurrencies.swift +// +// Created by Will Taylor on 5/21/25. + +import Foundation + +/// This class contains all the virtual currencies associated to the user. +@objc(RCVirtualCurrencies) public final class VirtualCurrencies: NSObject, Codable { + + /// Dictionary of all ``VirtualCurrency`` objects keyed by virtual currency code. + /// This dictionary can also be accessed through an index subscript on ``VirtualCurrencies``, e.g. + /// `virtualCurrencies["VC_CODE"]`. + @objc public let all: [String: VirtualCurrency] + + // swiftlint:disable:next missing_docs + @_spi(Internal) public init(virtualCurrencies: [String: VirtualCurrency]) { + self.all = virtualCurrencies + } + + /// #### Related Symbols + /// - ``all`` + @objc public subscript(key: String) -> VirtualCurrency? { + return self.all[key] + } +} + +extension VirtualCurrencies: Sendable {} + +extension VirtualCurrencies { + /// Compares two ``VirtualCurrencies`` objects for equality by comparing their underlying dictionaries. + /// - Parameter object: The object to compare against + /// - Returns: `true` if the objects are equal, `false` otherwise + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? VirtualCurrencies else { return false } + return self.all == other.all + } +} + +extension VirtualCurrencies { + internal convenience init(from response: VirtualCurrenciesResponse) { + let convertedVCMap = response.virtualCurrencies.mapValues({ virtualCurrencyResponse in + return VirtualCurrency(from: virtualCurrencyResponse) + }) + + self.init(virtualCurrencies: convertedVCMap) + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Virtual Currencies/VirtualCurrency.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Virtual Currencies/VirtualCurrency.swift new file mode 100644 index 00000000..c7b8091a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Virtual Currencies/VirtualCurrency.swift @@ -0,0 +1,75 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// VirtualCurrency.swift +// +// Created by Will Taylor on 2/27/25. + +import Foundation + +/// A class representing information about a virtual currency in the app. +/// +/// Use this class to access information about a virtual currency, such as its current balance. +/// +/// - Warning: This feature is currently in beta and is subject to change. +/// +@objc(RCVirtualCurrency) +public final class VirtualCurrency: NSObject, Codable { + + /// The customer's current balance of the virtual currency. + @objc public let balance: Int + + /// The virtual currency's name defined in the RevenueCat dashboard. + @objc public let name: String + + /// The virtual currency's code defined in the RevenueCat dashboard. + @objc public let code: String + + /// Virtual currency description defined in the RevenueCat dashboard. + @objc public let serverDescription: String? + + // swiftlint:disable:next missing_docs + @_spi(Internal) public init( + balance: Int, + name: String, + code: String, + serverDescription: String? + ) { + self.balance = balance + self.name = name + self.code = code + self.serverDescription = serverDescription + } +} + +extension VirtualCurrency: Sendable {} + +extension VirtualCurrency { + /// Compares this virtual currency with another one. + /// - Parameter object: The other object to compare with + /// - Returns: `true` if both objects are virtual currencies with the same balance, `false` otherwise + @objc public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? VirtualCurrency else { return false } + return self.balance == other.balance + && self.name == other.name + && self.code == other.code + && self.serverDescription == other.serverDescription + } +} + +extension VirtualCurrency { + internal convenience init(from response: VirtualCurrenciesResponse.VirtualCurrencyResponse) { + self.init( + balance: response.balance, + name: response.name, + code: response.code, + serverDescription: response.description + ) + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Virtual Currencies/VirtualCurrencyManager.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Virtual Currencies/VirtualCurrencyManager.swift new file mode 100644 index 00000000..e1f38c8e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/Virtual Currencies/VirtualCurrencyManager.swift @@ -0,0 +1,155 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// VirtualCurrencyManager.swift +// +// Created by Will Taylor on 5/28/25. + +import Foundation + +protocol VirtualCurrencyManagerType { + func virtualCurrencies() async throws -> VirtualCurrencies + + func cachedVirtualCurrencies() -> VirtualCurrencies? + + func invalidateVirtualCurrenciesCache() +} + +class VirtualCurrencyManager: VirtualCurrencyManagerType { + + private let identityManager: IdentityManager + private let deviceCache: DeviceCache + private let backend: Backend + private let systemInfo: SystemInfo + + init( + identityManager: IdentityManager, + deviceCache: DeviceCache, + backend: Backend, + systemInfo: SystemInfo + ) { + self.identityManager = identityManager + self.deviceCache = deviceCache + self.backend = backend + self.systemInfo = systemInfo + } + + func virtualCurrencies() async throws -> VirtualCurrencies { + let appUserID = identityManager.currentAppUserID + let isAppBackgrounded = systemInfo.isAppBackgroundedState + + if let cachedVirtualCurrencies = fetchCachedVirtualCurrencies( + appUserID: appUserID, + isAppBackgrounded: isAppBackgrounded + ) { + Logger.debug(Strings.virtualCurrencies.vending_from_cache) + return cachedVirtualCurrencies + } + + let virtualCurrencies = try await fetchVirtualCurrenciesFromBackend( + appUserID: appUserID, + isAppBackgrounded: isAppBackgrounded + ) + + cacheVirtualCurrencies(virtualCurrencies, appUserID: appUserID) + + return virtualCurrencies + } + + func cachedVirtualCurrencies() -> VirtualCurrencies? { + let appUserID = identityManager.currentAppUserID + if let cachedVirtualCurrencies = fetchCachedVirtualCurrencies( + appUserID: appUserID, + isAppBackgrounded: systemInfo.isAppBackgroundedState, + allowStaleCache: true + ) { + Logger.debug(Strings.virtualCurrencies.vending_from_cache) + return cachedVirtualCurrencies + } else { + return nil + } + } + + func invalidateVirtualCurrenciesCache() { + Logger.debug(Strings.virtualCurrencies.invalidating_virtual_currencies_cache) + let appUserID = identityManager.currentAppUserID + deviceCache.clearVirtualCurrenciesCache(appUserID: appUserID) + } + + private func cacheVirtualCurrencies( + _ virtualCurrencies: VirtualCurrencies, + appUserID: String + ) { + guard let virtualCurrenciesData = try? JSONEncoder().encode(virtualCurrencies) else { + return + } + + self.deviceCache.cache(virtualCurrencies: virtualCurrenciesData, appUserID: appUserID) + } + + private func fetchCachedVirtualCurrencies( + appUserID: String, + isAppBackgrounded: Bool, + allowStaleCache: Bool = false + ) -> VirtualCurrencies? { + if !allowStaleCache && self.deviceCache.isVirtualCurrenciesCacheStale( + appUserID: appUserID, + isAppBackgrounded: isAppBackgrounded + ) { + // The virtual currencies cache is stale and we don't want to fetch stale data, + // so return no cached virtual currencies. + Logger.debug(Strings.virtualCurrencies.virtual_currencies_stale_updating_from_network) + return nil + } + + let cachedVirtualCurrenciesData = self.deviceCache.cachedVirtualCurrenciesData( + forAppUserID: appUserID + ) + + guard let data = cachedVirtualCurrenciesData else { + Logger.debug(Strings.virtualCurrencies.no_cached_virtual_currencies) + return nil + } + + do { + let virtualCurrencies = try JSONDecoder().decode(VirtualCurrencies.self, from: data) + return virtualCurrencies + } catch { + Logger.warn(Strings.virtualCurrencies.error_decoding_cached_virtual_currencies(error)) + // We can't decode the cached virtual currencies, so return nil and refresh + // from the network. + return nil + } + + } + + private func fetchVirtualCurrenciesFromBackend( + appUserID: String, + isAppBackgrounded: Bool + ) async throws -> VirtualCurrencies { + + do { + let virtualCurrenciesResponse = try await Async.call { completion in + backend.virtualCurrenciesAPI.getVirtualCurrencies( + appUserID: appUserID, + isAppBackgrounded: isAppBackgrounded + ) { result in + completion(result.mapError(\.asPurchasesError)) + } + } + + Logger.debug(Strings.virtualCurrencies.virtual_currencies_updated_from_network) + return VirtualCurrencies(from: virtualCurrenciesResponse) + } catch { + Logger.error(Strings.virtualCurrencies.virtual_currencies_updated_from_network_error(error)) + + throw error + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/WebPurchaseRedemption/URL+WebPurchaseRedemption.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/WebPurchaseRedemption/URL+WebPurchaseRedemption.swift new file mode 100644 index 00000000..c58d4bfb --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/WebPurchaseRedemption/URL+WebPurchaseRedemption.swift @@ -0,0 +1,26 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// URL+WebPurchaseRedemption.swift +// +// Created by Antonio Rico Diez on 8/11/24. + +import Foundation + +extension URL { + + /// Parses a URL and converts it to a ``WebPurchaseRedemption`` if possible that can be + /// redeemed using ``Purchases/redeemWebPurchase(_:)` + /// + /// - Seealso: ``Purchases/redeemWebPurchase(_:)`` + public var asWebPurchaseRedemption: WebPurchaseRedemption? { + return Purchases.parseAsWebPurchaseRedemption(self) + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/WebPurchaseRedemption/WebPurchaseRedemption.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/WebPurchaseRedemption/WebPurchaseRedemption.swift new file mode 100644 index 00000000..c97b1d18 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/WebPurchaseRedemption/WebPurchaseRedemption.swift @@ -0,0 +1,27 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// WebPurchaseRedemption.swift +// +// Created by Antonio Rico Diez on 6/11/24. + +import Foundation + +/// Class representing a web redemption deep link that can be redeemed by the SDK. +/// +/// - Seealso: ``Purchases/redeemWebPurchase(_:)`` +@objc(RCWebPurchaseRedemption) public final class WebPurchaseRedemption: NSObject { + + internal let redemptionToken: String + + internal init(redemptionToken: String) { + self.redemptionToken = redemptionToken + } + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/WebPurchaseRedemption/WebPurchaseRedemptionHelper.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/WebPurchaseRedemption/WebPurchaseRedemptionHelper.swift new file mode 100644 index 00000000..d6f24f9c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/WebPurchaseRedemption/WebPurchaseRedemptionHelper.swift @@ -0,0 +1,69 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// WebPurchaseRedemptionHelper.swift +// +// Created by Antonio Rico Diez on 2024-10-17. + +import Foundation + +protocol WebPurchaseRedemptionHelperType { + + func handleRedeemWebPurchase(redemptionToken: String) async -> WebPurchaseRedemptionResult + +} + +actor WebPurchaseRedemptionHelper: WebPurchaseRedemptionHelperType { + + private let backend: Backend + private let identityManager: IdentityManager + private let customerInfoManager: CustomerInfoManager + + init(backend: Backend, + identityManager: IdentityManager, + customerInfoManager: CustomerInfoManager) { + self.backend = backend + self.identityManager = identityManager + self.customerInfoManager = customerInfoManager + } + + func handleRedeemWebPurchase(redemptionToken: String) async -> WebPurchaseRedemptionResult { + Logger.verbose(Strings.webRedemption.redeeming_web_purchase) + return await withCheckedContinuation { continuation in + self.backend.redeemWebPurchaseAPI.postRedeemWebPurchase(appUserID: identityManager.currentAppUserID, + redemptionToken: redemptionToken) { result in + switch result { + case let .success(customerInfo): + Logger.debug(Strings.webRedemption.redeemed_web_purchase) + self.customerInfoManager.cache(customerInfo: customerInfo, + appUserID: self.identityManager.currentAppUserID) + continuation.resume(returning: .success(customerInfo)) + case let .failure(error): + Logger.error(Strings.webRedemption.error_redeeming_web_purchase(error)) + let purchasesError = error.asPurchasesError + switch purchasesError.errorCode { + case ErrorCode.invalidWebPurchaseToken.rawValue: + continuation.resume(returning: .invalidToken) + case ErrorCode.purchaseBelongsToOtherUser.rawValue: + continuation.resume(returning: .purchaseBelongsToOtherUser) + case ErrorCode.expiredWebPurchaseToken.rawValue: + guard let obfuscatedEmail = purchasesError.userInfo[ErrorDetails.obfuscatedEmailKey] as? String + else { + continuation.resume(returning: .error(error.asPublicError)) + return + } + continuation.resume(returning: .expired(obfuscatedEmail)) + default: + continuation.resume(returning: .error(error.asPublicError)) + } + } + } + } + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/WebPurchaseRedemption/WebPurchaseRedemptionResult.swift b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/WebPurchaseRedemption/WebPurchaseRedemptionResult.swift new file mode 100644 index 00000000..68546e06 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/RevenueCat/Sources/WebPurchaseRedemption/WebPurchaseRedemptionResult.swift @@ -0,0 +1,34 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// WebPurchaseRedemptionResult.swift +// +// Created by Antonio Rico Diez on 29/10/24. + +import Foundation + +/// Represents the result of a web purchase redemption +/// +/// - Seealso: ``Purchases/redeemWebPurchase(_:)`` +public enum WebPurchaseRedemptionResult: Sendable { + + /// Represents that the web purchase was redeemed successfully + case success(_ customerInfo: CustomerInfo) + /// Represents that the web purchase failed to redeem + case error(_ error: PublicError) + /// Represents that the token was not a valid redemption token. Maybe the link was invalid or incomplete. + case invalidToken + /// Indicates that the web purchase belongs to a different user and can't be redeemed again. + case purchaseBelongsToOtherUser + /// Indicates that the redemption token has expired. An email with a new redemption token + /// might be sent if a new one wasn't already sent recently. + /// The email where it will be sent is indicated by the [obfuscatedEmail]. + case expired(_ obfuscatedEmail: String) + +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-Info.plist b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-Info.plist new file mode 100644 index 00000000..19cf209d --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + ${PODS_DEVELOPMENT_LANGUAGE} + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleIdentifier + ${PRODUCT_BUNDLE_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${PRODUCT_NAME} + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0.0 + CFBundleSignature + ???? + CFBundleVersion + ${CURRENT_PROJECT_VERSION} + NSPrincipalClass + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-acknowledgements.markdown b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-acknowledgements.markdown new file mode 100644 index 00000000..1dbc6ef5 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-acknowledgements.markdown @@ -0,0 +1,53 @@ +# Acknowledgements +This application makes use of the following third party libraries: + +## PurchasesHybridCommon + +MIT License + +Copyright (c) 2019 RevenueCat, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +## RevenueCat + +MIT License + +Copyright (c) 2024 RevenueCat, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Generated by CocoaPods - https://cocoapods.org diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-acknowledgements.plist b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-acknowledgements.plist new file mode 100644 index 00000000..43be8acb --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-acknowledgements.plist @@ -0,0 +1,91 @@ + + + + + PreferenceSpecifiers + + + FooterText + This application makes use of the following third party libraries: + Title + Acknowledgements + Type + PSGroupSpecifier + + + FooterText + MIT License + +Copyright (c) 2019 RevenueCat, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + License + MIT + Title + PurchasesHybridCommon + Type + PSGroupSpecifier + + + FooterText + MIT License + +Copyright (c) 2024 RevenueCat, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + License + MIT + Title + RevenueCat + Type + PSGroupSpecifier + + + FooterText + Generated by CocoaPods - https://cocoapods.org + Title + + Type + PSGroupSpecifier + + + StringsTable + Acknowledgements + Title + Acknowledgements + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-dummy.m b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-dummy.m new file mode 100644 index 00000000..ac0b6a18 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-dummy.m @@ -0,0 +1,5 @@ +#import +@interface PodsDummy_Pods_App : NSObject +@end +@implementation PodsDummy_Pods_App +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-frameworks-Debug-input-files.xcfilelist b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-frameworks-Debug-input-files.xcfilelist new file mode 100644 index 00000000..59e46ed6 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-frameworks-Debug-input-files.xcfilelist @@ -0,0 +1,3 @@ +${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh +${BUILT_PRODUCTS_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.framework +${BUILT_PRODUCTS_DIR}/RevenueCat/RevenueCat.framework \ No newline at end of file diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-frameworks-Debug-output-files.xcfilelist b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-frameworks-Debug-output-files.xcfilelist new file mode 100644 index 00000000..6f35ffc0 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-frameworks-Debug-output-files.xcfilelist @@ -0,0 +1,2 @@ +${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/PurchasesHybridCommon.framework +${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RevenueCat.framework \ No newline at end of file diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-frameworks-Release-input-files.xcfilelist b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-frameworks-Release-input-files.xcfilelist new file mode 100644 index 00000000..59e46ed6 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-frameworks-Release-input-files.xcfilelist @@ -0,0 +1,3 @@ +${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh +${BUILT_PRODUCTS_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.framework +${BUILT_PRODUCTS_DIR}/RevenueCat/RevenueCat.framework \ No newline at end of file diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-frameworks-Release-output-files.xcfilelist b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-frameworks-Release-output-files.xcfilelist new file mode 100644 index 00000000..6f35ffc0 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-frameworks-Release-output-files.xcfilelist @@ -0,0 +1,2 @@ +${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/PurchasesHybridCommon.framework +${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RevenueCat.framework \ No newline at end of file diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-frameworks.sh b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-frameworks.sh new file mode 100755 index 00000000..603f1141 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-frameworks.sh @@ -0,0 +1,188 @@ +#!/bin/sh +set -e +set -u +set -o pipefail + +function on_error { + echo "$(realpath -mq "${0}"):$1: error: Unexpected failure" +} +trap 'on_error $LINENO' ERR + +if [ -z ${FRAMEWORKS_FOLDER_PATH+x} ]; then + # If FRAMEWORKS_FOLDER_PATH is not set, then there's nowhere for us to copy + # frameworks to, so exit 0 (signalling the script phase was successful). + exit 0 +fi + +echo "mkdir -p ${CONFIGURATION_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}" +mkdir -p "${CONFIGURATION_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}" + +COCOAPODS_PARALLEL_CODE_SIGN="${COCOAPODS_PARALLEL_CODE_SIGN:-false}" +SWIFT_STDLIB_PATH="${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" +BCSYMBOLMAP_DIR="BCSymbolMaps" + + +# This protects against multiple targets copying the same framework dependency at the same time. The solution +# was originally proposed here: https://lists.samba.org/archive/rsync/2008-February/020158.html +RSYNC_PROTECT_TMP_FILES=(--filter "P .*.??????") + +# Copies and strips a vendored framework +install_framework() +{ + if [ -r "${BUILT_PRODUCTS_DIR}/$1" ]; then + local source="${BUILT_PRODUCTS_DIR}/$1" + elif [ -r "${BUILT_PRODUCTS_DIR}/$(basename "$1")" ]; then + local source="${BUILT_PRODUCTS_DIR}/$(basename "$1")" + elif [ -r "$1" ]; then + local source="$1" + fi + + local destination="${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}" + + if [ -L "${source}" ]; then + echo "Symlinked..." + source="$(readlink -f "${source}")" + fi + + if [ -d "${source}/${BCSYMBOLMAP_DIR}" ]; then + # Locate and install any .bcsymbolmaps if present, and remove them from the .framework before the framework is copied + find "${source}/${BCSYMBOLMAP_DIR}" -name "*.bcsymbolmap"|while read f; do + echo "Installing $f" + install_bcsymbolmap "$f" "$destination" + rm "$f" + done + rmdir "${source}/${BCSYMBOLMAP_DIR}" + fi + + # Use filter instead of exclude so missing patterns don't throw errors. + echo "rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --links --filter \"- CVS/\" --filter \"- .svn/\" --filter \"- .git/\" --filter \"- .hg/\" --filter \"- Headers\" --filter \"- PrivateHeaders\" --filter \"- Modules\" \"${source}\" \"${destination}\"" + rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --links --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" --filter "- Headers" --filter "- PrivateHeaders" --filter "- Modules" "${source}" "${destination}" + + local basename + basename="$(basename -s .framework "$1")" + binary="${destination}/${basename}.framework/${basename}" + + if ! [ -r "$binary" ]; then + binary="${destination}/${basename}" + elif [ -L "${binary}" ]; then + echo "Destination binary is symlinked..." + dirname="$(dirname "${binary}")" + binary="${dirname}/$(readlink "${binary}")" + fi + + # Strip invalid architectures so "fat" simulator / device frameworks work on device + if [[ "$(file "$binary")" == *"dynamically linked shared library"* ]]; then + strip_invalid_archs "$binary" + fi + + # Resign the code if required by the build settings to avoid unstable apps + code_sign_if_enabled "${destination}/$(basename "$1")" + + # Embed linked Swift runtime libraries. No longer necessary as of Xcode 7. + if [ "${XCODE_VERSION_MAJOR}" -lt 7 ]; then + local swift_runtime_libs + swift_runtime_libs=$(xcrun otool -LX "$binary" | grep --color=never @rpath/libswift | sed -E s/@rpath\\/\(.+dylib\).*/\\1/g | uniq -u) + for lib in $swift_runtime_libs; do + echo "rsync -auv \"${SWIFT_STDLIB_PATH}/${lib}\" \"${destination}\"" + rsync -auv "${SWIFT_STDLIB_PATH}/${lib}" "${destination}" + code_sign_if_enabled "${destination}/${lib}" + done + fi +} +# Copies and strips a vendored dSYM +install_dsym() { + local source="$1" + warn_missing_arch=${2:-true} + if [ -r "$source" ]; then + # Copy the dSYM into the targets temp dir. + echo "rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --filter \"- CVS/\" --filter \"- .svn/\" --filter \"- .git/\" --filter \"- .hg/\" --filter \"- Headers\" --filter \"- PrivateHeaders\" --filter \"- Modules\" \"${source}\" \"${DERIVED_FILES_DIR}\"" + rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" --filter "- Headers" --filter "- PrivateHeaders" --filter "- Modules" "${source}" "${DERIVED_FILES_DIR}" + + local basename + basename="$(basename -s .dSYM "$source")" + binary_name="$(ls "$source/Contents/Resources/DWARF")" + binary="${DERIVED_FILES_DIR}/${basename}.dSYM/Contents/Resources/DWARF/${binary_name}" + + # Strip invalid architectures from the dSYM. + if [[ "$(file "$binary")" == *"Mach-O "*"dSYM companion"* ]]; then + strip_invalid_archs "$binary" "$warn_missing_arch" + fi + if [[ $STRIP_BINARY_RETVAL == 0 ]]; then + # Move the stripped file into its final destination. + echo "rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --links --filter \"- CVS/\" --filter \"- .svn/\" --filter \"- .git/\" --filter \"- .hg/\" --filter \"- Headers\" --filter \"- PrivateHeaders\" --filter \"- Modules\" \"${DERIVED_FILES_DIR}/${basename}.framework.dSYM\" \"${DWARF_DSYM_FOLDER_PATH}\"" + rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --links --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" --filter "- Headers" --filter "- PrivateHeaders" --filter "- Modules" "${DERIVED_FILES_DIR}/${basename}.dSYM" "${DWARF_DSYM_FOLDER_PATH}" + else + # The dSYM was not stripped at all, in this case touch a fake folder so the input/output paths from Xcode do not reexecute this script because the file is missing. + mkdir -p "${DWARF_DSYM_FOLDER_PATH}" + touch "${DWARF_DSYM_FOLDER_PATH}/${basename}.dSYM" + fi + fi +} + +# Used as a return value for each invocation of `strip_invalid_archs` function. +STRIP_BINARY_RETVAL=0 + +# Strip invalid architectures +strip_invalid_archs() { + binary="$1" + warn_missing_arch=${2:-true} + # Get architectures for current target binary + binary_archs="$(lipo -info "$binary" | rev | cut -d ':' -f1 | awk '{$1=$1;print}' | rev)" + # Intersect them with the architectures we are building for + intersected_archs="$(echo ${ARCHS[@]} ${binary_archs[@]} | tr ' ' '\n' | sort | uniq -d)" + # If there are no archs supported by this binary then warn the user + if [[ -z "$intersected_archs" ]]; then + if [[ "$warn_missing_arch" == "true" ]]; then + echo "warning: [CP] Vendored binary '$binary' contains architectures ($binary_archs) none of which match the current build architectures ($ARCHS)." + fi + STRIP_BINARY_RETVAL=1 + return + fi + stripped="" + for arch in $binary_archs; do + if ! [[ "${ARCHS}" == *"$arch"* ]]; then + # Strip non-valid architectures in-place + lipo -remove "$arch" -output "$binary" "$binary" + stripped="$stripped $arch" + fi + done + if [[ "$stripped" ]]; then + echo "Stripped $binary of architectures:$stripped" + fi + STRIP_BINARY_RETVAL=0 +} + +# Copies the bcsymbolmap files of a vendored framework +install_bcsymbolmap() { + local bcsymbolmap_path="$1" + local destination="${BUILT_PRODUCTS_DIR}" + echo "rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" --filter "- Headers" --filter "- PrivateHeaders" --filter "- Modules" "${bcsymbolmap_path}" "${destination}"" + rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" --filter "- Headers" --filter "- PrivateHeaders" --filter "- Modules" "${bcsymbolmap_path}" "${destination}" +} + +# Signs a framework with the provided identity +code_sign_if_enabled() { + if [ -n "${EXPANDED_CODE_SIGN_IDENTITY:-}" -a "${CODE_SIGNING_REQUIRED:-}" != "NO" -a "${CODE_SIGNING_ALLOWED}" != "NO" ]; then + # Use the current code_sign_identity + echo "Code Signing $1 with Identity ${EXPANDED_CODE_SIGN_IDENTITY_NAME}" + local code_sign_cmd="/usr/bin/codesign --force --sign ${EXPANDED_CODE_SIGN_IDENTITY} ${OTHER_CODE_SIGN_FLAGS:-} --preserve-metadata=identifier,entitlements '$1'" + + if [ "${COCOAPODS_PARALLEL_CODE_SIGN}" == "true" ]; then + code_sign_cmd="$code_sign_cmd &" + fi + echo "$code_sign_cmd" + eval "$code_sign_cmd" + fi +} + +if [[ "$CONFIGURATION" == "Debug" ]]; then + install_framework "${BUILT_PRODUCTS_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.framework" + install_framework "${BUILT_PRODUCTS_DIR}/RevenueCat/RevenueCat.framework" +fi +if [[ "$CONFIGURATION" == "Release" ]]; then + install_framework "${BUILT_PRODUCTS_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.framework" + install_framework "${BUILT_PRODUCTS_DIR}/RevenueCat/RevenueCat.framework" +fi +if [ "${COCOAPODS_PARALLEL_CODE_SIGN}" == "true" ]; then + wait +fi diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-umbrella.h b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-umbrella.h new file mode 100644 index 00000000..e6a69e53 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App-umbrella.h @@ -0,0 +1,16 @@ +#ifdef __OBJC__ +#import +#else +#ifndef FOUNDATION_EXPORT +#if defined(__cplusplus) +#define FOUNDATION_EXPORT extern "C" +#else +#define FOUNDATION_EXPORT extern +#endif +#endif +#endif + + +FOUNDATION_EXPORT double Pods_AppVersionNumber; +FOUNDATION_EXPORT const unsigned char Pods_AppVersionString[]; + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig new file mode 100644 index 00000000..85d1ebbf --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig @@ -0,0 +1,16 @@ +ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES +CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO +FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon" "${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat" +GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 +HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.framework/Headers" +LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks' +LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift +OTHER_LDFLAGS = $(inherited) -framework "PurchasesHybridCommon" -framework "RevenueCat" -framework "StoreKit" +OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon" "-F${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat" +OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS +PODS_BUILD_DIR = ${BUILD_DIR} +PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) +PODS_PODFILE_DIR_PATH = ${SRCROOT}/. +PODS_ROOT = ${SRCROOT}/Pods +PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates +USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App.modulemap b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App.modulemap new file mode 100644 index 00000000..f4aba196 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App.modulemap @@ -0,0 +1,6 @@ +framework module Pods_App { + umbrella header "Pods-App-umbrella.h" + + export * + module * { export * } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig new file mode 100644 index 00000000..85d1ebbf --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig @@ -0,0 +1,16 @@ +ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES +CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO +FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon" "${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat" +GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 +HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.framework/Headers" +LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks' +LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift +OTHER_LDFLAGS = $(inherited) -framework "PurchasesHybridCommon" -framework "RevenueCat" -framework "StoreKit" +OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon" "-F${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat" +OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS +PODS_BUILD_DIR = ${BUILD_DIR} +PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) +PODS_PODFILE_DIR_PATH = ${SRCROOT}/. +PODS_ROOT = ${SRCROOT}/Pods +PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates +USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon-Info.plist b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon-Info.plist new file mode 100644 index 00000000..fab3b983 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon-Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + ${PODS_DEVELOPMENT_LANGUAGE} + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleIdentifier + ${PRODUCT_BUNDLE_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${PRODUCT_NAME} + CFBundlePackageType + FMWK + CFBundleShortVersionString + 17.41.1 + CFBundleSignature + ???? + CFBundleVersion + ${CURRENT_PROJECT_VERSION} + NSPrincipalClass + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon-dummy.m b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon-dummy.m new file mode 100644 index 00000000..05b3b8de --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon-dummy.m @@ -0,0 +1,5 @@ +#import +@interface PodsDummy_PurchasesHybridCommon : NSObject +@end +@implementation PodsDummy_PurchasesHybridCommon +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon-prefix.pch b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon-prefix.pch new file mode 100644 index 00000000..beb2a244 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon-prefix.pch @@ -0,0 +1,12 @@ +#ifdef __OBJC__ +#import +#else +#ifndef FOUNDATION_EXPORT +#if defined(__cplusplus) +#define FOUNDATION_EXPORT extern "C" +#else +#define FOUNDATION_EXPORT extern +#endif +#endif +#endif + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon-umbrella.h b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon-umbrella.h new file mode 100644 index 00000000..68a526d7 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon-umbrella.h @@ -0,0 +1,17 @@ +#ifdef __OBJC__ +#import +#else +#ifndef FOUNDATION_EXPORT +#if defined(__cplusplus) +#define FOUNDATION_EXPORT extern "C" +#else +#define FOUNDATION_EXPORT extern +#endif +#endif +#endif + +#import "PurchasesHybridCommon.h" + +FOUNDATION_EXPORT double PurchasesHybridCommonVersionNumber; +FOUNDATION_EXPORT const unsigned char PurchasesHybridCommonVersionString[]; + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon.debug.xcconfig b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon.debug.xcconfig new file mode 100644 index 00000000..5dd53773 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon.debug.xcconfig @@ -0,0 +1,17 @@ +CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO +CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon +DEFINES_MODULE = YES +FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat" +GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 +LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift +OTHER_LDFLAGS = $(inherited) -framework "RevenueCat" -framework "StoreKit" +OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS +PODS_BUILD_DIR = ${BUILD_DIR} +PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) +PODS_DEVELOPMENT_LANGUAGE = ${DEVELOPMENT_LANGUAGE} +PODS_ROOT = ${SRCROOT} +PODS_TARGET_SRCROOT = ${PODS_ROOT}/PurchasesHybridCommon +PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates +PRODUCT_BUNDLE_IDENTIFIER = org.cocoapods.${PRODUCT_NAME:rfc1034identifier} +SKIP_INSTALL = YES +USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon.modulemap b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon.modulemap new file mode 100644 index 00000000..fab4a8b3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon.modulemap @@ -0,0 +1,6 @@ +framework module PurchasesHybridCommon { + umbrella header "PurchasesHybridCommon-umbrella.h" + + export * + module * { export * } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon.release.xcconfig b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon.release.xcconfig new file mode 100644 index 00000000..5dd53773 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/PurchasesHybridCommon.release.xcconfig @@ -0,0 +1,17 @@ +CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO +CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon +DEFINES_MODULE = YES +FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat" +GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 +LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift +OTHER_LDFLAGS = $(inherited) -framework "RevenueCat" -framework "StoreKit" +OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS +PODS_BUILD_DIR = ${BUILD_DIR} +PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) +PODS_DEVELOPMENT_LANGUAGE = ${DEVELOPMENT_LANGUAGE} +PODS_ROOT = ${SRCROOT} +PODS_TARGET_SRCROOT = ${PODS_ROOT}/PurchasesHybridCommon +PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates +PRODUCT_BUNDLE_IDENTIFIER = org.cocoapods.${PRODUCT_NAME:rfc1034identifier} +SKIP_INSTALL = YES +USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/ResourceBundle-PurchasesHybridCommon-PurchasesHybridCommon-Info.plist b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/ResourceBundle-PurchasesHybridCommon-PurchasesHybridCommon-Info.plist new file mode 100644 index 00000000..3d4f1bdb --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/PurchasesHybridCommon/ResourceBundle-PurchasesHybridCommon-PurchasesHybridCommon-Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + ${PODS_DEVELOPMENT_LANGUAGE} + CFBundleIdentifier + ${PRODUCT_BUNDLE_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${PRODUCT_NAME} + CFBundlePackageType + BNDL + CFBundleShortVersionString + 17.41.1 + CFBundleSignature + ???? + CFBundleVersion + 1 + NSPrincipalClass + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/ResourceBundle-RevenueCat-RevenueCat-Info.plist b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/ResourceBundle-RevenueCat-RevenueCat-Info.plist new file mode 100644 index 00000000..227d868b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/ResourceBundle-RevenueCat-RevenueCat-Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + ${PODS_DEVELOPMENT_LANGUAGE} + CFBundleIdentifier + ${PRODUCT_BUNDLE_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${PRODUCT_NAME} + CFBundlePackageType + BNDL + CFBundleShortVersionString + 5.59.2 + CFBundleSignature + ???? + CFBundleVersion + 1 + NSPrincipalClass + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/RevenueCat-Info.plist b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/RevenueCat-Info.plist new file mode 100644 index 00000000..eba06cba --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/RevenueCat-Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + ${PODS_DEVELOPMENT_LANGUAGE} + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleIdentifier + ${PRODUCT_BUNDLE_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${PRODUCT_NAME} + CFBundlePackageType + FMWK + CFBundleShortVersionString + 5.59.2 + CFBundleSignature + ???? + CFBundleVersion + ${CURRENT_PROJECT_VERSION} + NSPrincipalClass + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/RevenueCat-dummy.m b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/RevenueCat-dummy.m new file mode 100644 index 00000000..8eecdd2e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/RevenueCat-dummy.m @@ -0,0 +1,5 @@ +#import +@interface PodsDummy_RevenueCat : NSObject +@end +@implementation PodsDummy_RevenueCat +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/RevenueCat-prefix.pch b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/RevenueCat-prefix.pch new file mode 100644 index 00000000..beb2a244 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/RevenueCat-prefix.pch @@ -0,0 +1,12 @@ +#ifdef __OBJC__ +#import +#else +#ifndef FOUNDATION_EXPORT +#if defined(__cplusplus) +#define FOUNDATION_EXPORT extern "C" +#else +#define FOUNDATION_EXPORT extern +#endif +#endif +#endif + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/RevenueCat-umbrella.h b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/RevenueCat-umbrella.h new file mode 100644 index 00000000..d780d98a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/RevenueCat-umbrella.h @@ -0,0 +1,16 @@ +#ifdef __OBJC__ +#import +#else +#ifndef FOUNDATION_EXPORT +#if defined(__cplusplus) +#define FOUNDATION_EXPORT extern "C" +#else +#define FOUNDATION_EXPORT extern +#endif +#endif +#endif + + +FOUNDATION_EXPORT double RevenueCatVersionNumber; +FOUNDATION_EXPORT const unsigned char RevenueCatVersionString[]; + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/RevenueCat.debug.xcconfig b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/RevenueCat.debug.xcconfig new file mode 100644 index 00000000..b858568e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/RevenueCat.debug.xcconfig @@ -0,0 +1,18 @@ +CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO +CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat +DEFINES_MODULE = YES +GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 +LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift +OTHER_LDFLAGS = $(inherited) -framework "StoreKit" +OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS +PODS_BUILD_DIR = ${BUILD_DIR} +PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) +PODS_DEVELOPMENT_LANGUAGE = ${DEVELOPMENT_LANGUAGE} +PODS_ROOT = ${SRCROOT} +PODS_TARGET_SRCROOT = ${PODS_ROOT}/RevenueCat +PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates +PRODUCT_BUNDLE_IDENTIFIER = org.cocoapods.${PRODUCT_NAME:rfc1034identifier} +SKIP_INSTALL = YES +SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=xros*] = $(inherited) VISION_OS +SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=xrsimulator*] = $(inherited) VISION_OS +USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/RevenueCat.modulemap b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/RevenueCat.modulemap new file mode 100644 index 00000000..aa6dbd4d --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/RevenueCat.modulemap @@ -0,0 +1,6 @@ +framework module RevenueCat { + umbrella header "RevenueCat-umbrella.h" + + export * + module * { export * } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/RevenueCat.release.xcconfig b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/RevenueCat.release.xcconfig new file mode 100644 index 00000000..b858568e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/Pods/Target Support Files/RevenueCat/RevenueCat.release.xcconfig @@ -0,0 +1,18 @@ +CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO +CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat +DEFINES_MODULE = YES +GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 +LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift +OTHER_LDFLAGS = $(inherited) -framework "StoreKit" +OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS +PODS_BUILD_DIR = ${BUILD_DIR} +PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) +PODS_DEVELOPMENT_LANGUAGE = ${DEVELOPMENT_LANGUAGE} +PODS_ROOT = ${SRCROOT} +PODS_TARGET_SRCROOT = ${PODS_ROOT}/RevenueCat +PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates +PRODUCT_BUNDLE_IDENTIFIER = org.cocoapods.${PRODUCT_NAME:rfc1034identifier} +SKIP_INSTALL = YES +SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=xros*] = $(inherited) VISION_OS +SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=xrsimulator*] = $(inherited) VISION_OS +USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES diff --git a/e2e-tests/MaestroTestApp/platforms/ios/cordova/Api.js b/e2e-tests/MaestroTestApp/platforms/ios/cordova/Api.js new file mode 100644 index 00000000..20413865 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/cordova/Api.js @@ -0,0 +1,20 @@ +/** + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +module.exports = require('cordova-ios'); diff --git a/e2e-tests/MaestroTestApp/platforms/ios/cordova/apple_ios_version b/e2e-tests/MaestroTestApp/platforms/ios/cordova/apple_ios_version new file mode 100755 index 00000000..133a7378 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/cordova/apple_ios_version @@ -0,0 +1,24 @@ +#!/usr/bin/env node + +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +const versions = require('cordova-ios/lib/versions.js'); + +versions.printOrDie('apple_ios'); diff --git a/e2e-tests/MaestroTestApp/platforms/ios/cordova/apple_osx_version b/e2e-tests/MaestroTestApp/platforms/ios/cordova/apple_osx_version new file mode 100755 index 00000000..ad484e63 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/cordova/apple_osx_version @@ -0,0 +1,24 @@ +#!/usr/bin/env node + +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +const versions = require('cordova-ios/lib/versions.js'); + +versions.printOrDie('apple_osx'); diff --git a/e2e-tests/MaestroTestApp/platforms/ios/cordova/apple_xcode_version b/e2e-tests/MaestroTestApp/platforms/ios/cordova/apple_xcode_version new file mode 100755 index 00000000..68908b3c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/cordova/apple_xcode_version @@ -0,0 +1,24 @@ +#!/usr/bin/env node + +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +const versions = require('cordova-ios/lib/versions.js'); + +versions.printOrDie('apple_xcode'); diff --git a/e2e-tests/MaestroTestApp/platforms/ios/cordova/build-debug.xcconfig b/e2e-tests/MaestroTestApp/platforms/ios/cordova/build-debug.xcconfig new file mode 100644 index 00000000..d7ee4f62 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/cordova/build-debug.xcconfig @@ -0,0 +1,29 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +// +// XCode Build settings for "Debug" Build Configuration. +// + +#include "build.xcconfig" +#include "build-extras.xcconfig" + +// (CB-11792) +// @COCOAPODS_SILENCE_WARNINGS@ // +#include "../pods-debug.xcconfig" diff --git a/e2e-tests/MaestroTestApp/platforms/ios/cordova/build-extras.xcconfig b/e2e-tests/MaestroTestApp/platforms/ios/cordova/build-extras.xcconfig new file mode 100644 index 00000000..e69de29b diff --git a/e2e-tests/MaestroTestApp/platforms/ios/cordova/build-release.xcconfig b/e2e-tests/MaestroTestApp/platforms/ios/cordova/build-release.xcconfig new file mode 100644 index 00000000..886e8229 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/cordova/build-release.xcconfig @@ -0,0 +1,29 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +// +// XCode Build settings for "Release" Build Configuration. +// + +#include "build.xcconfig" +#include "build-extras.xcconfig" + +// (CB-11792) +// @COCOAPODS_SILENCE_WARNINGS@ // +#include "../pods-release.xcconfig" diff --git a/e2e-tests/MaestroTestApp/platforms/ios/cordova/build.xcconfig b/e2e-tests/MaestroTestApp/platforms/ios/cordova/build.xcconfig new file mode 100644 index 00000000..c96c940a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/cordova/build.xcconfig @@ -0,0 +1,23 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +// +// XCode build settings shared by all Build Configurations. +// Settings are overridden by configuration-level .xcconfig file (build-release/build-debug). +// diff --git a/e2e-tests/MaestroTestApp/platforms/ios/cordova/defaults.xml b/e2e-tests/MaestroTestApp/platforms/ios/cordova/defaults.xml new file mode 100644 index 00000000..63db1be0 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/cordova/defaults.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/cordova/lib/list-devices b/e2e-tests/MaestroTestApp/platforms/ios/cordova/lib/list-devices new file mode 100755 index 00000000..81c3e3d7 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/cordova/lib/list-devices @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +const { run } = require('cordova-ios/lib/listDevices'); + +run().then(devices => { + devices.forEach(device => { + console.log(device); + }); +}); diff --git a/e2e-tests/MaestroTestApp/platforms/ios/cordova/lib/list-emulator-images b/e2e-tests/MaestroTestApp/platforms/ios/cordova/lib/list-emulator-images new file mode 100755 index 00000000..b422269c --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/cordova/lib/list-emulator-images @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +const { run } = require('cordova-ios/lib/listEmulatorImages'); + +run().then(names => { + names.forEach(name => { + console.log(name); + }); +}); diff --git a/e2e-tests/MaestroTestApp/platforms/ios/cordova/loggingHelper.js b/e2e-tests/MaestroTestApp/platforms/ios/cordova/loggingHelper.js new file mode 100644 index 00000000..871b6cf3 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/cordova/loggingHelper.js @@ -0,0 +1,30 @@ +/** + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +const CordovaLogger = require('cordova-common').CordovaLogger; + +module.exports = { + adjustLoggerLevel: function (opts) { + if (opts.verbose || (Array.isArray(opts) && opts.indexOf('--verbose') !== -1)) { + CordovaLogger.get().setLevel('verbose'); + } else if (opts.silent || (Array.isArray(opts) && opts.indexOf('--silent') !== -1)) { + CordovaLogger.get().setLevel('error'); + } + } +}; diff --git a/e2e-tests/MaestroTestApp/platforms/ios/cordova/version b/e2e-tests/MaestroTestApp/platforms/ios/cordova/version new file mode 100755 index 00000000..7234670b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/cordova/version @@ -0,0 +1,24 @@ +#!/usr/bin/env node + +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +const Api = require('./Api'); + +console.log(Api.version()); diff --git a/e2e-tests/MaestroTestApp/platforms/ios/ios.json b/e2e-tests/MaestroTestApp/platforms/ios/ios.json new file mode 100644 index 00000000..67abc468 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/ios.json @@ -0,0 +1,51 @@ +{ + "prepare_queue": { + "installed": [], + "uninstalled": [] + }, + "config_munge": { + "files": { + "config.xml": { + "parents": { + "/*": [ + { + "xml": "", + "count": 1 + }, + { + "xml": "", + "count": 1 + } + ] + } + } + } + }, + "installed_plugins": { + "cordova-annotated-plugin-android": { + "PACKAGE_NAME": "com.revenuecat.maestro.e2e" + }, + "cordova-plugin-add-swift-support": { + "PACKAGE_NAME": "com.revenuecat.maestro.e2e" + }, + "cordova-plugin-purchases": { + "PACKAGE_NAME": "com.revenuecat.maestro.e2e" + } + }, + "dependent_plugins": {}, + "modules": [ + { + "id": "cordova-plugin-purchases.plugin", + "file": "plugins/cordova-plugin-purchases/www/plugin.js", + "pluginId": "cordova-plugin-purchases", + "clobbers": [ + "Purchases" + ] + } + ], + "plugin_metadata": { + "cordova-annotated-plugin-android": "1.0.4", + "cordova-plugin-add-swift-support": "2.0.2", + "cordova-plugin-purchases": "7.3.1" + } +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios-plugins/Package.swift b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios-plugins/Package.swift new file mode 100644 index 00000000..705e2778 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios-plugins/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version:5.5 + +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +import PackageDescription + +let package = Package( + name: "CordovaPlugins", + platforms: [ + .iOS(.v13), + .macCatalyst(.v13) + ], + products: [ + .library(name: "CordovaPlugins", targets: ["CordovaPlugins"]) + ], + targets: [ + .target(name: "CordovaPlugins") + ] +) + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios-plugins/Sources/CordovaPlugins/CordovaPlugins.swift b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios-plugins/Sources/CordovaPlugins/CordovaPlugins.swift new file mode 100644 index 00000000..92baa917 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios-plugins/Sources/CordovaPlugins/CordovaPlugins.swift @@ -0,0 +1,21 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +// We need something here so that the empty package will compile successfully +public let isCordovaApp = true diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/CDVCommandDelegateImpl.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/CDVCommandDelegateImpl.h new file mode 100644 index 00000000..00d39c48 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/CDVCommandDelegateImpl.h @@ -0,0 +1,36 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import +#import + +@class CDVViewController; +@class CDVCommandQueue; + +@interface CDVCommandDelegateImpl : NSObject { + @private + __weak CDVViewController* _viewController; + NSRegularExpression* _callbackIdPattern; + @protected + __weak CDVCommandQueue* _commandQueue; + BOOL _delayResponses; +} +- (instancetype)initWithViewController:(CDVViewController *)viewController; +- (void)flushCommandQueueWithDelayedJs; +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/CDVCommandDelegateImpl.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/CDVCommandDelegateImpl.m new file mode 100644 index 00000000..70c0c134 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/CDVCommandDelegateImpl.m @@ -0,0 +1,179 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import "CDVCommandDelegateImpl.h" +#import "CDVJSON_private.h" +#import +#import +#import + +@implementation CDVCommandDelegateImpl + +- (instancetype)initWithViewController:(CDVViewController *)viewController +{ + self = [super init]; + if (self != nil) { + _viewController = viewController; + _commandQueue = _viewController.commandQueue; + + NSError* err = nil; + _callbackIdPattern = [NSRegularExpression regularExpressionWithPattern:@"[^A-Za-z0-9._-]" options:0 error:&err]; + if (err != nil) { + // Couldn't initialize Regex + NSLog(@"Error: Couldn't initialize regex"); + _callbackIdPattern = nil; + } + } + return self; +} + +- (NSString*)pathForResource:(NSString*)resourcepath +{ + NSBundle* mainBundle = [NSBundle mainBundle]; + NSMutableArray* directoryParts = [NSMutableArray arrayWithArray:[resourcepath componentsSeparatedByString:@"/"]]; + NSString* filename = [directoryParts lastObject]; + + [directoryParts removeLastObject]; + + NSString* directoryPartsJoined = [directoryParts componentsJoinedByString:@"/"]; + NSString* directoryStr = _viewController.webContentFolderName; + + if ([directoryPartsJoined length] > 0) { + directoryStr = [NSString stringWithFormat:@"%@/%@", _viewController.webContentFolderName, [directoryParts componentsJoinedByString:@"/"]]; + } + + return [mainBundle pathForResource:filename ofType:@"" inDirectory:directoryStr]; +} + +- (void)flushCommandQueueWithDelayedJs +{ + _delayResponses = YES; + [_commandQueue executePending]; + _delayResponses = NO; +} + +- (void)evalJsHelper2:(NSString*)js +{ + CDV_EXEC_LOG(@"Exec: evalling: %@", [js substringToIndex:MIN([js length], 160)]); + [_viewController.webViewEngine evaluateJavaScript:js completionHandler:^(id obj, NSError* error) { + // TODO: obj can be something other than string + if ([obj isKindOfClass:[NSString class]]) { + NSString* commandsJSON = (NSString*)obj; + if ([commandsJSON length] > 0) { + CDV_EXEC_LOG(@"Exec: Retrieved new exec messages by chaining."); + } + + [self->_commandQueue enqueueCommandBatch:commandsJSON]; + [self->_commandQueue executePending]; + } + }]; +} + +- (void)evalJsHelper:(NSString*)js +{ + // Cycle the run-loop before executing the JS. + // For _delayResponses - + // This ensures that we don't eval JS during the middle of an existing JS + // function (possible since WKWebViewDelegate callbacks can be synchronous). + // For !isMainThread - + // It's a hard error to eval on the non-UI thread. + // For !_commandQueue.currentlyExecuting - + // This works around a bug where sometimes alerts() within callbacks can cause + // dead-lock. + // If the commandQueue is currently executing, then we know that it is safe to + // execute the callback immediately. + // Using (dispatch_get_main_queue()) does *not* fix deadlocks for some reason, + // but performSelectorOnMainThread: does. + if (_delayResponses || ![NSThread isMainThread] || !_commandQueue.currentlyExecuting) { + [self performSelectorOnMainThread:@selector(evalJsHelper2:) withObject:js waitUntilDone:NO]; + } else { + [self evalJsHelper2:js]; + } +} + +- (BOOL)isValidCallbackId:(NSString*)callbackId +{ + if ((callbackId == nil) || (_callbackIdPattern == nil)) { + return NO; + } + + // Disallow if too long or if any invalid characters were found. + if (([callbackId length] > 100) || [_callbackIdPattern firstMatchInString:callbackId options:0 range:NSMakeRange(0, [callbackId length])]) { + return NO; + } + return YES; +} + +- (void)sendPluginResult:(CDVPluginResult*)result callbackId:(NSString*)callbackId +{ + CDV_EXEC_LOG(@"Exec(%@): Sending result. Status=%@", callbackId, result.status); + // This occurs when there is are no win/fail callbacks for the call. + if ([@"INVALID" isEqualToString:callbackId]) { + return; + } + // This occurs when the callback id is malformed. + if (![self isValidCallbackId:callbackId]) { + NSLog(@"Invalid callback id received by sendPluginResult"); + return; + } + int status = [result.status intValue]; + BOOL keepCallback = [result.keepCallback boolValue]; + NSString* argumentsAsJSON = [result argumentsAsJSON]; + BOOL debug = NO; + +#ifdef DEBUG + debug = YES; +#endif + + NSString* js = [NSString stringWithFormat:@"cordova.require('cordova/exec').nativeCallback('%@',%d,%@,%d, %d)", callbackId, status, argumentsAsJSON, keepCallback, debug]; + + [self evalJsHelper:js]; +} + +- (void)evalJs:(NSString*)js +{ + [self evalJs:js scheduledOnRunLoop:YES]; +} + +- (void)evalJs:(NSString*)js scheduledOnRunLoop:(BOOL)scheduledOnRunLoop +{ + js = [NSString stringWithFormat:@"try{cordova.require('cordova/exec').nativeEvalAndFetch(function(){%@})}catch(e){console.log('exception nativeEvalAndFetch : '+e);};", js]; + if (scheduledOnRunLoop) { + [self evalJsHelper:js]; + } else { + [self evalJsHelper2:js]; + } +} + +- (id)getCommandInstance:(NSString*)pluginName +{ + return [_viewController getCommandInstance:pluginName]; +} + +- (void)runInBackground:(void (^)(void))block +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), block); +} + +- (CDVSettingsDictionary*)settings +{ + return _viewController.settings; +} + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/CDVDebug.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/CDVDebug.h new file mode 100644 index 00000000..4a0d9f92 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/CDVDebug.h @@ -0,0 +1,25 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#ifdef DEBUG + #define DLog(fmt, ...) NSLog((@"%s [Line %d] " fmt), __PRETTY_FUNCTION__, __LINE__, ##__VA_ARGS__) +#else + #define DLog(...) +#endif +#define ALog(fmt, ...) NSLog((@"%s [Line %d] " fmt), __PRETTY_FUNCTION__, __LINE__, ##__VA_ARGS__) diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/CDVJSON_private.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/CDVJSON_private.h new file mode 100644 index 00000000..aaee42ca --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/CDVJSON_private.h @@ -0,0 +1,33 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +@interface NSArray (CDVJSONSerializingPrivate) +- (NSString*)cdv_JSONString; +@end + +@interface NSDictionary (CDVJSONSerializingPrivate) +- (NSString*)cdv_JSONString; +@end + +@interface NSString (CDVJSONSerializingPrivate) +- (id)cdv_JSONObject; +- (id)cdv_JSONFragment; +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/CDVJSON_private.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/CDVJSON_private.m new file mode 100644 index 00000000..6815e3ea --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/CDVJSON_private.m @@ -0,0 +1,98 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import "CDVJSON_private.h" + +@implementation NSArray (CDVJSONSerializingPrivate) + +- (NSString*)cdv_JSONString +{ + @autoreleasepool { + NSError* error = nil; + NSData* jsonData = [NSJSONSerialization dataWithJSONObject:self + options:0 + error:&error]; + + if (error != nil) { + NSLog(@"NSArray JSONString error: %@", [error localizedDescription]); + return nil; + } else { + return [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + } + } +} + +@end + +@implementation NSDictionary (CDVJSONSerializingPrivate) + +- (NSString*)cdv_JSONString +{ + @autoreleasepool { + NSError* error = nil; + NSData* jsonData = [NSJSONSerialization dataWithJSONObject:self + options:NSJSONWritingPrettyPrinted + error:&error]; + + if (error != nil) { + NSLog(@"NSDictionary JSONString error: %@", [error localizedDescription]); + return nil; + } else { + return [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + } + } +} + +@end + +@implementation NSString (CDVJSONSerializingPrivate) + +- (id)cdv_JSONObject +{ + @autoreleasepool { + NSError* error = nil; + id object = [NSJSONSerialization JSONObjectWithData:[self dataUsingEncoding:NSUTF8StringEncoding] + options:NSJSONReadingMutableContainers + error:&error]; + + if (error != nil) { + NSLog(@"NSString JSONObject error: %@, Malformed Data: %@", [error localizedDescription], self); + } + + return object; + } +} + +- (id)cdv_JSONFragment +{ + @autoreleasepool { + NSError* error = nil; + id object = [NSJSONSerialization JSONObjectWithData:[self dataUsingEncoding:NSUTF8StringEncoding] + options:NSJSONReadingAllowFragments + error:&error]; + + if (error != nil) { + NSLog(@"NSString JSONObject error: %@", [error localizedDescription]); + } + + return object; + } +} + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/CDVPlugin+Private.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/CDVPlugin+Private.h new file mode 100644 index 00000000..a06055cc --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/CDVPlugin+Private.h @@ -0,0 +1,26 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +@interface CDVPlugin (Private) + +- (instancetype)initWithWebViewEngine:(id )theWebViewEngine; + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/CDVViewController+Private.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/CDVViewController+Private.h new file mode 100644 index 00000000..27a907f9 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/CDVViewController+Private.h @@ -0,0 +1,28 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +@interface CDVViewController (Private) + +- (void)setStatusBarWebViewColor:(UIColor *)color; + +- (void)showStatusBar:(BOOL)visible; + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVGestureHandler/CDVGestureHandler.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVGestureHandler/CDVGestureHandler.h new file mode 100644 index 00000000..f39e066a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVGestureHandler/CDVGestureHandler.h @@ -0,0 +1,26 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +@interface CDVGestureHandler : CDVPlugin + +@property (nonatomic, strong) UILongPressGestureRecognizer* lpgr; + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVGestureHandler/CDVGestureHandler.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVGestureHandler/CDVGestureHandler.m new file mode 100644 index 00000000..839afc69 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVGestureHandler/CDVGestureHandler.m @@ -0,0 +1,68 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import "CDVGestureHandler.h" + +@implementation CDVGestureHandler + +- (void)pluginInitialize +{ + [self applyLongPressFix]; +} + +- (void)applyLongPressFix +{ + // You can't suppress 3D Touch and still have regular longpress, + // so if this is false, let's not consider the 3D Touch setting at all. + if (![self.commandDelegate.settings cordovaBoolSettingForKey:@"SuppressesLongPressGesture" defaultValue:NO]) { + return; + } + + self.lpgr = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPressGestures:)]; + self.lpgr.minimumPressDuration = 0.45f; + self.lpgr.allowableMovement = 200.0f; + + // 0.45 is ok for 'regular longpress', 0.05-0.08 is required for '3D Touch longpress', + // but since this will also kill onclick handlers (not ontouchend) it's optional. + if ([self.commandDelegate.settings cordovaBoolSettingForKey:@"Suppresses3DTouchGesture" defaultValue:NO]) { + self.lpgr.minimumPressDuration = 0.15f; + } + + NSArray *views = self.webView.subviews; + if (views.count == 0) { + NSLog(@"No webview subviews found, not applying the longpress fix."); + return; + } + for (int i=0; i + +@interface CDVHandleOpenURL : CDVPlugin + +@property (nonatomic, strong) NSURL* url; +@property (nonatomic, assign) BOOL pageLoaded; + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVHandleOpenURL/CDVHandleOpenURL.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVHandleOpenURL/CDVHandleOpenURL.m new file mode 100644 index 00000000..d68e00be --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVHandleOpenURL/CDVHandleOpenURL.m @@ -0,0 +1,85 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import "CDVHandleOpenURL.h" + +@implementation CDVHandleOpenURL + +- (void)pluginInitialize +{ + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationLaunchedWithUrl:) name:CDVPluginHandleOpenURLNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationPageDidLoad:) name:CDVPageDidLoadNotification object:nil]; +} + +- (void)applicationLaunchedWithUrl:(NSNotification*)notification +{ + NSURL* url = [notification object]; + + self.url = url; + + // warm-start handler + if (self.pageLoaded) { + [self processOpenUrl:self.url pageLoaded:YES]; + self.url = nil; + } +} + +- (void)applicationPageDidLoad:(NSNotification*)notification +{ + // cold-start handler + + self.pageLoaded = YES; + + if (self.url) { + [self processOpenUrl:self.url pageLoaded:YES]; + self.url = nil; + } +} + +- (void)processOpenUrl:(NSURL*)url pageLoaded:(BOOL)pageLoaded +{ + __weak __typeof(self) weakSelf = self; + + dispatch_block_t handleOpenUrl = ^(void) { + // calls into javascript global function 'handleOpenURL' + NSString* jsString = [NSString stringWithFormat:@"document.addEventListener('deviceready',function(){if (typeof handleOpenURL === 'function') { handleOpenURL(\"%@\");}});", url.absoluteString]; + + [weakSelf.webViewEngine evaluateJavaScript:jsString completionHandler:nil]; + }; + + if (!pageLoaded) { + NSString* jsString = @"document.readyState"; + [self.webViewEngine evaluateJavaScript:jsString + completionHandler:^(id object, NSError* error) { + if ((error == nil) && [object isKindOfClass:[NSString class]]) { + NSString* readyState = (NSString*)object; + BOOL ready = [readyState isEqualToString:@"loaded"] || [readyState isEqualToString:@"complete"]; + if (ready) { + handleOpenUrl(); + } else { + self.url = url; + } + } + }]; + } else { + handleOpenUrl(); + } +} + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVIntentAndNavigationFilter/CDVAllowList.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVIntentAndNavigationFilter/CDVAllowList.h new file mode 100644 index 00000000..57814b1e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVIntentAndNavigationFilter/CDVAllowList.h @@ -0,0 +1,34 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +extern NSString* const kCDVDefaultAllowListRejectionString; + +@interface CDVAllowList : NSObject + +@property (nonatomic, copy) NSString* allowListRejectionFormatString; + +- (id)initWithArray:(NSArray*)array; +- (BOOL)schemeIsAllowed:(NSString*)scheme; +- (BOOL)URLIsAllowed:(NSURL*)url; +- (BOOL)URLIsAllowed:(NSURL*)url logFailure:(BOOL)logFailure; +- (NSString*)errorStringForURL:(NSURL*)url; + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVIntentAndNavigationFilter/CDVAllowList.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVIntentAndNavigationFilter/CDVAllowList.m new file mode 100644 index 00000000..11567d26 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVIntentAndNavigationFilter/CDVAllowList.m @@ -0,0 +1,285 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import "CDVAllowList.h" + +NSString* const kCDVDefaultAllowListRejectionString = @"ERROR allowList rejection: url='%@'"; +NSString* const kCDVDefaultSchemeName = @"cdv-default-scheme"; + +@interface CDVAllowListPattern : NSObject { + @private + NSRegularExpression* _scheme; + NSRegularExpression* _host; + NSNumber* _port; + NSRegularExpression* _path; +} + ++ (NSString*)regexFromPattern:(NSString*)pattern allowWildcards:(bool)allowWildcards; +- (id)initWithScheme:(NSString*)scheme host:(NSString*)host port:(NSString*)port path:(NSString*)path; +- (bool)matches:(NSURL*)url; + +@end + +@implementation CDVAllowListPattern + ++ (NSString*)regexFromPattern:(NSString*)pattern allowWildcards:(bool)allowWildcards +{ + NSString* regex = [NSRegularExpression escapedPatternForString:pattern]; + + if (allowWildcards) { + regex = [regex stringByReplacingOccurrencesOfString:@"\\*" withString:@".*"]; + + /* [NSURL path] has the peculiarity that a trailing slash at the end of a path + * will be omitted. This regex tweak compensates for that. + */ + if ([regex hasSuffix:@"\\/.*"]) { + regex = [NSString stringWithFormat:@"%@(\\/.*)?", [regex substringToIndex:([regex length] - 4)]]; + } + } + return [NSString stringWithFormat:@"%@$", regex]; +} + +- (id)initWithScheme:(NSString*)scheme host:(NSString*)host port:(NSString*)port path:(NSString*)path +{ + self = [super init]; // Potentially change "self" + if (self) { + if ((scheme == nil) || [scheme isEqualToString:@"*"]) { + _scheme = nil; + } else { + _scheme = [NSRegularExpression regularExpressionWithPattern:[CDVAllowListPattern regexFromPattern:scheme allowWildcards:NO] options:NSRegularExpressionCaseInsensitive error:nil]; + } + if ([host isEqualToString:@"*"] || host == nil) { + _host = nil; + } else if ([host hasPrefix:@"*."]) { + _host = [NSRegularExpression regularExpressionWithPattern:[NSString stringWithFormat:@"([a-z0-9.-]*\\.)?%@", [CDVAllowListPattern regexFromPattern:[host substringFromIndex:2] allowWildcards:false]] options:NSRegularExpressionCaseInsensitive error:nil]; + } else { + _host = [NSRegularExpression regularExpressionWithPattern:[CDVAllowListPattern regexFromPattern:host allowWildcards:NO] options:NSRegularExpressionCaseInsensitive error:nil]; + } + if ((port == nil) || [port isEqualToString:@"*"]) { + _port = nil; + } else { + _port = [[NSNumber alloc] initWithInteger:[port integerValue]]; + } + if ((path == nil) || [path isEqualToString:@"/*"]) { + _path = nil; + } else { + _path = [NSRegularExpression regularExpressionWithPattern:[CDVAllowListPattern regexFromPattern:path allowWildcards:YES] options:0 error:nil]; + } + } + return self; +} + +- (bool)matches:(NSURL*)url +{ + return (_scheme == nil || [_scheme numberOfMatchesInString:[url scheme] options:NSMatchingAnchored range:NSMakeRange(0, [[url scheme] length])]) && + (_host == nil || ([url host] != nil && [_host numberOfMatchesInString:[url host] options:NSMatchingAnchored range:NSMakeRange(0, [[url host] length])])) && + (_port == nil || [[url port] isEqualToNumber:_port]) && + (_path == nil || [_path numberOfMatchesInString:[url path] options:NSMatchingAnchored range:NSMakeRange(0, [[url path] length])]) + ; +} + +@end + +@interface CDVAllowList () + +@property (nonatomic, readwrite, strong) NSMutableArray* allowList; +@property (nonatomic, readwrite, strong) NSMutableSet* permittedSchemes; + +- (void)addAllowListEntry:(NSString*)pattern; + +@end + +@implementation CDVAllowList + +@synthesize allowList, permittedSchemes, allowListRejectionFormatString; + +- (id)initWithArray:(NSArray*)array +{ + self = [super init]; + if (self) { + self.allowList = [[NSMutableArray alloc] init]; + self.permittedSchemes = [[NSMutableSet alloc] init]; + self.allowListRejectionFormatString = kCDVDefaultAllowListRejectionString; + + for (NSString* pattern in array) { + [self addAllowListEntry:pattern]; + } + } + return self; +} + +- (BOOL)isIPv4Address:(NSString*)externalHost +{ + // an IPv4 address has 4 octets b.b.b.b where b is a number between 0 and 255. + // for our purposes, b can also be the wildcard character '*' + + // we could use a regex to solve this problem but then I would have two problems + // anyways, this is much clearer and maintainable + NSArray* octets = [externalHost componentsSeparatedByString:@"."]; + NSUInteger num_octets = [octets count]; + + // quick check + if (num_octets != 4) { + return NO; + } + + // restrict number parsing to 0-255 + NSNumberFormatter* numberFormatter = [[NSNumberFormatter alloc] init]; + [numberFormatter setMinimum:[NSNumber numberWithUnsignedInteger:0]]; + [numberFormatter setMaximum:[NSNumber numberWithUnsignedInteger:255]]; + + // iterate through each octet, and test for a number between 0-255 or if it equals '*' + for (NSUInteger i = 0; i < num_octets; ++i) { + NSString* octet = [octets objectAtIndex:i]; + + if ([octet isEqualToString:@"*"]) { // passes - check next octet + continue; + } else if ([numberFormatter numberFromString:octet] == nil) { // fails - not a number and not within our range, return + return NO; + } + } + + return YES; +} + +- (void)addAllowListEntry:(NSString*)origin +{ + if (self.allowList == nil) { + return; + } + + if ([origin isEqualToString:@"*"]) { + NSLog(@"Unlimited access to network resources"); + self.allowList = nil; + self.permittedSchemes = nil; + } else { // specific access + NSRegularExpression* parts = [NSRegularExpression regularExpressionWithPattern:@"^((\\*|([a-z][a-z0-9+\\-.]*)):/?/?)?(((\\*\\.)?[^*/:]+)|\\*)?(:(\\d+))?(/.*)?" options:0 error:nil]; + NSTextCheckingResult* m = [parts firstMatchInString:origin options:NSMatchingAnchored range:NSMakeRange(0, [origin length])]; + if (m != nil) { + NSRange r; + NSString* scheme = nil; + r = [m rangeAtIndex:2]; + if (r.location != NSNotFound) { + scheme = [origin substringWithRange:r]; + } + + NSString* host = nil; + r = [m rangeAtIndex:4]; + if (r.location != NSNotFound) { + host = [origin substringWithRange:r]; + } + + // Special case for two urls which are allowed to have empty hosts + if (([scheme isEqualToString:@"file"] || [scheme isEqualToString:@"content"]) && (host == nil)) { + host = @"*"; + } + + NSString* port = nil; + r = [m rangeAtIndex:8]; + if (r.location != NSNotFound) { + port = [origin substringWithRange:r]; + } + + NSString* path = nil; + r = [m rangeAtIndex:9]; + if (r.location != NSNotFound) { + path = [origin substringWithRange:r]; + } + + if (scheme == nil) { + // XXX making it stupid friendly for people who forget to include protocol/SSL + [self.allowList addObject:[[CDVAllowListPattern alloc] initWithScheme:@"http" host:host port:port path:path]]; + [self.allowList addObject:[[CDVAllowListPattern alloc] initWithScheme:@"https" host:host port:port path:path]]; + } else { + [self.allowList addObject:[[CDVAllowListPattern alloc] initWithScheme:scheme host:host port:port path:path]]; + } + + if (self.permittedSchemes != nil) { + if ([scheme isEqualToString:@"*"]) { + self.permittedSchemes = nil; + } else if (scheme != nil) { + [self.permittedSchemes addObject:scheme]; + } + } + } + } +} + +- (BOOL)schemeIsAllowed:(NSString*)scheme +{ + if ([scheme isEqualToString:@"http"] || + [scheme isEqualToString:@"https"] || + [scheme isEqualToString:@"ftp"] || + [scheme isEqualToString:@"ftps"]) { + return YES; + } + + return (self.permittedSchemes == nil) || [self.permittedSchemes containsObject:scheme]; +} + +- (BOOL)URLIsAllowed:(NSURL*)url +{ + return [self URLIsAllowed:url logFailure:YES]; +} + +- (BOOL)URLIsAllowed:(NSURL*)url logFailure:(BOOL)logFailure +{ + // Shortcut acceptance: Are all urls allowListed ("*" in allowList)? + if (allowList == nil) { + return YES; + } + + // Shortcut rejection: Check that the scheme is supported + NSString* scheme = [[url scheme] lowercaseString]; + if (![self schemeIsAllowed:scheme]) { + if (logFailure) { + NSLog(@"%@", [self errorStringForURL:url]); + } + return NO; + } + + // http[s] and ftp[s] should also validate against the common set in the kCDVDefaultSchemeName list + if ([scheme isEqualToString:@"http"] || [scheme isEqualToString:@"https"] || [scheme isEqualToString:@"ftp"] || [scheme isEqualToString:@"ftps"]) { + NSURL* newUrl = [NSURL URLWithString:[NSString stringWithFormat:@"%@://%@%@", kCDVDefaultSchemeName, [url host], [[url path] stringByAddingPercentEncodingWithAllowedCharacters:NSCharacterSet.URLPathAllowedCharacterSet]]]; + // If it is allowed, we are done. If not, continue to check for the actual scheme-specific list + if ([self URLIsAllowed:newUrl logFailure:NO]) { + return YES; + } + } + + // Check the url against patterns in the allowList + for (CDVAllowListPattern* p in self.allowList) { + if ([p matches:url]) { + return YES; + } + } + + if (logFailure) { + NSLog(@"%@", [self errorStringForURL:url]); + } + // if we got here, the url host is not in the white-list, do nothing + return NO; +} + +- (NSString*)errorStringForURL:(NSURL*)url +{ + return [NSString stringWithFormat:self.allowListRejectionFormatString, [url absoluteString]]; +} + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVIntentAndNavigationFilter/CDVIntentAndNavigationFilter.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVIntentAndNavigationFilter/CDVIntentAndNavigationFilter.h new file mode 100644 index 00000000..3f6a1262 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVIntentAndNavigationFilter/CDVIntentAndNavigationFilter.h @@ -0,0 +1,34 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import +#import "CDVAllowList.h" + +typedef NS_ENUM(NSInteger, CDVIntentAndNavigationFilterValue) { + CDVIntentAndNavigationFilterValueIntentAllowed, + CDVIntentAndNavigationFilterValueNavigationAllowed, + CDVIntentAndNavigationFilterValueNoneAllowed +}; + +@interface CDVIntentAndNavigationFilter : CDVPlugin + ++ (CDVIntentAndNavigationFilterValue) filterUrl:(NSURL*)url allowIntentsList:(CDVAllowList*)allowIntentsList navigationsAllowList:(CDVAllowList*)navigationsAllowList; ++ (BOOL)shouldOverrideLoadWithRequest:(NSURLRequest*)request navigationType:(CDVWebViewNavigationType)navigationType filterValue:(CDVIntentAndNavigationFilterValue)filterValue; ++ (BOOL)shouldOpenURLRequest:(NSURLRequest*)request navigationType:(CDVWebViewNavigationType)navigationType; +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVIntentAndNavigationFilter/CDVIntentAndNavigationFilter.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVIntentAndNavigationFilter/CDVIntentAndNavigationFilter.m new file mode 100644 index 00000000..511d618d --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVIntentAndNavigationFilter/CDVIntentAndNavigationFilter.m @@ -0,0 +1,151 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import "CDVIntentAndNavigationFilter.h" +#import + +@interface CDVIntentAndNavigationFilter () + +@property (nonatomic, readwrite) NSMutableArray* allowIntents; +@property (nonatomic, readwrite) NSMutableArray* allowNavigations; +@property (nonatomic, readwrite) CDVAllowList* allowIntentsList; +@property (nonatomic, readwrite) CDVAllowList* allowNavigationsList; + +@end + +@implementation CDVIntentAndNavigationFilter + +#pragma mark NSXMLParserDelegate + +- (void)parser:(NSXMLParser*)parser didStartElement:(NSString*)elementName namespaceURI:(NSString*)namespaceURI qualifiedName:(NSString*)qualifiedName attributes:(NSDictionary*)attributeDict +{ + if ([elementName isEqualToString:@"allow-navigation"]) { + [self.allowNavigations addObject:attributeDict[@"href"]]; + } + if ([elementName isEqualToString:@"allow-intent"]) { + [self.allowIntents addObject:attributeDict[@"href"]]; + } +} + +- (void)parserDidStartDocument:(NSXMLParser*)parser +{ + // file: url are added by default + // navigation to the scheme used by the app is also allowed + self.allowNavigations = [[NSMutableArray alloc] initWithArray:@[ @"file://"]]; + + // If the custom app scheme is defined, append it to the allow navigation as default + NSString* scheme = ((CDVViewController*)self.viewController).appScheme; + if (scheme) { + [self.allowNavigations addObject: [NSString stringWithFormat:@"%@://", scheme]]; + } + + // no intents are added by default + self.allowIntents = [[NSMutableArray alloc] init]; +} + +- (void)parserDidEndDocument:(NSXMLParser*)parser +{ + self.allowIntentsList = [[CDVAllowList alloc] initWithArray:self.allowIntents]; + self.allowNavigationsList = [[CDVAllowList alloc] initWithArray:self.allowNavigations]; +} + +- (void)parser:(NSXMLParser*)parser parseErrorOccurred:(NSError*)parseError +{ + NSAssert(NO, @"config.xml parse error line %ld col %ld", (long)[parser lineNumber], (long)[parser columnNumber]); +} + +#pragma mark CDVPlugin + +- (void)pluginInitialize +{ + [CDVConfigParser parseConfigFile:self.viewController.configFilePath withDelegate:self]; +} + ++ (CDVIntentAndNavigationFilterValue) filterUrl:(NSURL*)url allowIntentsList:(CDVAllowList*)allowIntentsList navigationsAllowList:(CDVAllowList*)navigationsAllowList +{ + // a URL can only allow-intent OR allow-navigation, if both are specified, + // only allow-navigation is allowed + + BOOL allowNavigationsPass = [navigationsAllowList URLIsAllowed:url logFailure:NO]; + BOOL allowIntentPass = [allowIntentsList URLIsAllowed:url logFailure:NO]; + + if (allowNavigationsPass && allowIntentPass) { + return CDVIntentAndNavigationFilterValueNavigationAllowed; + } else if (allowNavigationsPass) { + return CDVIntentAndNavigationFilterValueNavigationAllowed; + } else if (allowIntentPass) { + return CDVIntentAndNavigationFilterValueIntentAllowed; + } + + return CDVIntentAndNavigationFilterValueNoneAllowed; +} + +- (CDVIntentAndNavigationFilterValue) filterUrl:(NSURL*)url +{ + return [[self class] filterUrl:url allowIntentsList:self.allowIntentsList navigationsAllowList:self.allowNavigationsList]; +} + +#define CDVWebViewNavigationTypeLinkClicked 0 +#define CDVWebViewNavigationTypeLinkOther -1 + ++ (BOOL)shouldOpenURLRequest:(NSURLRequest*)request navigationType:(CDVWebViewNavigationType)navigationType +{ + BOOL isMainNavigation = [[request.mainDocumentURL absoluteString] isEqualToString:[request.URL absoluteString]]; + + return ( + navigationType == CDVWebViewNavigationTypeLinkClicked || + (navigationType == CDVWebViewNavigationTypeLinkOther && isMainNavigation) + ); +} + ++ (BOOL)shouldOverrideLoadWithRequest:(NSURLRequest*)request navigationType:(CDVWebViewNavigationType)navigationType filterValue:(CDVIntentAndNavigationFilterValue)filterValue +{ + NSString* allowIntents_allowListRejectionFormatString = @"ERROR External navigation rejected - not set for url='%@'"; + NSString* allowNavigations_allowListRejectionFormatString = @"ERROR Internal navigation rejected - not set for url='%@'"; + + NSURL* url = [request URL]; + + switch (filterValue) { + case CDVIntentAndNavigationFilterValueNavigationAllowed: + return YES; + case CDVIntentAndNavigationFilterValueIntentAllowed: + // only allow-intent if it's a CDVWebViewNavigationTypeLinkClicked (anchor tag) or CDVWebViewNavigationTypeOther and it's an internal link + if ([[self class] shouldOpenURLRequest:request navigationType:navigationType]){ + [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil]; + } + + // consume the request (i.e. no error) if it wasn't handled above + return NO; + case CDVIntentAndNavigationFilterValueNoneAllowed: + // allow-navigation attempt failed for sure + NSLog(@"%@", [NSString stringWithFormat:allowNavigations_allowListRejectionFormatString, [url absoluteString]]); + // anchor tag link means it was an allow-intent attempt that failed as well + if (CDVWebViewNavigationTypeLinkClicked == navigationType) { + NSLog(@"%@", [NSString stringWithFormat:allowIntents_allowListRejectionFormatString, [url absoluteString]]); + } + return NO; + } +} + +- (BOOL)shouldOverrideLoadWithRequest:(NSURLRequest*)request navigationType:(CDVWebViewNavigationType)navigationType info:(NSDictionary *)navInfo +{ + return [[self class] shouldOverrideLoadWithRequest:request navigationType:navigationType filterValue:[self filterUrl:request.URL]]; +} + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVLaunchScreen/CDVLaunchScreen.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVLaunchScreen/CDVLaunchScreen.h new file mode 100644 index 00000000..e973e373 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVLaunchScreen/CDVLaunchScreen.h @@ -0,0 +1,27 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +@interface CDVLaunchScreen : CDVPlugin + +- (void)show:(CDVInvokedUrlCommand*)command; +- (void)hide:(CDVInvokedUrlCommand*)command; + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVLaunchScreen/CDVLaunchScreen.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVLaunchScreen/CDVLaunchScreen.m new file mode 100644 index 00000000..0609d6f4 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVLaunchScreen/CDVLaunchScreen.m @@ -0,0 +1,35 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import "CDVLaunchScreen.h" +#import "CDVViewController+Private.h" + +@implementation CDVLaunchScreen + +- (void)show:(CDVInvokedUrlCommand *)command +{ + [self.viewController showSplashScreen:YES]; +} + +- (void)hide:(CDVInvokedUrlCommand *)command +{ + [self.viewController showSplashScreen:NO]; +} + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVLogger/CDVLogger.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVLogger/CDVLogger.h new file mode 100644 index 00000000..7cfb3063 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVLogger/CDVLogger.h @@ -0,0 +1,26 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +@interface CDVLogger : CDVPlugin + +- (void)logLevel:(CDVInvokedUrlCommand*)command; + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVLogger/CDVLogger.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVLogger/CDVLogger.m new file mode 100644 index 00000000..810caa56 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVLogger/CDVLogger.m @@ -0,0 +1,37 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import "CDVLogger.h" + +@implementation CDVLogger + +/* log a message */ +- (void)logLevel:(CDVInvokedUrlCommand*)command +{ + id level = [command argumentAtIndex:0]; + id message = [command argumentAtIndex:1]; + + if ([level isEqualToString:@"LOG"]) { + NSLog(@"%@", message); + } else { + NSLog(@"%@: %@", level, message); + } +} + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVStatusBarInternal/CDVStatusBarInternal.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVStatusBarInternal/CDVStatusBarInternal.h new file mode 100644 index 00000000..9a83983b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVStatusBarInternal/CDVStatusBarInternal.h @@ -0,0 +1,27 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +@interface CDVStatusBarInternal : CDVPlugin + +- (void)setVisible:(CDVInvokedUrlCommand*)command; +- (void)setBackgroundColor:(CDVInvokedUrlCommand*)command; + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVStatusBarInternal/CDVStatusBarInternal.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVStatusBarInternal/CDVStatusBarInternal.m new file mode 100644 index 00000000..fc6412bd --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVStatusBarInternal/CDVStatusBarInternal.m @@ -0,0 +1,46 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import "CDVStatusBarInternal.h" +#import "CDVViewController+Private.h" + +@implementation CDVStatusBarInternal + +- (void)setVisible:(CDVInvokedUrlCommand *)command +{ + id value = [command argumentAtIndex:0]; + if (!([value isKindOfClass:[NSNumber class]])) { + value = [NSNumber numberWithBool:YES]; + } + + [self.viewController showStatusBar:[value boolValue]]; +} + +- (void)setBackgroundColor:(CDVInvokedUrlCommand *)command +{ + NSInteger valueR = [[command argumentAtIndex:0 withDefault:@0] integerValue]; + NSInteger valueG = [[command argumentAtIndex:1 withDefault:@0] integerValue]; + NSInteger valueB = [[command argumentAtIndex:2 withDefault:@0] integerValue]; + + UIColor *bgColor = [UIColor colorWithRed:valueR/255.f green:valueG/255.f blue:valueB/255.f alpha:1.f]; + [self.viewController setStatusBarBackgroundColor:bgColor]; +} + +@end + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVURLSchemeHandler.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVURLSchemeHandler.h new file mode 100644 index 00000000..bb2f998a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVURLSchemeHandler.h @@ -0,0 +1,30 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +@class CDVViewController; + +@interface CDVURLSchemeHandler : NSObject +NS_ASSUME_NONNULL_BEGIN + +- (instancetype)initWithViewController:(CDVViewController *)controller; + +NS_ASSUME_NONNULL_END +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVURLSchemeHandler.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVURLSchemeHandler.m new file mode 100644 index 00000000..19aec026 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVURLSchemeHandler.m @@ -0,0 +1,258 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + + +#import "CDVURLSchemeHandler.h" +#import +#import +#import +#import + +#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140000 +#import +#endif + +static const NSUInteger FILE_BUFFER_SIZE = 1024 * 1024 * 4; // 4 MiB + +@interface CDVURLSchemeHandler () + +@property (nonatomic, weak) CDVViewController *viewController; +@property (nonatomic) NSMapTable , CDVPlugin *> *handlerMap; + +@end + +@implementation CDVURLSchemeHandler + +- (instancetype)initWithViewController:(CDVViewController *)controller +{ + self = [super init]; + if (self) { + _viewController = controller; + _handlerMap = [NSMapTable weakToWeakObjectsMapTable]; + } + return self; +} + +- (void)webView:(WKWebView *)webView startURLSchemeTask:(id )urlSchemeTask +{ + // Give plugins the chance to handle the url + for (CDVPlugin *plugin in self.viewController.enumerablePlugins) { + if ([plugin respondsToSelector:@selector(overrideSchemeTask:)]) { + CDVPlugin *schemePlugin = (CDVPlugin *)plugin; + if ([schemePlugin overrideSchemeTask:urlSchemeTask]) { + // Store the plugin that is handling this particular request + [self.handlerMap setObject:schemePlugin forKey:urlSchemeTask]; + return; + } + } + } + + + NSURLRequest *req = urlSchemeTask.request; + if (![req.URL.scheme isEqualToString:self.viewController.appScheme]) { + return; + } + + // Indicate that we are handling this task, by adding an entry with a null plugin + // We do this so that we can (in future) detect if the task is cancelled before we finished feeding it response data + [self.handlerMap setObject:(id)[NSNull null] forKey:urlSchemeTask]; + + [self.viewController.commandDelegate runInBackground:^{ + NSURL *fileURL = [self fileURLForRequestURL:req.URL]; + NSError *error; + + NSFileHandle *fileHandle = [NSFileHandle fileHandleForReadingFromURL:fileURL error:&error]; + if (!fileHandle || error) { + if ([self taskActive:urlSchemeTask]) { + [urlSchemeTask didFailWithError:error]; + } + + @synchronized(self.handlerMap) { + [self.handlerMap removeObjectForKey:urlSchemeTask]; + } + return; + } + + NSInteger statusCode = 200; // Default to 200 OK status + NSString *mimeType = [self getMimeType:fileURL] ?: @"application/octet-stream"; + NSNumber *fileLength; + [fileURL getResourceValue:&fileLength forKey:NSURLFileSizeKey error:nil]; + + NSNumber *responseSize = fileLength; + NSUInteger responseSent = 0; + + NSMutableDictionary *headers = [NSMutableDictionary dictionaryWithCapacity:5]; + headers[@"Content-Type"] = mimeType; + headers[@"Cache-Control"] = @"no-cache"; + headers[@"Content-Length"] = [responseSize stringValue]; + + // Check for Range header + NSString *rangeHeader = [urlSchemeTask.request valueForHTTPHeaderField:@"Range"]; + if (rangeHeader) { + NSRange range = NSMakeRange(NSNotFound, 0); + + if ([rangeHeader hasPrefix:@"bytes="]) { + NSString *byteRange = [rangeHeader substringFromIndex:6]; + NSArray *rangeParts = [byteRange componentsSeparatedByString:@"-"]; + NSUInteger start = (NSUInteger)[rangeParts[0] integerValue]; + NSUInteger end = rangeParts.count > 1 && ![rangeParts[1] isEqualToString:@""] ? (NSUInteger)[rangeParts[1] integerValue] : [fileLength unsignedIntegerValue] - 1; + range = NSMakeRange(start, end - start + 1); + } + + if (range.location != NSNotFound) { + // Ensure range is valid + if (range.location >= [fileLength unsignedIntegerValue] && [self taskActive:urlSchemeTask]) { + headers[@"Content-Range"] = [NSString stringWithFormat:@"bytes */%@", fileLength]; + NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:req.URL statusCode:416 HTTPVersion:@"HTTP/1.1" headerFields:headers]; + [urlSchemeTask didReceiveResponse:response]; + [urlSchemeTask didFinish]; + + @synchronized(self.handlerMap) { + [self.handlerMap removeObjectForKey:urlSchemeTask]; + } + return; + } + + [fileHandle seekToFileOffset:range.location]; + responseSize = [NSNumber numberWithUnsignedInteger:range.length]; + statusCode = 206; // Partial Content + headers[@"Content-Range"] = [NSString stringWithFormat:@"bytes %lu-%lu/%@", (unsigned long)range.location, (unsigned long)(range.location + range.length - 1), fileLength]; + headers[@"Content-Length"] = [NSString stringWithFormat:@"%lu", (unsigned long)range.length]; + } + } + + NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:req.URL statusCode:statusCode HTTPVersion:@"HTTP/1.1" headerFields:headers]; + if ([self taskActive:urlSchemeTask]) { + [urlSchemeTask didReceiveResponse:response]; + } + + while ([self taskActive:urlSchemeTask] && responseSent < [responseSize unsignedIntegerValue]) { + @autoreleasepool { + NSData *data = [self readFromFileHandle:fileHandle upTo:FILE_BUFFER_SIZE error:&error]; + if (!data || error) { + if ([self taskActive:urlSchemeTask]) { + [urlSchemeTask didFailWithError:error]; + } + break; + } + + if ([self taskActive:urlSchemeTask]) { + [urlSchemeTask didReceiveData:data]; + } + + responseSent += data.length; + } + } + + [fileHandle closeFile]; + + if ([self taskActive:urlSchemeTask]) { + [urlSchemeTask didFinish]; + } + + @synchronized(self.handlerMap) { + [self.handlerMap removeObjectForKey:urlSchemeTask]; + } + }]; +} + +- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id )urlSchemeTask +{ + CDVPlugin *plugin; + @synchronized(self.handlerMap) { + plugin = [self.handlerMap objectForKey:urlSchemeTask]; + } + + if (![plugin isEqual:[NSNull null]] && [plugin respondsToSelector:@selector(stopSchemeTask:)]) { + [plugin stopSchemeTask:urlSchemeTask]; + } + + @synchronized(self.handlerMap) { + [self.handlerMap removeObjectForKey:urlSchemeTask]; + } +} + +#pragma mark - Utility methods + +- (NSURL *)fileURLForRequestURL:(NSURL *)url +{ + NSURL *resDir = [[NSBundle mainBundle] URLForResource:self.viewController.webContentFolderName withExtension:nil]; + NSURL *filePath; + + if ([url.path hasPrefix:@"/_app_file_"]) { + NSString *path = [url.path stringByReplacingOccurrencesOfString:@"/_app_file_" withString:@""]; + filePath = [resDir URLByAppendingPathComponent:path]; + } else { + if ([url.path isEqualToString:@""] || [url.pathExtension isEqualToString:@""]) { + filePath = [resDir URLByAppendingPathComponent:self.viewController.startPage]; + } else { + filePath = [resDir URLByAppendingPathComponent:url.path]; + } + } + + return filePath.URLByStandardizingPath; +} + +-(NSString *)getMimeType:(NSURL *)url +{ +#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140000 + if (@available(iOS 14.0, *)) { + UTType *uti; + [url getResourceValue:&uti forKey:NSURLContentTypeKey error:nil]; + return [uti preferredMIMEType]; + } +#endif + + NSString *type; + [url getResourceValue:&type forKey:NSURLTypeIdentifierKey error:nil]; + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + return (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)type, kUTTagClassMIMEType); +#pragma clang diagnostic pop +} + +- (nullable NSData *)readFromFileHandle:(NSFileHandle *)handle upTo:(NSUInteger)length error:(NSError **)err +{ +#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140000 + if (@available(iOS 14.0, *)) { + return [handle readDataUpToLength:length error:err]; + } +#endif + + @try { + return [handle readDataOfLength:length]; + } + @catch (NSError *error) { + if (err != nil) { + *err = error; + } + return nil; + } +} + +- (BOOL)taskActive:(id )task +{ + @synchronized(self.handlerMap) { + return [self.handlerMap objectForKey:task] != nil; + } +} + +@end + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVWebViewEngine.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVWebViewEngine.h new file mode 100644 index 00000000..29e6fd8e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVWebViewEngine.h @@ -0,0 +1,29 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import +#import + +@interface CDVWebViewEngine : CDVPlugin + +@property (nonatomic, strong, readonly) id uiDelegate; + +- (void)allowsBackForwardNavigationGestures:(CDVInvokedUrlCommand*)command; + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVWebViewEngine.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVWebViewEngine.m new file mode 100644 index 00000000..b62f5f81 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVWebViewEngine.m @@ -0,0 +1,660 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import "CDVWebViewEngine.h" +#import "CDVWebViewUIDelegate.h" +#import "CDVURLSchemeHandler.h" +#import +#import +#import "CDVViewController+Private.h" + +#import + +#define CDV_BRIDGE_NAME @"cordova" + +@interface CDVWebViewWeakScriptMessageHandler : NSObject + +@property (nonatomic, weak, readonly) idscriptMessageHandler; + +- (instancetype)initWithScriptMessageHandler:(id)scriptMessageHandler; + +@end + + +@interface CDVWebViewEngine () + +@property (nonatomic, strong, readwrite) UIView* engineWebView; +@property (nonatomic, strong, readwrite) id uiDelegate; +@property (nonatomic, weak) id weakScriptMessageHandler; +@property (nonatomic, strong) CDVURLSchemeHandler * schemeHandler; +@property (nonatomic, readwrite) NSString *CDV_ASSETS_URL; +@property (nonatomic, readwrite) Boolean cdvIsFileScheme; +@property (nullable, nonatomic, strong, readwrite) WKWebViewConfiguration *configuration; + +@end + +// see forwardingTargetForSelector: selector comment for the reason for this pragma +#pragma clang diagnostic ignored "-Wprotocol" + +@implementation CDVWebViewEngine + +@synthesize engineWebView = _engineWebView; + +- (nullable instancetype)initWithFrame:(CGRect)frame configuration:(nullable WKWebViewConfiguration *)configuration +{ + self = [super init]; + if (self) { + if (NSClassFromString(@"WKWebView") == nil) { + return nil; + } + + self.configuration = configuration; + self.engineWebView = configuration ? [[WKWebView alloc] initWithFrame:frame configuration:configuration] : [[WKWebView alloc] initWithFrame:frame]; + } + + return self; +} + +- (nullable instancetype)initWithFrame:(CGRect)frame +{ + return [self initWithFrame:frame configuration:nil]; +} + +- (WKWebViewConfiguration*) createConfigurationFromSettings:(CDVSettingsDictionary*)settings +{ + WKWebViewConfiguration* configuration; + if (_configuration) { + configuration = _configuration; + } else { + configuration = [[WKWebViewConfiguration alloc] init]; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + configuration.processPool = [[CDVWebViewProcessPoolFactory sharedFactory] sharedProcessPool]; +#pragma clang diagnostic pop + } + + if (settings == nil) { + return configuration; + } + + configuration.allowsInlineMediaPlayback = [settings cordovaBoolSettingForKey:@"AllowInlineMediaPlayback" defaultValue:NO]; + + // Set the media types that are required for user action for playback + WKAudiovisualMediaTypes mediaType = WKAudiovisualMediaTypeAll; // default + + // targetMediaType will always exist, either from user's "config.xml" or default ("defaults.xml"). + id targetMediaType = [settings cordovaSettingForKey:@"MediaTypesRequiringUserActionForPlayback"]; + if ([targetMediaType isEqualToString:@"none"]) { + mediaType = WKAudiovisualMediaTypeNone; + } else if ([targetMediaType isEqualToString:@"audio"]) { + mediaType = WKAudiovisualMediaTypeAudio; + } else if ([targetMediaType isEqualToString:@"video"]) { + mediaType = WKAudiovisualMediaTypeVideo; + } else if ([targetMediaType isEqualToString:@"all"]) { + mediaType = WKAudiovisualMediaTypeAll; + } else { + NSLog(@"Invalid \"MediaTypesRequiringUserActionForPlayback\" was detected. Fallback to default value of \"all\" types."); + } + configuration.mediaTypesRequiringUserActionForPlayback = mediaType; + + configuration.suppressesIncrementalRendering = [settings cordovaBoolSettingForKey:@"SuppressesIncrementalRendering" defaultValue:NO]; + + /* + * If the old preference key "MediaPlaybackAllowsAirPlay" exists, use it or default to "YES". + * Check if the new preference key "AllowsAirPlayForMediaPlayback" exists and overwrite the "MediaPlaybackAllowsAirPlay" value. + */ + BOOL allowsAirPlayForMediaPlayback = [settings cordovaBoolSettingForKey:@"MediaPlaybackAllowsAirPlay" defaultValue:YES]; + if([settings cordovaSettingForKey:@"AllowsAirPlayForMediaPlayback"] != nil) { + allowsAirPlayForMediaPlayback = [settings cordovaBoolSettingForKey:@"AllowsAirPlayForMediaPlayback" defaultValue:YES]; + } + configuration.allowsAirPlayForMediaPlayback = allowsAirPlayForMediaPlayback; + + /* + * Sets Custom User Agents + * - (Default) "userAgent" is set the the clean user agent. + * E.g. + * UserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 13_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148" + * + * - If "OverrideUserAgent" is set, it will overwrite the entire "userAgent" value. The "AppendUserAgent" will be iggnored if set. + * Notice: The override logic is handled in the "pluginInitialize" method. + * E.g. + * OverrideUserAgent = "foobar" + * UserAgent = "foobar" + * + * - If "AppendUserAgent" is set and "OverrideUserAgent" is not set, the user defined "AppendUserAgent" will be appended to the "userAgent" + * E.g. + * AppendUserAgent = "foobar" + * UserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 13_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 foobar" + */ + NSString *userAgent = configuration.applicationNameForUserAgent; + if ( + [settings cordovaSettingForKey:@"OverrideUserAgent"] == nil && + [settings cordovaSettingForKey:@"AppendUserAgent"] != nil + ) { + userAgent = [NSString stringWithFormat:@"%@ %@", userAgent, [settings cordovaSettingForKey:@"AppendUserAgent"]]; + } + configuration.applicationNameForUserAgent = userAgent; + + NSString *contentMode = [settings cordovaSettingForKey:@"PreferredContentMode"]; + if ([contentMode isEqual: @"mobile"]) { + configuration.defaultWebpagePreferences.preferredContentMode = WKContentModeMobile; + } else if ([contentMode isEqual: @"desktop"]) { + configuration.defaultWebpagePreferences.preferredContentMode = WKContentModeDesktop; + } + +#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140000 + if (@available(iOS 14.0, *)) { + configuration.limitsNavigationsToAppBoundDomains = [settings cordovaBoolSettingForKey:@"LimitsNavigationsToAppBoundDomains" defaultValue:NO]; + } +#endif + + return configuration; +} + +- (void)pluginInitialize +{ + CDVSettingsDictionary* settings = self.commandDelegate.settings; + + NSString *scheme = self.viewController.appScheme; + + // If scheme is file or nil, then default to file scheme + self.cdvIsFileScheme = [scheme isEqualToString:@"file"] || scheme == nil; + + NSString *hostname = @""; + if(!self.cdvIsFileScheme) { + if(scheme == nil || [WKWebView handlesURLScheme:scheme]){ + scheme = @"app"; + self.viewController.appScheme = scheme; + } + + hostname = [settings cordovaSettingForKey:@"hostname"]; + if(hostname == nil){ + hostname = @"localhost"; + } + + self.CDV_ASSETS_URL = [NSString stringWithFormat:@"%@://%@", scheme, hostname]; + } + + CDVWebViewUIDelegate* uiDelegate = [[CDVWebViewUIDelegate alloc] initWithViewController:self.viewController]; + uiDelegate.title = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleDisplayName"]; + uiDelegate.mediaPermissionGrantType = [self parsePermissionGrantType:[settings cordovaSettingForKey:@"MediaPermissionGrantType"]]; + uiDelegate.allowNewWindows = [settings cordovaBoolSettingForKey:@"AllowNewWindows" defaultValue:NO]; + self.uiDelegate = uiDelegate; + + CDVWebViewWeakScriptMessageHandler *weakScriptMessageHandler = [[CDVWebViewWeakScriptMessageHandler alloc] initWithScriptMessageHandler:self]; + + WKUserContentController* userContentController = [[WKUserContentController alloc] init]; + [userContentController addScriptMessageHandler:weakScriptMessageHandler name:CDV_BRIDGE_NAME]; + + if(self.CDV_ASSETS_URL) { + NSString *scriptCode = [NSString stringWithFormat:@"window.CDV_ASSETS_URL = '%@';", self.CDV_ASSETS_URL]; + WKUserScript *wkScript = [[WKUserScript alloc] initWithSource:scriptCode injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]; + + if (wkScript) { + [userContentController addUserScript:wkScript]; + } + } + + WKWebViewConfiguration* configuration = [self createConfigurationFromSettings:settings]; + configuration.userContentController = userContentController; + + // Do not configure the scheme handler if the scheme is default (file) + if(!self.cdvIsFileScheme) { + self.schemeHandler = [[CDVURLSchemeHandler alloc] initWithViewController:self.viewController]; + [configuration setURLSchemeHandler:self.schemeHandler forURLScheme:scheme]; + } + + // re-create WKWebView, since we need to update configuration + WKWebView* wkWebView = [[WKWebView alloc] initWithFrame:self.engineWebView.frame configuration:configuration]; + wkWebView.UIDelegate = self.uiDelegate; + +#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 160400 + // With the introduction of iOS 16.4 the webview is no longer inspectable by default. + // We'll honor that change for release builds, but will still allow inspection on debug builds by default. + // We also introduce an override option, so consumers can influence this decision in their own build. + if (@available(iOS 16.4, *)) { +#ifdef DEBUG + BOOL allowWebviewInspectionDefault = YES; +#else + BOOL allowWebviewInspectionDefault = NO; +#endif + wkWebView.inspectable = [settings cordovaBoolSettingForKey:@"InspectableWebview" defaultValue:allowWebviewInspectionDefault]; + } +#endif + + /* + * This is where the "OverrideUserAgent" is handled. This will replace the entire UserAgent + * with the user defined custom UserAgent. + */ + if ([settings cordovaSettingForKey:@"OverrideUserAgent"] != nil) { + wkWebView.customUserAgent = [settings cordovaSettingForKey:@"OverrideUserAgent"]; + } + + [wkWebView addObserver:self forKeyPath:@"themeColor" options:NSKeyValueObservingOptionInitial context:nil]; + + self.engineWebView = wkWebView; + + if ([self.viewController conformsToProtocol:@protocol(WKUIDelegate)]) { + wkWebView.UIDelegate = (id )self.viewController; + } + + if ([self.viewController conformsToProtocol:@protocol(WKNavigationDelegate)]) { + wkWebView.navigationDelegate = (id )self.viewController; + } else { + wkWebView.navigationDelegate = (id )self; + } + + if ([self.viewController conformsToProtocol:@protocol(WKScriptMessageHandler)]) { + [wkWebView.configuration.userContentController addScriptMessageHandler:(id < WKScriptMessageHandler >)self.viewController name:CDV_BRIDGE_NAME]; + } + + [self updateSettings:settings]; + + NSLog(@"Using WKWebView"); +} + +- (void)dispose +{ + WKWebView* wkWebView = (WKWebView*)_engineWebView; + [wkWebView.configuration.userContentController removeScriptMessageHandlerForName:CDV_BRIDGE_NAME]; + _engineWebView = nil; + + [super dispose]; +} + +- (id)loadRequest:(NSURLRequest*)request +{ + if ([self canLoadRequest:request]) { // can load, differentiate between file urls and other schemes + if(request.URL.fileURL && self.cdvIsFileScheme) { + NSURL* readAccessUrl = [request.URL URLByDeletingLastPathComponent]; + return [(WKWebView*)_engineWebView loadFileURL:request.URL allowingReadAccessToURL:readAccessUrl]; + } else if (request.URL.fileURL) { + NSURL* startURL = [NSURL URLWithString:self.viewController.startPage]; + NSString* startFilePath = [self.commandDelegate pathForResource:[startURL path]]; + NSURL *url = [[NSURL URLWithString:self.CDV_ASSETS_URL] URLByAppendingPathComponent:request.URL.path]; + if ([request.URL.path isEqualToString:startFilePath]) { + url = [NSURL URLWithString:[NSString stringWithFormat:@"%@/%@", self.CDV_ASSETS_URL, startURL]]; + } + if(request.URL.query) { + url = [NSURL URLWithString:[@"?" stringByAppendingString:request.URL.query] relativeToURL:url]; + } + if(request.URL.fragment) { + url = [NSURL URLWithString:[@"#" stringByAppendingString:request.URL.fragment] relativeToURL:url]; + } + // We ignore any existing cached data, since we're already loading it from the filesystem + request = [NSURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringLocalCacheData timeoutInterval:request.timeoutInterval]; + } + return [(WKWebView*)_engineWebView loadRequest:request]; + } else { // can't load, print out error + NSString* errorHtml = [NSString stringWithFormat: + @"" + @"Error" + @"
" + @"

The WebView engine '%@' is unable to load the request: %@

" + @"

Most likely the cause of the error is that the loading of file urls is not supported in iOS %@.

" + @"
", + NSStringFromClass([self class]), + [request.URL description], + [[UIDevice currentDevice] systemVersion] + ]; + return [self loadHTMLString:errorHtml baseURL:nil]; + } +} + +- (id)loadHTMLString:(NSString*)string baseURL:(NSURL*)baseURL +{ + return [(WKWebView*)_engineWebView loadHTMLString:string baseURL:baseURL]; +} + +- (NSURL*) URL +{ + return [(WKWebView*)_engineWebView URL]; +} + +- (BOOL) canLoadRequest:(NSURLRequest*)request +{ + return YES; +} + +- (void)updateSettings:(CDVSettingsDictionary*)settings +{ + WKWebView* wkWebView = (WKWebView*)_engineWebView; + + wkWebView.configuration.preferences.minimumFontSize = [settings cordovaFloatSettingForKey:@"MinimumFontSize" defaultValue:0.0]; + + /* + wkWebView.configuration.preferences.javaScriptEnabled = [settings cordovaBoolSettingForKey:@"JavaScriptEnabled" default:YES]; + wkWebView.configuration.preferences.javaScriptCanOpenWindowsAutomatically = [settings cordovaBoolSettingForKey:@"JavaScriptCanOpenWindowsAutomatically" default:NO]; + */ + + // By default, DisallowOverscroll is false (thus bounce is allowed) + BOOL bounceAllowed = !([settings cordovaBoolSettingForKey:@"DisallowOverscroll" defaultValue:NO]); + + // prevent webView from bouncing + if (!bounceAllowed) { + if ([wkWebView respondsToSelector:@selector(scrollView)]) { + UIScrollView* scrollView = [wkWebView scrollView]; + scrollView.bounces = NO; + scrollView.alwaysBounceVertical = NO; /* iOS 16 workaround */ + scrollView.alwaysBounceHorizontal = NO; /* iOS 16 workaround */ + } else { + for (id subview in wkWebView.subviews) { + if ([[subview class] isSubclassOfClass:[UIScrollView class]]) { + ((UIScrollView*)subview).bounces = NO; + } + } + } + } + + NSString* decelerationSetting = [settings cordovaSettingForKey:@"WKWebViewDecelerationSpeed"]; + + if (![@"fast" isEqualToString:decelerationSetting]) { + [wkWebView.scrollView setDecelerationRate:UIScrollViewDecelerationRateNormal]; + } else { + [wkWebView.scrollView setDecelerationRate:UIScrollViewDecelerationRateFast]; + } + + wkWebView.allowsBackForwardNavigationGestures = [settings cordovaBoolSettingForKey:@"AllowBackForwardNavigationGestures" defaultValue:NO]; + wkWebView.allowsLinkPreview = [settings cordovaBoolSettingForKey:@"Allow3DTouchLinkPreview" defaultValue:YES]; +} + +- (void)updateWithInfo:(NSDictionary*)info +{ + NSDictionary* scriptMessageHandlers = [info objectForKey:kCDVWebViewEngineScriptMessageHandlers]; + id settings = [info objectForKey:kCDVWebViewEngineWebViewPreferences]; + id navigationDelegate = [info objectForKey:kCDVWebViewEngineWKNavigationDelegate]; + id uiDelegate = [info objectForKey:kCDVWebViewEngineWKUIDelegate]; + + WKWebView* wkWebView = (WKWebView*)_engineWebView; + + if (scriptMessageHandlers && [scriptMessageHandlers isKindOfClass:[NSDictionary class]]) { + NSArray* allKeys = [scriptMessageHandlers allKeys]; + + for (NSString* key in allKeys) { + id object = [scriptMessageHandlers objectForKey:key]; + if ([object conformsToProtocol:@protocol(WKScriptMessageHandler)]) { + [wkWebView.configuration.userContentController addScriptMessageHandler:object name:key]; + } + } + } + + if (navigationDelegate && [navigationDelegate conformsToProtocol:@protocol(WKNavigationDelegate)]) { + wkWebView.navigationDelegate = navigationDelegate; + } + + if (uiDelegate && [uiDelegate conformsToProtocol:@protocol(WKUIDelegate)]) { + wkWebView.UIDelegate = uiDelegate; + } + + if (settings && [settings isKindOfClass:[CDVSettingsDictionary class]]) { + [self updateSettings:settings]; + } else if (settings && [settings isKindOfClass:[NSDictionary class]]) { + [self updateSettings:[[CDVSettingsDictionary alloc] initWithDictionary:settings]]; + } +} + +// This forwards the methods that are in the header that are not implemented here. +// Both WKWebView implement the below: +// loadHTMLString:baseURL: +// loadRequest: +- (id)forwardingTargetForSelector:(SEL)aSelector +{ + return _engineWebView; +} + +- (UIView*)webView +{ + return self.engineWebView; +} + +- (CDVWebViewPermissionGrantType)parsePermissionGrantType:(NSString*)optionString +{ + CDVWebViewPermissionGrantType result = CDVWebViewPermissionGrantType_GrantIfSameHost_ElsePrompt; + + if (optionString != nil){ + if ([optionString isEqualToString:@"prompt"]) { + result = CDVWebViewPermissionGrantType_Prompt; + } else if ([optionString isEqualToString:@"deny"]) { + result = CDVWebViewPermissionGrantType_Deny; + } else if ([optionString isEqualToString:@"grant"]) { + result = CDVWebViewPermissionGrantType_Grant; + } else if ([optionString isEqualToString:@"grantIfSameHostElsePrompt"]) { + result = CDVWebViewPermissionGrantType_GrantIfSameHost_ElsePrompt; + } else if ([optionString isEqualToString:@"grantIfSameHostElseDeny"]) { + result = CDVWebViewPermissionGrantType_GrantIfSameHost_ElseDeny; + } else { + NSLog(@"Invalid \"MediaPermissionGrantType\" was detected. Fallback to default value of \"grantIfSameHostElsePrompt\""); + } + } + + return result; +} + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context +{ + if ([keyPath isEqualToString:@"themeColor"]) { +#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 150000 + if (@available(iOS 15.0, *)) { + [self.viewController setStatusBarWebViewColor:((WKWebView *)self.engineWebView).themeColor]; + } +#endif + } +} + +#pragma mark - WKScriptMessageHandler implementation + +- (void)userContentController:(WKUserContentController*)userContentController didReceiveScriptMessage:(WKScriptMessage*)message +{ + if (![message.name isEqualToString:CDV_BRIDGE_NAME]) { + return; + } + + CDVViewController* vc = (CDVViewController*)self.viewController; + + NSArray* jsonEntry = message.body; // NSString:callbackId, NSString:service, NSString:action, NSArray:args + CDVInvokedUrlCommand* command = [CDVInvokedUrlCommand commandFromJson:jsonEntry]; + CDV_EXEC_LOG(@"Exec(%@): Calling %@.%@", command.callbackId, command.className, command.methodName); + + if (![vc.commandQueue execute:command]) { +#ifdef DEBUG + NSError* error = nil; + NSString* commandJson = nil; + NSData* jsonData = [NSJSONSerialization dataWithJSONObject:jsonEntry + options:0 + error:&error]; + + if (error == nil) { + commandJson = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + } + + static NSUInteger maxLogLength = 1024; + NSString* commandString = ([commandJson length] > maxLogLength) ? + [NSString stringWithFormat : @"%@[...]", [commandJson substringToIndex:maxLogLength]] : + commandJson; + + NSLog(@"FAILED pluginJSON = %@", commandString); +#endif + } +} + +#pragma mark - WKNavigationDelegate implementation + +- (void)webView:(WKWebView*)webView didStartProvisionalNavigation:(WKNavigation*)navigation +{ + [[NSNotificationCenter defaultCenter] postNotification:[NSNotification notificationWithName:CDVPluginResetNotification object:webView]]; +} + +- (void)webView:(WKWebView*)webView didFinishNavigation:(WKNavigation*)navigation +{ + [[NSNotificationCenter defaultCenter] postNotification:[NSNotification notificationWithName:CDVPageDidLoadNotification object:webView]]; +} + +- (void)webView:(WKWebView*)theWebView didFailProvisionalNavigation:(WKNavigation*)navigation withError:(NSError*)error +{ + [self webView:theWebView didFailNavigation:navigation withError:error]; +} + +- (void)webView:(WKWebView*)theWebView didFailNavigation:(WKNavigation*)navigation withError:(NSError*)error +{ + NSString* message = [NSString stringWithFormat:@"Failed to load webpage with error: %@", [error localizedDescription]]; + NSLog(@"%@", message); + + if (error.code != NSURLErrorCancelled) { + NSURL* errorUrl = self.viewController.errorURL; + if (errorUrl) { + NSCharacterSet *charSet = [NSCharacterSet URLFragmentAllowedCharacterSet]; + errorUrl = [NSURL URLWithString:[NSString stringWithFormat:@"?error=%@", [message stringByAddingPercentEncodingWithAllowedCharacters:charSet]] relativeToURL:errorUrl]; + NSLog(@"%@", [errorUrl absoluteString]); + [theWebView loadRequest:[NSURLRequest requestWithURL:errorUrl]]; + } + } +} + +- (void)webViewWebContentProcessDidTerminate:(WKWebView *)webView +{ + CDVSettingsDictionary *settings = self.commandDelegate.settings; + NSString *recoveryBehavior = [settings cordovaSettingForKey:@"CrashRecoveryBehavior"]; + + if ([recoveryBehavior isEqualToString:@"reload"]) { + [self.viewController loadStartPage]; + } else { + [webView reload]; + } +} + +- (BOOL)defaultResourcePolicyForURL:(NSURL*)url +{ + // all file:// urls are allowed + if ([url isFileURL]) { + return YES; + } + + return NO; +} + +- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler +{ + CDVViewController *vc = (CDVViewController *)self.viewController; + + NSURLRequest *request = navigationAction.request; + CDVWebViewNavigationType navType = (CDVWebViewNavigationType)navigationAction.navigationType; + NSMutableDictionary *info = [NSMutableDictionary dictionary]; + info[@"sourceFrame"] = navigationAction.sourceFrame; + info[@"targetFrame"] = navigationAction.targetFrame; +#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140500 + if (@available(iOS 14.5, *)) { + info[@"shouldPerformDownload"] = [NSNumber numberWithBool:navigationAction.shouldPerformDownload]; + } +#endif + + // Give plugins the chance to handle the url, as long as this WebViewEngine is still the WKNavigationDelegate. + // This allows custom delegates to choose to call this method for `default` cordova behavior without querying all plugins. + if (webView.navigationDelegate == self) { + BOOL anyPluginsResponded = NO; + BOOL shouldAllowRequest = NO; + + for (CDVPlugin *plugin in vc.enumerablePlugins) { + if ([plugin respondsToSelector:@selector(shouldOverrideLoadWithRequest:navigationType:info:)] || [plugin respondsToSelector:@selector(shouldOverrideLoadWithRequest:navigationType:)]) { + CDVPlugin *navPlugin = (CDVPlugin *)plugin; + anyPluginsResponded = YES; + + if ([navPlugin respondsToSelector:@selector(shouldOverrideLoadWithRequest:navigationType:info:)]) { + shouldAllowRequest = [navPlugin shouldOverrideLoadWithRequest:request navigationType:navType info:info]; + } else { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + shouldAllowRequest = [navPlugin shouldOverrideLoadWithRequest:request navigationType:navType]; +#pragma clang diagnostic pop + } + + if (!shouldAllowRequest) { + break; + } + } + } + + if (anyPluginsResponded) { + return decisionHandler(shouldAllowRequest ? WKNavigationActionPolicyAllow : WKNavigationActionPolicyCancel); + } + } else { + CDVPlugin *intentAndNavFilter = (CDVPlugin *)[vc getCommandInstance:@"IntentAndNavigationFilter"]; + if (intentAndNavFilter) { + BOOL shouldAllowRequest = [intentAndNavFilter shouldOverrideLoadWithRequest:request navigationType:navType info:info]; + return decisionHandler(shouldAllowRequest ? WKNavigationActionPolicyAllow : WKNavigationActionPolicyCancel); + } + } + + // Handle all other types of urls (tel:, sms:), and requests to load a url in the main webview. + BOOL shouldAllowNavigation = [self defaultResourcePolicyForURL:request.URL]; + if (!shouldAllowNavigation) { + [[NSNotificationCenter defaultCenter] postNotification:[NSNotification notificationWithName:CDVPluginHandleOpenURLNotification object:request.URL userInfo:@{}]]; + } + return decisionHandler(shouldAllowNavigation ? WKNavigationActionPolicyAllow : WKNavigationActionPolicyCancel); +} + +- (void)webView:(WKWebView *)webView didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential * _Nullable))completionHandler +{ + CDVViewController* vc = (CDVViewController*)self.viewController; + + for (CDVPlugin *plugin in vc.enumerablePlugins) { + if ([plugin respondsToSelector:@selector(willHandleAuthenticationChallenge:completionHandler:)]) { + CDVPlugin *challengePlugin = (CDVPlugin *)plugin; + if ([challengePlugin willHandleAuthenticationChallenge:challenge completionHandler:completionHandler]) { + return; + } + } + } + + completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil); +} + +#pragma mark - Plugin interface + +- (void)allowsBackForwardNavigationGestures:(CDVInvokedUrlCommand*)command; +{ + id value = [command argumentAtIndex:0]; + if (!([value isKindOfClass:[NSNumber class]])) { + value = [NSNumber numberWithBool:NO]; + } + + WKWebView* wkWebView = (WKWebView*)_engineWebView; + wkWebView.allowsBackForwardNavigationGestures = [value boolValue]; +} + +@end + +#pragma mark - CDVWebViewWeakScriptMessageHandler + +@implementation CDVWebViewWeakScriptMessageHandler + +- (instancetype)initWithScriptMessageHandler:(id)scriptMessageHandler +{ + self = [super init]; + if (self) { + _scriptMessageHandler = scriptMessageHandler; + } + return self; +} + +- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message +{ + [self.scriptMessageHandler userContentController:userContentController didReceiveScriptMessage:message]; +} + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVWebViewUIDelegate.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVWebViewUIDelegate.h new file mode 100644 index 00000000..2e4ac894 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVWebViewUIDelegate.h @@ -0,0 +1,51 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +#ifdef NS_SWIFT_UI_ACTOR +#define CDV_SWIFT_UI_ACTOR NS_SWIFT_UI_ACTOR +#else +#define CDV_SWIFT_UI_ACTOR +#endif + +@class CDVViewController; + +NS_ASSUME_NONNULL_BEGIN + +CDV_SWIFT_UI_ACTOR +@interface CDVWebViewUIDelegate : NSObject + +typedef NS_ENUM(NSInteger, CDVWebViewPermissionGrantType) { + CDVWebViewPermissionGrantType_Prompt, + CDVWebViewPermissionGrantType_Deny, + CDVWebViewPermissionGrantType_Grant, + CDVWebViewPermissionGrantType_GrantIfSameHost_ElsePrompt, + CDVWebViewPermissionGrantType_GrantIfSameHost_ElseDeny +}; + +@property (nonatomic, nullable, copy) NSString* title; +@property (nonatomic, assign) BOOL allowNewWindows; +@property (nonatomic, assign) CDVWebViewPermissionGrantType mediaPermissionGrantType; + +- (instancetype)initWithViewController:(CDVViewController*)vc; + +@end + +NS_ASSUME_NONNULL_END diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVWebViewUIDelegate.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVWebViewUIDelegate.m new file mode 100644 index 00000000..b07d7aae --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVWebViewUIDelegate.m @@ -0,0 +1,201 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import "CDVWebViewUIDelegate.h" +#import + +@interface CDVWebViewUIDelegate () + +@property (nonatomic, weak) CDVViewController *viewController; + +@end + +@implementation CDVWebViewUIDelegate +{ + NSMutableArray *windows; +} + +- (instancetype)initWithViewController:(CDVViewController *)vc +{ + self = [super init]; + + if (self) { + self.viewController = vc; + self.title = vc.title; + windows = [[NSMutableArray alloc] init]; + } + return self; +} + +- (void)webView:(WKWebView*)webView runJavaScriptAlertPanelWithMessage:(NSString*)message initiatedByFrame:(WKFrameInfo*)frame completionHandler:(CDV_SWIFT_UI_ACTOR void (^)(void))completionHandler +{ + UIAlertController* alert = [UIAlertController alertControllerWithTitle:self.title + message:message + preferredStyle:UIAlertControllerStyleAlert]; + + UIAlertAction* ok = [UIAlertAction actionWithTitle:NSLocalizedString(@"OK", @"OK") + style:UIAlertActionStyleDefault + handler:^(UIAlertAction* action) + { + completionHandler(); + [alert dismissViewControllerAnimated:YES completion:nil]; + }]; + + [alert addAction:ok]; + + [[self topViewController] presentViewController:alert animated:YES completion:nil]; +} + +- (void)webView:(WKWebView*)webView runJavaScriptConfirmPanelWithMessage:(NSString*)message initiatedByFrame:(WKFrameInfo*)frame completionHandler:(CDV_SWIFT_UI_ACTOR void (^)(BOOL result))completionHandler +{ + UIAlertController* alert = [UIAlertController alertControllerWithTitle:self.title + message:message + preferredStyle:UIAlertControllerStyleAlert]; + + UIAlertAction* ok = [UIAlertAction actionWithTitle:NSLocalizedString(@"OK", @"OK") + style:UIAlertActionStyleDefault + handler:^(UIAlertAction* action) + { + completionHandler(YES); + [alert dismissViewControllerAnimated:YES completion:nil]; + }]; + + [alert addAction:ok]; + + UIAlertAction* cancel = [UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", @"Cancel") + style:UIAlertActionStyleDefault + handler:^(UIAlertAction* action) + { + completionHandler(NO); + [alert dismissViewControllerAnimated:YES completion:nil]; + }]; + [alert addAction:cancel]; + + [[self topViewController] presentViewController:alert animated:YES completion:nil]; +} + +- (void)webView:(WKWebView*)webView runJavaScriptTextInputPanelWithPrompt:(NSString*)prompt defaultText:(NSString*)defaultText initiatedByFrame:(WKFrameInfo*)frame completionHandler:(CDV_SWIFT_UI_ACTOR void (^)(NSString* result))completionHandler +{ + UIAlertController* alert = [UIAlertController alertControllerWithTitle:self.title + message:prompt + preferredStyle:UIAlertControllerStyleAlert]; + + UIAlertAction* ok = [UIAlertAction actionWithTitle:NSLocalizedString(@"OK", @"OK") + style:UIAlertActionStyleDefault + handler:^(UIAlertAction* action) + { + completionHandler(((UITextField*)alert.textFields[0]).text); + [alert dismissViewControllerAnimated:YES completion:nil]; + }]; + + [alert addAction:ok]; + + UIAlertAction* cancel = [UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", @"Cancel") + style:UIAlertActionStyleDefault + handler:^(UIAlertAction* action) + { + completionHandler(nil); + [alert dismissViewControllerAnimated:YES completion:nil]; + }]; + [alert addAction:cancel]; + + [alert addTextFieldWithConfigurationHandler:^(UITextField* textField) { + textField.text = defaultText; + }]; + + [[self topViewController] presentViewController:alert animated:YES completion:nil]; +} + +- (nullable WKWebView*)webView:(WKWebView*)webView createWebViewWithConfiguration:(WKWebViewConfiguration*)configuration forNavigationAction:(WKNavigationAction*)navigationAction windowFeatures:(WKWindowFeatures*)windowFeatures +{ + if (!navigationAction.targetFrame.isMainFrame) { + if (self.allowNewWindows) { + WKWebView* v = [[WKWebView alloc] initWithFrame:webView.frame configuration:configuration]; + v.UIDelegate = webView.UIDelegate; + v.navigationDelegate = webView.navigationDelegate; + + UIViewController* vc = [[UIViewController alloc] init]; + vc.modalPresentationStyle = UIModalPresentationOverCurrentContext; + vc.view = v; + + [windows addObject:vc]; + + [[self topViewController] presentViewController:vc animated:YES completion:nil]; + return v; + } else { + [webView loadRequest:navigationAction.request]; + } + } + + return nil; +} + +- (void)webViewDidClose:(WKWebView*)webView +{ + for (UIViewController* vc in windows) { + if (vc.view == webView) { + [vc dismissViewControllerAnimated:YES completion:nil]; + [windows removeObject:vc]; + break; + } + } + + // We do not allow closing the primary WebView +} + +- (void)webView:(WKWebView *)webView requestMediaCapturePermissionForOrigin:(nonnull WKSecurityOrigin *)origin initiatedByFrame:(nonnull WKFrameInfo *)frame type:(WKMediaCaptureType)type decisionHandler:(nonnull void (^)(WKPermissionDecision))decisionHandler + API_AVAILABLE(ios(15.0)) +{ + WKPermissionDecision decision; + + if (_mediaPermissionGrantType == CDVWebViewPermissionGrantType_Prompt) { + decision = WKPermissionDecisionPrompt; + } + else if (_mediaPermissionGrantType == CDVWebViewPermissionGrantType_Deny) { + decision = WKPermissionDecisionDeny; + } + else if (_mediaPermissionGrantType == CDVWebViewPermissionGrantType_Grant) { + decision = WKPermissionDecisionGrant; + } + else { + if ([origin.host isEqualToString:webView.URL.host]) { + decision = WKPermissionDecisionGrant; + } + else { + decision =_mediaPermissionGrantType == CDVWebViewPermissionGrantType_GrantIfSameHost_ElsePrompt ? WKPermissionDecisionPrompt : WKPermissionDecisionDeny; + } + } + + decisionHandler(decision); +} + +#pragma mark - Utility Methods + +- (nullable UIViewController *)topViewController +{ + UIViewController *vc = self.viewController; + + while (vc.presentedViewController != nil && ![vc.presentedViewController isBeingDismissed]) { + vc = vc.presentedViewController; + } + + return vc; +} + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVAppDelegate.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVAppDelegate.m new file mode 100644 index 00000000..7dc9c2b1 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVAppDelegate.m @@ -0,0 +1,76 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import +#import +#import +#import + +@implementation CDVAppDelegate + +@synthesize window, viewController; + +- (BOOL)application:(UIApplication *)application willFinishLaunchingWithOptions:(NSDictionary *)launchOptions +{ +#if DEBUG + NSLog(@"Apache Cordova iOS platform version %@ is starting.", CDV_VERSION); +#endif + + return YES; +} + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions +{ + return YES; +} + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" +#pragma clang diagnostic ignored "-Wdeprecated-implementations" +// this happens while we are running ( in the background, or from within our own app ) +// only valid if Info.plist specifies a protocol to handle +- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary *)options +{ + if (!url) { + return NO; + } + + // all plugins will get the notification, and their handlers will be called + [[NSNotificationCenter defaultCenter] postNotificationName:CDVPluginHandleOpenURLNotification object:url userInfo:options]; + + // TODO: This should be deprecated and removed in Cordova iOS 9, since we're passing this data in the notification userInfo now + NSMutableDictionary * openURLData = [[NSMutableDictionary alloc] init]; + + [openURLData setValue:url forKey:@"url"]; + + if (options[UIApplicationOpenURLOptionsSourceApplicationKey]) { + [openURLData setValue:options[UIApplicationOpenURLOptionsSourceApplicationKey] forKey:@"sourceApplication"]; + } + + if (options[UIApplicationOpenURLOptionsAnnotationKey]) { + [openURLData setValue:options[UIApplicationOpenURLOptionsAnnotationKey] forKey:@"annotation"]; + } + + [[NSNotificationCenter defaultCenter] postNotification:[NSNotification notificationWithName:CDVPluginHandleOpenURLWithAppSourceAndAnnotationNotification object:openURLData]]; + + return YES; +} +#pragma clang diagnostic pop + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVCommandQueue.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVCommandQueue.m new file mode 100644 index 00000000..2b684c59 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVCommandQueue.m @@ -0,0 +1,201 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#include +#import +#import +#import +#import +#import "CDVCommandDelegateImpl.h" +#import "CDVJSON_private.h" +#import "CDVDebug.h" + +// Parse JS on the main thread if it's shorter than this. +static const NSInteger JSON_SIZE_FOR_MAIN_THREAD = 4 * 1024; // Chosen arbitrarily. +// Execute multiple commands in one go until this many seconds have passed. +static const double MAX_EXECUTION_TIME = .008; // Half of a 60fps frame. + +@interface CDVCommandQueue () { + NSInteger _lastCommandQueueFlushRequestId; + __weak CDVViewController* _viewController; + NSMutableArray* _queue; + NSTimeInterval _startExecutionTime; +} +@end + +@implementation CDVCommandQueue + +- (BOOL)currentlyExecuting +{ + return _startExecutionTime > 0; +} + +- (instancetype)init +{ + return [self initWithViewController:nil]; +} + +- (instancetype)initWithViewController:(CDVViewController *)viewController +{ + self = [super init]; + if (self != nil) { + _viewController = viewController; + _queue = [[NSMutableArray alloc] init]; + } + return self; +} + +- (void)dispose +{ + // TODO(agrieve): Make this a zeroing weak ref once we drop support for 4.3. + _viewController = nil; +} + +- (void)resetRequestId +{ + _lastCommandQueueFlushRequestId = 0; +} + +- (void)enqueueCommandBatch:(NSString*)batchJSON +{ + if ([batchJSON length] > 0) { + NSMutableArray* commandBatchHolder = [[NSMutableArray alloc] init]; + [_queue addObject:commandBatchHolder]; + if ([batchJSON length] < JSON_SIZE_FOR_MAIN_THREAD) { + [commandBatchHolder addObject:[batchJSON cdv_JSONObject]]; + } else { + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^() { + NSMutableArray* result = [batchJSON cdv_JSONObject]; + @synchronized(commandBatchHolder) { + [commandBatchHolder addObject:result]; + } + [self performSelectorOnMainThread:@selector(executePending) withObject:nil waitUntilDone:NO]; + }); + } + } +} + +- (void)fetchCommandsFromJs +{ + __weak CDVCommandQueue* weakSelf = self; + NSString* js = @"cordova.require('cordova/exec').nativeFetchMessages()"; + + [_viewController.webViewEngine evaluateJavaScript:js + completionHandler:^(id obj, NSError* error) { + if ((error == nil) && [obj isKindOfClass:[NSString class]]) { + NSString* queuedCommandsJSON = (NSString*)obj; + CDV_EXEC_LOG(@"Exec: Flushed JS->native queue (hadCommands=%d).", [queuedCommandsJSON length] > 0); + [weakSelf enqueueCommandBatch:queuedCommandsJSON]; + // this has to be called here now, because fetchCommandsFromJs is now async (previously: synchronous) + [self executePending]; + } + }]; +} + +- (void)executePending +{ + // Make us re-entrant-safe. + if (_startExecutionTime > 0) { + return; + } + @try { + _startExecutionTime = [NSDate timeIntervalSinceReferenceDate]; + + while ([_queue count] > 0) { + NSMutableArray* commandBatchHolder = _queue[0]; + NSMutableArray* commandBatch = nil; + @synchronized(commandBatchHolder) { + // If the next-up command is still being decoded, wait for it. + if ([commandBatchHolder count] == 0) { + break; + } + commandBatch = commandBatchHolder[0]; + } + + while ([commandBatch count] > 0) { + @autoreleasepool { + // Execute the commands one-at-a-time. + NSArray* jsonEntry = [commandBatch cdv_dequeue]; + if ([commandBatch count] == 0) { + [_queue removeObjectAtIndex:0]; + } + CDVInvokedUrlCommand* command = [CDVInvokedUrlCommand commandFromJson:jsonEntry]; + CDV_EXEC_LOG(@"Exec(%@): Calling %@.%@", command.callbackId, command.className, command.methodName); + + if (![self execute:command]) { +#ifdef DEBUG + NSString* commandJson = [jsonEntry cdv_JSONString]; + static NSUInteger maxLogLength = 1024; + NSString* commandString = ([commandJson length] > maxLogLength) ? + [NSString stringWithFormat : @"%@[...]", [commandJson substringToIndex:maxLogLength]] : + commandJson; + + DLog(@"FAILED pluginJSON = %@", commandString); +#endif + } + } + + // Yield if we're taking too long. + if (([_queue count] > 0) && ([NSDate timeIntervalSinceReferenceDate] - _startExecutionTime > MAX_EXECUTION_TIME)) { + [self performSelector:@selector(executePending) withObject:nil afterDelay:0]; + return; + } + } + } + } @finally + { + _startExecutionTime = 0; + } +} + +- (BOOL)execute:(CDVInvokedUrlCommand*)command +{ + if ((command.className == nil) || (command.methodName == nil)) { + NSLog(@"ERROR: Classname and/or methodName not found for command."); + return NO; + } + + // Fetch an instance of this class + CDVPlugin* obj = [_viewController.commandDelegate getCommandInstance:command.className]; + + if (!([obj isKindOfClass:[CDVPlugin class]])) { + NSLog(@"ERROR: Plugin '%@' not found, or is not a CDVPlugin. Check your plugin mapping in config.xml.", command.className); + return NO; + } + BOOL retVal = YES; + double started = [[NSDate date] timeIntervalSince1970] * 1000.0; + // Find the proper selector to call. + NSString* methodName = [NSString stringWithFormat:@"%@:", command.methodName]; + SEL normalSelector = NSSelectorFromString(methodName); + if ([obj respondsToSelector:normalSelector]) { + // [obj performSelector:normalSelector withObject:command]; + ((void (*)(id, SEL, id))objc_msgSend)(obj, normalSelector, command); + } else { + // There's no method to call, so throw an error. + NSLog(@"ERROR: Method '%@' not defined in Plugin '%@'", methodName, command.className); + retVal = NO; + } + double elapsed = [[NSDate date] timeIntervalSince1970] * 1000.0 - started; + if (elapsed > 10) { + NSLog(@"THREAD WARNING: ['%@'] took '%f' ms. Plugin should use a background thread.", command.className, elapsed); + } + return retVal; +} + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVConfigParser.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVConfigParser.m new file mode 100644 index 00000000..6f8da6a9 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVConfigParser.m @@ -0,0 +1,109 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +@interface CDVConfigParser () +{ + NSString *featureName; +} + +@property (nonatomic, readwrite, strong) NSMutableDictionary *pluginsDict; +@property (nonatomic, readwrite, strong) NSMutableDictionary *settings; +@property (nonatomic, readwrite, strong) NSMutableArray *startupPluginNames; +@property (nonatomic, readwrite, strong) NSString *startPage; + +@end + +@implementation CDVConfigParser + +@synthesize pluginsDict; +@synthesize settings; +@synthesize startPage; +@synthesize startupPluginNames; + ++ (instancetype)parseConfigFile:(NSURL *)filePath +{ + CDVConfigParser* delegate = [[CDVConfigParser alloc] init]; + [CDVConfigParser parseConfigFile:filePath withDelegate:delegate]; + return delegate; +} + ++ (BOOL)parseConfigFile:(NSURL *)filePath withDelegate:(id )delegate +{ + NSXMLParser *configParser = [[NSXMLParser alloc] initWithContentsOfURL:filePath]; + + if (configParser == nil) { + NSLog(@"Failed to initialize XML parser."); + return NO; + } + + [configParser setDelegate:delegate]; + [configParser parse]; + + return YES; +} + +- (instancetype)init +{ + self = [super init]; + if (self != nil) { + self.pluginsDict = [[NSMutableDictionary alloc] initWithCapacity:30]; + self.settings = [[NSMutableDictionary alloc] initWithCapacity:30]; + self.startupPluginNames = [[NSMutableArray alloc] initWithCapacity:8]; + featureName = nil; + } + return self; +} + +- (void)parser:(NSXMLParser *)parser didStartElement:(NSString *)elementName namespaceURI:(NSString *)namespaceURI qualifiedName:(NSString *)qualifiedName attributes:(NSDictionary *)attributeDict +{ + if ([elementName isEqualToString:@"preference"]) { + settings[[attributeDict[@"name"] lowercaseString]] = attributeDict[@"value"]; + } else if ([elementName isEqualToString:@"feature"]) { // store feature name to use with correct parameter set + featureName = [attributeDict[@"name"] lowercaseString]; + } else if ((featureName != nil) && [elementName isEqualToString:@"param"]) { + NSString *paramName = [attributeDict[@"name"] lowercaseString]; + id value = attributeDict[@"value"]; + if ([paramName isEqualToString:@"ios-package"]) { + pluginsDict[featureName] = value; + } + BOOL paramIsOnload = ([paramName isEqualToString:@"onload"] && [@"true" isEqualToString : value]); + BOOL attribIsOnload = [@"true" isEqualToString :[attributeDict[@"onload"] lowercaseString]]; + if (paramIsOnload || attribIsOnload) { + [self.startupPluginNames addObject:featureName]; + } + } else if ([elementName isEqualToString:@"content"]) { + self.startPage = attributeDict[@"src"]; + } +} + +- (void)parser:(NSXMLParser *)parser didEndElement:(NSString *)elementName namespaceURI:(NSString *)namespaceURI qualifiedName:(NSString *)qualifiedName +{ + if ([elementName isEqualToString:@"feature"]) { // no longer handling a feature so release + featureName = nil; + } +} + +- (void)parser:(NSXMLParser *)parser parseErrorOccurred:(NSError *)parseError +{ + NSAssert(NO, @"config.xml parse error line %ld col %ld", (long)[parser lineNumber], (long)[parser columnNumber]); +} + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVInvokedUrlCommand.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVInvokedUrlCommand.m new file mode 100644 index 00000000..aaad4edb --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVInvokedUrlCommand.m @@ -0,0 +1,121 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import +#import "CDVJSON_private.h" + +@implementation CDVInvokedUrlCommand + +@synthesize arguments = _arguments; +@synthesize callbackId = _callbackId; +@synthesize className = _className; +@synthesize methodName = _methodName; + ++ (instancetype)commandFromJson:(NSArray *)jsonEntry +{ + return [[CDVInvokedUrlCommand alloc] initFromJson:jsonEntry]; +} + +- (instancetype)init +{ + return [self initWithArguments:nil callbackId:nil className:nil methodName:nil]; +} + +- (instancetype)initFromJson:(NSArray *)jsonEntry +{ + id tmp = [jsonEntry objectAtIndex:0]; + NSString* callbackId = tmp == [NSNull null] ? nil : tmp; + NSString* className = [jsonEntry objectAtIndex:1]; + NSString* methodName = [jsonEntry objectAtIndex:2]; + NSMutableArray* arguments = [jsonEntry objectAtIndex:3]; + + return [self initWithArguments:arguments + callbackId:callbackId + className:className + methodName:methodName]; +} + +- (instancetype)initWithArguments:(NSArray *)arguments + callbackId:(NSString *)callbackId + className:(NSString *)className + methodName:(NSString *)methodName +{ + self = [super init]; + if (self != nil) { + _arguments = arguments; + _callbackId = callbackId; + _className = className; + _methodName = methodName; + } + [self massageArguments]; + return self; +} + +- (void)massageArguments +{ + NSMutableArray* newArgs = nil; + + for (NSUInteger i = 0, count = [_arguments count]; i < count; ++i) { + id arg = [_arguments objectAtIndex:i]; + if (![arg isKindOfClass:[NSDictionary class]]) { + continue; + } + NSDictionary* dict = arg; + NSString* type = [dict objectForKey:@"CDVType"]; + if (!type || ![type isEqualToString:@"ArrayBuffer"]) { + continue; + } + NSString* data = [dict objectForKey:@"data"]; + if (!data) { + continue; + } + if (newArgs == nil) { + newArgs = [NSMutableArray arrayWithArray:_arguments]; + _arguments = newArgs; + } + [newArgs replaceObjectAtIndex:i withObject:[[NSData alloc] initWithBase64EncodedString:data options:0]]; + } +} + +- (id)argumentAtIndex:(NSUInteger)index +{ + return [self argumentAtIndex:index withDefault:nil]; +} + +- (id)argumentAtIndex:(NSUInteger)index withDefault:(id)defaultValue +{ + return [self argumentAtIndex:index withDefault:defaultValue andClass:nil]; +} + +- (id)argumentAtIndex:(NSUInteger)index withDefault:(id)defaultValue andClass:(Class)aClass +{ + if (index >= [_arguments count]) { + return defaultValue; + } + id ret = [_arguments objectAtIndex:index]; + if (ret == [NSNull null]) { + ret = defaultValue; + } + if ((aClass != nil) && ![ret isKindOfClass:aClass]) { + ret = defaultValue; + } + return ret; +} + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVPlugin+Resources.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVPlugin+Resources.m new file mode 100644 index 00000000..f18f7429 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVPlugin+Resources.m @@ -0,0 +1,38 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +@implementation CDVPlugin (CDVPluginResources) + +- (NSString*)pluginLocalizedString:(NSString*)key +{ + NSBundle* bundle = [NSBundle bundleWithPath:[[NSBundle mainBundle] pathForResource:NSStringFromClass([self class]) ofType:@"bundle"]]; + + return [bundle localizedStringForKey:(key) value:nil table:nil]; +} + +- (UIImage*)pluginImageResource:(NSString*)name +{ + NSString* resourceIdentifier = [NSString stringWithFormat:@"%@.bundle/%@", NSStringFromClass([self class]), name]; + + return [UIImage imageNamed:resourceIdentifier]; +} + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVPlugin.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVPlugin.m new file mode 100644 index 00000000..52d95d34 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVPlugin.m @@ -0,0 +1,205 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import +#import +#import "CDVPlugin+Private.h" +#import + +@implementation UIView (org_apache_cordova_UIView_Extension) + +@dynamic scrollView; + +- (UIScrollView*)scrollView +{ + static UIView *caller = nil; + + if (caller != self && [self respondsToSelector:@selector(scrollView)]) { + caller = self; + UIScrollView *sv = [self performSelector:@selector(scrollView)]; + caller = nil; + return sv; + } + caller = nil; + return nil; +} + +@end + +const NSNotificationName CDVPageDidLoadNotification = @"CDVPageDidLoadNotification"; +const NSNotificationName CDVPluginHandleOpenURLNotification = @"CDVPluginHandleOpenURLNotification"; +const NSNotificationName CDVPluginHandleOpenURLWithAppSourceAndAnnotationNotification = @"CDVPluginHandleOpenURLWithAppSourceAndAnnotationNotification"; +const NSNotificationName CDVPluginResetNotification = @"CDVPluginResetNotification"; +const NSNotificationName CDVViewWillAppearNotification = @"CDVViewWillAppearNotification"; +const NSNotificationName CDVViewDidAppearNotification = @"CDVViewDidAppearNotification"; +const NSNotificationName CDVViewWillDisappearNotification = @"CDVViewWillDisappearNotification"; +const NSNotificationName CDVViewDidDisappearNotification = @"CDVViewDidDisappearNotification"; +const NSNotificationName CDVViewWillLayoutSubviewsNotification = @"CDVViewWillLayoutSubviewsNotification"; +const NSNotificationName CDVViewDidLayoutSubviewsNotification = @"CDVViewDidLayoutSubviewsNotification"; +const NSNotificationName CDVViewWillTransitionToSizeNotification = @"CDVViewWillTransitionToSizeNotification"; + +@interface CDVPlugin () + +@property (readwrite, assign) BOOL hasPendingOperation; +@property (nonatomic, readwrite, weak) id webViewEngine; + +@end + +@implementation CDVPlugin +@synthesize webViewEngine, viewController, commandDelegate, hasPendingOperation; +@dynamic webView; + +// Do not override these methods. Use pluginInitialize instead. +- (instancetype)initWithWebViewEngine:(id )theWebViewEngine +{ + self = [self init]; + if (self) { + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onAppTerminate) name:UIApplicationWillTerminateNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMemoryWarning) name:UIApplicationDidReceiveMemoryWarningNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleOpenURL:) name:CDVPluginHandleOpenURLNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onReset) name:CDVPluginResetNotification object:theWebViewEngine.engineWebView]; + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleOpenURLWithApplicationSourceAndAnnotation:) name:CDVPluginHandleOpenURLWithAppSourceAndAnnotationNotification object:nil]; +#pragma clang diagnostic pop + + self.webViewEngine = theWebViewEngine; + } + return self; +} + +- (void)pluginInitialize +{ + // You can listen to more app notifications, see: + // http://developer.apple.com/library/ios/#DOCUMENTATION/UIKit/Reference/UIApplication_Class/Reference/Reference.html#//apple_ref/doc/uid/TP40006728-CH3-DontLinkElementID_4 + + // NOTE: if you want to use these, make sure you uncomment the corresponding notification handler + + // [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onPause) name:UIApplicationDidEnterBackgroundNotification object:nil]; + // [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onResume) name:UIApplicationWillEnterForegroundNotification object:nil]; + // [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onOrientationWillChange) name:UIApplicationWillChangeStatusBarOrientationNotification object:nil]; + // [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onOrientationDidChange) name:UIApplicationDidChangeStatusBarOrientationNotification object:nil]; + + // Added in 2.5.0 + // [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(pageDidLoad:) name:CDVPageDidLoadNotification object:self.webView]; + //Added in 4.3.0 + // [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(viewWillAppear:) name:CDVViewWillAppearNotification object:nil]; + // [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(viewDidAppear:) name:CDVViewDidAppearNotification object:nil]; + // [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(viewWillDisappear:) name:CDVViewWillDisappearNotification object:nil]; + // [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(viewDidDisappear:) name:CDVViewDidDisappearNotification object:nil]; + // [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(viewWillLayoutSubviews:) name:CDVViewWillLayoutSubviewsNotification object:nil]; + // [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(viewDidLayoutSubviews:) name:CDVViewDidLayoutSubviewsNotification object:nil]; + // [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(viewWillTransitionToSize:) name:CDVViewWillTransitionToSizeNotification object:nil]; +} + +- (void)dispose +{ + viewController = nil; + commandDelegate = nil; +} + +- (UIView *)webView +{ + if (self.webViewEngine != nil) { + return self.webViewEngine.engineWebView; + } + + return nil; +} + +/* +// NOTE: for onPause and onResume, calls into JavaScript must not call or trigger any blocking UI, like alerts +- (void) onPause {} +- (void) onResume {} +- (void) onOrientationWillChange {} +- (void) onOrientationDidChange {} +*/ + +/* NOTE: calls into JavaScript must not call or trigger any blocking UI, like alerts */ +- (void)handleOpenURL:(NSNotification *)notification +{ + // override to handle urls sent to your app + // register your url schemes in your App-Info.plist + + NSURL* url = [notification object]; + + if ([url isKindOfClass:[NSURL class]]) { + /* Do your thing! */ + } +} + +/* + NOTE: calls into JavaScript must not call or trigger any blocking UI, like alerts + */ +- (void)handleOpenURLWithApplicationSourceAndAnnotation:(NSNotification *)notification +{ + + // override to handle urls sent to your app + // register your url schemes in your App-Info.plist + + // The notification object is an NSDictionary which contains + // - url which is a type of NSURL + // - sourceApplication which is a type of NSString and represents the package + // id of the app that calls our app + // - annotation which a type of Property list which can be several different types + // please see https://developer.apple.com/library/content/documentation/General/Conceptual/DevPedia-CocoaCore/PropertyList.html + + NSDictionary* notificationData = [notification object]; + + if ([notificationData isKindOfClass: NSDictionary.class]){ + + NSURL* url = notificationData[@"url"]; + NSString* sourceApplication = notificationData[@"sourceApplication"]; + id annotation = notificationData[@"annotation"]; + + if ([url isKindOfClass:NSURL.class] && [sourceApplication isKindOfClass:NSString.class] && annotation) { + /* Do your thing! */ + } + } +} + + +/* NOTE: calls into JavaScript must not call or trigger any blocking UI, like alerts */ +- (void)onAppTerminate +{ + // override this if you need to do any cleanup on app exit +} + +- (void)onMemoryWarning +{ + // override to remove caches, etc +} + +- (void)onReset +{ + // Override to cancel any long-running requests when the WebView navigates or refreshes. +} + +- (void)dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; // this will remove all notifications unless added using addObserverForName:object:queue:usingBlock: +} + +- (id)appDelegate +{ + return [[UIApplication sharedApplication] delegate]; +} + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVPluginResult.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVPluginResult.m new file mode 100644 index 00000000..d79e8bda --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVPluginResult.m @@ -0,0 +1,204 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import +#import "CDVJSON_private.h" +#import "CDVDebug.h" + +// This exists to preserve compatibility with early Swift plugins, who are +// using CDVCommandStatus as ObjC-style constants rather than as Swift enum +// values. +// These constants alias the enum values back to their previous names. +// TODO: Remove in Cordova iOS 9 +#define SWIFT_ENUM_COMPAT_HACK(enumVal) const CDVCommandStatus SWIFT_##enumVal NS_SWIFT_NAME(enumVal) = enumVal +SWIFT_ENUM_COMPAT_HACK(CDVCommandStatus_NO_RESULT); +SWIFT_ENUM_COMPAT_HACK(CDVCommandStatus_OK); +SWIFT_ENUM_COMPAT_HACK(CDVCommandStatus_CLASS_NOT_FOUND_EXCEPTION); +SWIFT_ENUM_COMPAT_HACK(CDVCommandStatus_ILLEGAL_ACCESS_EXCEPTION); +SWIFT_ENUM_COMPAT_HACK(CDVCommandStatus_INSTANTIATION_EXCEPTION); +SWIFT_ENUM_COMPAT_HACK(CDVCommandStatus_MALFORMED_URL_EXCEPTION); +SWIFT_ENUM_COMPAT_HACK(CDVCommandStatus_IO_EXCEPTION); +SWIFT_ENUM_COMPAT_HACK(CDVCommandStatus_INVALID_ACTION); +SWIFT_ENUM_COMPAT_HACK(CDVCommandStatus_JSON_EXCEPTION); +SWIFT_ENUM_COMPAT_HACK(CDVCommandStatus_ERROR); +#undef SWIFT_ENUM_COMPAT_HACK + +@interface CDVPluginResult () + +- (instancetype)initWithStatus:(CDVCommandStatus)statusOrdinal message:(id)theMessage; + +@end + +@implementation CDVPluginResult +@synthesize status, message, keepCallback, associatedObject; + +static NSArray* org_apache_cordova_CommandStatusMsgs; + +id messageFromArrayBuffer(NSData* data) +{ + return @{ + @"CDVType" : @"ArrayBuffer", + @"data" :[data base64EncodedStringWithOptions:0] + }; +} + +id massageMessage(id message) +{ + if ([message isKindOfClass:[NSData class]]) { + return messageFromArrayBuffer(message); + } + return message; +} + +id messageFromMultipart(NSArray* theMessages) +{ + NSMutableArray* messages = [NSMutableArray arrayWithArray:theMessages]; + + for (NSUInteger i = 0; i < messages.count; ++i) { + [messages replaceObjectAtIndex:i withObject:massageMessage([messages objectAtIndex:i])]; + } + + return @{ + @"CDVType" : @"MultiPart", + @"messages" : messages + }; +} + ++ (void)initialize +{ + org_apache_cordova_CommandStatusMsgs = [[NSArray alloc] initWithObjects:@"No result", + @"OK", + @"Class not found", + @"Illegal access", + @"Instantiation error", + @"Malformed url", + @"IO error", + @"Invalid action", + @"JSON error", + @"Error", + nil]; +} + +- (instancetype)init +{ + return [self initWithStatus:CDVCommandStatus_NO_RESULT message:nil]; +} + +- (instancetype)initWithStatus:(CDVCommandStatus)statusOrdinal message:(id)theMessage +{ + self = [super init]; + if (self) { + status = @(statusOrdinal); + message = theMessage; + keepCallback = [NSNumber numberWithBool:NO]; + } + return self; +} + ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal +{ + return [[self alloc] initWithStatus:statusOrdinal message:nil]; +} + ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageAsString:(NSString *)theMessage +{ + return [[self alloc] initWithStatus:statusOrdinal message:theMessage]; +} + ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageAsArray:(NSArray *)theMessage +{ + return [[self alloc] initWithStatus:statusOrdinal message:theMessage]; +} + ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageAsInt:(int)theMessage +{ + return [[self alloc] initWithStatus:statusOrdinal message:[NSNumber numberWithInt:theMessage]]; +} + ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageAsNSInteger:(NSInteger)theMessage +{ + return [[self alloc] initWithStatus:statusOrdinal message:[NSNumber numberWithInteger:theMessage]]; +} + ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageAsNSUInteger:(NSUInteger)theMessage +{ + return [[self alloc] initWithStatus:statusOrdinal message:[NSNumber numberWithUnsignedInteger:theMessage]]; +} + ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageAsDouble:(double)theMessage +{ + return [[self alloc] initWithStatus:statusOrdinal message:[NSNumber numberWithDouble:theMessage]]; +} + ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageAsBool:(BOOL)theMessage +{ + return [[self alloc] initWithStatus:statusOrdinal message:[NSNumber numberWithBool:theMessage]]; +} + ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageAsDictionary:(NSDictionary *)theMessage +{ + return [[self alloc] initWithStatus:statusOrdinal message:theMessage]; +} + ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageAsArrayBuffer:(NSData *)theMessage +{ + return [[self alloc] initWithStatus:statusOrdinal message:messageFromArrayBuffer(theMessage)]; +} + ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageAsMultipart:(NSArray *)theMessages +{ + return [[self alloc] initWithStatus:statusOrdinal message:messageFromMultipart(theMessages)]; +} + ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageToErrorObject:(int)errorCode +{ + NSDictionary* errDict = @{@"code" :[NSNumber numberWithInt:errorCode]}; + + return [[self alloc] initWithStatus:statusOrdinal message:errDict]; +} + +- (void)setKeepCallbackAsBool:(BOOL)bKeepCallback +{ + [self setKeepCallback:[NSNumber numberWithBool:bKeepCallback]]; +} + +- (NSString*)argumentsAsJSON +{ + id arguments = (self.message == nil ? [NSNull null] : self.message); + NSArray* argumentsWrappedInArray = [NSArray arrayWithObject:arguments]; + + NSString* argumentsJSON = [argumentsWrappedInArray cdv_JSONString]; + + argumentsJSON = [argumentsJSON substringWithRange:NSMakeRange(1, [argumentsJSON length] - 2)]; + + return argumentsJSON; +} + +static BOOL gIsVerbose = NO; ++ (void)setVerbose:(BOOL)verbose +{ + gIsVerbose = verbose; +} + ++ (BOOL)isVerbose +{ + return gIsVerbose; +} + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVSceneDelegate.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVSceneDelegate.m new file mode 100644 index 00000000..7492ca1a --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVSceneDelegate.m @@ -0,0 +1,50 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +#import +#import + +@implementation CDVSceneDelegate + +- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)connectionOptions +{ + // If the app was launched from a URL, that should also fire the CDVPluginOpenURLNotification + [self scene:scene openURLContexts:connectionOptions.URLContexts]; +} + +- (void)scene:(UIScene *)scene openURLContexts:(NSSet *)URLContexts +{ + for (UIOpenURLContext *context in URLContexts) { + NSMutableDictionary *options = [[NSMutableDictionary alloc] init]; + [options setValue:context.options.sourceApplication forKey:@"sourceApplication"]; + [options setValue:context.options.annotation forKey:@"annotation"]; + [options setValue:@(context.options.openInPlace) forKey:@"openInPlace"]; + +#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140500 + if (@available(iOS 14.5, *)) { + [options setValue:context.options.eventAttribution forKey:@"eventAttribution"]; + } +#endif + + [[NSNotificationCenter defaultCenter] postNotificationName:CDVPluginHandleOpenURLNotification object:context.URL userInfo:options]; + + } +} + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVSettingsDictionary.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVSettingsDictionary.m new file mode 100644 index 00000000..0c9e5059 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVSettingsDictionary.m @@ -0,0 +1,159 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +#import + +@interface CDVSettingsDictionary () { + // Ideally this should not be mutable, but we've got legacy API that allows + // plugins to set values in here, so this is the world we have to live in + NSMutableDictionary *_dict; +} +@end + +@implementation CDVSettingsDictionary + +- (instancetype)init +{ + return [self initWithDictionary:@{}]; +} + +- (instancetype)initWithDictionary:(NSDictionary *)dict +{ + self = [super init]; + if (self != nil) { + if ([dict isKindOfClass:[NSMutableDictionary class]]) { + _dict = (NSMutableDictionary *)dict; + } else { + _dict = [NSMutableDictionary dictionaryWithDictionary:dict]; + } + } + return self; +} + +- (instancetype)initWithObjects:(const id _Nonnull [ _Nullable ])objects forKeys:(const id _Nonnull [ _Nullable ])keys count:(NSUInteger)cnt +{ + self = [self init]; + if (self != nil) { + _dict = [NSMutableDictionary dictionaryWithObjects:objects forKeys:keys count:cnt]; + } + return self; +} + +- (instancetype)initWithCoder:(NSCoder *)coder +{ + NSMutableDictionary *dict = [[NSMutableDictionary alloc] initWithCoder:coder]; + + if (dict != nil) { + self = [self initWithDictionary:dict]; + } else { + self = [self initWithDictionary:@{}]; + } + return self; +} + ++ (BOOL)supportsSecureCoding +{ + return YES; +} + +- (Class)classForCoder +{ + return [self class]; +} + +- (id)forwardingTargetForSelector:(SEL)selector +{ + return _dict; +} + +- (NSUInteger)count +{ + return _dict.count; +} + +- (id)objectForKey:(NSString *)key +{ + return [_dict objectForKey:[key lowercaseString]]; +} + +- (NSEnumerator *)keyEnumerator +{ + return [_dict keyEnumerator]; +} + +- (id)cordovaSettingForKey:(NSString *)key +{ + return [self objectForKey:key]; +} + +- (BOOL)cordovaBoolSettingForKey:(NSString *)key defaultValue:(BOOL)defaultValue +{ + BOOL value = defaultValue; + + id prefObj = [self objectForKey:key]; + if (prefObj == nil) { +#ifdef DEBUG + NSLog(@"The preference key \"%@\" is not defined and will default to \"%@\"", key, (defaultValue ? @"TRUE" : @"FALSE")); +#endif + return value; + } + + if ([prefObj isKindOfClass:NSString.class]) { + prefObj = [prefObj lowercaseString]; + + if ([prefObj isEqualToString:@"true"] || [prefObj isEqualToString:@"1"] || [prefObj isEqualToString:@"yes"]) { + return YES; + } else if ([prefObj isEqualToString:@"false"] || [prefObj isEqualToString:@"0"] || [prefObj isEqualToString:@"no"]) { + return NO; + } + } else if ([prefObj isKindOfClass:NSNumber.class] && ([prefObj isEqual:@YES] || [prefObj isEqual:@NO])) { + return [prefObj isEqual:@YES]; + } + + return value; +} + +- (CGFloat)cordovaFloatSettingForKey:(NSString *)key defaultValue:(CGFloat)defaultValue +{ + CGFloat value = defaultValue; + + id prefObj = [self objectForKey:key]; + if (prefObj != nil) { + value = [prefObj floatValue]; + } + + return value; +} + +- (void)setObject:(id)value forKey:(NSString *)key +{ + [_dict setObject:value forKey:[key lowercaseString]]; +} + +- (void)setObject:(id)value forKeyedSubscript:(NSString *)key +{ + [_dict setObject:value forKey:[key lowercaseString]]; +} + +- (void)setCordovaSetting:(id)value forKey:(NSString *)key +{ + [self setObject:value forKey:key]; +} + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVTimer.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVTimer.m new file mode 100644 index 00000000..1d62f952 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVTimer.m @@ -0,0 +1,123 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +#pragma mark CDVTimerItem + +@interface CDVTimerItem : NSObject + +@property (nonatomic, strong) NSString* name; +@property (nonatomic, strong) NSDate* started; +@property (nonatomic, strong) NSDate* ended; + +- (void)log; + +@end + +@implementation CDVTimerItem + +- (void)log +{ + NSLog(@"[CDVTimer][%@] %fms", self.name, [self.ended timeIntervalSinceDate:self.started] * 1000.0); +} + +@end + +#pragma mark CDVTimer + +@interface CDVTimer () + +@property (nonatomic, strong) NSMutableDictionary* items; + +@end + +@implementation CDVTimer + +#pragma mark object methods + +- (id)init +{ + if (self = [super init]) { + self.items = [NSMutableDictionary dictionaryWithCapacity:6]; + } + + return self; +} + +- (void)add:(NSString*)name +{ + if ([self.items objectForKey:[name lowercaseString]] == nil) { + CDVTimerItem* item = [CDVTimerItem new]; + item.name = name; + item.started = [NSDate new]; + [self.items setObject:item forKey:[name lowercaseString]]; + } else { + NSLog(@"Timer called '%@' already exists.", name); + } +} + +- (void)remove:(NSString*)name +{ + CDVTimerItem* item = [self.items objectForKey:[name lowercaseString]]; + + if (item != nil) { + item.ended = [NSDate new]; + [item log]; + [self.items removeObjectForKey:[name lowercaseString]]; + } else { + NSLog(@"Timer called '%@' does not exist.", name); + } +} + +- (void)removeAll +{ + [self.items removeAllObjects]; +} + +#pragma mark class methods + ++ (void)start:(NSString*)name +{ + [[CDVTimer sharedInstance] add:name]; +} + ++ (void)stop:(NSString*)name +{ + [[CDVTimer sharedInstance] remove:name]; +} + ++ (void)clearAll +{ + [[CDVTimer sharedInstance] removeAll]; +} + ++ (CDVTimer*)sharedInstance +{ + static dispatch_once_t pred = 0; + __strong static CDVTimer* _sharedObject = nil; + + dispatch_once(&pred, ^{ + _sharedObject = [[self alloc] init]; + }); + + return _sharedObject; +} + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVViewController.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVViewController.m new file mode 100644 index 00000000..886f9b00 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVViewController.m @@ -0,0 +1,895 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import +#import +#import +#import + +#import +#import +#import "CDVPlugin+Private.h" +#import +#import +#import +#import "CDVCommandDelegateImpl.h" + +static UIColor *defaultBackgroundColor(void) { + return UIColor.systemBackgroundColor; +} + +@interface CDVViewController () { + id _webViewEngine; + id _commandDelegate; + NSMutableDictionary *_pluginObjects; + NSMutableDictionary *_pluginsMap; + CDVCommandQueue* _commandQueue; + UIColor *_backgroundColor; + UIColor *_splashBackgroundColor; + UIColor *_statusBarBackgroundColor; + UIColor *_statusBarWebViewColor; + UIColor *_statusBarDefaultColor; + CDVSettingsDictionary* _settings; +} + +@property (nonatomic, readwrite, strong) NSMutableArray* startupPluginNames; +@property (nonatomic, readwrite, strong) UIView *launchView; +@property (nonatomic, readwrite, strong) UIView *statusBar; +@property (readwrite, assign) BOOL initialized; + +@end + +@implementation CDVViewController + +@synthesize pluginObjects = _pluginObjects; +@synthesize pluginsMap = _pluginsMap; +@synthesize commandDelegate = _commandDelegate; +@synthesize commandQueue = _commandQueue; +@synthesize webViewEngine = _webViewEngine; +@synthesize backgroundColor = _backgroundColor; +@synthesize splashBackgroundColor = _splashBackgroundColor; +@synthesize statusBarBackgroundColor = _statusBarBackgroundColor; +@synthesize settings = _settings; +@dynamic webView; +@dynamic enumerablePlugins; + +#pragma mark - Initializers + +- (instancetype)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil +{ + self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]; + if (self != nil) { + [self _cdv_init]; + } + return self; +} + +- (instancetype)initWithCoder:(NSCoder *)aDecoder +{ + self = [super initWithCoder:aDecoder]; + if (self != nil) { + [self _cdv_init]; + } + return self; +} + +- (instancetype)init +{ + self = [super init]; + if (self != nil) { + [self _cdv_init]; + } + return self; +} + +- (void)_cdv_init +{ + if (!self.initialized) { + _commandQueue = [[CDVCommandQueue alloc] initWithViewController:self]; + _commandDelegate = [[CDVCommandDelegateImpl alloc] initWithViewController:self]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onAppWillTerminate:) + name:UIApplicationWillTerminateNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onAppWillResignActive:) + name:UIApplicationWillResignActiveNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onAppDidBecomeActive:) + name:UIApplicationDidBecomeActiveNotification object:nil]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onAppWillEnterForeground:) + name:UIApplicationWillEnterForegroundNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onAppDidEnterBackground:) + name:UIApplicationDidEnterBackgroundNotification object:nil]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onWebViewPageDidLoad:) + name:CDVPageDidLoadNotification object:nil]; + + // Default property values + self.configFile = @"config.xml"; + self.webContentFolderName = @"www"; + self.showInitialSplashScreen = YES; + + // Initialize the plugin objects dict. + _pluginObjects = [[NSMutableDictionary alloc] initWithCapacity:20]; + + // Prevent reinitializing + self.initialized = YES; + } +} + +#pragma mark - + +- (void)dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [_commandQueue dispose]; + + [self.webViewEngine loadHTMLString:@"about:blank" baseURL:nil]; + + @synchronized(_pluginObjects) { + [[_pluginObjects allValues] makeObjectsPerformSelector:@selector(dispose)]; + [_pluginObjects removeAllObjects]; + } + + [self.webView removeFromSuperview]; + [self.launchView removeFromSuperview]; + + _webViewEngine = nil; +} + +#pragma mark - Getters & Setters + +- (NSArray *)enumerablePlugins +{ + @synchronized(_pluginObjects) { + return [_pluginObjects allValues]; + } +} + +- (NSString *)wwwFolderName +{ + return self.webContentFolderName; +} + +- (void)setWwwFolderName:(NSString *)name +{ + self.webContentFolderName = name; +} + +- (void)setBackgroundColor:(UIColor *)color +{ + _backgroundColor = color ?: defaultBackgroundColor(); + + [self.webView setBackgroundColor:self.backgroundColor]; +} + +- (void)setSplashBackgroundColor:(UIColor *)color +{ + _splashBackgroundColor = color ?: self.backgroundColor; + + [self.launchView setBackgroundColor:self.splashBackgroundColor]; +} + +- (UIColor *)statusBarBackgroundColor +{ + // If a status bar background color has been explicitly set using the JS API, we always use that. + // Otherwise, if the webview reports a themeColor meta tag (iOS 15.4+) we use that. + // Otherwise, we use the status bar background color provided in IB (from config.xml). + // Otherwise, we use the background color. + return _statusBarBackgroundColor ?: _statusBarWebViewColor ?: _statusBarDefaultColor ?: self.backgroundColor; +} + +- (void)setStatusBarBackgroundColor:(UIColor *)color +{ + // We want the initial value from IB to set the statusBarDefaultColor and + // then all future changes to set the statusBarBackgroundColor. + // + // The reason for this is that statusBarBackgroundColor is treated like a + // forced override when it is set, and we don't want that for the initial + // value from config.xml set via IB. + + if (!_statusBarBackgroundColor && !_statusBarWebViewColor && !_statusBarDefaultColor) { + _statusBarDefaultColor = color; + } else { + _statusBarBackgroundColor = color; + } + + [self.statusBar setBackgroundColor:self.statusBarBackgroundColor]; +} + +- (void)setStatusBarWebViewColor:(UIColor *)color +{ + _statusBarWebViewColor = color; + + [self.statusBar setBackgroundColor:self.statusBarBackgroundColor]; +} + +// Only for testing +- (void)setSettings:(CDVSettingsDictionary *)settings +{ + _settings = settings; +} + +- (nullable NSURL *)configFilePath +{ + NSString* path = self.configFile; + + // if path is relative, resolve it against the main bundle + if (![path isAbsolutePath]) { + NSString* absolutePath = [[NSBundle mainBundle] pathForResource:path ofType:nil]; + if(!absolutePath){ + NSAssert(NO, @"ERROR: %@ not found in the main bundle!", path); + } + path = absolutePath; + } + + // Assert file exists + if (![[NSFileManager defaultManager] fileExistsAtPath:path]) { + NSAssert(NO, @"ERROR: %@ does not exist.", path); + return nil; + } + + return [NSURL fileURLWithPath:path]; +} + +- (NSURL *)appUrl +{ + NSURL* appURL = nil; + + if ([self.startPage rangeOfString:@"://"].location != NSNotFound) { + appURL = [NSURL URLWithString:self.startPage]; + } else if ([self.webContentFolderName rangeOfString:@"://"].location != NSNotFound) { + appURL = [NSURL URLWithString:[NSString stringWithFormat:@"%@/%@", self.webContentFolderName, self.startPage]]; + } else if([self.webContentFolderName rangeOfString:@".bundle"].location != NSNotFound){ + // www folder is actually a bundle + NSBundle* bundle = [NSBundle bundleWithPath:self.webContentFolderName]; + appURL = [bundle URLForResource:self.startPage withExtension:nil]; + } else if([self.webContentFolderName rangeOfString:@".framework"].location != NSNotFound){ + // www folder is actually a framework + NSBundle* bundle = [NSBundle bundleWithPath:self.webContentFolderName]; + appURL = [bundle URLForResource:self.startPage withExtension:nil]; + } else { + // CB-3005 strip parameters from start page to check if page exists in resources + NSURL* startURL = [NSURL URLWithString:self.startPage]; + NSString* startFilePath = [self.commandDelegate pathForResource:[startURL path]]; + + if (startFilePath == nil) { + appURL = nil; + } else { + appURL = [NSURL fileURLWithPath:startFilePath]; + // CB-3005 Add on the query params or fragment. + NSString* startPageNoParentDirs = self.startPage; + NSRange r = [startPageNoParentDirs rangeOfCharacterFromSet:[NSCharacterSet characterSetWithCharactersInString:@"?#"] options:0]; + if (r.location != NSNotFound) { + NSString* queryAndOrFragment = [self.startPage substringFromIndex:r.location]; + appURL = [NSURL URLWithString:queryAndOrFragment relativeToURL:appURL]; + } + } + } + + return appURL; +} + +- (nullable NSURL *)errorURL +{ + NSString *setting = [self.settings cordovaSettingForKey:@"ErrorUrl"]; + if (setting == nil) { + return nil; + } + + if ([setting rangeOfString:@"://"].location != NSNotFound) { + return [NSURL URLWithString:setting]; + } else { + NSURL *url = [NSURL URLWithString:setting]; + NSString *errorFilePath = [self.commandDelegate pathForResource:[url path]]; + if (errorFilePath) { + return [NSURL fileURLWithPath:errorFilePath]; + } + } + + return nil; +} + +- (nullable UIView *)webView +{ + if (_webViewEngine != nil) { + return _webViewEngine.engineWebView; + } + + return nil; +} + +- (nullable NSString *)appURLScheme +{ + NSString* URLScheme = nil; + + NSArray* URLTypes = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleURLTypes"]; + + if (URLTypes != nil) { + NSDictionary* dict = [URLTypes objectAtIndex:0]; + if (dict != nil) { + NSArray* URLSchemes = [dict objectForKey:@"CFBundleURLSchemes"]; + if (URLSchemes != nil) { + URLScheme = [URLSchemes objectAtIndex:0]; + } + } + } + + return URLScheme; +} + +#pragma mark - UIViewController & App Lifecycle + +// Implement viewDidLoad to do additional setup after loading the view, typically from a nib. +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // TODO: Remove in Cordova iOS 9 + if ([UIApplication.sharedApplication.delegate isKindOfClass:[CDVAppDelegate class]]) { + CDVAppDelegate *appDelegate = (CDVAppDelegate *)UIApplication.sharedApplication.delegate; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + if (appDelegate.viewController == nil) { + appDelegate.viewController = self; + } +#pragma clang diagnostic pop + } + + // Load settings + [self loadSettings]; + + // Instantiate the Launch screen + if (!self.launchView) { + [self createLaunchView]; + } + + // Instantiate the WebView + if (!self.webView) { + [self createGapView]; + } + + // Instantiate the status bar + if (!self.statusBar) { + [self createStatusBarView]; + } + + // ///////////////// + + if ([self.startupPluginNames count] > 0) { + [CDVTimer start:@"TotalPluginStartup"]; + + for (NSString* pluginName in self.startupPluginNames) { + [CDVTimer start:pluginName]; + [self getCommandInstance:pluginName]; + [CDVTimer stop:pluginName]; + } + + [CDVTimer stop:@"TotalPluginStartup"]; + } + + [self loadStartPage]; + + [self.webView setBackgroundColor:self.backgroundColor]; + [self.launchView setBackgroundColor:self.splashBackgroundColor]; + [self.statusBar setBackgroundColor:self.statusBarBackgroundColor]; + + if (self.showInitialSplashScreen) { + [self.launchView setAlpha:1]; + } +} + +-(void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + [[NSNotificationCenter defaultCenter] postNotification:[NSNotification notificationWithName:CDVViewWillAppearNotification object:nil]]; +} + +-(void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + +#if TARGET_OS_MACCATALYST + BOOL hideTitlebar = [self.settings cordovaBoolSettingForKey:@"HideDesktopTitlebar" defaultValue:NO]; + if (hideTitlebar) { + UIWindowScene *scene = self.view.window.windowScene; + if (scene) { + scene.titlebar.titleVisibility = UITitlebarTitleVisibilityHidden; + scene.titlebar.toolbar = nil; + } + } else { + // We need to fix the web content going behind the title bar + self.webView.translatesAutoresizingMaskIntoConstraints = NO; + [self.webView.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor].active = YES; + [self.webView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor].active = YES; + [self.webView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor].active = YES; + [self.webView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor].active = YES; + + if ([self.webView respondsToSelector:@selector(scrollView)]) { + UIScrollView *scrollView = [self.webView performSelector:@selector(scrollView)]; + scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; + } + } +#endif + + [[NSNotificationCenter defaultCenter] postNotification:[NSNotification notificationWithName:CDVViewDidAppearNotification object:nil]]; +} + +-(void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + [[NSNotificationCenter defaultCenter] postNotification:[NSNotification notificationWithName:CDVViewWillDisappearNotification object:nil]]; +} + +-(void)viewDidDisappear:(BOOL)animated +{ + [super viewDidDisappear:animated]; + [[NSNotificationCenter defaultCenter] postNotification:[NSNotification notificationWithName:CDVViewDidDisappearNotification object:nil]]; +} + +-(void)viewWillLayoutSubviews +{ + [super viewWillLayoutSubviews]; + [[NSNotificationCenter defaultCenter] postNotification:[NSNotification notificationWithName:CDVViewWillLayoutSubviewsNotification object:nil]]; +} + +-(void)viewDidLayoutSubviews +{ + [super viewDidLayoutSubviews]; + [[NSNotificationCenter defaultCenter] postNotification:[NSNotification notificationWithName:CDVViewDidLayoutSubviewsNotification object:nil]]; +} + +-(void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id )coordinator +{ + [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; + [[NSNotificationCenter defaultCenter] postNotification:[NSNotification notificationWithName:CDVViewWillTransitionToSizeNotification object:[NSValue valueWithCGSize:size]]]; +} + +/* + This method lets your application know that it is about to be terminated and purged from memory entirely + */ +- (void)onAppWillTerminate:(NSNotification *)notification +{ + // empty the tmp directory + NSFileManager* fileMgr = [[NSFileManager alloc] init]; + NSError* __autoreleasing err = nil; + + // clear contents of NSTemporaryDirectory + NSString* tempDirectoryPath = NSTemporaryDirectory(); + NSDirectoryEnumerator* directoryEnumerator = [fileMgr enumeratorAtPath:tempDirectoryPath]; + NSString* fileName = nil; + BOOL result; + + while ((fileName = [directoryEnumerator nextObject])) { + NSString* filePath = [tempDirectoryPath stringByAppendingPathComponent:fileName]; + result = [fileMgr removeItemAtPath:filePath error:&err]; + if (!result && err) { + NSLog(@"Failed to delete: %@ (error: %@)", filePath, err); + } + } +} + +/* + This method is called to let your application know that it is about to move from the active to inactive state. + You should use this method to pause ongoing tasks, disable timer, ... + */ +- (void)onAppWillResignActive:(NSNotification *)notification +{ + [self checkAndReinitViewUrl]; + // NSLog(@"%@",@"applicationWillResignActive"); + [self.commandDelegate evalJs:@"cordova.fireDocumentEvent('resign');" scheduledOnRunLoop:NO]; +} + +/* + In iOS 4.0 and later, this method is called instead of the applicationWillTerminate: method + when the user quits an application that supports background execution. + */ +- (void)onAppDidEnterBackground:(NSNotification *)notification +{ + [self checkAndReinitViewUrl]; + // NSLog(@"%@",@"applicationDidEnterBackground"); + [self.commandDelegate evalJs:@"cordova.fireDocumentEvent('pause', null, true);" scheduledOnRunLoop:NO]; +} + +/* + In iOS 4.0 and later, this method is called as part of the transition from the background to the inactive state. + You can use this method to undo many of the changes you made to your application upon entering the background. + invariably followed by applicationDidBecomeActive + */ +- (void)onAppWillEnterForeground:(NSNotification *)notification +{ + [self checkAndReinitViewUrl]; + // NSLog(@"%@",@"applicationWillEnterForeground"); + [self.commandDelegate evalJs:@"cordova.fireDocumentEvent('resume');"]; +} + +// This method is called to let your application know that it moved from the inactive to active state. +- (void)onAppDidBecomeActive:(NSNotification *)notification +{ + [self checkAndReinitViewUrl]; + // NSLog(@"%@",@"applicationDidBecomeActive"); + [self.commandDelegate evalJs:@"cordova.fireDocumentEvent('active');"]; +} + +- (void)didReceiveMemoryWarning +{ + BOOL doPurge = YES; + + // iterate through all the plugin objects, and call hasPendingOperation + // if at least one has a pending operation, we don't call [super didReceiveMemoryWarning] + for (CDVPlugin *plugin in self.enumerablePlugins) { + if (plugin.hasPendingOperation) { + NSLog(@"Plugin '%@' has a pending operation, memory purge is delayed for didReceiveMemoryWarning.", NSStringFromClass([plugin class])); + doPurge = NO; + } + } + + if (doPurge) { + // Releases the view if it doesn't have a superview. + [super didReceiveMemoryWarning]; + } + + // Release any cached data, images, etc. that aren't in use. +} + +/** + Show the webview and fade out the intermediary view + This is to prevent the flashing of the mainViewController + */ +- (void)onWebViewPageDidLoad:(NSNotification*)notification +{ + self.webView.hidden = NO; + + if ([self.webView respondsToSelector:@selector(scrollView)]) { + UIScrollView *scrollView = [self.webView performSelector:@selector(scrollView)]; + [self scrollViewDidChangeAdjustedContentInset:scrollView]; + } + + if ([self.settings cordovaBoolSettingForKey:@"AutoHideSplashScreen" defaultValue:YES]) { + CGFloat splashScreenDelaySetting = [self.settings cordovaFloatSettingForKey:@"SplashScreenDelay" defaultValue:0]; + + if (splashScreenDelaySetting == 0) { + [self showSplashScreen:NO]; + } else { + // Divide by 1000 because config returns milliseconds and NSTimer takes seconds + CGFloat splashScreenDelay = splashScreenDelaySetting / 1000; + + [NSTimer scheduledTimerWithTimeInterval:splashScreenDelay repeats:NO block:^(NSTimer * _Nonnull timer) { + [self showSplashScreen:NO]; + }]; + } + } +} + +- (void)scrollViewDidChangeAdjustedContentInset:(UIScrollView *)scrollView +{ + if (self.webView.hidden) { + self.statusBar.hidden = true; + return; + } + + self.statusBar.hidden = (scrollView.contentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentNever); +} + +- (BOOL)prefersStatusBarHidden +{ + // The CDVStatusBar plugin overrides this in a category extension, and + // should bypass this implementation entirely + return self.statusBar.alpha < 0.0001f; +} + +#pragma mark - View Setup + +- (void)loadSettings +{ + CDVConfigParser *parser = [CDVConfigParser parseConfigFile:self.configFilePath]; + + // Get the plugin dictionary, allowList and settings from the delegate. + _pluginsMap = parser.pluginsDict; + self.startupPluginNames = parser.startupPluginNames; + self.settings = [[CDVSettingsDictionary alloc] initWithDictionary:parser.settings]; + + // And the start page + if(parser.startPage && self.startPage == nil){ + self.startPage = parser.startPage; + } + if (self.startPage == nil) { + self.startPage = @"index.html"; + } + + self.appScheme = [self.settings cordovaSettingForKey:@"Scheme"] ?: @"app"; +} + +/// Retrieves the view from a newwly initialized webViewEngine +/// @param bounds The bounds with which the webViewEngine will be initialized +- (nonnull UIView*)newCordovaViewWithFrame:(CGRect)bounds +{ + NSString* defaultWebViewEngineClassName = [self.settings cordovaSettingForKey:@"CordovaDefaultWebViewEngine"]; + NSString* webViewEngineClassName = [self.settings cordovaSettingForKey:@"CordovaWebViewEngine"]; + + if (!defaultWebViewEngineClassName) { + defaultWebViewEngineClassName = @"CDVWebViewEngine"; + } + if (!webViewEngineClassName) { + webViewEngineClassName = defaultWebViewEngineClassName; + } + + // Determine if a provided custom web view engine is sufficient + id engine; + Class customWebViewEngineClass = NSClassFromString(webViewEngineClassName); + if (customWebViewEngineClass) { + id customWebViewEngine = [self initWebViewEngine:customWebViewEngineClass bounds:bounds]; + BOOL customConformsToProtocol = [customWebViewEngine conformsToProtocol:@protocol(CDVWebViewEngineProtocol)]; + BOOL customCanLoad = [customWebViewEngine canLoadRequest:[NSURLRequest requestWithURL:self.appUrl]]; + if (customConformsToProtocol && customCanLoad) { + engine = customWebViewEngine; + } + } + + // Otherwise use the default web view engine + if (!engine) { + Class defaultWebViewEngineClass = NSClassFromString(defaultWebViewEngineClassName); + id defaultWebViewEngine = [self initWebViewEngine:defaultWebViewEngineClass bounds:bounds]; + NSAssert([defaultWebViewEngine conformsToProtocol:@protocol(CDVWebViewEngineProtocol)], + @"we expected the default web view engine to conform to the CDVWebViewEngineProtocol"); + engine = defaultWebViewEngine; + } + + if ([engine isKindOfClass:[CDVPlugin class]]) { + [self registerPlugin:(CDVPlugin*)engine withClassName:webViewEngineClassName]; + } + + _webViewEngine = engine; + return _webViewEngine.engineWebView; +} + +/// Initialiizes the webViewEngine, with config, if supported and provided +/// @param engineClass A class that must conform to the `CDVWebViewEngineProtocol` +/// @param bounds with which the webview will be initialized +- (id _Nullable) initWebViewEngine:(nonnull Class)engineClass bounds:(CGRect)bounds { + WKWebViewConfiguration *config = [self respondsToSelector:@selector(configuration)] ? [self configuration] : nil; + if (config && [engineClass instancesRespondToSelector:@selector(initWithFrame:configuration:)]) { + return [[engineClass alloc] initWithFrame:bounds configuration:config]; + } else { + return [[engineClass alloc] initWithFrame:bounds]; + } +} + +- (void)createLaunchView +{ + CGRect webViewBounds = self.view.bounds; + webViewBounds.origin = self.view.bounds.origin; + + UIView* view = [[UIView alloc] initWithFrame:webViewBounds]; + view.translatesAutoresizingMaskIntoConstraints = NO; + [view setAlpha:0]; + + NSString* launchStoryboardName = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"UILaunchStoryboardName"]; + if (launchStoryboardName != nil) { + UIStoryboard* storyboard = [UIStoryboard storyboardWithName:launchStoryboardName bundle:[NSBundle mainBundle]]; + UIViewController* vc = [storyboard instantiateInitialViewController]; + [self addChildViewController:vc]; + + UIView* imgView = vc.view; + imgView.translatesAutoresizingMaskIntoConstraints = NO; + [view addSubview:imgView]; + + [NSLayoutConstraint activateConstraints:@[ + [NSLayoutConstraint constraintWithItem:imgView attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:view attribute:NSLayoutAttributeWidth multiplier:1 constant:0], + [NSLayoutConstraint constraintWithItem:imgView attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:view attribute:NSLayoutAttributeHeight multiplier:1 constant:0], + [NSLayoutConstraint constraintWithItem:imgView attribute:NSLayoutAttributeCenterY relatedBy:NSLayoutRelationEqual toItem:view attribute:NSLayoutAttributeCenterY multiplier:1 constant:0], + [NSLayoutConstraint constraintWithItem:imgView attribute:NSLayoutAttributeCenterX relatedBy:NSLayoutRelationEqual toItem:view attribute:NSLayoutAttributeCenterX multiplier:1 constant:0] + ]]; + } + + self.launchView = view; + [self.view addSubview:view]; + + [NSLayoutConstraint activateConstraints:@[ + [NSLayoutConstraint constraintWithItem:view attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:self.view attribute:NSLayoutAttributeWidth multiplier:1 constant:0], + [NSLayoutConstraint constraintWithItem:view attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:self.view attribute:NSLayoutAttributeHeight multiplier:1 constant:0], + [NSLayoutConstraint constraintWithItem:view attribute:NSLayoutAttributeCenterY relatedBy:NSLayoutRelationEqual toItem:self.view attribute:NSLayoutAttributeCenterY multiplier:1 constant:0], + [NSLayoutConstraint constraintWithItem:view attribute:NSLayoutAttributeCenterX relatedBy:NSLayoutRelationEqual toItem:self.view attribute:NSLayoutAttributeCenterX multiplier:1 constant:0] + ]]; +} + +- (void)createGapView +{ + CGRect webViewBounds = self.view.bounds; + webViewBounds.origin = self.view.bounds.origin; + + UIView* view = [self newCordovaViewWithFrame:webViewBounds]; + view.hidden = YES; + view.autoresizingMask = (UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight); + + [self.view addSubview:view]; + [self.view sendSubviewToBack:view]; + + if ([self.webView respondsToSelector:@selector(scrollView)]) { + UIScrollView *scrollView = [self.webView performSelector:@selector(scrollView)]; + scrollView.delegate = self; + } +} + +- (void)createStatusBarView +{ + // If cordova-plugin-statusbar is loaded, we'll let it handle the status + // bar to avoid introducing conflict + if (NSClassFromString(@"CDVStatusBar") != nil) + return; + + self.statusBar = [[UIView alloc] init]; + self.statusBar.translatesAutoresizingMaskIntoConstraints = NO; + + [self.view addSubview:self.statusBar]; + + [self.statusBar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor].active = YES; + [self.statusBar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor].active = YES; + [self.statusBar.topAnchor constraintEqualToAnchor:self.view.topAnchor].active = YES; + [self.statusBar.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor].active = YES; + + self.statusBar.hidden = YES; +} + +- (void)loadStartPage +{ + NSURL *appURL = [self appUrl]; + + if (appURL) { + NSURLRequest *appReq = [NSURLRequest requestWithURL:appURL cachePolicy:NSURLRequestUseProtocolCachePolicy timeoutInterval:20.0]; + [_webViewEngine loadRequest:appReq]; + } else { + NSString *loadErr = [NSString stringWithFormat:@"ERROR: Start Page at '%@/%@' was not found.", self.webContentFolderName, self.startPage]; + NSLog(@"%@", loadErr); + + NSURL *errorUrl = [self errorURL]; + if (errorUrl) { + errorUrl = [NSURL URLWithString:[NSString stringWithFormat:@"?error=%@", [loadErr stringByAddingPercentEncodingWithAllowedCharacters:NSCharacterSet.URLPathAllowedCharacterSet]] relativeToURL:errorUrl]; + NSLog(@"%@", [errorUrl absoluteString]); + [_webViewEngine loadRequest:[NSURLRequest requestWithURL:errorUrl]]; + } else { + NSString *html = [NSString stringWithFormat:@" %@ ", loadErr]; + [_webViewEngine loadHTMLString:html baseURL:nil]; + } + } +} + +#pragma mark CordovaCommands + +- (void)registerPlugin:(CDVPlugin*)plugin withClassName:(NSString*)className +{ + plugin.viewController = self; + plugin.commandDelegate = _commandDelegate; + + @synchronized(_pluginObjects) { + [_pluginObjects setObject:plugin forKey:className]; + } + [plugin pluginInitialize]; +} + +- (void)registerPlugin:(CDVPlugin*)plugin withPluginName:(NSString*)pluginName +{ + plugin.viewController = self; + plugin.commandDelegate = _commandDelegate; + + NSString* className = NSStringFromClass([plugin class]); + + @synchronized(_pluginObjects) { + [_pluginObjects setObject:plugin forKey:className]; + } + [_pluginsMap setValue:className forKey:[pluginName lowercaseString]]; + [plugin pluginInitialize]; +} + +/** + Returns an instance of a CordovaCommand object, based on its name. If one exists already, it is returned. + */ +- (nullable CDVPlugin *)getCommandInstance:(NSString *)pluginName +{ + // first, we try to find the pluginName in the pluginsMap + // (acts as a allowList as well) if it does not exist, we return nil + // NOTE: plugin names are matched as lowercase to avoid problems - however, a + // possible issue is there can be duplicates possible if you had: + // "org.apache.cordova.Foo" and "org.apache.cordova.foo" - only the lower-cased entry will match + NSString* className = [_pluginsMap objectForKey:[pluginName lowercaseString]]; + + if (className == nil) { + return nil; + } + + id obj = nil; + @synchronized(_pluginObjects) { + obj = [_pluginObjects objectForKey:className]; + } + + if (!obj) { + obj = [[NSClassFromString(className) alloc] initWithWebViewEngine:_webViewEngine]; + if (!obj) { + NSString* fullClassName = [NSString stringWithFormat:@"%@.%@", + NSBundle.mainBundle.infoDictionary[@"CFBundleExecutable"], + className]; + obj = [[NSClassFromString(fullClassName)alloc] initWithWebViewEngine:_webViewEngine]; + } + + if (obj != nil) { + [self registerPlugin:obj withClassName:className]; + } else { + NSLog(@"CDVPlugin class %@ (pluginName: %@) does not exist.", className, pluginName); + } + } + return obj; +} + +#pragma mark - + +- (bool)isUrlEmpty:(NSURL *)url +{ + if (!url || (url == (id) [NSNull null])) { + return true; + } + NSString *urlAsString = [url absoluteString]; + return (urlAsString == (id) [NSNull null] || [urlAsString length]==0 || [urlAsString isEqualToString:@"about:blank"]); +} + +- (bool)checkAndReinitViewUrl +{ + NSURL* appURL = [self appUrl]; + if ([self isUrlEmpty: [_webViewEngine URL]] && ![self isUrlEmpty: appURL]) { + [self loadStartPage]; + return true; + } + return false; +} + +#pragma mark - API Methods for Plugins + +- (void)showLaunchScreen:(BOOL)visible +{ + [self showSplashScreen:visible]; +} + +- (void)showSplashScreen:(BOOL)visible +{ + CGFloat fadeSplashScreenDuration = [self.settings cordovaFloatSettingForKey:@"FadeSplashScreenDuration" defaultValue:250.f]; + + // Setting minimum value for fade to 0.25 seconds + fadeSplashScreenDuration = fadeSplashScreenDuration < 250 ? 250 : fadeSplashScreenDuration; + + // AnimateWithDuration takes seconds but cordova documentation specifies milliseconds + CGFloat fadeDuration = fadeSplashScreenDuration/1000; + + [UIView animateWithDuration:fadeDuration animations:^{ + [self.launchView setAlpha:(visible ? 1 : 0)]; + + if (!visible) { + [self.webView becomeFirstResponder]; + } + }]; +} + +- (void)showStatusBar:(BOOL)visible +{ + [self.statusBar setAlpha:(visible ? 1 : 0)]; + [self setNeedsStatusBarAppearanceUpdate]; +} + +- (void)parseSettingsWithParser:(id )delegate +{ + [CDVConfigParser parseConfigFile:self.configFilePath withDelegate:delegate]; +} + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVWebViewProcessPoolFactory.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVWebViewProcessPoolFactory.m new file mode 100644 index 00000000..b4107e64 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/CDVWebViewProcessPoolFactory.m @@ -0,0 +1,54 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import +#import + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" +#pragma clang diagnostic ignored "-Wdeprecated-implementations" + +static CDVWebViewProcessPoolFactory *factory = nil; + +@implementation CDVWebViewProcessPoolFactory + ++ (instancetype)sharedFactory +{ + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + factory = [[CDVWebViewProcessPoolFactory alloc] init]; + }); + + return factory; +} + +- (instancetype)init +{ + if (self = [super init]) { + _sharedPool = [[WKProcessPool alloc] init]; + } + return self; +} + +- (WKProcessPool*) sharedProcessPool { + return _sharedPool; +} +@end + +#pragma clang diagnostic pop diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/NSDictionary+CordovaPreferences.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/NSDictionary+CordovaPreferences.m new file mode 100644 index 00000000..c508e841 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/NSDictionary+CordovaPreferences.m @@ -0,0 +1,95 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +#define __CORDOVA_SILENCE_HEADER_DEPRECATIONS +#import +#undef __CORDOVA_SILENCE_HEADER_DEPRECATIONS + +@implementation NSDictionary (CordovaPreferences) + +- (id)cordovaSettingForKey:(NSString*)key +{ + return [self objectForKey:[key lowercaseString]]; +} + +- (BOOL)cordovaBoolSettingForKey:(NSString*)key defaultValue:(BOOL)defaultValue +{ + BOOL value = defaultValue; + id prefObj = [self cordovaSettingForKey:key]; + + if (prefObj == nil) { + NSLog(@"The preference key \"%@\" is not defined and will default to \"%@\"", + key, + (defaultValue ? @"TRUE" : @"FALSE")); + + return value; + } + + if ([prefObj isKindOfClass:NSString.class]) { + prefObj = [prefObj lowercaseString]; + + if ( + // True Case + [prefObj isEqualToString:@"true"] || + [prefObj isEqualToString:@"1"] || + // False Case + [prefObj isEqualToString:@"false"] || + [prefObj isEqualToString:@"0"] + ) + { + value = [prefObj isEqualToString:@"true"] || [prefObj isEqualToString:@"1"]; + } + } else if ( + [prefObj isKindOfClass:NSNumber.class] && + ( + [prefObj isEqual: @YES] || + [prefObj isEqual: @NO] + ) + ) + { + value = [prefObj isEqual: @YES]; + } + + return value; +} + +- (CGFloat)cordovaFloatSettingForKey:(NSString*)key defaultValue:(CGFloat)defaultValue +{ + CGFloat value = defaultValue; + id prefObj = [self cordovaSettingForKey:key]; + + if (prefObj != nil) { + value = [prefObj floatValue]; + } + + return value; +} + +@end + +@implementation NSMutableDictionary (CordovaPreferences) + +- (void)setCordovaSetting:(id)value forKey:(NSString*)key +{ + [self setObject:value forKey:[key lowercaseString]]; +} + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/NSMutableArray+QueueAdditions.m b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/NSMutableArray+QueueAdditions.m new file mode 100644 index 00000000..65b97a0e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Classes/Public/NSMutableArray+QueueAdditions.m @@ -0,0 +1,58 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +@implementation NSMutableArray (QueueAdditions) + +- (id)cdv_queueHead +{ + if ([self count] == 0) { + return nil; + } + + return [self objectAtIndex:0]; +} + +- (__autoreleasing id)cdv_dequeue +{ + if ([self count] == 0) { + return nil; + } + + id head = [self objectAtIndex:0]; + if (head != nil) { + // [[head retain] autorelease]; ARC - the __autoreleasing on the return value should so the same thing + [self removeObjectAtIndex:0]; + } + + return head; +} + +- (id)cdv_pop +{ + return [self cdv_dequeue]; +} + +- (void)cdv_enqueue:(id)object +{ + [self addObject:object]; +} + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/CordovaLib.docc/CordovaLib.md b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/CordovaLib.docc/CordovaLib.md new file mode 100644 index 00000000..eaf8a564 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/CordovaLib.docc/CordovaLib.md @@ -0,0 +1,83 @@ +# ``Cordova`` + + +Build your app using web technologies + +## Overview + +For more information about Apache Cordova, visit [https://cordova.apache.org](https://cordova.apache.org). + +## Topics + +### Using Cordova in your app + +- ``CDVAppDelegate`` +- ``CDVSceneDelegate`` +- ``CDVViewController`` + +### Cordova plugins + +- ``CDVPlugin`` + +### Plugin communication + +- ``CDVPluginResult`` +- ``CDVCommandStatus`` +- ``CDVInvokedUrlCommand`` + +### Plugin Protocols + +- ``CDVPluginAuthenticationHandler`` +- ``CDVPluginNavigationHandler`` +- ``CDVPluginSchemeHandler`` + +### Web View plugins + +- ``CDVWebViewEngineProtocol`` +- ``CDVWebViewEngineConfigurationDelegate`` + +### Utilities + +- ``CDVSettingsDictionary`` +- ``CDVCommandDelegate`` +- ``CDVCommandQueue`` +- ``CDVConfigParser`` +- ``CDVTimer`` + +### Upgrade Guides +- + +### Deprecated + +- ``IsAtLeastiOSVersion`` +- ``CDVScreenOrientationDelegate`` +- ``CDVWebViewProcessPoolFactory`` +- ``CDVCommandStatus_NO_RESULT`` +- ``CDVCommandStatus_OK`` +- ``CDVCommandStatus_CLASS_NOT_FOUND_EXCEPTION`` +- ``CDVCommandStatus_ILLEGAL_ACCESS_EXCEPTION`` +- ``CDVCommandStatus_INSTANTIATION_EXCEPTION`` +- ``CDVCommandStatus_MALFORMED_URL_EXCEPTION`` +- ``CDVCommandStatus_IO_EXCEPTION`` +- ``CDVCommandStatus_INVALID_ACTION`` +- ``CDVCommandStatus_JSON_EXCEPTION`` +- ``CDVCommandStatus_ERROR`` diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/CordovaLib.docc/upgrading-8.md b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/CordovaLib.docc/upgrading-8.md new file mode 100644 index 00000000..a1752905 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/CordovaLib.docc/upgrading-8.md @@ -0,0 +1,349 @@ +# Upgrading Plugins to Cordova iOS 8.x + + +A guide for plugin authors to understand the API changes in Cordova iOS 8. + +Cordova iOS 8 introduces some significant changes to the exposed API for plugin authors and framework consumers. Many of these changes have been made to better align the framework with modern iOS development practices, such as adopting the `UIScene` APIs and fixing conflicts with SwiftUI projects, as well as work to improve the API experience for apps that consume Cordova as a framework (sometimes called the "platform centric workflow"). In all cases, great care has been taken to try to minimize the risk of breakage to existing 3rd party plugins. + +Many plugins will notice new deprecation warnings when built against Cordova iOS 8, rather than outright breaking changes. This document aims to explain the changes, the rationale behind the changes, and offer sample code for plugin authors to ensure their plugin code is compatible with future versions of Cordova iOS. + +In cases where different behavior is required for different Cordova versions, the ``CORDOVA_VERSION_MIN_REQUIRED`` macro can be used in Objective-C code to test the current Cordova version: + +```objc +#if defined(__CORDOVA_8_0_0) && CORDOVA_VERSION_MIN_REQUIRED >= __CORDOVA_8_0_0 + // Code for Cordova iOS 8 goes here +#else + // Code for older versions goes here +#endif +``` + +## Major Breaking Changes +### Minimum iOS version update +The Cordova iOS framework has increased the minimum supported iOS version from 11.0 to 13.0. + +The minimum supported Xcode version for the Cordova tooling and generated template app is now Xcode 15. + +### Change to the generated app project naming + +The generated app template is now consistently named "App", including "App.xcodeproj", and "App.xcworkspace". The Xcode build target for the app is also named "App". If you are expecting the name of the Xcode project or target to match the name specified in config.xml, it will now fail to find the project. + +Use the cordova-ios nodeJS API to retrieve the Xcode project path and other project details: + +```js +// Old code +const projectRoot = context.opts.projectRoot; +const platformPath = path.join(projectRoot, 'platforms', 'ios'); + +const config = getConfigParser(context, path.join(projectRoot, 'config.xml')); +const projectName = config.name(); + +const pbxprojPath = path.join(platformPath, `${projectName}.xcodeproj`, 'project.pbxproj'); +const xcodeProject = xcode.project(pbxprojPath); +``` + +```js +// New code +const projectRoot = context.opts.projectRoot; +const platformPath = path.join(projectRoot, 'platforms', 'ios'); + +const cordova_ios = require('cordova-ios'); +const iosProject = new cordova_ios('ios', platformPath); + +const xcodeProject = xcode.project(iosProject.locations.pbxproj); +``` + +This updated pattern is backwards compatible with existing versions of Cordova iOS 5.0.0 and newer. + +Moving to a consistent name for the Xcode project and target resolves a number of issues around dynamic file lookups, Xcode projects with multiple targets, and Unicode compatibility with other tools such as CocoaPods that have issues with non-ASCII project names. Beyond fixing those issues, it was never actually safe to use the `name` from config.xml directly, because even in previous versions of Cordova iOS the project name could be normalized to remove Unicode characters. + +Using the cordova-ios JavaScript API ensures that plugins and the Cordova tooling treat projects the same way. The `locations` object contains properties for several useful paths: + +* `root` - The platform root directory +* `www` - The platform's generated web content folder +* `pbxproj` - The Xcode project file (the project.pbxproj file) +* `xcodeProjDir` - The Xcode project path (the .xcodeproj directory) +* `xcodeCordovaProj` - The platform folder containing the Cordova iOS app code + +You can find the app's Info.plist file in a backwards-compatible way by doing something like this: + +```javascript +const projName = path.basename(iosProject.locations.xcodeCordovaProj); +const infoPlistPath = path.join(iosProject.locations.xcodeCordovaProj, `${projName}-Info.plist`); +``` + +### CDVAppDelegate window deprecation + +The generated app template now uses the iOS scene API (using `UISceneDelegate`) to support multiple windows, which means that it's no longer a safe assumption that an app has only a single window. + +The ``CDVAppDelegate/window`` property of ``CDVAppDelegate`` is deprecated as a result. This property will always have a `nil` value. + +In a plugin class, you should retrieve the `UIWindow` for the current view controller: + +```objc +// Old code +CDVAppDelegate *delegate = (CDVAppDelegate *)[[UIApplication sharedApplication] delegate]; +UIWindow *currentWindow = delegate.window; +``` + +```objc +// New code +UIWindow *currentWindow = self.viewController.view.window; +``` + +There may be other cases where things that previously assumed a single window (such as `UIScreen` bounds) require updating to support iOS scenes. + +### CDVAppDelegate viewController deprecation + +The ``CDVAppDelegate/viewController`` property of ``CDVAppDelegate`` is deprecated, and may return `nil` if a `CDVViewController` has not yet been initialized. + +Plugins should prefer accessing the view controller using their ``CDVPlugin/viewController`` property (which is now typed as ``CDVViewController``). + +### UIView scrollView property deprecation + +The `scrollView` property added as a global category extension to `UIView` by Cordova is now deprecated in Objective C code and **removed entirely in Swift code**. This is to prevent conflicts with other Swift classes that extend `UIView` and have their own `scrollView` properties. You can read more about the scrollView property in the Cordova discussion [Cordova iOS 8.x Upgrade Guide: UIView scrollView property deprecation](https://github.com/apache/cordova/discussions/565#discussioncomment-14621123). + +You can still access the `scrollView` property of the web view by dynamically invoking the method: + +```objc +// Old code +UIScrollView *scrollView = self.webView.scrollView; +``` + +```objc +// New code (Objective-C) +if ([self.webView respondsToSelector:@selector(scrollView)]) { + UIScrollView *scrollView = [self.webView performSelector:@selector(scrollView)]; +} +``` + +```swift +// New code (Swift) +var scroller : UIScrollView? +let scrollViewSelector = NSSelectorFromString("scrollView") + +if webView.responds(to: scrollViewSelector) { + scroller = webView.perform(scrollViewSelector)?.takeUnretainedValue() as? UIScrollView +} +``` + +This updated code is compatible with existing versions of Cordova iOS. + +### Precompiled prefix header removal + +Previously, Cordova projects included a precompiled prefix header that automatically imported the `Foundation` and `UIKit` frameworks. This made these frameworks available globally, without requiring explicit imports in each Objective-C file. + +While this may have offered convenience, it also introduced an implicit dependency on the Cordova-managed prefix header and prefix headers have gradually been replaced with module imports in Objective-C and were never supported in Swift. + +To align with Xcode defaults and improve long-term maintainability, the precompiled prefix header has been removed from generated Cordova app projects. While this may be a breaking change for some plugins, developers are now expected to explicitly declare the frameworks their code depends on by adding the appropriate import statements directly in their source files. + +```objc +// New code (Objective-C) +#import +#import +``` + +```swift +// New code (Swift) +import Foundation +import UIKit +``` + +### `CDVPluginResult` Swift optionality + +The `CDVPluginResult` constructors have been annotated as returning a non-null object, which means the constructor in Swift no longer returns an optional value that needs to be unwrapped. However, this means that attempts to unwrap the value will now be errors. + +In most cases, you shouldn't need to worry about the optionality of the result before passing it to `commandDelegate.send` but if you are setting other options then you might need to explicitly store as an optional for backwards compatibility: + +```swift +// Old code (Swift) +let result = CDVPluginResult(status: .ok, messageAs: "some value")! +result.setKeepCallbackAs(true) +self.commandDelegate.send(result, callbackId: callback) +``` + +```swift +// New code (Swift) +let result: CDVPluginResult? = CDVPluginResult(status: .ok, messageAs: "some value") +result?.setKeepCallbackAs(true) +self.commandDelegate.send(result, callbackId: callback) +``` + +## Other Major Changes +### Deprecating AppDelegate category extensions + +Please extend the ``CDVAppDelegate`` base class instead: + +```objc +// Old code +#import "AppDelegate.h" + +@interface AppDelegate (myplugin) + // Added extension methods here +@end +``` + +```objc +// New code +#import + +@interface CDVAppDelegate (myplugin) + // Added extension methods here +@end +``` + +It was never a completely safe assumption that an app using Cordova would include a class named `AppDelegate` that was a subclass of `CDVAppDelegate` due to the ability to embed CordovaLib in an existing iOS app project as a library. If your plugin needs to add behaviour to the app delegate, it should do so to the ``CDVAppDelegate`` base class. + +The updated code is backwards compatible with several existing Cordova iOS versions. + +### Deprecating MainViewController category extensions + +Please extend the ``CDVViewController`` base class instead: + +```objc +// Old code +#import "MainViewController.h" + +@interface MainViewController (myplugin) + // Added extension methods here +@end +``` + +```objc +// New code +#import + +@interface CDVViewController (myplugin) + // Added extension methods here +@end +``` + +It was never a completely safe assumption that an app using Cordova would include a class named `MainViewController` that was a subclass of `CDVViewController` due to the ability to embed CordovaLib in an existing iOS app project as a library. If your plugin needs to add behaviour to the Cordova view controller, it should do so to the ``CDVViewController`` base class. + +This updated code is backwards compatible with several existing Cordova iOS versions. + +### Deprecating CDVCommandStatus constants in Swift + +For plugins written in Swift, the old `CDVCommandStatus_*` constants are deprecated in favour of the enum based aliases: + +```swift +// Old code +self.commandDelegate.send(CDVPluginResult(status: CDVCommandStatus_OK), callbackId: command.callbackId); +``` + +```swift +// New code +self.commandDelegate.send(CDVPluginResult(status: .ok), callbackId: command.callbackId); +``` + +These aliases were introduced in and are backwards compatible with all existing versions since Cordova iOS 5.0.0. See ``CDVCommandStatus`` for a list of the enum values. + +### Deprecating CDVWebViewProcessPoolFactory + +Apple has deprecated the `WKProcessPool` API, saying that it has no effect in iOS 15 and newer. As such, the `CDVWebViewProcessPoolFactory` API is marked as deprecated, but still exists to support iOS 13 and 14. + +The `CDVWebViewProcessPoolFactory` API was also problematic because it exposed WebKit-specific API types to the public API interface of Cordova, potentially causing issues if those APIs need to change in the future. With this deprecation and eventual removal, Cordova is better insulated from upstream WebView changes. + +## Public API Removals +The following classes were previously exposed as part of the Cordova iOS public API, but were only used as internal implementation details. To better establish the public/private API boundary within Cordova iOS, they have been removed from the public API in Cordova iOS 8. + +* `CDVAllowList` +* `CDVURLSchemeHandler` + +The following headers are deprecated due to adding global category extensions to system classes and will be removed in a future version of Cordova iOS: + +* `` + Use the new ``CDVSettingsDictionary`` class, which provides all the same methods. + +* `` + This was only ever intended as an internal implementation detail. + +* `` + Use `` instead. + +## Other Changes +* ``CDVCommandDelegate`` + * The ``CDVCommandDelegate/urlTransformer`` property is deprecated. + This property was never used, and does not need to be a required part of the protocol. + + * The ``CDVCommandDelegate/settings`` property is now typed as ``CDVSettingsDictionary``. + +* ``CDVConfigParser`` + * Added a ``CDVConfigParser/parseConfigFile:`` class method. + + * Added a ``CDVConfigParser/parseConfigFile:withDelegate:`` class method. + +* ``CDVPlugin`` + * The ``CDVPlugin/viewController`` property is now typed as ``CDVViewController``. + Previously this was typed as the more generic `UIViewController`. + + * Plugin classes that intend to override WebKit scheme handling should implement the ``CDVPluginSchemeHandler`` protocol to ensure compliance with the required methods. + + * The ``CDVPluginHandleOpenURLWithAppSourceAndAnnotationNotification`` notification is now deprecated. + The existing ``CDVPluginHandleOpenURLNotification`` notification now includes the source and annotation in its `userInfo` dictionary. + +* ``CDVPluginAuthenticationHandler`` + * Newly added protocol for plugins wishing to handle server authentication requests. + +* ``CDVPluginNavigationHandler`` + * Newly added protocol for plugins wishing to handle navigation request permitting or denying within the webview. + +* ``CDVPluginSchemeHandler`` + * Newly added protocol for plugins wishing to override WebKit scheme handling for web requests. + +* ``CDVScreenOrientationDelegate`` + * This protocol is now deprecated and no longer used. + +* ``CDVSettingsDictionary`` + * Newly added class to provide `NSDictionary`-like access to config.xml preferences, without relying on global category extensions to `NSDictionary`. + +* ``CDVViewController`` + * The ``CDVViewController/settings`` property is now typed as ``CDVSettingsDictionary``. + + * The ``CDVViewController/wwwFolderName`` property is deprecated. + This property has been renamed to ``CDVViewController/webContentFolderName``. + + * The ``CDVViewController/appURLScheme`` property is deprecated. + This property was not used internally by Cordova iOS and should not be used by plugins. + + * The ``CDVViewController/pluginsMap`` and ``CDVViewController/pluginObjects`` properties are deprecated. + These were internal implementation details that should not have been exposed. + + * Added an ``CDVViewController/enumerablePlugins`` property that can safely be iterated to loop over all loaded plugins. + + * The ``CDVViewController/configParser`` property is deprecated due to not being used. + + * The ``CDVViewController/parseSettingsWithParser:`` method is deprecated. + Use the ``CDVConfigParser/parseConfigFile:withDelegate:`` class method on ``CDVConfigParser`` instead. + + * Added a new ``CDVViewController/showInitialSplashScreen`` property. + This property is inspectable in Interface Builder for embedding apps to indicate if the splash screen should be displayed during web content loading. + + * Added a new ``CDVViewController/backgroundColor`` property. + This property is inspectable in Interface Builder for embedding apps to set the view controller background color. + + * Added a new ``CDVViewController/splashBackgroundColor`` property. + This property is inspectable in Interface Builder for embedding apps to set the splash screen background color. + + * The ``CDVViewController/showLaunchScreen:`` method is deprecated. + This method has been renamed to ``CDVViewController/showSplashScreen:``. + + * Added a new ``CDVViewController/loadStartPage`` method to load the initial starting page in the web view, replacing any existing content. diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/CordovaLib.xcodeproj/project.pbxproj b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/CordovaLib.xcodeproj/project.pbxproj new file mode 100644 index 00000000..47c5a5ca --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/CordovaLib.xcodeproj/project.pbxproj @@ -0,0 +1,932 @@ +// !$*UTF8*$! +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 28BFF9141F355A4E00DDF01A /* CDVLogger.h in Headers */ = {isa = PBXBuildFile; fileRef = 28BFF9121F355A4E00DDF01A /* CDVLogger.h */; }; + 28BFF9151F355A4E00DDF01A /* CDVLogger.m in Sources */ = {isa = PBXBuildFile; fileRef = 28BFF9131F355A4E00DDF01A /* CDVLogger.m */; }; + 2FCCEA17247E7366007276A8 /* CDVLaunchScreen.m in Sources */ = {isa = PBXBuildFile; fileRef = 4E714D3423F535B500A321AF /* CDVLaunchScreen.m */; }; + 2FCCEA18247E7366007276A8 /* CDVLaunchScreen.h in Headers */ = {isa = PBXBuildFile; fileRef = 4E714D3223F535B500A321AF /* CDVLaunchScreen.h */; }; + 3093E2231B16D6A3003F381A /* CDVIntentAndNavigationFilter.h in Headers */ = {isa = PBXBuildFile; fileRef = 3093E2211B16D6A3003F381A /* CDVIntentAndNavigationFilter.h */; }; + 3093E2241B16D6A3003F381A /* CDVIntentAndNavigationFilter.m in Sources */ = {isa = PBXBuildFile; fileRef = 3093E2221B16D6A3003F381A /* CDVIntentAndNavigationFilter.m */; }; + 4E23F8FB23E16E96006CD852 /* CDVWebViewProcessPoolFactory.m in Sources */ = {isa = PBXBuildFile; fileRef = 4E23F8F523E16E96006CD852 /* CDVWebViewProcessPoolFactory.m */; }; + 4E23F8FC23E16E96006CD852 /* CDVWebViewUIDelegate.h in Headers */ = {isa = PBXBuildFile; fileRef = 4E23F8F623E16E96006CD852 /* CDVWebViewUIDelegate.h */; }; + 4E23F8FD23E16E96006CD852 /* CDVWebViewUIDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 4E23F8F723E16E96006CD852 /* CDVWebViewUIDelegate.m */; }; + 4E23F8FE23E16E96006CD852 /* CDVWebViewEngine.m in Sources */ = {isa = PBXBuildFile; fileRef = 4E23F8F823E16E96006CD852 /* CDVWebViewEngine.m */; }; + 4E23F8FF23E16E96006CD852 /* CDVWebViewProcessPoolFactory.h in Headers */ = {isa = PBXBuildFile; fileRef = 4E23F8F923E16E96006CD852 /* CDVWebViewProcessPoolFactory.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 4E23F90023E16E96006CD852 /* CDVWebViewEngine.h in Headers */ = {isa = PBXBuildFile; fileRef = 4E23F8FA23E16E96006CD852 /* CDVWebViewEngine.h */; }; + 4E23F90323E17FFA006CD852 /* CDVWebViewUIDelegate.h in Headers */ = {isa = PBXBuildFile; fileRef = 4E23F8F623E16E96006CD852 /* CDVWebViewUIDelegate.h */; }; + 4E714D3623F535B500A321AF /* CDVLaunchScreen.h in Headers */ = {isa = PBXBuildFile; fileRef = 4E714D3223F535B500A321AF /* CDVLaunchScreen.h */; }; + 4E714D3823F535B500A321AF /* CDVLaunchScreen.m in Sources */ = {isa = PBXBuildFile; fileRef = 4E714D3423F535B500A321AF /* CDVLaunchScreen.m */; }; + 4F56D82D254A2EB50063F1D6 /* CDVWebViewEngine.m in Sources */ = {isa = PBXBuildFile; fileRef = 4E23F8F823E16E96006CD852 /* CDVWebViewEngine.m */; }; + 4F56D830254A2ED70063F1D6 /* CDVWebViewUIDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 4E23F8F723E16E96006CD852 /* CDVWebViewUIDelegate.m */; }; + 4F56D833254A2ED90063F1D6 /* CDVWebViewProcessPoolFactory.m in Sources */ = {isa = PBXBuildFile; fileRef = 4E23F8F523E16E96006CD852 /* CDVWebViewProcessPoolFactory.m */; }; + 4F56D836254A2EE10063F1D6 /* CDVWebViewEngine.h in Headers */ = {isa = PBXBuildFile; fileRef = 4E23F8FA23E16E96006CD852 /* CDVWebViewEngine.h */; }; + 4F56D839254A2EE40063F1D6 /* CDVWebViewProcessPoolFactory.h in Headers */ = {isa = PBXBuildFile; fileRef = 4E23F8F923E16E96006CD852 /* CDVWebViewProcessPoolFactory.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7E7F69B91ABA3692007546F4 /* CDVHandleOpenURL.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95CF81AB9028C008C4574 /* CDVHandleOpenURL.h */; }; + 7ED95D021AB9028C008C4574 /* CDVDebug.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95CF21AB9028C008C4574 /* CDVDebug.h */; }; + 7ED95D031AB9028C008C4574 /* CDVJSON_private.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95CF31AB9028C008C4574 /* CDVJSON_private.h */; }; + 7ED95D041AB9028C008C4574 /* CDVJSON_private.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95CF41AB9028C008C4574 /* CDVJSON_private.m */; }; + 7ED95D051AB9028C008C4574 /* CDVPlugin+Private.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95CF51AB9028C008C4574 /* CDVPlugin+Private.h */; }; + 7ED95D071AB9028C008C4574 /* CDVHandleOpenURL.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95CF91AB9028C008C4574 /* CDVHandleOpenURL.m */; }; + 7ED95D351AB9029B008C4574 /* CDV.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D0F1AB9029B008C4574 /* CDV.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7ED95D361AB9029B008C4574 /* CDVAppDelegate.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D101AB9029B008C4574 /* CDVAppDelegate.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7ED95D371AB9029B008C4574 /* CDVAppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D111AB9029B008C4574 /* CDVAppDelegate.m */; }; + 7ED95D381AB9029B008C4574 /* CDVAvailability.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D121AB9029B008C4574 /* CDVAvailability.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7ED95D391AB9029B008C4574 /* CDVAvailabilityDeprecated.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D131AB9029B008C4574 /* CDVAvailabilityDeprecated.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7ED95D3A1AB9029B008C4574 /* CDVCommandDelegate.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D141AB9029B008C4574 /* CDVCommandDelegate.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7ED95D3B1AB9029B008C4574 /* CDVCommandDelegateImpl.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D151AB9029B008C4574 /* CDVCommandDelegateImpl.h */; }; + 7ED95D3C1AB9029B008C4574 /* CDVCommandDelegateImpl.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D161AB9029B008C4574 /* CDVCommandDelegateImpl.m */; }; + 7ED95D3D1AB9029B008C4574 /* CDVCommandQueue.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D171AB9029B008C4574 /* CDVCommandQueue.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7ED95D3E1AB9029B008C4574 /* CDVCommandQueue.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D181AB9029B008C4574 /* CDVCommandQueue.m */; }; + 7ED95D3F1AB9029B008C4574 /* CDVConfigParser.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D191AB9029B008C4574 /* CDVConfigParser.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7ED95D401AB9029B008C4574 /* CDVConfigParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D1A1AB9029B008C4574 /* CDVConfigParser.m */; }; + 7ED95D411AB9029B008C4574 /* CDVInvokedUrlCommand.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D1B1AB9029B008C4574 /* CDVInvokedUrlCommand.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7ED95D421AB9029B008C4574 /* CDVInvokedUrlCommand.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D1C1AB9029B008C4574 /* CDVInvokedUrlCommand.m */; }; + 7ED95D431AB9029B008C4574 /* CDVPlugin+Resources.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D1D1AB9029B008C4574 /* CDVPlugin+Resources.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7ED95D441AB9029B008C4574 /* CDVPlugin+Resources.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D1E1AB9029B008C4574 /* CDVPlugin+Resources.m */; }; + 7ED95D451AB9029B008C4574 /* CDVPlugin.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D1F1AB9029B008C4574 /* CDVPlugin.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7ED95D461AB9029B008C4574 /* CDVPlugin.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D201AB9029B008C4574 /* CDVPlugin.m */; }; + 7ED95D471AB9029B008C4574 /* CDVPluginResult.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D211AB9029B008C4574 /* CDVPluginResult.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7ED95D481AB9029B008C4574 /* CDVPluginResult.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D221AB9029B008C4574 /* CDVPluginResult.m */; }; + 7ED95D491AB9029B008C4574 /* CDVScreenOrientationDelegate.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D231AB9029B008C4574 /* CDVScreenOrientationDelegate.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7ED95D4A1AB9029B008C4574 /* CDVTimer.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D241AB9029B008C4574 /* CDVTimer.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7ED95D4B1AB9029B008C4574 /* CDVTimer.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D251AB9029B008C4574 /* CDVTimer.m */; }; + 7ED95D501AB9029B008C4574 /* CDVViewController.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D2A1AB9029B008C4574 /* CDVViewController.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7ED95D511AB9029B008C4574 /* CDVViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D2B1AB9029B008C4574 /* CDVViewController.m */; }; + 7ED95D521AB9029B008C4574 /* CDVWebViewEngineProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D2C1AB9029B008C4574 /* CDVWebViewEngineProtocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7ED95D571AB9029B008C4574 /* NSDictionary+CordovaPreferences.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D311AB9029B008C4574 /* NSDictionary+CordovaPreferences.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7ED95D581AB9029B008C4574 /* NSDictionary+CordovaPreferences.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D321AB9029B008C4574 /* NSDictionary+CordovaPreferences.m */; }; + 7ED95D591AB9029B008C4574 /* NSMutableArray+QueueAdditions.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D331AB9029B008C4574 /* NSMutableArray+QueueAdditions.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7ED95D5A1AB9029B008C4574 /* NSMutableArray+QueueAdditions.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D341AB9029B008C4574 /* NSMutableArray+QueueAdditions.m */; }; + 90227B4C2D499A1B005DB74E /* CDVViewController+Private.h in Headers */ = {isa = PBXBuildFile; fileRef = 90227B4B2D499A1B005DB74E /* CDVViewController+Private.h */; }; + 90227B4D2D499A1B005DB74E /* CDVViewController+Private.h in Headers */ = {isa = PBXBuildFile; fileRef = 90227B4B2D499A1B005DB74E /* CDVViewController+Private.h */; }; + 90227B512D49A042005DB74E /* CDVStatusBarInternal.h in Headers */ = {isa = PBXBuildFile; fileRef = 90227B4E2D49A042005DB74E /* CDVStatusBarInternal.h */; }; + 90227B522D49A042005DB74E /* CDVStatusBarInternal.m in Sources */ = {isa = PBXBuildFile; fileRef = 90227B4F2D49A042005DB74E /* CDVStatusBarInternal.m */; }; + 90227B532D49A042005DB74E /* CDVStatusBarInternal.h in Headers */ = {isa = PBXBuildFile; fileRef = 90227B4E2D49A042005DB74E /* CDVStatusBarInternal.h */; }; + 90227B542D49A042005DB74E /* CDVStatusBarInternal.m in Sources */ = {isa = PBXBuildFile; fileRef = 90227B4F2D49A042005DB74E /* CDVStatusBarInternal.m */; }; + 902B30742C6C5A7E00C6804C /* CordovaLib.docc in Sources */ = {isa = PBXBuildFile; fileRef = 902B30732C6C5A7E00C6804C /* CordovaLib.docc */; }; + 902B30752C6C5A7E00C6804C /* CordovaLib.docc in Sources */ = {isa = PBXBuildFile; fileRef = 902B30732C6C5A7E00C6804C /* CordovaLib.docc */; }; + 9036843D2C6EB06500A3338C /* CDVAllowList.h in Headers */ = {isa = PBXBuildFile; fileRef = 9036843B2C6EB06500A3338C /* CDVAllowList.h */; }; + 9036843E2C6EB06500A3338C /* CDVAllowList.m in Sources */ = {isa = PBXBuildFile; fileRef = 9036843C2C6EB06500A3338C /* CDVAllowList.m */; }; + 9036843F2C6EB06500A3338C /* CDVAllowList.m in Sources */ = {isa = PBXBuildFile; fileRef = 9036843C2C6EB06500A3338C /* CDVAllowList.m */; }; + 903684402C6EB06500A3338C /* CDVAllowList.h in Headers */ = {isa = PBXBuildFile; fileRef = 9036843B2C6EB06500A3338C /* CDVAllowList.h */; }; + 9044ED4C2E67F80E003B58ED /* CDVSceneDelegate.h in Headers */ = {isa = PBXBuildFile; fileRef = 9044ED4B2E67F80E003B58ED /* CDVSceneDelegate.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 9044ED4D2E67F80E003B58ED /* CDVSceneDelegate.h in Headers */ = {isa = PBXBuildFile; fileRef = 9044ED4B2E67F80E003B58ED /* CDVSceneDelegate.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 9044ED4F2E67F821003B58ED /* CDVSceneDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 9044ED4E2E67F821003B58ED /* CDVSceneDelegate.m */; }; + 9044ED502E67F821003B58ED /* CDVSceneDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 9044ED4E2E67F821003B58ED /* CDVSceneDelegate.m */; }; + 9044ED532E67FEBB003B58ED /* CDVPluginNotifications.h in Headers */ = {isa = PBXBuildFile; fileRef = 9044ED522E67FEBB003B58ED /* CDVPluginNotifications.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 9044ED542E67FEBB003B58ED /* CDVPluginNotifications.h in Headers */ = {isa = PBXBuildFile; fileRef = 9044ED522E67FEBB003B58ED /* CDVPluginNotifications.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 9047732F2C7A57E900373636 /* CDVURLSchemeHandler.h in Headers */ = {isa = PBXBuildFile; fileRef = 9047732D2C7A57E900373636 /* CDVURLSchemeHandler.h */; }; + 904773302C7A57E900373636 /* CDVURLSchemeHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 9047732E2C7A57E900373636 /* CDVURLSchemeHandler.m */; }; + 904773312C7A57E900373636 /* CDVURLSchemeHandler.h in Headers */ = {isa = PBXBuildFile; fileRef = 9047732D2C7A57E900373636 /* CDVURLSchemeHandler.h */; }; + 904773322C7A57E900373636 /* CDVURLSchemeHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 9047732E2C7A57E900373636 /* CDVURLSchemeHandler.m */; }; + 9052DE712150D040008E83D4 /* CDVAppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D111AB9029B008C4574 /* CDVAppDelegate.m */; }; + 9052DE722150D040008E83D4 /* CDVCommandDelegateImpl.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D161AB9029B008C4574 /* CDVCommandDelegateImpl.m */; }; + 9052DE732150D040008E83D4 /* CDVCommandQueue.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D181AB9029B008C4574 /* CDVCommandQueue.m */; }; + 9052DE742150D040008E83D4 /* CDVConfigParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D1A1AB9029B008C4574 /* CDVConfigParser.m */; }; + 9052DE752150D040008E83D4 /* CDVInvokedUrlCommand.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D1C1AB9029B008C4574 /* CDVInvokedUrlCommand.m */; }; + 9052DE762150D040008E83D4 /* CDVPlugin+Resources.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D1E1AB9029B008C4574 /* CDVPlugin+Resources.m */; }; + 9052DE772150D040008E83D4 /* CDVPlugin.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D201AB9029B008C4574 /* CDVPlugin.m */; }; + 9052DE782150D040008E83D4 /* CDVPluginResult.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D221AB9029B008C4574 /* CDVPluginResult.m */; }; + 9052DE792150D040008E83D4 /* CDVTimer.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D251AB9029B008C4574 /* CDVTimer.m */; }; + 9052DE7C2150D040008E83D4 /* CDVViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D2B1AB9029B008C4574 /* CDVViewController.m */; }; + 9052DE7E2150D040008E83D4 /* NSDictionary+CordovaPreferences.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D321AB9029B008C4574 /* NSDictionary+CordovaPreferences.m */; }; + 9052DE7F2150D040008E83D4 /* NSMutableArray+QueueAdditions.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95D341AB9029B008C4574 /* NSMutableArray+QueueAdditions.m */; }; + 9052DE802150D040008E83D4 /* CDVJSON_private.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95CF41AB9028C008C4574 /* CDVJSON_private.m */; }; + 9052DE812150D040008E83D4 /* CDVLogger.m in Sources */ = {isa = PBXBuildFile; fileRef = 28BFF9131F355A4E00DDF01A /* CDVLogger.m */; }; + 9052DE822150D040008E83D4 /* CDVGestureHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = A3B082D31BB15CEA00D8DC35 /* CDVGestureHandler.m */; }; + 9052DE832150D040008E83D4 /* CDVIntentAndNavigationFilter.m in Sources */ = {isa = PBXBuildFile; fileRef = 3093E2221B16D6A3003F381A /* CDVIntentAndNavigationFilter.m */; }; + 9052DE842150D040008E83D4 /* CDVHandleOpenURL.m in Sources */ = {isa = PBXBuildFile; fileRef = 7ED95CF91AB9028C008C4574 /* CDVHandleOpenURL.m */; }; + 9052DE892150D06B008E83D4 /* CDVDebug.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95CF21AB9028C008C4574 /* CDVDebug.h */; }; + 9052DE8A2150D06B008E83D4 /* CDVJSON_private.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95CF31AB9028C008C4574 /* CDVJSON_private.h */; }; + 9052DE8B2150D06B008E83D4 /* CDVPlugin+Private.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95CF51AB9028C008C4574 /* CDVPlugin+Private.h */; }; + 9052DE8C2150D06B008E83D4 /* CDVLogger.h in Headers */ = {isa = PBXBuildFile; fileRef = 28BFF9121F355A4E00DDF01A /* CDVLogger.h */; }; + 9052DE8D2150D06B008E83D4 /* CDVGestureHandler.h in Headers */ = {isa = PBXBuildFile; fileRef = A3B082D21BB15CEA00D8DC35 /* CDVGestureHandler.h */; }; + 9052DE8E2150D06B008E83D4 /* CDVIntentAndNavigationFilter.h in Headers */ = {isa = PBXBuildFile; fileRef = 3093E2211B16D6A3003F381A /* CDVIntentAndNavigationFilter.h */; }; + 9052DE8F2150D06B008E83D4 /* CDVHandleOpenURL.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95CF81AB9028C008C4574 /* CDVHandleOpenURL.h */; }; + 9068B5332C6DFE2000B13532 /* CDVSettingsDictionary.h in Headers */ = {isa = PBXBuildFile; fileRef = 9068B5322C6DFE2000B13532 /* CDVSettingsDictionary.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 9068B5342C6DFE2000B13532 /* CDVSettingsDictionary.h in Headers */ = {isa = PBXBuildFile; fileRef = 9068B5322C6DFE2000B13532 /* CDVSettingsDictionary.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 9068B5362C6E007400B13532 /* CDVSettingsDictionary.m in Sources */ = {isa = PBXBuildFile; fileRef = 9068B5352C6E007400B13532 /* CDVSettingsDictionary.m */; }; + 9068B5372C6E007400B13532 /* CDVSettingsDictionary.m in Sources */ = {isa = PBXBuildFile; fileRef = 9068B5352C6E007400B13532 /* CDVSettingsDictionary.m */; }; + 90B382512AEB72DD00F3F4D7 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 902D0BC12AEB64EB009C68E5 /* PrivacyInfo.xcprivacy */; }; + 90DE61742B8F11D300810C2E /* Cordova.h in Headers */ = {isa = PBXBuildFile; fileRef = C0C01EB41E3911D50056E6CB /* Cordova.h */; settings = {ATTRIBUTES = (Public, ); }; }; + A3B082D41BB15CEA00D8DC35 /* CDVGestureHandler.h in Headers */ = {isa = PBXBuildFile; fileRef = A3B082D21BB15CEA00D8DC35 /* CDVGestureHandler.h */; }; + A3B082D51BB15CEA00D8DC35 /* CDVGestureHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = A3B082D31BB15CEA00D8DC35 /* CDVGestureHandler.m */; }; + C0C01EB61E3911D50056E6CB /* Cordova.h in Headers */ = {isa = PBXBuildFile; fileRef = C0C01EB41E3911D50056E6CB /* Cordova.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C0C01EBB1E39131A0056E6CB /* CDV.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D0F1AB9029B008C4574 /* CDV.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C0C01EBC1E39131A0056E6CB /* CDVAppDelegate.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D101AB9029B008C4574 /* CDVAppDelegate.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C0C01EBD1E39131A0056E6CB /* CDVAvailability.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D121AB9029B008C4574 /* CDVAvailability.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C0C01EBE1E39131A0056E6CB /* CDVAvailabilityDeprecated.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D131AB9029B008C4574 /* CDVAvailabilityDeprecated.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C0C01EBF1E39131A0056E6CB /* CDVCommandDelegate.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D141AB9029B008C4574 /* CDVCommandDelegate.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C0C01EC01E39131A0056E6CB /* CDVCommandDelegateImpl.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D151AB9029B008C4574 /* CDVCommandDelegateImpl.h */; }; + C0C01EC11E39131A0056E6CB /* CDVCommandQueue.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D171AB9029B008C4574 /* CDVCommandQueue.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C0C01EC21E39131A0056E6CB /* CDVConfigParser.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D191AB9029B008C4574 /* CDVConfigParser.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C0C01EC31E39131A0056E6CB /* CDVInvokedUrlCommand.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D1B1AB9029B008C4574 /* CDVInvokedUrlCommand.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C0C01EC41E39131A0056E6CB /* CDVPlugin+Resources.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D1D1AB9029B008C4574 /* CDVPlugin+Resources.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C0C01EC51E39131A0056E6CB /* CDVPlugin.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D1F1AB9029B008C4574 /* CDVPlugin.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C0C01EC61E39131A0056E6CB /* CDVPluginResult.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D211AB9029B008C4574 /* CDVPluginResult.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C0C01EC71E39131A0056E6CB /* CDVScreenOrientationDelegate.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D231AB9029B008C4574 /* CDVScreenOrientationDelegate.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C0C01EC81E39131A0056E6CB /* CDVTimer.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D241AB9029B008C4574 /* CDVTimer.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C0C01ECB1E39131A0056E6CB /* CDVViewController.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D2A1AB9029B008C4574 /* CDVViewController.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C0C01ECC1E39131A0056E6CB /* CDVWebViewEngineProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D2C1AB9029B008C4574 /* CDVWebViewEngineProtocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C0C01ECE1E39131A0056E6CB /* NSDictionary+CordovaPreferences.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D311AB9029B008C4574 /* NSDictionary+CordovaPreferences.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C0C01ECF1E39131A0056E6CB /* NSMutableArray+QueueAdditions.h in Headers */ = {isa = PBXBuildFile; fileRef = 7ED95D331AB9029B008C4574 /* NSMutableArray+QueueAdditions.h */; settings = {ATTRIBUTES = (Public, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 28BFF9121F355A4E00DDF01A /* CDVLogger.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVLogger.h; sourceTree = ""; }; + 28BFF9131F355A4E00DDF01A /* CDVLogger.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CDVLogger.m; sourceTree = ""; }; + 3093E2211B16D6A3003F381A /* CDVIntentAndNavigationFilter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVIntentAndNavigationFilter.h; sourceTree = ""; }; + 3093E2221B16D6A3003F381A /* CDVIntentAndNavigationFilter.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CDVIntentAndNavigationFilter.m; sourceTree = ""; }; + 4E23F8F523E16E96006CD852 /* CDVWebViewProcessPoolFactory.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CDVWebViewProcessPoolFactory.m; sourceTree = ""; }; + 4E23F8F623E16E96006CD852 /* CDVWebViewUIDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVWebViewUIDelegate.h; sourceTree = ""; }; + 4E23F8F723E16E96006CD852 /* CDVWebViewUIDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CDVWebViewUIDelegate.m; sourceTree = ""; }; + 4E23F8F823E16E96006CD852 /* CDVWebViewEngine.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CDVWebViewEngine.m; sourceTree = ""; }; + 4E23F8F923E16E96006CD852 /* CDVWebViewProcessPoolFactory.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVWebViewProcessPoolFactory.h; sourceTree = ""; }; + 4E23F8FA23E16E96006CD852 /* CDVWebViewEngine.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVWebViewEngine.h; sourceTree = ""; }; + 4E714D3223F535B500A321AF /* CDVLaunchScreen.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVLaunchScreen.h; sourceTree = ""; }; + 4E714D3423F535B500A321AF /* CDVLaunchScreen.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CDVLaunchScreen.m; sourceTree = ""; }; + 68A32D7114102E1C006B237C /* libCordova.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libCordova.a; sourceTree = BUILT_PRODUCTS_DIR; }; + 7ED95CF21AB9028C008C4574 /* CDVDebug.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVDebug.h; sourceTree = ""; }; + 7ED95CF31AB9028C008C4574 /* CDVJSON_private.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVJSON_private.h; sourceTree = ""; }; + 7ED95CF41AB9028C008C4574 /* CDVJSON_private.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CDVJSON_private.m; sourceTree = ""; }; + 7ED95CF51AB9028C008C4574 /* CDVPlugin+Private.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "CDVPlugin+Private.h"; sourceTree = ""; }; + 7ED95CF81AB9028C008C4574 /* CDVHandleOpenURL.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVHandleOpenURL.h; sourceTree = ""; }; + 7ED95CF91AB9028C008C4574 /* CDVHandleOpenURL.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CDVHandleOpenURL.m; sourceTree = ""; }; + 7ED95D0F1AB9029B008C4574 /* CDV.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDV.h; sourceTree = ""; }; + 7ED95D101AB9029B008C4574 /* CDVAppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVAppDelegate.h; sourceTree = ""; }; + 7ED95D111AB9029B008C4574 /* CDVAppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CDVAppDelegate.m; sourceTree = ""; }; + 7ED95D121AB9029B008C4574 /* CDVAvailability.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVAvailability.h; sourceTree = ""; }; + 7ED95D131AB9029B008C4574 /* CDVAvailabilityDeprecated.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVAvailabilityDeprecated.h; sourceTree = ""; }; + 7ED95D141AB9029B008C4574 /* CDVCommandDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVCommandDelegate.h; sourceTree = ""; }; + 7ED95D151AB9029B008C4574 /* CDVCommandDelegateImpl.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVCommandDelegateImpl.h; sourceTree = ""; }; + 7ED95D161AB9029B008C4574 /* CDVCommandDelegateImpl.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CDVCommandDelegateImpl.m; sourceTree = ""; }; + 7ED95D171AB9029B008C4574 /* CDVCommandQueue.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVCommandQueue.h; sourceTree = ""; }; + 7ED95D181AB9029B008C4574 /* CDVCommandQueue.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CDVCommandQueue.m; sourceTree = ""; }; + 7ED95D191AB9029B008C4574 /* CDVConfigParser.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVConfigParser.h; sourceTree = ""; }; + 7ED95D1A1AB9029B008C4574 /* CDVConfigParser.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CDVConfigParser.m; sourceTree = ""; }; + 7ED95D1B1AB9029B008C4574 /* CDVInvokedUrlCommand.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVInvokedUrlCommand.h; sourceTree = ""; }; + 7ED95D1C1AB9029B008C4574 /* CDVInvokedUrlCommand.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CDVInvokedUrlCommand.m; sourceTree = ""; }; + 7ED95D1D1AB9029B008C4574 /* CDVPlugin+Resources.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "CDVPlugin+Resources.h"; sourceTree = ""; }; + 7ED95D1E1AB9029B008C4574 /* CDVPlugin+Resources.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "CDVPlugin+Resources.m"; sourceTree = ""; }; + 7ED95D1F1AB9029B008C4574 /* CDVPlugin.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVPlugin.h; sourceTree = ""; }; + 7ED95D201AB9029B008C4574 /* CDVPlugin.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CDVPlugin.m; sourceTree = ""; }; + 7ED95D211AB9029B008C4574 /* CDVPluginResult.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVPluginResult.h; sourceTree = ""; }; + 7ED95D221AB9029B008C4574 /* CDVPluginResult.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CDVPluginResult.m; sourceTree = ""; }; + 7ED95D231AB9029B008C4574 /* CDVScreenOrientationDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVScreenOrientationDelegate.h; sourceTree = ""; }; + 7ED95D241AB9029B008C4574 /* CDVTimer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVTimer.h; sourceTree = ""; }; + 7ED95D251AB9029B008C4574 /* CDVTimer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CDVTimer.m; sourceTree = ""; }; + 7ED95D2A1AB9029B008C4574 /* CDVViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVViewController.h; sourceTree = ""; }; + 7ED95D2B1AB9029B008C4574 /* CDVViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CDVViewController.m; sourceTree = ""; }; + 7ED95D2C1AB9029B008C4574 /* CDVWebViewEngineProtocol.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVWebViewEngineProtocol.h; sourceTree = ""; }; + 7ED95D311AB9029B008C4574 /* NSDictionary+CordovaPreferences.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSDictionary+CordovaPreferences.h"; sourceTree = ""; }; + 7ED95D321AB9029B008C4574 /* NSDictionary+CordovaPreferences.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSDictionary+CordovaPreferences.m"; sourceTree = ""; }; + 7ED95D331AB9029B008C4574 /* NSMutableArray+QueueAdditions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSMutableArray+QueueAdditions.h"; sourceTree = ""; }; + 7ED95D341AB9029B008C4574 /* NSMutableArray+QueueAdditions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSMutableArray+QueueAdditions.m"; sourceTree = ""; }; + 90227B4B2D499A1B005DB74E /* CDVViewController+Private.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "CDVViewController+Private.h"; sourceTree = ""; }; + 90227B4E2D49A042005DB74E /* CDVStatusBarInternal.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CDVStatusBarInternal.h; sourceTree = ""; }; + 90227B4F2D49A042005DB74E /* CDVStatusBarInternal.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CDVStatusBarInternal.m; sourceTree = ""; }; + 902B30732C6C5A7E00C6804C /* CordovaLib.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = CordovaLib.docc; sourceTree = ""; }; + 902D0BC12AEB64EB009C68E5 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 9036843B2C6EB06500A3338C /* CDVAllowList.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CDVAllowList.h; sourceTree = ""; }; + 9036843C2C6EB06500A3338C /* CDVAllowList.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CDVAllowList.m; sourceTree = ""; }; + 9044ED4B2E67F80E003B58ED /* CDVSceneDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CDVSceneDelegate.h; sourceTree = ""; }; + 9044ED4E2E67F821003B58ED /* CDVSceneDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CDVSceneDelegate.m; sourceTree = ""; }; + 9044ED522E67FEBB003B58ED /* CDVPluginNotifications.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CDVPluginNotifications.h; sourceTree = ""; }; + 9047732D2C7A57E900373636 /* CDVURLSchemeHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CDVURLSchemeHandler.h; sourceTree = ""; }; + 9047732E2C7A57E900373636 /* CDVURLSchemeHandler.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CDVURLSchemeHandler.m; sourceTree = ""; }; + 9068B5322C6DFE2000B13532 /* CDVSettingsDictionary.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CDVSettingsDictionary.h; sourceTree = ""; }; + 9068B5352C6E007400B13532 /* CDVSettingsDictionary.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CDVSettingsDictionary.m; sourceTree = ""; }; + A3B082D21BB15CEA00D8DC35 /* CDVGestureHandler.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CDVGestureHandler.h; sourceTree = ""; }; + A3B082D31BB15CEA00D8DC35 /* CDVGestureHandler.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CDVGestureHandler.m; sourceTree = ""; }; + C0C01EB21E3911D50056E6CB /* Cordova.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Cordova.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C0C01EB41E3911D50056E6CB /* Cordova.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Cordova.h; sourceTree = ""; }; + C0C01EB51E3911D50056E6CB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + C0C01EAE1E3911D50056E6CB /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D2AAC07C0554694100DB518D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 034768DFFF38A50411DB9C8B /* Products */ = { + isa = PBXGroup; + children = ( + 68A32D7114102E1C006B237C /* libCordova.a */, + C0C01EB21E3911D50056E6CB /* Cordova.framework */, + ); + name = Products; + sourceTree = ""; + }; + 0867D691FE84028FC02AAC07 = { + isa = PBXGroup; + children = ( + 902B30732C6C5A7E00C6804C /* CordovaLib.docc */, + 9064EF5E26FAB74200C9D65B /* include */, + 7ED95D0E1AB9029B008C4574 /* Public */, + 7ED95CF11AB9028C008C4574 /* Private */, + C0C01EB51E3911D50056E6CB /* Info.plist */, + 902D0BC12AEB64EB009C68E5 /* PrivacyInfo.xcprivacy */, + 034768DFFF38A50411DB9C8B /* Products */, + ); + sourceTree = ""; + }; + 28BFF9111F355A1D00DDF01A /* CDVLogger */ = { + isa = PBXGroup; + children = ( + 28BFF9121F355A4E00DDF01A /* CDVLogger.h */, + 28BFF9131F355A4E00DDF01A /* CDVLogger.m */, + ); + path = CDVLogger; + sourceTree = ""; + }; + 3093E2201B16D6A3003F381A /* CDVIntentAndNavigationFilter */ = { + isa = PBXGroup; + children = ( + 9036843B2C6EB06500A3338C /* CDVAllowList.h */, + 9036843C2C6EB06500A3338C /* CDVAllowList.m */, + 3093E2211B16D6A3003F381A /* CDVIntentAndNavigationFilter.h */, + 3093E2221B16D6A3003F381A /* CDVIntentAndNavigationFilter.m */, + ); + path = CDVIntentAndNavigationFilter; + sourceTree = ""; + }; + 4E23F8F423E16D30006CD852 /* CDVWebViewEngine */ = { + isa = PBXGroup; + children = ( + 9047732D2C7A57E900373636 /* CDVURLSchemeHandler.h */, + 9047732E2C7A57E900373636 /* CDVURLSchemeHandler.m */, + 4E23F8FA23E16E96006CD852 /* CDVWebViewEngine.h */, + 4E23F8F823E16E96006CD852 /* CDVWebViewEngine.m */, + 4E23F8F623E16E96006CD852 /* CDVWebViewUIDelegate.h */, + 4E23F8F723E16E96006CD852 /* CDVWebViewUIDelegate.m */, + ); + path = CDVWebViewEngine; + sourceTree = ""; + }; + 4E714D3123F5356700A321AF /* CDVLaunchScreen */ = { + isa = PBXGroup; + children = ( + 4E714D3223F535B500A321AF /* CDVLaunchScreen.h */, + 4E714D3423F535B500A321AF /* CDVLaunchScreen.m */, + ); + path = CDVLaunchScreen; + sourceTree = ""; + }; + 7ED95CF11AB9028C008C4574 /* Private */ = { + isa = PBXGroup; + children = ( + 7ED95D151AB9029B008C4574 /* CDVCommandDelegateImpl.h */, + 7ED95D161AB9029B008C4574 /* CDVCommandDelegateImpl.m */, + 7ED95CF21AB9028C008C4574 /* CDVDebug.h */, + 7ED95CF31AB9028C008C4574 /* CDVJSON_private.h */, + 7ED95CF41AB9028C008C4574 /* CDVJSON_private.m */, + 7ED95CF51AB9028C008C4574 /* CDVPlugin+Private.h */, + 90227B4B2D499A1B005DB74E /* CDVViewController+Private.h */, + 7ED95CF61AB9028C008C4574 /* Plugins */, + ); + name = Private; + path = Classes/Private; + sourceTree = ""; + }; + 7ED95CF61AB9028C008C4574 /* Plugins */ = { + isa = PBXGroup; + children = ( + 90227B502D49A042005DB74E /* CDVStatusBarInternal */, + 4E714D3123F5356700A321AF /* CDVLaunchScreen */, + 4E23F8F423E16D30006CD852 /* CDVWebViewEngine */, + 28BFF9111F355A1D00DDF01A /* CDVLogger */, + A3B082D11BB15CEA00D8DC35 /* CDVGestureHandler */, + 3093E2201B16D6A3003F381A /* CDVIntentAndNavigationFilter */, + 7ED95CF71AB9028C008C4574 /* CDVHandleOpenURL */, + ); + path = Plugins; + sourceTree = ""; + }; + 7ED95CF71AB9028C008C4574 /* CDVHandleOpenURL */ = { + isa = PBXGroup; + children = ( + 7ED95CF81AB9028C008C4574 /* CDVHandleOpenURL.h */, + 7ED95CF91AB9028C008C4574 /* CDVHandleOpenURL.m */, + ); + path = CDVHandleOpenURL; + sourceTree = ""; + }; + 7ED95D0E1AB9029B008C4574 /* Public */ = { + isa = PBXGroup; + children = ( + 7ED95D111AB9029B008C4574 /* CDVAppDelegate.m */, + 7ED95D181AB9029B008C4574 /* CDVCommandQueue.m */, + 7ED95D1A1AB9029B008C4574 /* CDVConfigParser.m */, + 7ED95D1C1AB9029B008C4574 /* CDVInvokedUrlCommand.m */, + 7ED95D1E1AB9029B008C4574 /* CDVPlugin+Resources.m */, + 7ED95D201AB9029B008C4574 /* CDVPlugin.m */, + 7ED95D221AB9029B008C4574 /* CDVPluginResult.m */, + 9044ED4E2E67F821003B58ED /* CDVSceneDelegate.m */, + 9068B5352C6E007400B13532 /* CDVSettingsDictionary.m */, + 7ED95D251AB9029B008C4574 /* CDVTimer.m */, + 4E23F8F523E16E96006CD852 /* CDVWebViewProcessPoolFactory.m */, + 7ED95D2B1AB9029B008C4574 /* CDVViewController.m */, + 7ED95D321AB9029B008C4574 /* NSDictionary+CordovaPreferences.m */, + 7ED95D341AB9029B008C4574 /* NSMutableArray+QueueAdditions.m */, + ); + name = Public; + path = Classes/Public; + sourceTree = ""; + }; + 90227B502D49A042005DB74E /* CDVStatusBarInternal */ = { + isa = PBXGroup; + children = ( + 90227B4E2D49A042005DB74E /* CDVStatusBarInternal.h */, + 90227B4F2D49A042005DB74E /* CDVStatusBarInternal.m */, + ); + path = CDVStatusBarInternal; + sourceTree = ""; + }; + 9064EF5E26FAB74200C9D65B /* include */ = { + isa = PBXGroup; + children = ( + 9064EF5F26FAB74800C9D65B /* Cordova */, + ); + path = include; + sourceTree = ""; + }; + 9064EF5F26FAB74800C9D65B /* Cordova */ = { + isa = PBXGroup; + children = ( + C0C01EB41E3911D50056E6CB /* Cordova.h */, + 7ED95D0F1AB9029B008C4574 /* CDV.h */, + 7ED95D101AB9029B008C4574 /* CDVAppDelegate.h */, + 7ED95D121AB9029B008C4574 /* CDVAvailability.h */, + 7ED95D131AB9029B008C4574 /* CDVAvailabilityDeprecated.h */, + 7ED95D141AB9029B008C4574 /* CDVCommandDelegate.h */, + 7ED95D171AB9029B008C4574 /* CDVCommandQueue.h */, + 7ED95D191AB9029B008C4574 /* CDVConfigParser.h */, + 7ED95D1B1AB9029B008C4574 /* CDVInvokedUrlCommand.h */, + 7ED95D1D1AB9029B008C4574 /* CDVPlugin+Resources.h */, + 7ED95D1F1AB9029B008C4574 /* CDVPlugin.h */, + 9044ED522E67FEBB003B58ED /* CDVPluginNotifications.h */, + 7ED95D211AB9029B008C4574 /* CDVPluginResult.h */, + 9044ED4B2E67F80E003B58ED /* CDVSceneDelegate.h */, + 7ED95D231AB9029B008C4574 /* CDVScreenOrientationDelegate.h */, + 9068B5322C6DFE2000B13532 /* CDVSettingsDictionary.h */, + 7ED95D241AB9029B008C4574 /* CDVTimer.h */, + 7ED95D2A1AB9029B008C4574 /* CDVViewController.h */, + 4E23F8F923E16E96006CD852 /* CDVWebViewProcessPoolFactory.h */, + 7ED95D2C1AB9029B008C4574 /* CDVWebViewEngineProtocol.h */, + 7ED95D311AB9029B008C4574 /* NSDictionary+CordovaPreferences.h */, + 7ED95D331AB9029B008C4574 /* NSMutableArray+QueueAdditions.h */, + ); + path = Cordova; + sourceTree = ""; + }; + A3B082D11BB15CEA00D8DC35 /* CDVGestureHandler */ = { + isa = PBXGroup; + children = ( + A3B082D21BB15CEA00D8DC35 /* CDVGestureHandler.h */, + A3B082D31BB15CEA00D8DC35 /* CDVGestureHandler.m */, + ); + path = CDVGestureHandler; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + C0C01EAF1E3911D50056E6CB /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + C0C01EB61E3911D50056E6CB /* Cordova.h in Headers */, + 90227B4C2D499A1B005DB74E /* CDVViewController+Private.h in Headers */, + C0C01EBB1E39131A0056E6CB /* CDV.h in Headers */, + C0C01EBC1E39131A0056E6CB /* CDVAppDelegate.h in Headers */, + 9068B5332C6DFE2000B13532 /* CDVSettingsDictionary.h in Headers */, + 9044ED532E67FEBB003B58ED /* CDVPluginNotifications.h in Headers */, + C0C01EBD1E39131A0056E6CB /* CDVAvailability.h in Headers */, + 9044ED4D2E67F80E003B58ED /* CDVSceneDelegate.h in Headers */, + C0C01EBE1E39131A0056E6CB /* CDVAvailabilityDeprecated.h in Headers */, + C0C01EBF1E39131A0056E6CB /* CDVCommandDelegate.h in Headers */, + C0C01EC01E39131A0056E6CB /* CDVCommandDelegateImpl.h in Headers */, + 4F56D839254A2EE40063F1D6 /* CDVWebViewProcessPoolFactory.h in Headers */, + C0C01EC11E39131A0056E6CB /* CDVCommandQueue.h in Headers */, + C0C01EC21E39131A0056E6CB /* CDVConfigParser.h in Headers */, + C0C01EC31E39131A0056E6CB /* CDVInvokedUrlCommand.h in Headers */, + C0C01EC41E39131A0056E6CB /* CDVPlugin+Resources.h in Headers */, + C0C01EC51E39131A0056E6CB /* CDVPlugin.h in Headers */, + C0C01EC61E39131A0056E6CB /* CDVPluginResult.h in Headers */, + C0C01EC71E39131A0056E6CB /* CDVScreenOrientationDelegate.h in Headers */, + 4F56D836254A2EE10063F1D6 /* CDVWebViewEngine.h in Headers */, + C0C01EC81E39131A0056E6CB /* CDVTimer.h in Headers */, + C0C01ECB1E39131A0056E6CB /* CDVViewController.h in Headers */, + C0C01ECC1E39131A0056E6CB /* CDVWebViewEngineProtocol.h in Headers */, + C0C01ECE1E39131A0056E6CB /* NSDictionary+CordovaPreferences.h in Headers */, + 4E23F90323E17FFA006CD852 /* CDVWebViewUIDelegate.h in Headers */, + C0C01ECF1E39131A0056E6CB /* NSMutableArray+QueueAdditions.h in Headers */, + 9052DE892150D06B008E83D4 /* CDVDebug.h in Headers */, + 9052DE8A2150D06B008E83D4 /* CDVJSON_private.h in Headers */, + 9052DE8B2150D06B008E83D4 /* CDVPlugin+Private.h in Headers */, + 9052DE8C2150D06B008E83D4 /* CDVLogger.h in Headers */, + 9052DE8D2150D06B008E83D4 /* CDVGestureHandler.h in Headers */, + 903684402C6EB06500A3338C /* CDVAllowList.h in Headers */, + 9052DE8E2150D06B008E83D4 /* CDVIntentAndNavigationFilter.h in Headers */, + 9052DE8F2150D06B008E83D4 /* CDVHandleOpenURL.h in Headers */, + 9047732F2C7A57E900373636 /* CDVURLSchemeHandler.h in Headers */, + 90227B532D49A042005DB74E /* CDVStatusBarInternal.h in Headers */, + 2FCCEA18247E7366007276A8 /* CDVLaunchScreen.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D2AAC07A0554694100DB518D /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 7ED95D351AB9029B008C4574 /* CDV.h in Headers */, + 90227B4D2D499A1B005DB74E /* CDVViewController+Private.h in Headers */, + 7ED95D361AB9029B008C4574 /* CDVAppDelegate.h in Headers */, + 7ED95D381AB9029B008C4574 /* CDVAvailability.h in Headers */, + 9068B5342C6DFE2000B13532 /* CDVSettingsDictionary.h in Headers */, + 9044ED542E67FEBB003B58ED /* CDVPluginNotifications.h in Headers */, + 7ED95D391AB9029B008C4574 /* CDVAvailabilityDeprecated.h in Headers */, + 9044ED4C2E67F80E003B58ED /* CDVSceneDelegate.h in Headers */, + 7ED95D3A1AB9029B008C4574 /* CDVCommandDelegate.h in Headers */, + 7ED95D3B1AB9029B008C4574 /* CDVCommandDelegateImpl.h in Headers */, + 7ED95D3D1AB9029B008C4574 /* CDVCommandQueue.h in Headers */, + 4E23F8FF23E16E96006CD852 /* CDVWebViewProcessPoolFactory.h in Headers */, + 7ED95D3F1AB9029B008C4574 /* CDVConfigParser.h in Headers */, + 7ED95D411AB9029B008C4574 /* CDVInvokedUrlCommand.h in Headers */, + 7ED95D431AB9029B008C4574 /* CDVPlugin+Resources.h in Headers */, + 7ED95D451AB9029B008C4574 /* CDVPlugin.h in Headers */, + 90DE61742B8F11D300810C2E /* Cordova.h in Headers */, + 7ED95D471AB9029B008C4574 /* CDVPluginResult.h in Headers */, + 7ED95D491AB9029B008C4574 /* CDVScreenOrientationDelegate.h in Headers */, + 4E23F8FC23E16E96006CD852 /* CDVWebViewUIDelegate.h in Headers */, + 7ED95D4A1AB9029B008C4574 /* CDVTimer.h in Headers */, + 7ED95D501AB9029B008C4574 /* CDVViewController.h in Headers */, + 7ED95D521AB9029B008C4574 /* CDVWebViewEngineProtocol.h in Headers */, + 4E23F90023E16E96006CD852 /* CDVWebViewEngine.h in Headers */, + 7ED95D571AB9029B008C4574 /* NSDictionary+CordovaPreferences.h in Headers */, + 7ED95D591AB9029B008C4574 /* NSMutableArray+QueueAdditions.h in Headers */, + 7ED95D021AB9028C008C4574 /* CDVDebug.h in Headers */, + 7ED95D031AB9028C008C4574 /* CDVJSON_private.h in Headers */, + 7ED95D051AB9028C008C4574 /* CDVPlugin+Private.h in Headers */, + 28BFF9141F355A4E00DDF01A /* CDVLogger.h in Headers */, + A3B082D41BB15CEA00D8DC35 /* CDVGestureHandler.h in Headers */, + 9036843D2C6EB06500A3338C /* CDVAllowList.h in Headers */, + 3093E2231B16D6A3003F381A /* CDVIntentAndNavigationFilter.h in Headers */, + 7E7F69B91ABA3692007546F4 /* CDVHandleOpenURL.h in Headers */, + 904773312C7A57E900373636 /* CDVURLSchemeHandler.h in Headers */, + 90227B512D49A042005DB74E /* CDVStatusBarInternal.h in Headers */, + 4E714D3623F535B500A321AF /* CDVLaunchScreen.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + C0C01EB11E3911D50056E6CB /* Cordova */ = { + isa = PBXNativeTarget; + buildConfigurationList = C0C01EB91E3911D50056E6CB /* Build configuration list for PBXNativeTarget "Cordova" */; + buildPhases = ( + C0C01EAF1E3911D50056E6CB /* Headers */, + C0C01EAD1E3911D50056E6CB /* Sources */, + C0C01EAE1E3911D50056E6CB /* Frameworks */, + 90B382502AEB72D300F3F4D7 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Cordova; + productName = Cordova; + productReference = C0C01EB21E3911D50056E6CB /* Cordova.framework */; + productType = "com.apple.product-type.framework"; + }; + D2AAC07D0554694100DB518D /* CordovaLib */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1DEB921E08733DC00010E9CD /* Build configuration list for PBXNativeTarget "CordovaLib" */; + buildPhases = ( + D2AAC07A0554694100DB518D /* Headers */, + D2AAC07B0554694100DB518D /* Sources */, + D2AAC07C0554694100DB518D /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = CordovaLib; + productName = CordovaLib; + productReference = 68A32D7114102E1C006B237C /* libCordova.a */; + productType = "com.apple.product-type.library.static"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 0867D690FE84028FC02AAC07 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1540; + TargetAttributes = { + C0C01EB11E3911D50056E6CB = { + CreatedOnToolsVersion = 10.1; + ProvisioningStyle = Automatic; + }; + D2AAC07D0554694100DB518D = { + CreatedOnToolsVersion = 10.1; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 1DEB922208733DC00010E9CD /* Build configuration list for PBXProject "CordovaLib" */; + compatibilityVersion = "Xcode 11.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 0867D691FE84028FC02AAC07; + productRefGroup = 034768DFFF38A50411DB9C8B /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + D2AAC07D0554694100DB518D /* CordovaLib */, + C0C01EB11E3911D50056E6CB /* Cordova */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 90B382502AEB72D300F3F4D7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 90B382512AEB72DD00F3F4D7 /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + C0C01EAD1E3911D50056E6CB /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 90227B542D49A042005DB74E /* CDVStatusBarInternal.m in Sources */, + 9052DE712150D040008E83D4 /* CDVAppDelegate.m in Sources */, + 9052DE722150D040008E83D4 /* CDVCommandDelegateImpl.m in Sources */, + 9052DE732150D040008E83D4 /* CDVCommandQueue.m in Sources */, + 902B30742C6C5A7E00C6804C /* CordovaLib.docc in Sources */, + 9052DE742150D040008E83D4 /* CDVConfigParser.m in Sources */, + 9052DE752150D040008E83D4 /* CDVInvokedUrlCommand.m in Sources */, + 9044ED502E67F821003B58ED /* CDVSceneDelegate.m in Sources */, + 9052DE762150D040008E83D4 /* CDVPlugin+Resources.m in Sources */, + 9068B5362C6E007400B13532 /* CDVSettingsDictionary.m in Sources */, + 9052DE772150D040008E83D4 /* CDVPlugin.m in Sources */, + 9052DE782150D040008E83D4 /* CDVPluginResult.m in Sources */, + 9036843F2C6EB06500A3338C /* CDVAllowList.m in Sources */, + 4F56D830254A2ED70063F1D6 /* CDVWebViewUIDelegate.m in Sources */, + 9052DE792150D040008E83D4 /* CDVTimer.m in Sources */, + 4F56D833254A2ED90063F1D6 /* CDVWebViewProcessPoolFactory.m in Sources */, + 4F56D82D254A2EB50063F1D6 /* CDVWebViewEngine.m in Sources */, + 9052DE7C2150D040008E83D4 /* CDVViewController.m in Sources */, + 9052DE7E2150D040008E83D4 /* NSDictionary+CordovaPreferences.m in Sources */, + 9052DE7F2150D040008E83D4 /* NSMutableArray+QueueAdditions.m in Sources */, + 9052DE802150D040008E83D4 /* CDVJSON_private.m in Sources */, + 9052DE812150D040008E83D4 /* CDVLogger.m in Sources */, + 904773302C7A57E900373636 /* CDVURLSchemeHandler.m in Sources */, + 9052DE822150D040008E83D4 /* CDVGestureHandler.m in Sources */, + 9052DE832150D040008E83D4 /* CDVIntentAndNavigationFilter.m in Sources */, + 9052DE842150D040008E83D4 /* CDVHandleOpenURL.m in Sources */, + 2FCCEA17247E7366007276A8 /* CDVLaunchScreen.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D2AAC07B0554694100DB518D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 90227B522D49A042005DB74E /* CDVStatusBarInternal.m in Sources */, + 7ED95D371AB9029B008C4574 /* CDVAppDelegate.m in Sources */, + 7ED95D3C1AB9029B008C4574 /* CDVCommandDelegateImpl.m in Sources */, + 7ED95D3E1AB9029B008C4574 /* CDVCommandQueue.m in Sources */, + 902B30752C6C5A7E00C6804C /* CordovaLib.docc in Sources */, + 7ED95D401AB9029B008C4574 /* CDVConfigParser.m in Sources */, + 7ED95D421AB9029B008C4574 /* CDVInvokedUrlCommand.m in Sources */, + 9044ED4F2E67F821003B58ED /* CDVSceneDelegate.m in Sources */, + 7ED95D441AB9029B008C4574 /* CDVPlugin+Resources.m in Sources */, + 9068B5372C6E007400B13532 /* CDVSettingsDictionary.m in Sources */, + 7ED95D461AB9029B008C4574 /* CDVPlugin.m in Sources */, + 9036843E2C6EB06500A3338C /* CDVAllowList.m in Sources */, + 7ED95D481AB9029B008C4574 /* CDVPluginResult.m in Sources */, + 7ED95D4B1AB9029B008C4574 /* CDVTimer.m in Sources */, + 4E23F8FE23E16E96006CD852 /* CDVWebViewEngine.m in Sources */, + 4E23F8FB23E16E96006CD852 /* CDVWebViewProcessPoolFactory.m in Sources */, + 7ED95D511AB9029B008C4574 /* CDVViewController.m in Sources */, + 7ED95D581AB9029B008C4574 /* NSDictionary+CordovaPreferences.m in Sources */, + 7ED95D5A1AB9029B008C4574 /* NSMutableArray+QueueAdditions.m in Sources */, + 7ED95D041AB9028C008C4574 /* CDVJSON_private.m in Sources */, + 4E23F8FD23E16E96006CD852 /* CDVWebViewUIDelegate.m in Sources */, + 28BFF9151F355A4E00DDF01A /* CDVLogger.m in Sources */, + 904773322C7A57E900373636 /* CDVURLSchemeHandler.m in Sources */, + A3B082D51BB15CEA00D8DC35 /* CDVGestureHandler.m in Sources */, + 3093E2241B16D6A3003F381A /* CDVIntentAndNavigationFilter.m in Sources */, + 7ED95D071AB9028C008C4574 /* CDVHandleOpenURL.m in Sources */, + 4E714D3823F535B500A321AF /* CDVLaunchScreen.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 1DEB921F08733DC00010E9CD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ENABLE_MODULE_VERIFIER = YES; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; + PUBLIC_HEADERS_FOLDER_PATH = include/Cordova; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; + SUPPORTS_MACCATALYST = YES; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 1.0; + }; + name = Debug; + }; + 1DEB922008733DC00010E9CD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ENABLE_MODULE_VERIFIER = YES; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; + PUBLIC_HEADERS_FOLDER_PATH = include/Cordova; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; + SUPPORTS_MACCATALYST = YES; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 1.0; + }; + name = Release; + }; + 1DEB922308733DC00010E9CD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + 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++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = ""; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEFINES_MODULE = YES; + DOCC_EXTRACT_SWIFT_INFO_FOR_OBJC_SYMBOLS = YES; + ENABLE_MODULE_VERIFIER = YES; + 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; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PRECOMPILE_PREFIX_HEADER = YES; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MERGEABLE_LIBRARY = YES; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + OTHER_LDFLAGS = "-ObjC"; + PRODUCT_NAME = Cordova; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + }; + name = Debug; + }; + 1DEB922408733DC00010E9CD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + 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++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = ""; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEFINES_MODULE = YES; + DOCC_EXTRACT_SWIFT_INFO_FOR_OBJC_SYMBOLS = YES; + ENABLE_MODULE_VERIFIER = YES; + 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_PRECOMPILE_PREFIX_HEADER = YES; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MERGEABLE_LIBRARY = YES; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + OTHER_LDFLAGS = "-ObjC"; + PRODUCT_NAME = Cordova; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + C0C01EB71E3911D50056E6CB /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + INFOPLIST_FILE = Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; + PRODUCT_BUNDLE_IDENTIFIER = org.apache.cordova.Cordova; + SKIP_INSTALL = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; + SUPPORTS_MACCATALYST = YES; + TARGETED_DEVICE_FAMILY = "1,2,7"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + XROS_DEPLOYMENT_TARGET = 1.0; + }; + name = Debug; + }; + C0C01EB81E3911D50056E6CB /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + INFOPLIST_FILE = Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; + PRODUCT_BUNDLE_IDENTIFIER = org.apache.cordova.Cordova; + SKIP_INSTALL = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; + SUPPORTS_MACCATALYST = YES; + TARGETED_DEVICE_FAMILY = "1,2,7"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + XROS_DEPLOYMENT_TARGET = 1.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 1DEB921E08733DC00010E9CD /* Build configuration list for PBXNativeTarget "CordovaLib" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1DEB921F08733DC00010E9CD /* Debug */, + 1DEB922008733DC00010E9CD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 1DEB922208733DC00010E9CD /* Build configuration list for PBXProject "CordovaLib" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1DEB922308733DC00010E9CD /* Debug */, + 1DEB922408733DC00010E9CD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C0C01EB91E3911D50056E6CB /* Build configuration list for PBXNativeTarget "Cordova" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C0C01EB71E3911D50056E6CB /* Debug */, + C0C01EB81E3911D50056E6CB /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 0867D690FE84028FC02AAC07 /* Project object */; +} diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/CordovaLib.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/CordovaLib.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..38402560 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/CordovaLib.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,25 @@ + + + + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/CordovaLib.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/CordovaLib.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/CordovaLib.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/CordovaLib.xcodeproj/xcshareddata/xcschemes/Cordova.xcscheme b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/CordovaLib.xcodeproj/xcshareddata/xcschemes/Cordova.xcscheme new file mode 100644 index 00000000..0c8724e2 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/CordovaLib.xcodeproj/xcshareddata/xcschemes/Cordova.xcscheme @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Info.plist b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Info.plist new file mode 100644 index 00000000..22d6e2a5 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/Info.plist @@ -0,0 +1,44 @@ + + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + CordovaLib + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 8.0.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSHumanReadableCopyright + Copyright 2012 The Apache Software Foundation + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/PrivacyInfo.xcprivacy b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..4b2e70fd --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/PrivacyInfo.xcprivacy @@ -0,0 +1,32 @@ + + + + + + NSPrivacyTracking + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + NSPrivacyTrackingDomains + + + diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDV.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDV.h new file mode 100644 index 00000000..24ed1e5e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDV.h @@ -0,0 +1,24 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#ifndef __CORDOVA_SILENCE_HEADER_DEPRECATIONS + #warning Import rather than +#endif + +#import diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVAppDelegate.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVAppDelegate.h new file mode 100644 index 00000000..86665c6e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVAppDelegate.h @@ -0,0 +1,72 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import +#import + +@class CDVViewController; + +NS_ASSUME_NONNULL_BEGIN + +/** + App delegate class with some additional Cordova-specific behavior. + + The app delegate object manages your app’s shared behaviors. Your app should + include its own AppDelegate class which is a subclass of `CDVAppDelegate`. + + `CDVAppDelegate` provides an extension point for Cordova plugins to safely add + behavior to the app by building on system events such as URL handling, push + notification registration, and deep linking. + + See `UIApplicationDelegate` for more details about app delegates. + + @Metadata { + @Available(Cordova, introduced: "4.0.0") + } + */ +@interface CDVAppDelegate : UIResponder + +/** + The application window. + + @Metadata { + @Available(iOS, introduced: "2.0", deprecated: "13.0") + @Available(iPadOS, introduced: "2.0", deprecated: "13.0") + @Available(MacCatalyst, introduced: "2.0", deprecated: "13.0") + @Available(Cordova, introduced: "4.0.0", deprecated: "8.0.0") + } + @DeprecationSummary { + Deprecated in Cordova 8 in favour of UIScene protocols. + } + */ +@property (nullable, nonatomic, strong) IBOutlet UIWindow *window API_DEPRECATED_WITH_REPLACEMENT("SceneDelegate:window", ios(2.0, 13.0)); + +// TODO: Remove in Cordova iOS 9 +/** + The ``CDVViewController`` instance. + + @Metadata { + @Available(Cordova, introduced: "4.0.0", deprecated: "8.0.0") + } + */ +@property (nullable, nonatomic, strong) IBOutlet CDVViewController *viewController CDV_DEPRECATED(8.0.0, ""); + +@end + +NS_ASSUME_NONNULL_END diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVAvailability.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVAvailability.h new file mode 100644 index 00000000..8af746e2 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVAvailability.h @@ -0,0 +1,126 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +#define __CORDOVA_IOS__ + +#define __CORDOVA_0_9_6 906 +#define __CORDOVA_1_0_0 10000 +#define __CORDOVA_1_1_0 10100 +#define __CORDOVA_1_2_0 10200 +#define __CORDOVA_1_3_0 10300 +#define __CORDOVA_1_4_0 10400 +#define __CORDOVA_1_4_1 10401 +#define __CORDOVA_1_5_0 10500 +#define __CORDOVA_1_6_0 10600 +#define __CORDOVA_1_6_1 10601 +#define __CORDOVA_1_7_0 10700 +#define __CORDOVA_1_8_0 10800 +#define __CORDOVA_1_8_1 10801 +#define __CORDOVA_1_9_0 10900 +#define __CORDOVA_2_0_0 20000 +#define __CORDOVA_2_1_0 20100 +#define __CORDOVA_2_2_0 20200 +#define __CORDOVA_2_3_0 20300 +#define __CORDOVA_2_4_0 20400 +#define __CORDOVA_2_5_0 20500 +#define __CORDOVA_2_6_0 20600 +#define __CORDOVA_2_7_0 20700 +#define __CORDOVA_2_8_0 20800 +#define __CORDOVA_2_9_0 20900 +#define __CORDOVA_3_0_0 30000 +#define __CORDOVA_3_1_0 30100 +#define __CORDOVA_3_2_0 30200 +#define __CORDOVA_3_3_0 30300 +#define __CORDOVA_3_4_0 30400 +#define __CORDOVA_3_4_1 30401 +#define __CORDOVA_3_5_0 30500 +#define __CORDOVA_3_6_0 30600 +#define __CORDOVA_3_7_0 30700 +#define __CORDOVA_3_8_0 30800 +#define __CORDOVA_3_9_0 30900 +#define __CORDOVA_3_9_1 30901 +#define __CORDOVA_3_9_2 30902 +#define __CORDOVA_4_0_0 40000 +#define __CORDOVA_4_0_1 40001 +#define __CORDOVA_4_1_0 40100 +#define __CORDOVA_4_1_1 40101 +#define __CORDOVA_4_2_0 40200 +#define __CORDOVA_4_2_1 40201 +#define __CORDOVA_4_3_0 40300 +#define __CORDOVA_4_3_1 40301 +#define __CORDOVA_4_4_0 40400 +#define __CORDOVA_4_5_0 40500 +#define __CORDOVA_4_5_1 40501 +#define __CORDOVA_4_5_2 40502 +#define __CORDOVA_4_5_4 40504 +#define __CORDOVA_5_0_0 50000 +#define __CORDOVA_5_0_1 50001 +#define __CORDOVA_5_1_0 50100 +#define __CORDOVA_5_1_1 50101 +#define __CORDOVA_6_0_0 60000 +#define __CORDOVA_6_1_0 60100 +#define __CORDOVA_6_2_0 60200 +#define __CORDOVA_6_3_0 60300 +#define __CORDOVA_7_0_0 70000 +#define __CORDOVA_7_0_1 70001 +#define __CORDOVA_7_1_0 70100 +#define __CORDOVA_7_1_1 70101 +#define __CORDOVA_8_0_0 80000 +/* coho:next-version,insert-before */ +#define __CORDOVA_NA 99999 /* not available */ + +/* + #if CORDOVA_VERSION_MIN_REQUIRED >= __CORDOVA_4_0_0 + // do something when its at least 4.0.0 + #else + // do something else (non 4.0.0) + #endif + */ +#ifndef CORDOVA_VERSION_MIN_REQUIRED + /* coho:next-version-min-required,replace-after */ + #define CORDOVA_VERSION_MIN_REQUIRED __CORDOVA_8_0_0 +#endif + +// TODO: Remove in Cordova iOS 9 +/** + Returns YES if it is at least version specified as NSString(X) + Usage: + if (IsAtLeastiOSVersion(@"5.1")) { + // do something for iOS 5.1 or greater + } + */ +#define IsAtLeastiOSVersion(X) ([[[UIDevice currentDevice] systemVersion] compare:X options:NSNumericSearch] != NSOrderedAscending) +#pragma clang deprecated(IsAtLeastiOSVersion, "Use the built-in #available syntax") + +/** Return the string version of the decimal version */ +#define CDV_VERSION [NSString stringWithFormat:@"%d.%d.%d", \ + (CORDOVA_VERSION_MIN_REQUIRED / 10000), \ + (CORDOVA_VERSION_MIN_REQUIRED % 10000) / 100, \ + (CORDOVA_VERSION_MIN_REQUIRED % 10000) % 100] + +// Enable this to log all exec() calls. +#define CDV_ENABLE_EXEC_LOGGING 0 +#if CDV_ENABLE_EXEC_LOGGING + #define CDV_EXEC_LOG NSLog +#else + #define CDV_EXEC_LOG(...) do { \ +} while (NO) +#endif diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVAvailabilityDeprecated.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVAvailabilityDeprecated.h new file mode 100644 index 00000000..407641b2 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVAvailabilityDeprecated.h @@ -0,0 +1,21 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#define CDV_DEPRECATED(version, msg) __attribute__((deprecated("Deprecated in Cordova " #version ". " msg))) +#define CDV_DEPRECATED_WITH_REPLACEMENT(version, msg, repl) __attribute__((deprecated("Deprecated in Cordova " #version ". " msg, repl))) diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVCommandDelegate.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVCommandDelegate.h new file mode 100644 index 00000000..10cd12d7 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVCommandDelegate.h @@ -0,0 +1,97 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import +#import + +@class CDVPlugin; +@class CDVPluginResult; +@class CDVSettingsDictionary; + +NS_ASSUME_NONNULL_BEGIN + +@protocol CDVCommandDelegate + +@optional + +@property (nonatomic, nullable, copy) NSURL *(^urlTransformer)(NSURL *) CDV_DEPRECATED(8.0.0, ""); + +@required +/** + The Cordova preferences for the web view. + + This is a dictionary populated from the preference key/value pairs in the + Cordova XML configuration file. + */ +@property (nonatomic, readonly) CDVSettingsDictionary* settings; + +- (NSString *)pathForResource:(NSString *)resourcepath; + +/** + Returns the CDVPlugin instance of the given plugin name. + + - Parameters: + - pluginName: The name of the plugin to return. + - Returns: The ``CDVPlugin`` instance, or `nil` if no plugin instance was + found with the given name. + */ +- (nullable CDVPlugin *)getCommandInstance:(NSString *)pluginName; + +/** + Sends a plugin result to the web view. This is thread-safe. + + - Parameters: + - result: The plugin result to send to the web view. + - callbackId: The ID of the JavaScript callback to invoke. + */ +- (void)sendPluginResult:(CDVPluginResult *)result callbackId:(NSString *)callbackId; + +/** + Evaluates the given JavaScript string in the web view. This is thread-safe. + + - Parameters: + - js: The string of JavaScript code to run. + */ +- (void)evalJs:(NSString *)js; + +/** + Evaluates the given JavaScript string right away instead of scheduling it on + the run-loop. + + This is required for dispatching `resign` and `pause` events, but should not + be used without reason. Without the run-loop delay, alerts used in JS callbacks + may result in dead-lock. This method must be called from the UI thread. + + - Parameters: + - js: The string of JavaScript code to run. + - scheduledOnRunLoop: Whether to schedule the code to run on the run-loop. + */ +- (void)evalJs:(NSString *)js scheduledOnRunLoop:(BOOL)scheduledOnRunLoop; + +/** + Runs the given block on a background thread using a shared thread-pool. + + - Parameters: + - block: The block to be run. + */ +- (void)runInBackground:(void (^)(void))block; + +@end + +NS_ASSUME_NONNULL_END diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVCommandQueue.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVCommandQueue.h new file mode 100644 index 00000000..fbf36f6e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVCommandQueue.h @@ -0,0 +1,40 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +@class CDVInvokedUrlCommand; +@class CDVViewController; + +@interface CDVCommandQueue : NSObject + +@property (nonatomic, readonly) BOOL currentlyExecuting; + +- (instancetype)init NS_UNAVAILABLE; +- (instancetype)initWithViewController:(CDVViewController *)viewController; +- (void)dispose; + +- (void)resetRequestId; +- (void)enqueueCommandBatch:(NSString*)batchJSON; + +- (void)fetchCommandsFromJs; +- (void)executePending; +- (BOOL)execute:(CDVInvokedUrlCommand*)command; + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVConfigParser.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVConfigParser.h new file mode 100644 index 00000000..84350c27 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVConfigParser.h @@ -0,0 +1,112 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + An object that handles parsing Cordova XML configuration files. + + ## Overview + The `CDVConfigParser` class provides methods to parse a Cordova XML + configuration file and then access the resulting configuration data. + + Use ``parseConfigFile:`` to load data from a file path. + + Use ``parseConfigFile:withDelegate:`` if you need to intercept the XML parsing + and handle the data yourself with an `NSXMLParserDelegate`. + + @Metadata { + @Available(Cordova, introduced: "2.3.0") + } + */ +@interface CDVConfigParser : NSObject + +/** + A dictionary mapping Cordova plugin name keys to the plugin classes that implement them. + + @Metadata { + @Available(Cordova, introduced: "2.3.0") + } + */ +@property (nonatomic, readonly, strong) NSMutableDictionary *pluginsDict; + +/** + A dictionary of Cordova preference keys and their values. + + This should not be used directly, you should only use it to initialize a + ``CDVSettingsDictionary`` object. + + @Metadata { + @Available(Cordova, introduced: "2.3.0") + } + */ +@property (nonatomic, readonly, strong) NSMutableDictionary *settings; + +/** + An array of plugin names to load immediately when Cordova initializes. + + @Metadata { + @Available(Cordova, introduced: "2.5.0") + } + */ +@property (nonatomic, readonly, strong) NSMutableArray *startupPluginNames; + +/** + The path to the HTML page to display when the web view loads. + + @Metadata { + @Available(Cordova, introduced: "2.4.0") + } + */ +@property (nonatomic, nullable, readonly, strong) NSString *startPage; + +/** + Parses the given path as a Cordova XML configuration file. + + If the file could not be loaded or parsed, the resulting ``CDVConfigParser`` + object will have empty values. + + - Parameters: + - filePath: The file path URL to the configuration file. + - Returns: A ``CDVConfigParser`` with the parsed result. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ ++ (instancetype)parseConfigFile:(NSURL *)filePath; + +/** + Parses the given path as a Cordova XML configuration file using the given delegate. + + - Parameters: + - filePath: The file path URL to the configuration file. + - delegate: The delegate to handle the parsed XML data. + - Returns: Whether the given file was successfully parsed. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ ++ (BOOL)parseConfigFile:(NSURL *)filePath withDelegate:(id )delegate; +@end + +NS_ASSUME_NONNULL_END diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVInvokedUrlCommand.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVInvokedUrlCommand.h new file mode 100644 index 00000000..64637560 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVInvokedUrlCommand.h @@ -0,0 +1,52 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +@interface CDVInvokedUrlCommand : NSObject { + NSString* _callbackId; + NSString* _className; + NSString* _methodName; + NSArray* _arguments; +} + +@property (nonatomic, readonly) NSArray* arguments; +@property (nonatomic, readonly) NSString* callbackId; +@property (nonatomic, readonly) NSString* className; +@property (nonatomic, readonly) NSString* methodName; + ++ (instancetype)commandFromJson:(NSArray *)jsonEntry; + +- (instancetype)initWithArguments:(NSArray *)arguments + callbackId:(NSString *)callbackId + className:(NSString *)className + methodName:(NSString *)methodName NS_DESIGNATED_INITIALIZER; + +- (instancetype)initFromJson:(NSArray *)jsonEntry; + +// Returns the argument at the given index. +// If index >= the number of arguments, returns nil. +// If the argument at the given index is NSNull, returns nil. +- (id)argumentAtIndex:(NSUInteger)index; +// Same as above, but returns defaultValue instead of nil. +- (id)argumentAtIndex:(NSUInteger)index withDefault:(id)defaultValue; +// Same as above, but returns defaultValue instead of nil, and if the argument is not of the expected class, returns defaultValue +- (id)argumentAtIndex:(NSUInteger)index withDefault:(id)defaultValue andClass:(Class)aClass; + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVPlugin+Resources.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVPlugin+Resources.h new file mode 100644 index 00000000..16019adf --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVPlugin+Resources.h @@ -0,0 +1,67 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import +#import + +@interface CDVPlugin (CDVPluginResources) + +/** + Returns the localized string with the given key from a plugin bundle. + + The plugin bundle must be named the same as the plugin class. + + For example, if your plugin class was called `Foo`, and you have a Spanish + localized strings file, this method will try to load the desired key from + `Foo.bundle/es.lproj/Localizable.strings`. + + - Parameters: + - key: The key of the localized string to retrieve. + + - Returns: The localized string, or an empty string if the key could not be + found. + + @Metadata { + @Available(Cordova, introduced: "4.0.0") + } + */ +- (NSString *)pluginLocalizedString:(NSString *)key; + +/** + Returns the image with the given name from a plugin bundle. + + The plugin bundle must be named the same as the plugin class. + + For example, if your plugin class was called `Foo`, and you have an image + called `"bar"`, this method will try to load the image from + `Foo.bundle/bar.png` (and appropriately named retina versions). + + - Parameters: + - name: The file name of the image resource to retrieve. + + - Returns: The image, or `nil` if an image with the provided name could not be + found. + + @Metadata { + @Available(Cordova, introduced: "4.0.0") + } + */ +- (UIImage *)pluginImageResource:(NSString *)name; + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVPlugin.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVPlugin.h new file mode 100644 index 00000000..1e5f10bb --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVPlugin.h @@ -0,0 +1,352 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import + +// Forward declaration to avoid bringing WebKit API into public headers +@protocol WKURLSchemeTask; + +typedef int CDVWebViewNavigationType; + +NS_ASSUME_NONNULL_BEGIN + +#ifndef __swift__ +// This global extension to the UIView class causes issues for Swift subclasses +// of UIView with their own scrollView properties, so we're removing it from +// the exposed Swift API and marking it as deprecated +// TODO: Remove in Cordova iOS 9 +@interface UIView (org_apache_cordova_UIView_Extension) +@property (nonatomic, weak, nullable) UIScrollView *scrollView CDV_DEPRECATED(8.0.0, "Check for a scrollView property on the view object at runtime and invoke it dynamically."); +@end +#endif + +NS_ASSUME_NONNULL_END + +/** + The base class for all Cordova plugins. + + @Metadata { + @Available("Cordova", introduced: "1.0.0") + } + */ +@interface CDVPlugin : NSObject {} + +/** + The web view engine that manages the web view displaying the Cordova + application's content. + + By default, this will be a `CDVWebViewEngine` instance, but could also be a + custom implementation as well. + + @Metadata { + @Available("Cordova", introduced: "4.0.0") + } + */ +@property (nonatomic, readonly, weak) id webViewEngine; + +/** + The web view that displays the Cordova application's content. + + This is a shortcode for the `webView` property of the plugin's + ``webViewEngine``. If the `webViewEngine` is a `CDVWebViewEngine`, this can + safely be cast to a `WKWebView`. + + @Metadata { + @Available("Cordova", introduced: "1.0.0") + } + */ +@property (nonatomic, readonly, weak) UIView *webView; + +/** + The application view controller the owns the Cordova web view to which this + plugin belongs. + + @Metadata { + @Available("Cordova", introduced: "1.4.0") + } +*/ +@property (nonatomic, weak) CDVViewController *viewController; + +/** + The application's Cordova command delegate instance. + + @Metadata { + @Available("Cordova", introduced: "1.4.0") + } +*/ +@property (nonatomic, weak) id commandDelegate; + +/** + Flag for pending plugin work that requires keeping the web view alive. + + This flag is used during low memory situations to check if any plugins have + active work ongoing that prevents jettisoning the web view and associated view + controller from memory. + + @Metadata { + @Available("Cordova", introduced: "1.7.0") + } + */ +@property (readonly, assign) BOOL hasPendingOperation; + +/** + Plugin initializer method. Override this method with your plugin initialization + logic. + + ``CDVPlugin`` objects are constructed by the plugin manager, so it's not safe + to override the standard `init` method. Instead, use the ``pluginInitialize`` + method to do any initialization logic needed by your plugin. + + @Metadata { + @Available("Cordova", introduced: "2.5.0") + } + */ +- (void)pluginInitialize; + +/** + @Metadata { + @Available("Cordova", introduced: "1.0.0") + } + */ +- (void)handleOpenURL:(nonnull NSNotification *)notification; + +/** + @Metadata { + @Available("Cordova", introduced: "4.5.0", deprecated: "8.0.0") + } + @DeprecationSummary { + Use ``handleOpenURL:`` instead with the notification `userData`. + } + */ +- (void)handleOpenURLWithApplicationSourceAndAnnotation:(nonnull NSNotification *)notification CDV_DEPRECATED(8.0.0, "Use the handleOpenUrl method and the notification userInfo data."); + +/** + @Metadata { + @Available("Cordova", introduced: "1.0.0") + } + */ +- (void)onAppTerminate; + +/** + @Metadata { + @Available("Cordova", introduced: "1.0.0") + } + */ +- (void)onMemoryWarning; + +/** + @Metadata { + @Available("Cordova", introduced: "2.2.0") + } + */ +- (void)onReset; + +/** + @Metadata { + @Available("Cordova", introduced: "2.2.0") + } + */ +- (void)dispose; + +/* + // see initWithWebView implementation + - (void) onPause {} + - (void) onResume {} + - (void) onOrientationWillChange {} + - (void) onOrientationDidChange {} + */ + +/** + The application delegate. + + @Metadata { + @Available("Cordova", introduced: "1.0.0") + } + */ +- (nonnull id)appDelegate; + +@end + +#pragma mark - Plugin protocols + +NS_ASSUME_NONNULL_BEGIN + +/** + A protocol for Cordova plugins to intercept and respond to server + authentication challenges through WebKit. + + Your plugin should implement this protocol and the + ``willHandleAuthenticationChallenge:completionHandler:`` method to return + `YES` if it wants to support responses to server-side authentication + challenges, otherwise the default NSURLSession handling for authentication + challenges will be used. + + @Metadata { + @Available("Cordova", introduced: "8.0.0") + } + */ +@protocol CDVPluginAuthenticationHandler + +/** + Asks your plugin to respond to an authentication challenge. + + Return `YES` if the plugin is handling the challenge, and `NO` to fallback to + the default handling. + + - Parameters: + - challenge: The authentication challenge. + - completionHandler: A completion handler block to execute with the response. + This handler has no return value and takes the following parameters: + - disposition: The option to use to handle the challenge. For a list of + options, see `NSURLSessionAuthChallengeDisposition`. + - credential: The credential to use for authentication when the + `disposition` parameter contains the value + `NSURLSessionAuthChallengeUseCredential`. Specify `nil` to continue + without a credential. + - Returns: A Boolean value indicating if the plugin is handling the request. + + @Metadata { + @Available("Cordova", introduced: "8.0.0") + } + */ +- (BOOL)willHandleAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential * _Nullable))completionHandler; + +@end + + +/** + A protocol for Cordova plugins to manage permitting and denying of webview + navigations. + + You plugin should implement this protocol if it wants to control whether the + webview is allowed to navigate to a requested URL. + + @Metadata { + @Available("Cordova", introduced: "8.0.0") + } + */ +@protocol CDVPluginNavigationHandler + +/** + Asks your plugin to decide whether a navigation request should be permitted or + denied. + + - Parameters: + - request: The navigation request. + - navigationType: The type of action triggering the navigation. + - navInfo: Descriptive information about the action triggering the navigation. + + - Returns: A Boolean representing whether the navigation should be allowed or not. + + @Metadata { + @Available("Cordova", introduced: "8.0.0") + } + */ +- (BOOL)shouldOverrideLoadWithRequest:(NSURLRequest *)request navigationType:(CDVWebViewNavigationType)navigationType info:(NSDictionary *)navInfo; + +@optional +/** + Asks your plugin to decide whether a navigation request should be permitted or + denied. + + - Parameters: + - request: The navigation request. + - navigationType: The type of action triggering the navigation. + - navInfo: Descriptive information about the action triggering the navigation. + + - Returns: A Boolean representing whether the navigation should be allowed or not. + + @Metadata { + @Available("Cordova", introduced: "4.0.0", deprecated: "8.0.0") + } + @DeprecationSummary { + Use ``shouldOverrideLoadWithRequest:navigationType:info:`` instead. + } + */ +- (BOOL)shouldOverrideLoadWithRequest:(NSURLRequest *)request navigationType:(CDVWebViewNavigationType)navigationType CDV_DEPRECATED_WITH_REPLACEMENT(8.0.0, "Use shouldOverrideLoadWithRequest:navigationType:info: instead", "shouldOverrideLoadWithRequest:navigationType:info:"); + +@end + + +/** + A protocol for Cordova plugins to intercept handling of WebKit resource + loading for a custom URL scheme. + + Your plugin should implement this protocol if it wants to intercept requests + to a custom URL scheme and provide its own resource loading. Otherwise, + Cordova will use its default resource loading behavior from the app bundle. + + When a WebKit-based web view encounters a resource that uses a custom scheme, + it creates a WKURLSchemeTask object and Cordova passes it to the methods of + your scheme handler plugin for processing. Use the ``overrideSchemeTask:`` + method to indicate that your plugin will handle the request and to begin + loading the resource. While your handler loads the object, Cordova may call + your plugin’s ``stopSchemeTask:`` method to notify you that the resource is no + longer needed. + + @Metadata { + @Available("Cordova", introduced: "8.0.0") + } + */ +@protocol CDVPluginSchemeHandler + +/** + Asks your plugin to handle the specified request and begin loading data. + + If your plugin intends to handle the request and return data, this method + should return `YES` as soon as possible to prevent the default request + handling. If this method returns `NO`, Cordova will handle the resource + loading using its default behavior. + + - Parameters: + - task: The task object that identifies the resource to load. You also use + this object to report the progress of the load operation back to the web + view. + - Returns: A Boolean value indicating if the plugin is handling the request. + + @Metadata { + @Available("Cordova", introduced: "6.2.0") + } + */ +- (BOOL)overrideSchemeTask:(id )task; + +/** + Asks your plugin to stop loading the data for the specified resource. + + - Parameters: + - task: The task object that identifies the resource the web view no + longer needs. + + @Metadata { + @Available("Cordova", introduced: "6.2.0") + } + */ +- (void)stopSchemeTask:(id )task; +@end + +NS_ASSUME_NONNULL_END diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVPluginNotifications.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVPluginNotifications.h new file mode 100644 index 00000000..bcdf0979 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVPluginNotifications.h @@ -0,0 +1,34 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import +#import + +extern const NSNotificationName CDVPageDidLoadNotification; +extern const NSNotificationName CDVPluginHandleOpenURLNotification; +extern const NSNotificationName CDVPluginResetNotification; +extern const NSNotificationName CDVViewWillAppearNotification; +extern const NSNotificationName CDVViewDidAppearNotification; +extern const NSNotificationName CDVViewWillDisappearNotification; +extern const NSNotificationName CDVViewDidDisappearNotification; +extern const NSNotificationName CDVViewWillLayoutSubviewsNotification; +extern const NSNotificationName CDVViewDidLayoutSubviewsNotification; +extern const NSNotificationName CDVViewWillTransitionToSizeNotification; + +extern const NSNotificationName CDVPluginHandleOpenURLWithAppSourceAndAnnotationNotification CDV_DEPRECATED(8.0.0, "Find sourceApplication and annotations in the userInfo of the CDVPluginHandleOpenURLNotification notification."); diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVPluginResult.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVPluginResult.h new file mode 100644 index 00000000..81a5039e --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVPluginResult.h @@ -0,0 +1,119 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import +#import + +/** + An enumeration that describes the result of handling a plugin command. + + ## See Also + + - ``CDVPluginResult`` + + @Metadata { + @Available(Cordova, introduced: "5.0.0") + } + */ +typedef NS_ENUM(NSUInteger, CDVCommandStatus) { + /** Status code indicating no command result. */ + CDVCommandStatus_NO_RESULT NS_SWIFT_NAME(noResult) = 0, + + /** Status code indicating successful handling of the command. */ + CDVCommandStatus_OK NS_SWIFT_NAME(ok), + + /** Status code indicating the command's plugin class could not be found. */ + CDVCommandStatus_CLASS_NOT_FOUND_EXCEPTION NS_SWIFT_NAME(classNotFoundException), + + /** Status code indicating there was an illegal access exception while handling the command. */ + CDVCommandStatus_ILLEGAL_ACCESS_EXCEPTION NS_SWIFT_NAME(illegalAccessException), + + /** Status code indicating the command's plugin class could not be instantiated. */ + CDVCommandStatus_INSTANTIATION_EXCEPTION NS_SWIFT_NAME(instantiationException), + + /** Status code indicating the command included a malformed URL. */ + CDVCommandStatus_MALFORMED_URL_EXCEPTION NS_SWIFT_NAME(malformedUrlException), + + /** Status code indicating there was an I/O exception while handling the command. */ + CDVCommandStatus_IO_EXCEPTION NS_SWIFT_NAME(ioException), + + /** Status code indicating the command's action was not valid. */ + CDVCommandStatus_INVALID_ACTION NS_SWIFT_NAME(invalidAction), + + /** Status code indicating the command's JSON data was invalid. */ + CDVCommandStatus_JSON_EXCEPTION NS_SWIFT_NAME(jsonException), + + /** Status code indicating there was an error handling the command. */ + CDVCommandStatus_ERROR NS_SWIFT_NAME(error) +}; + +#ifdef __swift__ +// This exists to preserve compatibility with early Swift plugins, who are +// using CDVCommandStatus as ObjC-style constants rather than as Swift enum +// values. +// This declares extern'ed constants (implemented in CDVPluginResult.m) +// TODO: Remove this in Cordova iOS 9 +#define SWIFT_ENUM_COMPAT_HACK(enumVal, replacement) extern const CDVCommandStatus SWIFT_##enumVal NS_SWIFT_NAME(enumVal) CDV_DEPRECATED_WITH_REPLACEMENT(8.0.0, "Use the CDVCommandStatus." #replacement " enum value instead", "CDVCommandStatus." #replacement) +SWIFT_ENUM_COMPAT_HACK(CDVCommandStatus_NO_RESULT, noResult); +SWIFT_ENUM_COMPAT_HACK(CDVCommandStatus_OK, ok); +SWIFT_ENUM_COMPAT_HACK(CDVCommandStatus_CLASS_NOT_FOUND_EXCEPTION, classNotFoundException); +SWIFT_ENUM_COMPAT_HACK(CDVCommandStatus_ILLEGAL_ACCESS_EXCEPTION, illegalAccessException); +SWIFT_ENUM_COMPAT_HACK(CDVCommandStatus_INSTANTIATION_EXCEPTION, instantiationException); +SWIFT_ENUM_COMPAT_HACK(CDVCommandStatus_MALFORMED_URL_EXCEPTION, malformedUrlException); +SWIFT_ENUM_COMPAT_HACK(CDVCommandStatus_IO_EXCEPTION, ioException); +SWIFT_ENUM_COMPAT_HACK(CDVCommandStatus_INVALID_ACTION, invalidAction); +SWIFT_ENUM_COMPAT_HACK(CDVCommandStatus_JSON_EXCEPTION, jsonException); +SWIFT_ENUM_COMPAT_HACK(CDVCommandStatus_ERROR, error); +#undef SWIFT_ENUM_COMPAT_HACK +#endif + + +NS_ASSUME_NONNULL_BEGIN + +@interface CDVPluginResult : NSObject {} + +@property (nonatomic, strong, readonly) NSNumber *status; +@property (nonatomic, nullable, strong, readonly) id message; +@property (nonatomic, strong) NSNumber *keepCallback; +@property (nonatomic, strong) id associatedObject CDV_DEPRECATED(8.0.0, ""); + +- (instancetype)init; ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal; ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageAsString:(NSString *)theMessage; ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageAsArray:(NSArray *)theMessage; ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageAsInt:(int)theMessage; ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageAsNSInteger:(NSInteger)theMessage; ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageAsNSUInteger:(NSUInteger)theMessage; ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageAsDouble:(double)theMessage; ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageAsBool:(BOOL)theMessage; ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageAsDictionary:(NSDictionary *)theMessage; ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageAsArrayBuffer:(NSData *)theMessage; ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageAsMultipart:(NSArray *)theMessages; ++ (instancetype)resultWithStatus:(CDVCommandStatus)statusOrdinal messageToErrorObject:(int)errorCode; + ++ (void)setVerbose:(BOOL)verbose CDV_DEPRECATED(8.0.0, ""); ++ (BOOL)isVerbose CDV_DEPRECATED(8.0.0, ""); + +- (void)setKeepCallbackAsBool:(BOOL)bKeepCallback; + +- (NSString*)argumentsAsJSON; + +@end + +NS_ASSUME_NONNULL_END diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVSceneDelegate.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVSceneDelegate.h new file mode 100644 index 00000000..565e6ad9 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVSceneDelegate.h @@ -0,0 +1,53 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + UI scene delegate class with some additional Cordova-specific behavior. + + The scene delegate object manages your app’s window behaviors. Your app should + include its own SceneDelegate class which is a subclass of `CDVSceneDelegate`. + + `CDVSceneDelegate` provides an extension point for Cordova plugins to safely + add behavior to the app by building on system events such as URL handling and + scene/window management. + + See `UIWindowSceneDelegate` for more details about app delegates. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } +*/ +@interface CDVSceneDelegate : UIResponder + +/** + The application window for the current UI scene. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } +*/ +@property (strong, nonatomic) UIWindow *window; + +@end + +NS_ASSUME_NONNULL_END diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVScreenOrientationDelegate.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVScreenOrientationDelegate.h new file mode 100644 index 00000000..b55a41bd --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVScreenOrientationDelegate.h @@ -0,0 +1,35 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import +#import + +/** + @Metadata { + @Available(Cordova, introduced: "2.3.0", deprecated: "8.0.0") + } + */ +CDV_DEPRECATED(8.0.0, "") +@protocol CDVScreenOrientationDelegate + +- (UIInterfaceOrientationMask)supportedInterfaceOrientations; + +- (BOOL)shouldAutorotate; + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVSettingsDictionary.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVSettingsDictionary.h new file mode 100644 index 00000000..66cafe26 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVSettingsDictionary.h @@ -0,0 +1,180 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + A dictionary-like interface providing access to the preference settings for a Cordova web view. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ +@interface CDVSettingsDictionary : NSDictionary + +/** + The number of entries in the dictionary. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ +@property(readonly) NSUInteger count; + +/** + Initializes a newly allocated dictionary by placing in it the keys and values + contained in another given dictionary. + + - Parameters: + - dict: A dictionary containing the keys and values with which to initialize + the new dictionary. + - Returns: An initialized dictionary containing the keys and values found in `dict`. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ +- (instancetype)initWithDictionary:(NSDictionary *)dict NS_DESIGNATED_INITIALIZER; + +/** + Returns the value associated with a given key. + + - Parameters: + - key: The key for which to return the corresponding value. + - Returns: The value associated with `key`, or `nil` if no value is associated with `key`. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ +- (id)objectForKey:(NSString *)key; + +/** + Provides an enumerator to access the keys in the dictionary. + + - Returns: An enumerator object that lets you access each key in the dictionary. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ +- (NSEnumerator *)keyEnumerator; + +/** + Returns the value associated with a given key. + + - Parameters: + - key: The key for which to return the corresponding value. + - Returns: The value associated with `key`, or `nil` if no value is associated with `key`. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ +- (id)cordovaSettingForKey:(NSString *)key; + +/** + Returns the boolean value associated with a given key, or the given default + value if the key is not found. + + - Parameters: + - key: The key for which to return the corresponding value. + - defaultValue: The default value to return if the key is missing. + - Returns: The value associated with `key`, or the provided default value. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ +- (BOOL)cordovaBoolSettingForKey:(NSString *)key defaultValue:(BOOL)defaultValue; + +/** + Returns the floating-point numeric value associated with a given key, or the + given default value if the key is not found. + + - Parameters: + - key: The key for which to return the corresponding value. + - defaultValue: The default value to return if the key is missing. + - Returns: The value associated with `key`, or the provided default value. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ +- (CGFloat)cordovaFloatSettingForKey:(NSString *)key defaultValue:(CGFloat)defaultValue; + +/** + Adds a preference with the given name and value to the dictionary. + + > Warning: Use of this method is highly discouraged. Preferences should be set + > and customized by app authors in the Cordova XML configuration file, not + > changed at runtime by plugins. + + - Parameters: + - value: The value to be stored with the given key. + - key: The preference name. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ +- (void)setObject:(id)value forKey:(NSString *)key; + +/** + Adds a preference with the given name and value to the dictionary. + + You shouldn’t need to call this method directly. Instead, this method is called + when setting an object for a key using subscripting. + + > Warning: Use of this method is highly discouraged. Preferences should be set + > and customized by app authors in the Cordova XML configuration file, not + > changed at runtime by plugins. + + - Parameters: + - value: The value to be stored with the given key. + - key: The preference name. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ +- (void)setObject:(id)value forKeyedSubscript:(NSString *)key; + +/** + Adds a preference with the given name and value to the dictionary. + + > Warning: Use of this method is highly discouraged. Preferences should be set + > and customized by app authors in the Cordova XML configuration file, not + > changed at runtime by plugins. + + - Parameters: + - value: The value to be stored with the given key. + - key: The preference name. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ +- (void)setCordovaSetting:(id)value forKey:(NSString *)key; + +@end + +NS_ASSUME_NONNULL_END diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVTimer.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVTimer.h new file mode 100644 index 00000000..5be94a18 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVTimer.h @@ -0,0 +1,85 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +/** + Utility class for measuring the time spent on an operation. + + You can use `CDVTimer` to measure named operations, and it will print the + elapsed time to the application log upon completion of the operation. This + allows for simple benchmarking of potentially expensive operations like plugin + loading and initialization. + + At the start of an operation, call ``start:`` with a unique name, then when the + operation finishes, call ``stop:`` with the same name: + + @TabNavigator { + @Tab("Swift") { + ```swift + CDVTimer.start("ExpensiveOperation") + + doExpensiveOperation(); + + CDVTimer.stop("ExpensiveOperation") + ``` + } + @Tab("Objective-C") { + ```objc + [CDVTimer start:@"ExpensiveOperation"]; + + doExpensiveOperation(); + + [CDVTimer stop:@"ExpensiveOperation"]; + ``` + } + } + + @Metadata { + @Available(Cordova, introduced: "2.7.0") + } + */ +@interface CDVTimer : NSObject + +/** + Begins measuring elapsed time for the named operation. + + - Parameters: + - name: A unique name to identify the timed operation. + + @Metadata { + @Available(Cordova, introduced: "2.7.0") + } + */ ++ (void)start:(NSString *)name; + +/** + Stops measuring elapsed time for the named operation, and prints the elapsed + time to the log. + + - Parameters: + - name: The unique name to identify the timed operation. + + @Metadata { + @Available(Cordova, introduced: "2.7.0") + } + */ ++ (void)stop:(NSString *)name; + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVViewController.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVViewController.h new file mode 100644 index 00000000..00cc9e20 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVViewController.h @@ -0,0 +1,390 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import +#import +#import +#import +#import +#import +#import +#import + +@class CDVPlugin; + +NS_ASSUME_NONNULL_BEGIN + +/** + A view controller that specializes in displaying Cordova web content. + + ## Overview + `CDVViewController` presents a view that displays Cordova web content in a + web view. Although often presented as the root view controller for an app, + a `CDVViewController` can safely be placed within view controller + hierarchies—such as navigation and tabbed controllers—or presented modally. + + The behavior preferences and web content to be loaded are defined in a Cordova + XML configuration file, for which a separate [reference + guide](https://cordova.apache.org/docs/en/latest/config_ref/index.html) + exists. The web content displayed within the view has access to Cordova + plugins via their exposed JavaScript APIs. + + > Important: In accordance with [App Store review + > guidelines](https://developer.apple.com/app-store/review/guidelines/), you + > must not expose Apple device APIs to web content that is not bundled within + > the app. + + @Metadata { + @Available(Cordova, introduced: "2.0.0") + } + */ +@interface CDVViewController : UIViewController + +#pragma mark - Properties + +/** + The view displaying web content for this Cordova controller. + + The exact type of this UIView subclass varies based on which + plugin is being used to provide the web view implementation. Interactions + with the web view and its content should be done through the ``webViewEngine`` + property. + + @Metadata { + @Available(Cordova, introduced: "2.0.0") + } + */ +@property (nonatomic, readonly, nullable, weak) IBOutlet UIView *webView; + +/** + An array of loaded Cordova plugin instances. + + This array is safe to iterate using a `for...in` loop. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ +@property (nonatomic, readonly, copy) NSArray *enumerablePlugins; + +/** + The scheme being used to load web content from the app bundle into the Cordova + web view. + + The default value is `app` but can be customized via the `Scheme` preference + in the Cordova XML configuration file. Setting this to `file` will results in + web content being loaded using the File URL protocol, which has inherent + security limitations. It is encouraged that you use a custom scheme to load + your app content. + + It is not valid to set this to an existing protocol scheme such as `http` or + `https`. + + @Metadata { + @Available(Cordova, introduced: "6.0.0") + } + */ +@property (nonatomic, nullable, readwrite, copy) NSString *appScheme; + +/** + @Metadata { + @Available(Cordova, introduced: "3.0.0") + } + */ +@property (nonatomic, readonly, strong) CDVCommandQueue *commandQueue; + +/** + @Metadata { + @Available(Cordova, introduced: "2.0.0") + } + */ +@property (nonatomic, readonly, strong) id commandDelegate; + +/** + The associated web view engine implementation. + + This provides a reference to the web view plugin class, which + implements ``CDVWebViewEngineProtocol`` and allows for interaction with the + web view. + + @Metadata { + @Available(Cordova, introduced: "4.0.0") + } + */ +@property (nonatomic, readonly, strong) id webViewEngine; + +/** + The Cordova preferences for this view. + + This is a dictionary populated from the preference key/value pairs in the + Cordova XML configuration file. + + @Metadata { + @Available(Cordova, introduced: "2.0.0") + } + */ +@property (nonatomic, readonly, strong) CDVSettingsDictionary *settings; + +/** + The filename of the Cordova XML configuration file. + + The default value is `"config.xml"`. + + This can be set in the storyboard file as a view controller attribute. + + @Metadata { + @Available(Cordova, introduced: "5.0.0") + } + */ +@property (nonatomic, readwrite, copy) IBInspectable NSString *configFile; + +/** + The file path to the Cordova XML configuration file. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ +@property (nonatomic, nullable, readonly, copy) NSURL *configFilePath; + +/** + The file path to the HTML error fallback page, if one has been provided. + + @Metadata { + @Available(Cordova, introduced: "4.0.0") + } + */ +@property (nonatomic, nullable, readonly, copy) NSURL *errorURL; + +/** + The folder path containing the web content to be displayed. + + The default value is `"www"`. + + This can be set in the storyboard file as a view controller attribute. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ +@property (nonatomic, readwrite, copy) IBInspectable NSString *webContentFolderName; + +/** + The filename of the HTML file to load into the web view. + + The default value will be read from the Cordova XML configuration file, and + fall back to `"index.html"` if not specified. + + This can be set in the storyboard file as a view controller attribute. + + @Metadata { + @Available(Cordova, introduced: "2.0.0") + } + */ +@property (nonatomic, nullable, readwrite, copy) IBInspectable NSString *startPage; + +/** + A boolean value indicating whether to show the splash screen while the web view + is initially loading. + + The default value is `YES`. + + This can be set in the storyboard file as a view controller attribute. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ +@property (nonatomic) IBInspectable BOOL showInitialSplashScreen; + +/** + The color drawn behind the web content. + + This is used as the background color for the web view behind any HTML content + and during loading before web content has been rendered. The default value is + the system background color. + + This can be set in the storyboard file as a view controller attribute. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ +@property (nonatomic, null_resettable, copy) IBInspectable UIColor *backgroundColor; + +/** + The color drawn behind the splash screen content. + + This is used as the background color for the splash screen while the web + content is loading. If a page background color has been specified, that will + be used as the default value, otherwise the system background color is used. + + This can be set in the storyboard file as a view controller attribute. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ +@property (nonatomic, null_resettable, copy) IBInspectable UIColor *splashBackgroundColor; + +/** + The color drawn behind the status bar. + + This can be set in the storyboard file as a view controller attribute. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ +@property (nonatomic, null_resettable, copy) IBInspectable UIColor *statusBarBackgroundColor; + +#pragma mark - Methods + +/** + @Metadata { + @Available(Cordova, introduced: "2.0.0") + } + */ +- (UIView*)newCordovaViewWithFrame:(CGRect)bounds; + +/** + Loads the starting page in the web view, replacing any existing content. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ +- (void)loadStartPage; + +/** + Returns the ``CDVPlugin`` instance of the given plugin name, creating the + instance if one does not exist. + + - Parameters: + - pluginName: The name of the plugin to return. + - Returns: The ``CDVPlugin`` instance, or `nil` if no plugin exists with the + given name. + + @Metadata { + @Available(Cordova, introduced: "3.0.0") + } + */ +- (nullable CDVPlugin *)getCommandInstance:(NSString *)pluginName; + +/** + @Metadata { + @Available(Cordova, introduced: "3.0.0") + } + */ +- (void)registerPlugin:(CDVPlugin*)plugin withClassName:(NSString*)className; + +/** + @Metadata { + @Available(Cordova, introduced: "3.0.0") + } + */ +- (void)registerPlugin:(CDVPlugin*)plugin withPluginName:(NSString*)pluginName; + +/** + Toggles the display of the splash screen overtop of the web view. + + - Parameters: + - visible: Whether to make the splash screen visible or not. + + @Metadata { + @Available(Cordova, introduced: "8.0.0") + } + */ +- (void)showSplashScreen:(BOOL)visible; + + +#pragma mark - Deprecated + +/** + @Metadata { + @Available(Cordova, introduced: "3.0.0", deprecated: "8.0.0") + } + */ +@property (nonatomic, nullable, readonly, strong) NSXMLParser *configParser CDV_DEPRECATED(8.0.0, ""); + +/** + @Metadata { + @Available(Cordova, introduced: "2.0.0", deprecated: "8.0.0") + } + */ +@property (nonatomic, nullable, readonly, copy) NSString *appURLScheme CDV_DEPRECATED(8.0.0, ""); + +/** + @Metadata { + @Available(Cordova, introduced: "2.0.0", deprecated: "8.0.0") + } + */ +@property (nonatomic, readonly, strong) NSDictionary *pluginObjects CDV_DEPRECATED(8.0.0, "Internal implementation detail, should not be used"); + +/** + @Metadata { + @Available(Cordova, introduced: "2.0.0", deprecated: "8.0.0") + } + */ +@property (nullable, nonatomic, readonly, strong) NSDictionary *pluginsMap CDV_DEPRECATED(8.0.0, "Internal implementation detail, should not be used"); + +/** + The folder path containing the web content to be displayed. + + The default value is `"www"`. + + @Metadata { + @Available(Cordova, introduced: "2.0.0", deprecated: "8.0.0") + } + @DeprecationSummary { + Use ``webContentFolderName`` instead. + } + */ +@property (nonatomic, readwrite, copy) NSString *wwwFolderName CDV_DEPRECATED_WITH_REPLACEMENT(8.0.0, "Use webContentFolderName instead", "webContentFolderName"); + +/** + Toggles the display of the splash screen overtop of the web view. + + - Parameters: + - visible: Whether to make the splash screen visible or not. + + @Metadata { + @Available(Cordova, introduced: "6.0.0", deprecated: "8.0.0") + } + @DeprecationSummary { + Use ``showSplashScreen:`` instead. + } + */ +- (void)showLaunchScreen:(BOOL)visible CDV_DEPRECATED_WITH_REPLACEMENT(8.0.0, "Use showSplashScreen: instead", "showSplashScreen"); + +/** + Parses the Cordova XML configuration file using the given delegate. + + @Metadata { + @Available(Cordova, introduced: "4.0.0", deprecated: "8.0.0") + } + @DeprecationSummary { + Use `CDVConfigParser` ``CDVConfigParser/parseConfigFile:withDelegate:`` instead. + } + */ +- (void)parseSettingsWithParser:(id )delegate CDV_DEPRECATED(8.0.0, "Use CDVConfigParser parseConfigFile:withDelegate: instead"); + +@end + +NS_ASSUME_NONNULL_END diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVWebViewEngineProtocol.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVWebViewEngineProtocol.h new file mode 100644 index 00000000..d7110536 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVWebViewEngineProtocol.h @@ -0,0 +1,75 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +#define kCDVWebViewEngineScriptMessageHandlers @"kCDVWebViewEngineScriptMessageHandlers" +#define kCDVWebViewEngineWKNavigationDelegate @"kCDVWebViewEngineWKNavigationDelegate" +#define kCDVWebViewEngineWKUIDelegate @"kCDVWebViewEngineWKUIDelegate" +#define kCDVWebViewEngineWebViewPreferences @"kCDVWebViewEngineWebViewPreferences" + +@class WKWebViewConfiguration; + +@protocol CDVWebViewEngineProtocol + +NS_ASSUME_NONNULL_BEGIN + +@property (nonatomic, strong, readonly) UIView* engineWebView; + +- (id)loadRequest:(NSURLRequest*)request; +- (id)loadHTMLString:(NSString*)string baseURL:(nullable NSURL*)baseURL; +- (void)evaluateJavaScript:(NSString*)javaScriptString completionHandler:(void (^_Nullable)(id, NSError*))completionHandler; + +- (NSURL*)URL; +- (BOOL)canLoadRequest:(NSURLRequest*)request; +- (nullable instancetype)initWithFrame:(CGRect)frame; + +/// Convenience Initializer +/// @param frame The frame for the new web view. +/// @param configuration The configuration for the new web view. +- (nullable instancetype)initWithFrame:(CGRect)frame configuration:(nullable WKWebViewConfiguration *)configuration; + +- (void)updateWithInfo:(NSDictionary*)info; + +NS_ASSUME_NONNULL_END + +@end + + +@protocol CDVWebViewEngineConfigurationDelegate + +@optional +/** + Provides a fully configured WKWebViewConfiguration which will be overridden + with any related settings you add to config.xml (e.g., `PreferredContentMode`). + This is useful for more complex configuration, including `websiteDataStore`. + + ## Example usage + + ```swift + extension CDVViewController: CDVWebViewEngineConfigurationDelegate { + public func configuration() -> WKWebViewConfiguration { + // return your config here + } + } + ``` + */ +- (nonnull WKWebViewConfiguration*)configuration; + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVWebViewProcessPoolFactory.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVWebViewProcessPoolFactory.h new file mode 100644 index 00000000..cfdec198 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/CDVWebViewProcessPoolFactory.h @@ -0,0 +1,35 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import + +@class WKProcessPool; + +/** + @Metadata { + @Available(Cordova, introduced: "6.2.0", deprecated: "8.0.0") + } + */ +CDV_DEPRECATED(8.0.0, "WebKit WKProcessPool is deprecated in iOS") +@interface CDVWebViewProcessPoolFactory : NSObject +@property (nonatomic, retain) WKProcessPool* sharedPool; + ++(instancetype) sharedFactory; +-(WKProcessPool*) sharedProcessPool; +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/Cordova.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/Cordova.h new file mode 100644 index 00000000..321b2948 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/Cordova.h @@ -0,0 +1,46 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#define __CORDOVA_SILENCE_HEADER_DEPRECATIONS + +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import + +// Deprecated +#import +#import +#import + +#undef __CORDOVA_SILENCE_HEADER_DEPRECATIONS diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/NSDictionary+CordovaPreferences.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/NSDictionary+CordovaPreferences.h new file mode 100644 index 00000000..89450891 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/NSDictionary+CordovaPreferences.h @@ -0,0 +1,39 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import +#import + +#ifndef __CORDOVA_SILENCE_HEADER_DEPRECATIONS +#warning "Use CDVSettingsDictionary.h and the CDVSettingsDictionary class instead" +#endif + +@interface NSDictionary (CordovaPreferences) + +- (id)cordovaSettingForKey:(NSString*)key CDV_DEPRECATED(8.0.0, "Use CDVSettingsDictionary"); +- (BOOL)cordovaBoolSettingForKey:(NSString*)key defaultValue:(BOOL)defaultValue CDV_DEPRECATED(8.0.0, "Use CDVSettingsDictionary"); +- (CGFloat)cordovaFloatSettingForKey:(NSString*)key defaultValue:(CGFloat)defaultValue CDV_DEPRECATED(8.0.0, "Use CDVSettingsDictionary"); + +@end + +@interface NSMutableDictionary (CordovaPreferences) + +- (void)setCordovaSetting:(id)value forKey:(NSString*)key CDV_DEPRECATED(8.0.0, "Use CDVSettingsDictionary"); + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/NSMutableArray+QueueAdditions.h b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/NSMutableArray+QueueAdditions.h new file mode 100644 index 00000000..df471f96 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/CordovaLib/include/Cordova/NSMutableArray+QueueAdditions.h @@ -0,0 +1,34 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +#import +#import + +#ifndef __CORDOVA_SILENCE_HEADER_DEPRECATIONS +//#warning "This should not be used" +#endif + +@interface NSMutableArray (QueueAdditions) + +- (id)cdv_pop CDV_DEPRECATED(8.0.0, ""); +- (id)cdv_queueHead CDV_DEPRECATED(8.0.0, ""); +- (id)cdv_dequeue; +- (void)cdv_enqueue:(id)obj CDV_DEPRECATED(8.0.0, ""); + +@end diff --git a/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/Package.swift b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/Package.swift new file mode 100644 index 00000000..6571d093 --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/packages/cordova-ios/Package.swift @@ -0,0 +1,47 @@ +// swift-tools-version:5.5 + +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ + +import PackageDescription + +let package = Package( + name: "Cordova", + platforms: [ + .iOS(.v13), + .macCatalyst(.v13) + ], + products: [ + .library(name: "Cordova", targets: ["Cordova"]) + ], + dependencies: [], + targets: [ + .target( + name: "Cordova", + path: "CordovaLib/", + exclude: ["Info.plist"], + resources: [ + .copy("PrivacyInfo.xcprivacy") + ], + cSettings: [ + .headerSearchPath("Classes/Private") + ] + ) + ] +) diff --git a/e2e-tests/MaestroTestApp/platforms/ios/platform_www/cordova.js b/e2e-tests/MaestroTestApp/platforms/ios/platform_www/cordova.js new file mode 100644 index 00000000..136d639b --- /dev/null +++ b/e2e-tests/MaestroTestApp/platforms/ios/platform_www/cordova.js @@ -0,0 +1,2175 @@ +// Platform: cordova-ios +// cordova-js 6.1.0 +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ +;(function() { +var PLATFORM_VERSION_BUILD_LABEL = '8.0.0'; +// file: src/scripts/require.js +var require; +var define; + +(function () { + var modules = {}; + // Stack of moduleIds currently being built. + var requireStack = []; + // Map of module ID -> index into requireStack of modules currently being built. + var inProgressModules = {}; + var SEPARATOR = '.'; + + function build (module) { + var factory = module.factory; + var localRequire = function (id) { + var resultantId = id; + // Its a relative path, so lop off the last portion and add the id (minus "./") + if (id.charAt(0) === '.') { + resultantId = module.id.slice(0, module.id.lastIndexOf(SEPARATOR)) + SEPARATOR + id.slice(2); + } + return require(resultantId); + }; + module.exports = {}; + delete module.factory; + factory(localRequire, module.exports, module); + return module.exports; + } + + require = function (id) { + if (!modules[id]) { + throw new Error('module ' + id + ' not found'); + } else if (id in inProgressModules) { + var cycle = requireStack.slice(inProgressModules[id]).join('->') + '->' + id; + throw new Error('Cycle in require graph: ' + cycle); + } + if (modules[id].factory) { + try { + inProgressModules[id] = requireStack.length; + requireStack.push(id); + return build(modules[id]); + } finally { + delete inProgressModules[id]; + requireStack.pop(); + } + } + return modules[id].exports; + }; + + define = function (id, factory) { + if (Object.prototype.hasOwnProperty.call(modules, id)) { + throw new Error('module ' + id + ' already defined'); + } + + modules[id] = { + id: id, + factory: factory + }; + }; + + define.remove = function (id) { + delete modules[id]; + }; + + define.moduleMap = modules; +})(); + +// Export for use in node +if (typeof module === 'object' && typeof require === 'function') { + module.exports.require = require; + module.exports.define = define; +} + +// file: src/cordova.js +define("cordova", function(require, exports, module) { + +// Workaround for Windows 10 in hosted environment case +// http://www.w3.org/html/wg/drafts/html/master/browsers.html#named-access-on-the-window-object +if (window.cordova && !(window.cordova instanceof HTMLElement)) { + throw new Error('cordova already defined'); +} + +var channel = require('cordova/channel'); +var platform = require('cordova/platform'); + +/** + * Intercept calls to addEventListener + removeEventListener and handle deviceready, + * resume, and pause events. + */ +var m_document_addEventListener = document.addEventListener; +var m_document_removeEventListener = document.removeEventListener; +var m_window_addEventListener = window.addEventListener; +var m_window_removeEventListener = window.removeEventListener; + +/** + * Houses custom event handlers to intercept on document + window event listeners. + */ +var documentEventHandlers = {}; +var windowEventHandlers = {}; + +document.addEventListener = function (evt, handler, capture) { + var e = evt.toLowerCase(); + if (typeof documentEventHandlers[e] !== 'undefined') { + documentEventHandlers[e].subscribe(handler); + } else { + m_document_addEventListener.call(document, evt, handler, capture); + } +}; + +window.addEventListener = function (evt, handler, capture) { + var e = evt.toLowerCase(); + if (typeof windowEventHandlers[e] !== 'undefined') { + windowEventHandlers[e].subscribe(handler); + } else { + m_window_addEventListener.call(window, evt, handler, capture); + } +}; + +document.removeEventListener = function (evt, handler, capture) { + var e = evt.toLowerCase(); + // If unsubscribing from an event that is handled by a plugin + if (typeof documentEventHandlers[e] !== 'undefined') { + documentEventHandlers[e].unsubscribe(handler); + } else { + m_document_removeEventListener.call(document, evt, handler, capture); + } +}; + +window.removeEventListener = function (evt, handler, capture) { + var e = evt.toLowerCase(); + // If unsubscribing from an event that is handled by a plugin + if (typeof windowEventHandlers[e] !== 'undefined') { + windowEventHandlers[e].unsubscribe(handler); + } else { + m_window_removeEventListener.call(window, evt, handler, capture); + } +}; + +function createEvent (type, data) { + var event = document.createEvent('Events'); + event.initEvent(type, false, false); + if (data) { + for (var i in data) { + if (Object.prototype.hasOwnProperty.call(data, i)) { + event[i] = data[i]; + } + } + } + return event; +} + +var cordova = { + define: define, + require: require, + version: PLATFORM_VERSION_BUILD_LABEL, + platformVersion: PLATFORM_VERSION_BUILD_LABEL, + platformId: platform.id, + + /** + * Methods to add/remove your own addEventListener hijacking on document + window. + */ + addWindowEventHandler: function (event) { + return (windowEventHandlers[event] = channel.create(event)); + }, + addStickyDocumentEventHandler: function (event) { + return (documentEventHandlers[event] = channel.createSticky(event)); + }, + addDocumentEventHandler: function (event) { + return (documentEventHandlers[event] = channel.create(event)); + }, + removeWindowEventHandler: function (event) { + delete windowEventHandlers[event]; + }, + removeDocumentEventHandler: function (event) { + delete documentEventHandlers[event]; + }, + + /** + * Retrieve original event handlers that were replaced by Cordova + * + * @return object + */ + getOriginalHandlers: function () { + return { + document: { + addEventListener: m_document_addEventListener, + removeEventListener: m_document_removeEventListener + }, + window: { + addEventListener: m_window_addEventListener, + removeEventListener: m_window_removeEventListener + } + }; + }, + + /** + * Method to fire event from native code + * bNoDetach is required for events which cause an exception which needs to be caught in native code + */ + fireDocumentEvent: function (type, data, bNoDetach) { + var evt = createEvent(type, data); + if (typeof documentEventHandlers[type] !== 'undefined') { + if (bNoDetach) { + documentEventHandlers[type].fire(evt); + } else { + setTimeout(function () { + // Fire deviceready on listeners that were registered before cordova.js was loaded. + if (type === 'deviceready') { + document.dispatchEvent(evt); + } + documentEventHandlers[type].fire(evt); + }, 0); + } + } else { + document.dispatchEvent(evt); + } + }, + + fireWindowEvent: function (type, data) { + var evt = createEvent(type, data); + if (typeof windowEventHandlers[type] !== 'undefined') { + setTimeout(function () { + windowEventHandlers[type].fire(evt); + }, 0); + } else { + window.dispatchEvent(evt); + } + }, + + /** + * Plugin callback mechanism. + */ + // Randomize the starting callbackId to avoid collisions after refreshing or navigating. + // This way, it's very unlikely that any new callback would get the same callbackId as an old callback. + callbackId: Math.floor(Math.random() * 2000000000), + callbacks: {}, + callbackStatus: { + NO_RESULT: 0, + OK: 1, + CLASS_NOT_FOUND_EXCEPTION: 2, + ILLEGAL_ACCESS_EXCEPTION: 3, + INSTANTIATION_EXCEPTION: 4, + MALFORMED_URL_EXCEPTION: 5, + IO_EXCEPTION: 6, + INVALID_ACTION: 7, + JSON_EXCEPTION: 8, + ERROR: 9 + }, + + /** + * Called by native code when returning successful result from an action. + */ + callbackSuccess: function (callbackId, args) { + cordova.callbackFromNative(callbackId, true, args.status, [args.message], args.keepCallback); + }, + + /** + * Called by native code when returning error result from an action. + */ + callbackError: function (callbackId, args) { + // TODO: Deprecate callbackSuccess and callbackError in favour of callbackFromNative. + // Derive success from status. + cordova.callbackFromNative(callbackId, false, args.status, [args.message], args.keepCallback); + }, + + /** + * Called by native code when returning the result from an action. + */ + callbackFromNative: function (callbackId, isSuccess, status, args, keepCallback) { + try { + var callback = cordova.callbacks[callbackId]; + if (callback) { + if (isSuccess && status === cordova.callbackStatus.OK) { + callback.success && callback.success.apply(null, args); + } else if (!isSuccess) { + callback.fail && callback.fail.apply(null, args); + } + /* + else + Note, this case is intentionally not caught. + this can happen if isSuccess is true, but callbackStatus is NO_RESULT + which is used to remove a callback from the list without calling the callbacks + typically keepCallback is false in this case + */ + // Clear callback if not expecting any more results + if (!keepCallback) { + delete cordova.callbacks[callbackId]; + } + } + } catch (err) { + var msg = 'Error in ' + (isSuccess ? 'Success' : 'Error') + ' callbackId: ' + callbackId + ' : ' + err; + cordova.fireWindowEvent('cordovacallbackerror', { message: msg, error: err }); + throw err; + } + }, + + addConstructor: function (func) { + channel.onCordovaReady.subscribe(function () { + try { + func(); + } catch (e) { + console.log('Failed to run constructor: ' + e); + } + }); + } +}; + +module.exports = cordova; + +}); + +// file: src/common/argscheck.js +define("cordova/argscheck", function(require, exports, module) { + +var utils = require('cordova/utils'); + +var moduleExports = module.exports; + +var typeMap = { + A: 'Array', + D: 'Date', + N: 'Number', + S: 'String', + F: 'Function', + O: 'Object' +}; + +function extractParamName (callee, argIndex) { + return (/\(\s*([^)]*?)\s*\)/).exec(callee)[1].split(/\s*,\s*/)[argIndex]; +} + +/** + * Checks the given arguments' types and throws if they are not as expected. + * + * `spec` is a string where each character stands for the required type of the + * argument at the same position. In other words: the character at `spec[i]` + * specifies the required type for `args[i]`. The characters in `spec` are the + * first letter of the required type's name. The supported types are: + * + * Array, Date, Number, String, Function, Object + * + * Lowercase characters specify arguments that must not be `null` or `undefined` + * while uppercase characters allow those values to be passed. + * + * Finally, `*` can be used to allow any type at the corresponding position. + * + * @example + * function foo (arr, opts) { + * // require `arr` to be an Array and `opts` an Object, null or undefined + * checkArgs('aO', 'my.package.foo', arguments); + * // ... + * } + * @param {String} spec - the type specification for `args` as described above + * @param {String} functionName - full name of the callee. + * Used in the error message + * @param {Array|arguments} args - the arguments to be checked against `spec` + * @param {Function} [opt_callee=args.callee] - the recipient of `args`. + * Used to extract parameter names for the error message + * @throws {TypeError} if args do not satisfy spec + */ +function checkArgs (spec, functionName, args, opt_callee) { + if (!moduleExports.enableChecks) { + return; + } + var errMsg = null; + var typeName; + for (var i = 0; i < spec.length; ++i) { + var c = spec.charAt(i); + var cUpper = c.toUpperCase(); + var arg = args[i]; + // Asterix means allow anything. + if (c === '*') { + continue; + } + typeName = utils.typeName(arg); + if ((arg === null || arg === undefined) && c === cUpper) { + continue; + } + if (typeName !== typeMap[cUpper]) { + errMsg = 'Expected ' + typeMap[cUpper]; + break; + } + } + if (errMsg) { + errMsg += ', but got ' + typeName + '.'; + errMsg = 'Wrong type for parameter "' + extractParamName(opt_callee || args.callee, i) + '" of ' + functionName + ': ' + errMsg; + // Don't log when running unit tests. + if (typeof jasmine === 'undefined') { + console.error(errMsg); + } + throw TypeError(errMsg); + } +} + +function getValue (value, defaultValue) { + return value === undefined ? defaultValue : value; +} + +moduleExports.checkArgs = checkArgs; +moduleExports.getValue = getValue; +moduleExports.enableChecks = true; + +}); + +// file: src/common/base64.js +define("cordova/base64", function(require, exports, module) { + +var base64 = exports; + +base64.fromArrayBuffer = function (arrayBuffer) { + var array = new Uint8Array(arrayBuffer); + return uint8ToBase64(array); +}; + +base64.toArrayBuffer = function (str) { + var decodedStr = atob(str); + var arrayBuffer = new ArrayBuffer(decodedStr.length); + var array = new Uint8Array(arrayBuffer); + for (var i = 0, len = decodedStr.length; i < len; i++) { + array[i] = decodedStr.charCodeAt(i); + } + return arrayBuffer; +}; + +// ------------------------------------------------------------------------------ + +/* This code is based on the performance tests at http://jsperf.com/b64tests + * This 12-bit-at-a-time algorithm was the best performing version on all + * platforms tested. + */ + +var b64_6bit = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +var b64_12bit; + +var b64_12bitTable = function () { + b64_12bit = []; + for (var i = 0; i < 64; i++) { + for (var j = 0; j < 64; j++) { + b64_12bit[i * 64 + j] = b64_6bit[i] + b64_6bit[j]; + } + } + b64_12bitTable = function () { return b64_12bit; }; + return b64_12bit; +}; + +function uint8ToBase64 (rawData) { + var numBytes = rawData.byteLength; + var output = ''; + var segment; + var table = b64_12bitTable(); + for (var i = 0; i < numBytes - 2; i += 3) { + segment = (rawData[i] << 16) + (rawData[i + 1] << 8) + rawData[i + 2]; + output += table[segment >> 12]; + output += table[segment & 0xfff]; + } + if (numBytes - i === 2) { + segment = (rawData[i] << 16) + (rawData[i + 1] << 8); + output += table[segment >> 12]; + output += b64_6bit[(segment & 0xfff) >> 6]; + output += '='; + } else if (numBytes - i === 1) { + segment = (rawData[i] << 16); + output += table[segment >> 12]; + output += '=='; + } + return output; +} + +}); + +// file: src/common/builder.js +define("cordova/builder", function(require, exports, module) { + +var utils = require('cordova/utils'); + +function each (objects, func, context) { + for (var prop in objects) { + if (Object.prototype.hasOwnProperty.call(objects, prop)) { + func.apply(context, [objects[prop], prop]); + } + } +} + +function clobber (obj, key, value) { + var needsProperty = false; + try { + obj[key] = value; + } catch (e) { + needsProperty = true; + } + // Getters can only be overridden by getters. + if (needsProperty || obj[key] !== value) { + utils.defineGetter(obj, key, function () { + return value; + }); + } +} + +function assignOrWrapInDeprecateGetter (obj, key, value, message) { + if (message) { + utils.defineGetter(obj, key, function () { + console.log(message); + delete obj[key]; + clobber(obj, key, value); + return value; + }); + } else { + clobber(obj, key, value); + } +} + +function include (parent, objects, clobber, merge) { + each(objects, function (obj, key) { + try { + var result = obj.path ? require(obj.path) : {}; + + if (clobber) { + // Clobber if it doesn't exist. + if (typeof parent[key] === 'undefined') { + assignOrWrapInDeprecateGetter(parent, key, result, obj.deprecated); + } else if (typeof obj.path !== 'undefined') { + // If merging, merge properties onto parent, otherwise, clobber. + if (merge) { + recursiveMerge(parent[key], result); + } else { + assignOrWrapInDeprecateGetter(parent, key, result, obj.deprecated); + } + } + result = parent[key]; + } else { + // Overwrite if not currently defined. + if (typeof parent[key] === 'undefined') { + assignOrWrapInDeprecateGetter(parent, key, result, obj.deprecated); + } else { + // Set result to what already exists, so we can build children into it if they exist. + result = parent[key]; + } + } + + if (obj.children) { + include(result, obj.children, clobber, merge); + } + } catch (e) { + utils.alert('Exception building Cordova JS globals: ' + e + ' for key "' + key + '"'); + } + }); +} + +/** + * Merge properties from one object onto another recursively. Properties from + * the src object will overwrite existing target property. + * + * @param target Object to merge properties into. + * @param src Object to merge properties from. + */ +function recursiveMerge (target, src) { + for (var prop in src) { + if (Object.prototype.hasOwnProperty.call(src, prop)) { + if (target.prototype && target.prototype.constructor === target) { + // If the target object is a constructor override off prototype. + clobber(target.prototype, prop, src[prop]); + } else { + if (typeof src[prop] === 'object' && typeof target[prop] === 'object') { + recursiveMerge(target[prop], src[prop]); + } else { + clobber(target, prop, src[prop]); + } + } + } + } +} + +exports.buildIntoButDoNotClobber = function (objects, target) { + include(target, objects, false, false); +}; +exports.buildIntoAndClobber = function (objects, target) { + include(target, objects, true, false); +}; +exports.buildIntoAndMerge = function (objects, target) { + include(target, objects, true, true); +}; +exports.recursiveMerge = recursiveMerge; +exports.assignOrWrapInDeprecateGetter = assignOrWrapInDeprecateGetter; + +}); + +// file: src/common/channel.js +define("cordova/channel", function(require, exports, module) { + +var utils = require('cordova/utils'); +var nextGuid = 1; + +/** + * Custom pub-sub "channel" that can have functions subscribed to it + * This object is used to define and control firing of events for + * cordova initialization, as well as for custom events thereafter. + * + * The order of events during page load and Cordova startup is as follows: + * + * onDOMContentLoaded* Internal event that is received when the web page is loaded and parsed. + * onNativeReady* Internal event that indicates the Cordova native side is ready. + * onCordovaReady* Internal event fired when all Cordova JavaScript objects have been created. + * onDeviceReady* User event fired to indicate that Cordova is ready + * onResume User event fired to indicate a start/resume lifecycle event + * onPause User event fired to indicate a pause lifecycle event + * + * The events marked with an * are sticky. Once they have fired, they will stay in the fired state. + * All listeners that subscribe after the event is fired will be executed right away. + * + * The only Cordova events that user code should register for are: + * deviceready Cordova native code is initialized and Cordova APIs can be called from JavaScript + * pause App has moved to background + * resume App has returned to foreground + * + * Listeners can be registered as: + * document.addEventListener("deviceready", myDeviceReadyListener, false); + * document.addEventListener("resume", myResumeListener, false); + * document.addEventListener("pause", myPauseListener, false); + * + * The DOM lifecycle events should be used for saving and restoring state + * window.onload + * window.onunload + * + */ + +/** + * Channel + * @constructor + * @param type String the channel name + */ +var Channel = function (type, sticky) { + this.type = type; + // Map of guid -> function. + this.handlers = {}; + // 0 = Non-sticky, 1 = Sticky non-fired, 2 = Sticky fired. + this.state = sticky ? 1 : 0; + // Used in sticky mode to remember args passed to fire(). + this.fireArgs = null; + // Used by onHasSubscribersChange to know if there are any listeners. + this.numHandlers = 0; + // Function that is called when the first listener is subscribed, or when + // the last listener is unsubscribed. + this.onHasSubscribersChange = null; +}; +var channel = { + /** + * Calls the provided function only after all of the channels specified + * have been fired. All channels must be sticky channels. + */ + join: function (h, c) { + var len = c.length; + var i = len; + var f = function () { + if (!(--i)) h(); + }; + for (var j = 0; j < len; j++) { + if (c[j].state === 0) { + throw Error('Can only use join with sticky channels.'); + } + c[j].subscribe(f); + } + if (!len) h(); + }, + + create: function (type) { + return (channel[type] = new Channel(type, false)); + }, + createSticky: function (type) { + return (channel[type] = new Channel(type, true)); + }, + + /** + * cordova Channels that must fire before "deviceready" is fired. + */ + deviceReadyChannelsArray: [], + deviceReadyChannelsMap: {}, + + /** + * Indicate that a feature needs to be initialized before it is ready to be used. + * This holds up Cordova's "deviceready" event until the feature has been initialized + * and Cordova.initComplete(feature) is called. + * + * @param feature {String} The unique feature name + */ + waitForInitialization: function (feature) { + if (feature) { + var c = channel[feature] || this.createSticky(feature); + this.deviceReadyChannelsMap[feature] = c; + this.deviceReadyChannelsArray.push(c); + } + }, + + /** + * Indicate that initialization code has completed and the feature is ready to be used. + * + * @param feature {String} The unique feature name + */ + initializationComplete: function (feature) { + var c = this.deviceReadyChannelsMap[feature]; + if (c) { + c.fire(); + } + } +}; + +function checkSubscriptionArgument (argument) { + if (typeof argument !== 'function' && typeof argument.handleEvent !== 'function') { + throw new Error( + 'Must provide a function or an EventListener object ' + + 'implementing the handleEvent interface.' + ); + } +} + +/** + * Subscribes the given function to the channel. Any time that + * Channel.fire is called so too will the function. + * Optionally specify an execution context for the function + * and a guid that can be used to stop subscribing to the channel. + * Returns the guid. + */ +Channel.prototype.subscribe = function (eventListenerOrFunction, eventListener) { + checkSubscriptionArgument(eventListenerOrFunction); + var handleEvent, guid; + + if (eventListenerOrFunction && typeof eventListenerOrFunction === 'object') { + // Received an EventListener object implementing the handleEvent interface + handleEvent = eventListenerOrFunction.handleEvent; + eventListener = eventListenerOrFunction; + } else { + // Received a function to handle event + handleEvent = eventListenerOrFunction; + } + + if (this.state === 2) { + handleEvent.apply(eventListener || this, this.fireArgs); + return; + } + + guid = eventListenerOrFunction.observer_guid; + if (typeof eventListener === 'object') { + handleEvent = utils.close(eventListener, handleEvent); + } + + if (!guid) { + // First time any channel has seen this subscriber + guid = '' + nextGuid++; + } + handleEvent.observer_guid = guid; + eventListenerOrFunction.observer_guid = guid; + + // Don't add the same handler more than once. + if (!this.handlers[guid]) { + this.handlers[guid] = handleEvent; + this.numHandlers++; + if (this.numHandlers === 1) { + this.onHasSubscribersChange && this.onHasSubscribersChange(); + } + } +}; + +/** + * Unsubscribes the function with the given guid from the channel. + */ +Channel.prototype.unsubscribe = function (eventListenerOrFunction) { + checkSubscriptionArgument(eventListenerOrFunction); + var handleEvent, guid, handler; + + if (eventListenerOrFunction && typeof eventListenerOrFunction === 'object') { + // Received an EventListener object implementing the handleEvent interface + handleEvent = eventListenerOrFunction.handleEvent; + } else { + // Received a function to handle event + handleEvent = eventListenerOrFunction; + } + + guid = handleEvent.observer_guid; + handler = this.handlers[guid]; + if (handler) { + delete this.handlers[guid]; + this.numHandlers--; + if (this.numHandlers === 0) { + this.onHasSubscribersChange && this.onHasSubscribersChange(); + } + } +}; + +/** + * Calls all functions subscribed to this channel. + */ +Channel.prototype.fire = function (e) { + var fireArgs = Array.prototype.slice.call(arguments); + // Apply stickiness. + if (this.state === 1) { + this.state = 2; + this.fireArgs = fireArgs; + } + if (this.numHandlers) { + // Copy the values first so that it is safe to modify it from within + // callbacks. + var toCall = []; + for (var item in this.handlers) { + toCall.push(this.handlers[item]); + } + for (var i = 0; i < toCall.length; ++i) { + toCall[i].apply(this, fireArgs); + } + if (this.state === 2 && this.numHandlers) { + this.numHandlers = 0; + this.handlers = {}; + this.onHasSubscribersChange && this.onHasSubscribersChange(); + } + } +}; + +// defining them here so they are ready super fast! +// DOM event that is received when the web page is loaded and parsed. +channel.createSticky('onDOMContentLoaded'); + +// Event to indicate the Cordova native side is ready. +channel.createSticky('onNativeReady'); + +// Event to indicate that all Cordova JavaScript objects have been created +// and it's time to run plugin constructors. +channel.createSticky('onCordovaReady'); + +// Event to indicate that all automatically loaded JS plugins are loaded and ready. +// FIXME remove this +channel.createSticky('onPluginsReady'); + +// Event to indicate that Cordova is ready +channel.createSticky('onDeviceReady'); + +// Event to indicate a resume lifecycle event +channel.create('onResume'); + +// Event to indicate a pause lifecycle event +channel.create('onPause'); + +// Channels that must fire before "deviceready" is fired. +channel.waitForInitialization('onCordovaReady'); +channel.waitForInitialization('onDOMContentLoaded'); + +module.exports = channel; + +}); + +// file: ../../cordova-js-src/exec.js +define("cordova/exec", function(require, exports, module) { + +/** + * Creates the exec bridge used to notify the native code of + * commands. + */ +var cordova = require('cordova'); +var utils = require('cordova/utils'); +var base64 = require('cordova/base64'); + +function massageArgsJsToNative (args) { + if (!args || utils.typeName(args) !== 'Array') { + return args; + } + var ret = []; + args.forEach(function (arg, i) { + if (utils.typeName(arg) === 'ArrayBuffer') { + ret.push({ + CDVType: 'ArrayBuffer', + data: base64.fromArrayBuffer(arg) + }); + } else { + ret.push(arg); + } + }); + return ret; +} + +function massageMessageNativeToJs (message) { + if (message.CDVType === 'ArrayBuffer') { + var stringToArrayBuffer = function (str) { + var ret = new Uint8Array(str.length); + for (var i = 0; i < str.length; i++) { + ret[i] = str.charCodeAt(i); + } + return ret.buffer; + }; + var base64ToArrayBuffer = function (b64) { + return stringToArrayBuffer(atob(b64)); + }; + message = base64ToArrayBuffer(message.data); + } + return message; +} + +function convertMessageToArgsNativeToJs (message) { + var args = []; + if (!message || !Object.prototype.hasOwnProperty.call(message, 'CDVType')) { + args.push(message); + } else if (message.CDVType === 'MultiPart') { + message.messages.forEach(function (e) { + args.push(massageMessageNativeToJs(e)); + }); + } else { + args.push(massageMessageNativeToJs(message)); + } + return args; +} + +var iOSExec = function () { + var successCallback, failCallback, service, action, actionArgs; + var callbackId = null; + if (typeof arguments[0] !== 'string') { + // FORMAT ONE + successCallback = arguments[0]; + failCallback = arguments[1]; + service = arguments[2]; + action = arguments[3]; + actionArgs = arguments[4]; + + // Since we need to maintain backwards compatibility, we have to pass + // an invalid callbackId even if no callback was provided since plugins + // will be expecting it. The Cordova.exec() implementation allocates + // an invalid callbackId and passes it even if no callbacks were given. + callbackId = 'INVALID'; + } else { + throw new Error('The old format of this exec call has been removed (deprecated since 2.1). Change to: ' + // eslint-disable-line + 'cordova.exec(null, null, \'Service\', \'action\', [ arg1, arg2 ]);'); + } + + // If actionArgs is not provided, default to an empty array + actionArgs = actionArgs || []; + + // Register the callbacks and add the callbackId to the positional + // arguments if given. + if (successCallback || failCallback) { + callbackId = service + cordova.callbackId++; + cordova.callbacks[callbackId] = + { success: successCallback, fail: failCallback }; + } + + actionArgs = massageArgsJsToNative(actionArgs); + + // CB-10133 DataClone DOM Exception 25 guard (fast function remover) + var command = [callbackId, service, action, JSON.parse(JSON.stringify(actionArgs))]; + window.webkit.messageHandlers.cordova.postMessage(command); +}; + +iOSExec.nativeCallback = function (callbackId, status, message, keepCallback, debug) { + var success = status === 0 || status === 1; + var args = convertMessageToArgsNativeToJs(message); + Promise.resolve().then(function () { + cordova.callbackFromNative(callbackId, success, status, args, keepCallback); + }); +}; + +// for backwards compatibility +iOSExec.nativeEvalAndFetch = function (func) { + try { + func(); + } catch (e) { + console.log(e); + } +}; + +// Proxy the exec for bridge changes. See CB-10106 + +function cordovaExec () { + var cexec = require('cordova/exec'); + var cexec_valid = (typeof cexec.nativeFetchMessages === 'function') && (typeof cexec.nativeEvalAndFetch === 'function') && (typeof cexec.nativeCallback === 'function'); + return (cexec_valid && execProxy !== cexec) ? cexec : iOSExec; +} + +function execProxy () { + cordovaExec().apply(null, arguments); +} + +execProxy.nativeFetchMessages = function () { + return cordovaExec().nativeFetchMessages.apply(null, arguments); +}; + +execProxy.nativeEvalAndFetch = function () { + return cordovaExec().nativeEvalAndFetch.apply(null, arguments); +}; + +execProxy.nativeCallback = function () { + return cordovaExec().nativeCallback.apply(null, arguments); +}; + +module.exports = execProxy; + +}); + +// file: src/common/exec/proxy.js +define("cordova/exec/proxy", function(require, exports, module) { + +// internal map of proxy function +var CommandProxyMap = {}; + +module.exports = { + + // example: cordova.commandProxy.add("Accelerometer",{getCurrentAcceleration: function(successCallback, errorCallback, options) {...},...); + add: function (id, proxyObj) { + console.log('adding proxy for ' + id); + CommandProxyMap[id] = proxyObj; + return proxyObj; + }, + + // cordova.commandProxy.remove("Accelerometer"); + remove: function (id) { + var proxy = CommandProxyMap[id]; + delete CommandProxyMap[id]; + CommandProxyMap[id] = null; + return proxy; + }, + + get: function (service, action) { + return (CommandProxyMap[service] ? CommandProxyMap[service][action] : null); + } +}; + +}); + +// file: src/common/init.js +define("cordova/init", function(require, exports, module) { + +var channel = require('cordova/channel'); +var cordova = require('cordova'); +var modulemapper = require('cordova/modulemapper'); +var platform = require('cordova/platform'); +var pluginloader = require('cordova/pluginloader'); + +var platformInitChannelsArray = [channel.onNativeReady, channel.onPluginsReady]; + +function logUnfiredChannels (arr) { + for (var i = 0; i < arr.length; ++i) { + if (arr[i].state !== 2) { + console.log('Channel not fired: ' + arr[i].type); + } + } +} + +window.setTimeout(function () { + if (channel.onDeviceReady.state !== 2) { + console.log('deviceready has not fired after 5 seconds.'); + logUnfiredChannels(platformInitChannelsArray); + logUnfiredChannels(channel.deviceReadyChannelsArray); + } +}, 5000); + +if (!window.console) { + window.console = { + log: function () {} + }; +} +if (!window.console.warn) { + window.console.warn = function (msg) { + this.log('warn: ' + msg); + }; +} + +// Register pause, resume and deviceready channels as events on document. +channel.onPause = cordova.addDocumentEventHandler('pause'); +channel.onResume = cordova.addDocumentEventHandler('resume'); +channel.onActivated = cordova.addDocumentEventHandler('activated'); +channel.onDeviceReady = cordova.addStickyDocumentEventHandler('deviceready'); + +// Listen for DOMContentLoaded and notify our channel subscribers. +if (document.readyState === 'complete' || document.readyState === 'interactive') { + channel.onDOMContentLoaded.fire(); +} else { + document.addEventListener('DOMContentLoaded', function () { + channel.onDOMContentLoaded.fire(); + }, false); +} + +// _nativeReady is global variable that the native side can set +// to signify that the native code is ready. It is a global since +// it may be called before any cordova JS is ready. +if (window._nativeReady) { + channel.onNativeReady.fire(); +} + +modulemapper.clobbers('cordova', 'cordova'); +modulemapper.clobbers('cordova/exec', 'cordova.exec'); +modulemapper.clobbers('cordova/exec', 'Cordova.exec'); + +// Call the platform-specific initialization. +platform.bootstrap && platform.bootstrap(); + +// Wrap in a setTimeout to support the use-case of having plugin JS appended to cordova.js. +// The delay allows the attached modules to be defined before the plugin loader looks for them. +setTimeout(function () { + pluginloader.load(function () { + channel.onPluginsReady.fire(); + }); +}, 0); + +/** + * Create all cordova objects once native side is ready. + */ +channel.join(function () { + modulemapper.mapModules(window); + + platform.initialize && platform.initialize(); + + // Fire event to notify that all objects are created + channel.onCordovaReady.fire(); + + // Fire onDeviceReady event once page has fully loaded, all + // constructors have run and cordova info has been received from native + // side. + channel.join(function () { + require('cordova').fireDocumentEvent('deviceready'); + }, channel.deviceReadyChannelsArray); +}, platformInitChannelsArray); + +}); + +// file: src/common/modulemapper.js +define("cordova/modulemapper", function(require, exports, module) { + +var builder = require('cordova/builder'); +var moduleMap = define.moduleMap; +var symbolList; +var deprecationMap; + +exports.reset = function () { + symbolList = []; + deprecationMap = {}; +}; + +function addEntry (strategy, moduleName, symbolPath, opt_deprecationMessage) { + if (!(moduleName in moduleMap)) { + throw new Error('Module ' + moduleName + ' does not exist.'); + } + symbolList.push(strategy, moduleName, symbolPath); + if (opt_deprecationMessage) { + deprecationMap[symbolPath] = opt_deprecationMessage; + } +} + +// Note: Android 2.3 does have Function.bind(). +exports.clobbers = function (moduleName, symbolPath, opt_deprecationMessage) { + addEntry('c', moduleName, symbolPath, opt_deprecationMessage); +}; + +exports.merges = function (moduleName, symbolPath, opt_deprecationMessage) { + addEntry('m', moduleName, symbolPath, opt_deprecationMessage); +}; + +exports.defaults = function (moduleName, symbolPath, opt_deprecationMessage) { + addEntry('d', moduleName, symbolPath, opt_deprecationMessage); +}; + +exports.runs = function (moduleName) { + addEntry('r', moduleName, null); +}; + +function prepareNamespace (symbolPath, context) { + if (!symbolPath) { + return context; + } + return symbolPath.split('.').reduce(function (cur, part) { + return (cur[part] = cur[part] || {}); + }, context); +} + +exports.mapModules = function (context) { + var origSymbols = {}; + context.CDV_origSymbols = origSymbols; + for (var i = 0, len = symbolList.length; i < len; i += 3) { + var strategy = symbolList[i]; + var moduleName = symbolList[i + 1]; + var module = require(moduleName); + // + if (strategy === 'r') { + continue; + } + var symbolPath = symbolList[i + 2]; + var lastDot = symbolPath.lastIndexOf('.'); + var namespace = symbolPath.substr(0, lastDot); + var lastName = symbolPath.substr(lastDot + 1); + + var deprecationMsg = symbolPath in deprecationMap ? 'Access made to deprecated symbol: ' + symbolPath + '. ' + deprecationMsg : null; + var parentObj = prepareNamespace(namespace, context); + var target = parentObj[lastName]; + + if (strategy === 'm' && target) { + builder.recursiveMerge(target, module); + } else if ((strategy === 'd' && !target) || (strategy !== 'd')) { + if (!(symbolPath in origSymbols)) { + origSymbols[symbolPath] = target; + } + builder.assignOrWrapInDeprecateGetter(parentObj, lastName, module, deprecationMsg); + } + } +}; + +exports.getOriginalSymbol = function (context, symbolPath) { + var origSymbols = context.CDV_origSymbols; + if (origSymbols && (symbolPath in origSymbols)) { + return origSymbols[symbolPath]; + } + var parts = symbolPath.split('.'); + var obj = context; + for (var i = 0; i < parts.length; ++i) { + obj = obj && obj[parts[i]]; + } + return obj; +}; + +exports.reset(); + +}); + +// file: ../../cordova-js-src/platform.js +define("cordova/platform", function(require, exports, module) { + +module.exports = { + id: 'ios', + bootstrap: function () { + // Attach the console polyfill that is iOS-only to window.console + // see the file under plugin/ios/console.js + require('cordova/modulemapper').clobbers('cordova/plugin/ios/console', 'window.console'); + + // Attach the wkwebkit utility to window.WkWebView + // see the file under plugin/ios/wkwebkit.js + require('cordova/modulemapper').clobbers('cordova/plugin/ios/wkwebkit', 'window.WkWebView'); + + // Attach the splashscreen utility to window.navigator.splashscreen + // see the file under plugin/ios/launchscreen.js + require('cordova/modulemapper').clobbers('cordova/plugin/ios/launchscreen', 'navigator.splashscreen'); + + // Attach the internal statusBar utility to window.statusbar + // see the file under plugin/ios/statusbar.js + require('cordova/modulemapper').clobbers('cordova/plugin/ios/statusbar', 'window.statusbar'); + + require('cordova/channel').onNativeReady.fire(); + } +}; + +}); + +// file: ../../cordova-js-src/plugin/ios/console.js +define("cordova/plugin/ios/console", function(require, exports, module) { + +// ------------------------------------------------------------------------------ + +var logger = require('cordova/plugin/ios/logger'); + +// ------------------------------------------------------------------------------ +// object that we're exporting +// ------------------------------------------------------------------------------ +var console = module.exports; + +// ------------------------------------------------------------------------------ +// copy of the original console object +// ------------------------------------------------------------------------------ +var WinConsole = window.console; + +// ------------------------------------------------------------------------------ +// whether to use the logger +// ------------------------------------------------------------------------------ +var UseLogger = false; + +// ------------------------------------------------------------------------------ +// Timers +// ------------------------------------------------------------------------------ +var Timers = {}; + +// ------------------------------------------------------------------------------ +// used for unimplemented methods +// ------------------------------------------------------------------------------ +function noop () {} + +// ------------------------------------------------------------------------------ +// used for unimplemented methods +// ------------------------------------------------------------------------------ +console.useLogger = function (value) { + if (arguments.length) UseLogger = !!value; + + if (UseLogger) { + if (logger.useConsole()) { + throw new Error('console and logger are too intertwingly'); + } + } + + return UseLogger; +}; + +// ------------------------------------------------------------------------------ +console.log = function () { + if (logger.useConsole()) return; + logger.log.apply(logger, [].slice.call(arguments)); +}; + +// ------------------------------------------------------------------------------ +console.error = function () { + if (logger.useConsole()) return; + logger.error.apply(logger, [].slice.call(arguments)); +}; + +// ------------------------------------------------------------------------------ +console.warn = function () { + if (logger.useConsole()) return; + logger.warn.apply(logger, [].slice.call(arguments)); +}; + +// ------------------------------------------------------------------------------ +console.info = function () { + if (logger.useConsole()) return; + logger.info.apply(logger, [].slice.call(arguments)); +}; + +// ------------------------------------------------------------------------------ +console.debug = function () { + if (logger.useConsole()) return; + logger.debug.apply(logger, [].slice.call(arguments)); +}; + +// ------------------------------------------------------------------------------ +console.assert = function (expression) { + if (expression) return; + + var message = logger.format.apply(logger.format, [].slice.call(arguments, 1)); + console.log('ASSERT: ' + message); +}; + +// ------------------------------------------------------------------------------ +console.clear = function () {}; + +// ------------------------------------------------------------------------------ +console.dir = function (object) { + console.log('%o', object); +}; + +// ------------------------------------------------------------------------------ +console.dirxml = function (node) { + console.log(node.innerHTML); +}; + +// ------------------------------------------------------------------------------ +console.trace = noop; + +// ------------------------------------------------------------------------------ +console.group = console.log; + +// ------------------------------------------------------------------------------ +console.groupCollapsed = console.log; + +// ------------------------------------------------------------------------------ +console.groupEnd = noop; + +// ------------------------------------------------------------------------------ +console.time = function (name) { + Timers[name] = new Date().valueOf(); +}; + +// ------------------------------------------------------------------------------ +console.timeEnd = function (name) { + var timeStart = Timers[name]; + if (!timeStart) { + console.warn('unknown timer: ' + name); + return; + } + + var timeElapsed = new Date().valueOf() - timeStart; + console.log(name + ': ' + timeElapsed + 'ms'); +}; + +// ------------------------------------------------------------------------------ +console.timeStamp = noop; + +// ------------------------------------------------------------------------------ +console.profile = noop; + +// ------------------------------------------------------------------------------ +console.profileEnd = noop; + +// ------------------------------------------------------------------------------ +console.count = noop; + +// ------------------------------------------------------------------------------ +console.exception = console.log; + +// ------------------------------------------------------------------------------ +console.table = function (data, columns) { + console.log('%o', data); +}; + +// ------------------------------------------------------------------------------ +// return a new function that calls both functions passed as args +// ------------------------------------------------------------------------------ +function wrappedOrigCall (orgFunc, newFunc) { + return function () { + var args = [].slice.call(arguments); + try { orgFunc.apply(WinConsole, args); } catch (e) {} + try { newFunc.apply(console, args); } catch (e) {} + }; +} + +// ------------------------------------------------------------------------------ +// For every function that exists in the original console object, that +// also exists in the new console object, wrap the new console method +// with one that calls both +// ------------------------------------------------------------------------------ +for (var key in console) { + if (typeof WinConsole[key] === 'function') { + console[key] = wrappedOrigCall(WinConsole[key], console[key]); + } +} + +}); + +// file: ../../cordova-js-src/plugin/ios/launchscreen.js +define("cordova/plugin/ios/launchscreen", function(require, exports, module) { + +var exec = require('cordova/exec'); + +var launchscreen = { + show: function () { + exec(null, null, 'LaunchScreen', 'show', []); + }, + hide: function () { + exec(null, null, 'LaunchScreen', 'hide', []); + } +}; + +module.exports = launchscreen; + +}); + +// file: ../../cordova-js-src/plugin/ios/logger.js +define("cordova/plugin/ios/logger", function(require, exports, module) { + +// ------------------------------------------------------------------------------ +// The logger module exports the following properties/functions: +// +// LOG - constant for the level LOG +// ERROR - constant for the level ERROR +// WARN - constant for the level WARN +// INFO - constant for the level INFO +// DEBUG - constant for the level DEBUG +// logLevel() - returns current log level +// logLevel(value) - sets and returns a new log level +// useConsole() - returns whether logger is using console +// useConsole(value) - sets and returns whether logger is using console +// log(message,...) - logs a message at level LOG +// error(message,...) - logs a message at level ERROR +// warn(message,...) - logs a message at level WARN +// info(message,...) - logs a message at level INFO +// debug(message,...) - logs a message at level DEBUG +// logLevel(level,message,...) - logs a message specified level +// +// ------------------------------------------------------------------------------ + +var logger = exports; + +var exec = require('cordova/exec'); + +var UseConsole = false; +var UseLogger = true; +var Queued = []; +var DeviceReady = false; +var CurrentLevel; + +var originalConsole = console; + +/** + * Logging levels + */ + +var Levels = [ + 'LOG', + 'ERROR', + 'WARN', + 'INFO', + 'DEBUG' +]; + +/* + * add the logging levels to the logger object and + * to a separate levelsMap object for testing + */ + +var LevelsMap = {}; +for (var i = 0; i < Levels.length; i++) { + var level = Levels[i]; + LevelsMap[level] = i; + logger[level] = level; +} + +CurrentLevel = LevelsMap.WARN; + +/** + * Getter/Setter for the logging level + * + * Returns the current logging level. + * + * When a value is passed, sets the logging level to that value. + * The values should be one of the following constants: + * logger.LOG + * logger.ERROR + * logger.WARN + * logger.INFO + * logger.DEBUG + * + * The value used determines which messages get printed. The logging + * values above are in order, and only messages logged at the logging + * level or above will actually be displayed to the user. E.g., the + * default level is WARN, so only messages logged with LOG, ERROR, or + * WARN will be displayed; INFO and DEBUG messages will be ignored. + */ +logger.level = function (value) { + if (arguments.length) { + if (LevelsMap[value] === null) { + throw new Error('invalid logging level: ' + value); + } + CurrentLevel = LevelsMap[value]; + } + + return Levels[CurrentLevel]; +}; + +/** + * Getter/Setter for the useConsole functionality + * + * When useConsole is true, the logger will log via the + * browser 'console' object. + */ +logger.useConsole = function (value) { + if (arguments.length) UseConsole = !!value; + + if (UseConsole) { + if (typeof console === 'undefined') { + throw new Error('global console object is not defined'); + } + + if (typeof console.log !== 'function') { + throw new Error('global console object does not have a log function'); + } + + if (typeof console.useLogger === 'function') { + if (console.useLogger()) { + throw new Error('console and logger are too intertwingly'); + } + } + } + + return UseConsole; +}; + +/** + * Getter/Setter for the useLogger functionality + * + * When useLogger is true, the logger will log via the + * native Logger plugin. + */ +logger.useLogger = function (value) { + // Enforce boolean + if (arguments.length) UseLogger = !!value; + return UseLogger; +}; + +/** + * Logs a message at the LOG level. + * + * Parameters passed after message are used applied to + * the message with utils.format() + */ +logger.log = function (message) { logWithArgs('LOG', arguments); }; + +/** + * Logs a message at the ERROR level. + * + * Parameters passed after message are used applied to + * the message with utils.format() + */ +logger.error = function (message) { logWithArgs('ERROR', arguments); }; + +/** + * Logs a message at the WARN level. + * + * Parameters passed after message are used applied to + * the message with utils.format() + */ +logger.warn = function (message) { logWithArgs('WARN', arguments); }; + +/** + * Logs a message at the INFO level. + * + * Parameters passed after message are used applied to + * the message with utils.format() + */ +logger.info = function (message) { logWithArgs('INFO', arguments); }; + +/** + * Logs a message at the DEBUG level. + * + * Parameters passed after message are used applied to + * the message with utils.format() + */ +logger.debug = function (message) { logWithArgs('DEBUG', arguments); }; + +// log at the specified level with args +function logWithArgs (level, args) { + args = [level].concat([].slice.call(args)); + logger.logLevel.apply(logger, args); +} + +// return the correct formatString for an object +function formatStringForMessage (message) { + return (typeof message === 'string') ? '' : '%o'; +} + +/** + * Logs a message at the specified level. + * + * Parameters passed after message are used applied to + * the message with utils.format() + */ +logger.logLevel = function (level /* , ... */) { + // format the message with the parameters + var formatArgs = [].slice.call(arguments, 1); + var fmtString = formatStringForMessage(formatArgs[0]); + if (fmtString.length > 0) { + formatArgs.unshift(fmtString); // add formatString + } + + var message = logger.format.apply(logger.format, formatArgs); + + if (LevelsMap[level] === null) { + throw new Error('invalid logging level: ' + level); + } + + if (LevelsMap[level] > CurrentLevel) return; + + // queue the message if not yet at deviceready + if (!DeviceReady && !UseConsole) { + Queued.push([level, message]); + return; + } + + // Log using the native logger if that is enabled + if (UseLogger) { + exec(null, null, 'Console', 'logLevel', [level, message]); + } + + // Log using the console if that is enabled + if (UseConsole) { + // make sure console is not using logger + if (console.useLogger()) { + throw new Error('console and logger are too intertwingly'); + } + + // log to the console + switch (level) { + case logger.LOG: originalConsole.log(message); break; + case logger.ERROR: originalConsole.log('ERROR: ' + message); break; + case logger.WARN: originalConsole.log('WARN: ' + message); break; + case logger.INFO: originalConsole.log('INFO: ' + message); break; + case logger.DEBUG: originalConsole.log('DEBUG: ' + message); break; + } + } +}; + +/** + * Formats a string and arguments following it ala console.log() + * + * Any remaining arguments will be appended to the formatted string. + * + * for rationale, see FireBug's Console API: + * http://getfirebug.com/wiki/index.php/Console_API + */ +logger.format = function (formatString, args) { + return __format(arguments[0], [].slice.call(arguments, 1)).join(' '); +}; + +// ------------------------------------------------------------------------------ +/** + * Formats a string and arguments following it ala vsprintf() + * + * format chars: + * %j - format arg as JSON + * %o - format arg as JSON + * %c - format arg as '' + * %% - replace with '%' + * any other char following % will format it's + * arg via toString(). + * + * Returns an array containing the formatted string and any remaining + * arguments. + */ +function __format (formatString, args) { + if (formatString === null || formatString === undefined) return ['']; + if (arguments.length === 1) return [formatString.toString()]; + + if (typeof formatString !== 'string') { formatString = formatString.toString(); } + + var pattern = /(.*?)%(.)(.*)/; + var rest = formatString; + var result = []; + + while (args.length) { + var match = pattern.exec(rest); + if (!match) break; + + var arg = args.shift(); + rest = match[3]; + result.push(match[1]); + + if (match[2] === '%') { + result.push('%'); + args.unshift(arg); + continue; + } + + result.push(__formatted(arg, match[2])); + } + + result.push(rest); + + var remainingArgs = [].slice.call(args); + remainingArgs.unshift(result.join('')); + return remainingArgs; +} + +function __formatted (object, formatChar) { + try { + switch (formatChar) { + case 'j': + case 'o': return JSON.stringify(object); + case 'c': return ''; + } + } catch (e) { + return 'error JSON.stringify()ing argument: ' + e; + } + + if ((object === null) || (object === undefined)) { + return Object.prototype.toString.call(object); + } + + return object.toString(); +} + +// ------------------------------------------------------------------------------ +// when deviceready fires, log queued messages +logger.__onDeviceReady = function () { + if (DeviceReady) return; + + DeviceReady = true; + + for (var i = 0; i < Queued.length; i++) { + var messageArgs = Queued[i]; + logger.logLevel(messageArgs[0], messageArgs[1]); + } + + Queued = null; +}; + +// add a deviceready event to log queued messages +document.addEventListener('deviceready', logger.__onDeviceReady, false); + +}); + +// file: ../../cordova-js-src/plugin/ios/statusbar.js +define("cordova/plugin/ios/statusbar", function(require, exports, module) { + +var exec = require('cordova/exec'); + +var statusBarVisible = true; +var statusBar = {}; + +// This + + + diff --git a/e2e-tests/MaestroTestApp/www/js/app.js b/e2e-tests/MaestroTestApp/www/js/app.js new file mode 100644 index 00000000..09fa0736 --- /dev/null +++ b/e2e-tests/MaestroTestApp/www/js/app.js @@ -0,0 +1,130 @@ +document.addEventListener('deviceready', function() { + Purchases.setLogLevel(Purchases.LOG_LEVEL.DEBUG); + Purchases.configure('MAESTRO_TESTS_REVENUECAT_API_KEY'); + + window.addEventListener('onCustomerInfoUpdated', function(info) { + var hasPro = info.entitlements.active && info.entitlements.active['pro'] !== undefined; + var label = document.getElementById('entitlements-label'); + if (label) { + label.textContent = 'Entitlements: ' + (hasPro ? 'pro' : 'none'); + } + }); + + showTestCases(); +}, false); + +function showTestCases() { + document.getElementById('app').innerHTML = + '

Test Cases

' + + ''; +} + +function showPurchaseScreen() { + document.getElementById('app').innerHTML = + '

Entitlements: none

' + + '' + + ''; + + Purchases.getCustomerInfo( + function(info) { + var hasPro = info.entitlements.active && info.entitlements.active['pro'] !== undefined; + var label = document.getElementById('entitlements-label'); + if (label) { + label.textContent = 'Entitlements: ' + (hasPro ? 'pro' : 'none'); + } + }, + function(error) { + console.error('Error getting customer info:', error); + } + ); +} + +function showError(message) { + var el = document.getElementById('error-message'); + if (!el) { + el = document.createElement('p'); + el.id = 'error-message'; + el.style.cssText = 'color:red;font-size:14px'; + document.getElementById('app').appendChild(el); + } + el.textContent = 'Error: ' + message; +} + +function clearError() { + var el = document.getElementById('error-message'); + if (el) el.parentNode.removeChild(el); +} + +function presentPaywall() { + clearError(); + Purchases.getOfferings( + function(offerings) { + if (offerings.current && offerings.current.availablePackages.length > 0) { + var pkg = offerings.current.availablePackages[0]; + showPaywallOverlay(pkg); + } + }, + function(error) { + console.error('Error getting offerings:', error); + showError(error.message || String(error)); + } + ); +} + +function showPaywallOverlay(pkg) { + var overlay = document.createElement('div'); + overlay.id = 'paywall-overlay'; + overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.8);display:flex;align-items:center;justify-content:center;z-index:1000'; + + var card = document.createElement('div'); + card.style.cssText = 'background:white;padding:30px;border-radius:12px;text-align:center;max-width:300px;width:90%'; + + var title = document.createElement('h2'); + title.textContent = 'Premium Access'; + + var description = document.createElement('p'); + description.textContent = 'Get access to all features'; + + var subscribeBtn = document.createElement('button'); + subscribeBtn.textContent = 'Subscribe'; + subscribeBtn.style.cssText = 'background:#4CAF50;color:white;border:none;padding:15px 40px;border-radius:8px;font-size:18px;cursor:pointer;margin-top:10px'; + subscribeBtn.onclick = function() { + Purchases.purchasePackage( + pkg, + function(productIdentifier, customerInfo) { + removePaywallOverlay(); + var hasPro = customerInfo.entitlements.active && customerInfo.entitlements.active['pro'] !== undefined; + var label = document.getElementById('entitlements-label'); + if (label) { + label.textContent = 'Entitlements: ' + (hasPro ? 'pro' : 'none'); + } + }, + function(errorInfo) { + removePaywallOverlay(); + if (!errorInfo.userCancelled) { + console.error('Purchase error:', errorInfo.error); + showError(errorInfo.error || 'Purchase failed'); + } + } + ); + }; + + var cancelBtn = document.createElement('button'); + cancelBtn.textContent = 'Cancel'; + cancelBtn.style.cssText = 'background:transparent;color:#666;border:none;padding:10px 20px;font-size:14px;cursor:pointer;margin-top:10px;display:block;width:100%'; + cancelBtn.onclick = removePaywallOverlay; + + card.appendChild(title); + card.appendChild(description); + card.appendChild(subscribeBtn); + card.appendChild(cancelBtn); + overlay.appendChild(card); + document.body.appendChild(overlay); +} + +function removePaywallOverlay() { + var overlay = document.getElementById('paywall-overlay'); + if (overlay) { + overlay.parentNode.removeChild(overlay); + } +} diff --git a/e2e-tests/maestro/purchase_flow.yaml b/e2e-tests/maestro/purchase_flow.yaml new file mode 100644 index 00000000..64e6ced2 --- /dev/null +++ b/e2e-tests/maestro/purchase_flow.yaml @@ -0,0 +1,12 @@ +appId: com.revenuecat.maestro.e2e +--- +- launchApp +- extendedWaitUntil: + visible: "Test Cases" + timeout: 15000 +- assertVisible: "Purchase through paywall" +- tapOn: "Purchase through paywall" +- extendedWaitUntil: + visible: "Entitlements: none" + timeout: 15000 +- assertVisible: "Present Paywall"