diff --git a/.github/PULL_REQUEST_TEMPLATE/bug_template.yml b/.github/PULL_REQUEST_TEMPLATE/bug_template.md similarity index 100% rename from .github/PULL_REQUEST_TEMPLATE/bug_template.yml rename to .github/PULL_REQUEST_TEMPLATE/bug_template.md diff --git a/.github/PULL_REQUEST_TEMPLATE/feature_template.yml b/.github/PULL_REQUEST_TEMPLATE/feature_template.md similarity index 100% rename from .github/PULL_REQUEST_TEMPLATE/feature_template.yml rename to .github/PULL_REQUEST_TEMPLATE/feature_template.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 587f5487e..33acbd8d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,13 +8,10 @@ on: pull_request: paths: - '.swiftlint.yml' - branches: - - main - - dev - -concurrency: - group: ci - cancel-in-progress: true + - ".github/workflows/**" + - "Package.swift" + - "Source/**" + - "Tests/**" jobs: SwiftLint: @@ -27,39 +24,216 @@ jobs: args: --strict env: DIFF_BASE: ${{ github.base_ref }} - Latest: - name: Test Latest (iOS, macOS, tvOS, watchOS) - runs-on: macOS-12 + macOS: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} + env: + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - xcode: "Xcode_15.0" + runsOn: macos-13 + name: "macOS 13, Xcode 15.0, Swift 5.9.0" + - xcode: "Xcode_14.3.1" + runsOn: macos-13 + name: "macOS 13, Xcode 14.3.1, Swift 5.8.0" + - xcode: "Xcode_14.2" + runsOn: macOS-12 + name: "macOS 12, Xcode 14.2, Swift 5.7.2" + - xcode: "Xcode_14.1" + runsOn: macOS-12 + name: "macOS 12, Xcode 14.1, Swift 5.7.1" + steps: + - uses: actions/checkout@v3 + - name: ${{ matrix.name }} + run: xcodebuild test -scheme "Flare" -destination "platform=macOS" clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3.1.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + xcode: true + xcode_archive_path: test_output/${{ matrix.name }}.xcresult + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.name }} + path: test_output + + iOS: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} + env: + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - destination: "OS=17.0.1,name=iPhone 14 Pro" + name: "iOS 17.0.1" + xcode: "Xcode_15.0" + runsOn: macos-13 + - destination: "OS=16.4,name=iPhone 14 Pro" + name: "iOS 16.4" + xcode: "Xcode_14.3.1" + runsOn: macos-13 + steps: + - uses: actions/checkout@v3 + - name: Install Dependencies + run: make setup_build_tools + - name: Generate project + run: make generate + - name: ${{ matrix.name }} + run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" -testPlan AllTests clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.name }} + path: test_output + + tvOS: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} + env: + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - destination: "OS=17.0,name=Apple TV" + name: "tvOS 17.0" + xcode: "Xcode_15.0" + runsOn: macos-13 + - destination: "OS=16.4,name=Apple TV" + name: "tvOS 16.4" + xcode: "Xcode_14.3.1" + runsOn: macos-13 + steps: + - uses: actions/checkout@v3 + - name: Install Dependencies + run: make setup_build_tools + - name: Generate project + run: make generate + - name: ${{ matrix.name }} + run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" -testPlan AllTests clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3.1.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + xcode: true + xcode_archive_path: test_output/${{ matrix.name }}.xcresult + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.name }} + path: test_output + + watchOS: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} env: - DEVELOPER_DIR: "/Applications/Xcode_14.1.app/Contents/Developer" - timeout-minutes: 10 + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" + timeout-minutes: 20 strategy: fail-fast: false matrix: include: - - destination: "OS=16.1,name=iPhone 14 Pro" - name: "iOS" - scheme: "Flare" - sdk: iphonesimulator - - destination: "OS=16.1,name=Apple TV" - name: "tvOS" - scheme: "Flare" - sdk: appletvsimulator - - destination: "OS=9.1,name=Apple Watch Series 8 (45mm)" - name: "watchOS" - scheme: "Flare" - sdk: watchsimulator - - destination: "platform=macOS" - name: "macOS" - scheme: "Flare" - sdk: macosx + - destination: "OS=10.0,name=Apple Watch Series 9 (45mm)" + name: "watchOS 10.0" + xcode: "Xcode_15.0" + runsOn: macos-13 + - destination: "OS=9.4,name=Apple Watch Series 8 (45mm)" + name: "watchOS 9.4" + xcode: "Xcode_14.3.1" + runsOn: macos-13 + - destination: "OS=8.5,name=Apple Watch Series 7 (45mm)" + name: "watchOS 8.5" + xcode: "Xcode_14.3.1" + runsOn: macos-13 steps: - uses: actions/checkout@v3 + - name: Install Dependencies + run: make setup_build_tools + - name: Generate project + run: make generate - name: ${{ matrix.name }} - run: xcodebuild test -scheme "${{ matrix.scheme }}" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES -resultBundlePath "./${{ matrix.sdk }}.xcresult" | xcpretty -r junit + run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" -testPlan UnitTests clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v3.1.0 with: token: ${{ secrets.CODECOV_TOKEN }} xcode: true - xcode_archive_path: "./${{ matrix.sdk }}.xcresult" \ No newline at end of file + xcode_archive_path: test_output/${{ matrix.name }}.xcresult + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.name }} + path: test_output + + spm: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} + env: + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - name: "Xcode 15" + xcode: "Xcode_15.0" + runsOn: macos-13 + - name: "Xcode 14" + xcode: "Xcode_14.3.1" + runsOn: macos-13 + steps: + - uses: actions/checkout@v3 + - name: ${{ matrix.name }} + run: swift build -c release --target Flare + + merge-test-reports: + needs: [iOS, macOS, watchOS, tvOS] + runs-on: macos-13 + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: test_output + - run: xcrun xcresulttool merge test_output/**/*.xcresult --output-path test_output/final/final.xcresult + - name: Upload Merged Artifact + uses: actions/upload-artifact@v4 + with: + name: MergedResult + path: test_output/final + + discover-typos: + name: Discover Typos + runs-on: macOS-12 + env: + DEVELOPER_DIR: /Applications/Xcode_14.1.app/Contents/Developer + steps: + - uses: actions/checkout@v2 + - name: Discover typos + run: | + export PATH="$PATH:/Library/Frameworks/Python.framework/Versions/3.11/bin" + python3 -m pip install --upgrade pip + python3 -m pip install codespell + codespell --ignore-words-list="hart,inout,msdos,sur" --skip="./.build/*,./.git/*" + + # Beta: + # name: ${{ matrix.name }} + # runs-on: firebreak + # env: + # DEVELOPER_DIR: "/Applications/Xcode_15.0.app/Contents/Developer" + # timeout-minutes: 10 + # strategy: + # fail-fast: false + # matrix: + # include: + # - destination: "OS=1.0,name=Apple Vision Pro" + # name: "visionOS 1.0" + # scheme: "Flare" + # steps: + # - uses: actions/checkout@v3 + # - name: ${{ matrix.name }} + # run: xcodebuild test -scheme "${{ matrix.scheme }}" -destination "${{ matrix.destination }}" clean || exit 1 \ No newline at end of file diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml new file mode 100644 index 000000000..158ca8723 --- /dev/null +++ b/.github/workflows/danger.yml @@ -0,0 +1,29 @@ +name: Danger + +on: + pull_request: + types: [synchronize, opened, reopened, labeled, unlabeled, edited] + +env: + LC_CTYPE: en_US.UTF-8 + LANG: en_US.UTF-8 + +jobs: + run-danger: + runs-on: ubuntu-latest + steps: + - name: ruby setup + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.4 + bundler-cache: true + - name: Checkout code + uses: actions/checkout@v2 + - name: Setup gems + run: | + gem install bundler + bundle install --clean --path vendor/bundle + - name: danger + env: + DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }} + run: bundle exec danger --verbose \ No newline at end of file diff --git a/.github/workflows/publish-pages.yml b/.github/workflows/publish-pages.yml new file mode 100644 index 000000000..c5e2c6c7f --- /dev/null +++ b/.github/workflows/publish-pages.yml @@ -0,0 +1,42 @@ +name: Deploy DocC + +on: + push: + branches: + - main + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: macos-12 + steps: + - name: Checkout 🛎️ + uses: actions/checkout@v3 + - name: Build DocC + run: | + swift build; + swift package \ + --allow-writing-to-directory ./docs \ + generate-documentation \ + --target Flare \ + --output-path ./docs \ + --transform-for-static-hosting \ + --hosting-base-path flare; + - name: Upload artifact + uses: actions/upload-pages-artifact@v1 + with: + path: 'docs' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v1 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 330d1674f..5ca27c72b 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,4 @@ fastlane/test_output # https://github.com/johnno1962/injectionforxcode iOSInjectionProject/ +*.xcodeproj \ No newline at end of file diff --git a/.swiftformat b/.swiftformat index 2cb62753f..a4294246c 100644 --- a/.swiftformat +++ b/.swiftformat @@ -36,8 +36,8 @@ --enable redundantSelf --enable redundantVoidReturnType --enable semicolons ---enable sortedImports ---enable sortedSwitchCases +--enable sortImports +--enable sortSwitchCases --enable spaceAroundBraces --enable spaceAroundBrackets --enable spaceAroundComments diff --git a/.swiftlint.yml b/.swiftlint.yml index b5956417d..530b2f63f 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,6 +1,9 @@ excluded: - Tests - Package.swift + - Package@swift-5.7.swift + - Package@swift-5.8.swift + - Sources/Flare/Classes/Generated/Strings.swift - .build # Rules @@ -9,6 +12,7 @@ disabled_rules: - trailing_comma - todo - opening_brace + - unneeded_synthesized_initializer opt_in_rules: # some rules are only opt-in - anyobject_protocol diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme index 46bcfc41a..866e55a53 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme @@ -34,6 +34,20 @@ ReferencedContainer = "container:"> + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ca19a1bc..c2c2d4c03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,63 @@ # Change Log All notable changes to this project will be documented in this file. +#### 3.x Releases +- `3.0.0` Release Candidates - [`3.0.0-rc.1`](#300-rc1) + #### 2.x Releases - `2.0.x` Releases - [2.0.0](#200) +#### 1.x Releases +- `1.0.x` Releases - [1.0.0](#100) + +## [3.0.0-rc.1](https://github.com/space-code/flare/releases/tag/3.0.0-rc.1) +Released on 2024-02-12. + +## Added +- Implement products caching mechanism + - Added in Pull Request [#19](https://github.com/space-code/flare/pull/19). + +- Implement Logging Functionality + - Added in Pull Request [#17](https://github.com/space-code/flare/pull/17). + +- Implement Support for Promotional Offers + - Added in Pull Request [#16](https://github.com/space-code/flare/pull/16). + +- Add additional badges to `README.md` + - Added in Pull Request [#15](https://github.com/space-code/flare/pull/15). + +- Add files to comply with community standards + - Added in Pull Request [#13](https://github.com/space-code/flare/pull/13). + +- Implement typo checking + - Added in Pull Request [#12](https://github.com/space-code/flare/pull/12). + +- Build a documentation using `Docc` + - Added in Pull Request [#11](https://github.com/space-code/flare/pull/11). + +- Integrate the `StoreKit2` purchase method + - Added in Pull Request [#10](https://github.com/space-code/flare/pull/10). + +- Add badges for `Swift Version Compatibility` and `Platform Compatibility` + - Added in Pull Request [#8](https://github.com/space-code/flare/pull/8). + +- Integrate `danger` + - Added in Pull Request [#7](https://github.com/space-code/flare/pull/7). + +- Implement a refund for purchases + - Added in Pull Request [#6](https://github.com/space-code/flare/pull/6). + +- Added `visionOS` to list of supported platforms + - Added in Pull Request [#5](https://github.com/space-code/flare/pull/5). + +## Updated +- Update the documentation page for Promotional Offer + - Updated in Pull Request [#18](https://github.com/space-code/flare/pull/18). + +## Fixed +- Fix typos in `CONTRIBUTING.md` + - Added in Pull Request[#14](https://github.com/space-code/flare/pull/14). + ## [2.0.0](https://github.com/space-code/flare/releases/tag/2.0.0) Released on 2023-09-13. @@ -13,12 +67,9 @@ Released on 2023-09-13. #### Updated - Rename public methods and parameters to increase readability. -#### 1.x Releases -- `1.0.x` Releases - [1.0.0](#100) - ## [1.0.0](https://github.com/space-code/flare/releases/tag/1.0.0) Released on 2023-01-20. #### Added - Initial release of Flare. - - Added by [Nikita Vasilev](https://github.com/nik3212). \ No newline at end of file + - Added by [Nikita Vasilev](https://github.com/nik3212). diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..56c1661b0 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,74 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting one of the project maintainers https://github.com/orgs/space-code/people. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..293fcb589 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# Contributing Guidelines + +This document contains information and guidelines about contributing to this project. +Please read it before you start participating. + +**Topics** + +* [Reporting Issues](#reporting-issues) +* [Submitting Pull Requests](#submitting-pull-requests) +* [Developers Certificate of Origin](#developers-certificate-of-origin) +* [Code of Conduct](#code-of-conduct) + +## Reporting Issues + +A great way to contribute to the project is to send a detailed issue when you encounter a problem. We always appreciate a well-written, thorough bug report. + +Check that the project issues database doesn't already include that problem or suggestion before submitting an issue. If you find a match, feel free to vote for the issue by adding a reaction. Doing this helps prioritize the most common problems and requests. + +When reporting issues, please fill out our issue template. The information the template asks for will help us review and fix your issue faster. + +## Submitting Pull Requests + +You can contribute by fixing bugs or adding new features. For larger code changes, we recommend first discussing your ideas on our [GitHub Discussions](https://github.com/space-code/flare/discussions). When submitting a pull request, please add relevant tests and ensure your changes don't break any existing tests. + +## Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +- (a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +- (b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +- (c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +- (d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. + +## Code of Conduct + +The Code of Conduct governs how we behave in public or in private +whenever the project will be judged by our actions. +We expect it to be honored by everyone who contributes to this project. + +See [CODE_OF_CONDUCT.md](https://github.com/space-code/flare/blob/master/CODE_OF_CONDUCT.md) for details. + +--- + +*Some of the ideas and wording for the statements above were based on work by the [Docker](https://github.com/docker/docker/blob/master/CONTRIBUTING.md) and [Linux](https://elinux.org/Developer_Certificate_Of_Origin) communities. \ No newline at end of file diff --git a/Dangerfile b/Dangerfile new file mode 100644 index 000000000..b26698244 --- /dev/null +++ b/Dangerfile @@ -0,0 +1 @@ +danger.import_dangerfile(github: 'space-code/dangerfile') \ No newline at end of file diff --git a/Documentation/Resources/flare.png b/Documentation/Resources/flare.png deleted file mode 100644 index 2ac351475..000000000 Binary files a/Documentation/Resources/flare.png and /dev/null differ diff --git a/Documentation/Usage.md b/Documentation/Usage.md deleted file mode 100644 index d9a400b4e..000000000 --- a/Documentation/Usage.md +++ /dev/null @@ -1,171 +0,0 @@ -# Documentation - -### Overview - -* [Introduction](#introduction) -* [Flare Diagram](#flare-diagram) -* [In-App Purchases](#in-app-purchases) - - [Getting Products](#getting-products) - - [Purchasing Product](#purchasing-product) - - [Refreshing Receipt](#refreshing-receipt) - - [Finishing Transaction](#finishing-transaction) - - [Setup Observers](#setup-observers) -* [Errors](#handling-errors) - - [IAPError](#iaperror) - -## Introduction - -Flare provides an elegant interface for In-App Purchases, supporting non-consumable and consumable purchases as well as subscriptions. - -## Flare Diagram - -![Flare: Components](https://raw.githubusercontent.com/space-code/flare/dev/Documentation/Resources/flare.png) - -- `Flare` is a central component that serves as the client API for managing various aspects of in-app purchases and payments in your application. It is designed to simplify the integration of payment processing and in-app purchase functionality into your software. -- `IAPProvider` is a fundamental component of `Flare` that handles all in-app purchase operations. It offers an API to initiate, verify, and manage in-app purchases within your application. -- `IPaymentProvider` is a central component in `Flare` that orchestrates various payment-related operations within your application. It acts as the bridge between the payment gateway and your app's logic. -- `IProductProvider` is a component of `Flare` that helps managing the products or services available for purchase within your app. -- `IReceiptRefreshProvider` is responsible for refreshing and managing receipt associated with in-app purchases. -- `IAppStoreReceiptProvider` manages and provides access to the app's receipt, which contains a record of all in-app purchases made by the user. - -## In-App Purchases - -### Getting Products - -Before attempting to add a payment always check if the user can actually make payments. The `Flare` does it under the hood, if a user cannot make payments, you will get an `IAPError` with the value `paymentNotAllowed`. - -The `fetch` method sends a request to the App Store, which retrieves the products if they are available. The `productIDs` parameter takes the product ids, which should be given from the App Store. - -```swift -Flare.default.fetch(productIDs: Set(arrayLiteral: ["product_id"])) { result in - switch result { - case let .success(products): - debugPrint("Fetched products: \(products)") - case let .failure(error): - debugPrint("An error occurred while fetching products: \(error.localizedDescription)") - } -} -``` - -Additionally, there are versions of both `fetch` that provide an `async` method, allowing the use of `await`. - -```swift - do { - let products = try await Flare.default.fetch(productIDs: Set(arrayLiteral: ["product_id"])) - } catch { - debugPrint("An error occurred while fetching products: \(error.localizedDescription)") - } -``` - -### Purchasing Product - -The `purchase` method performs a purchase of the product. The method accepts an `productID` parameter which represents a product's id. - -```swift -Flare.default.purchase(productID: "product_id") { result in - switch result { - case let .success(transaction): - debugPrint("A transaction was received: \(transaction)") - case let .failure(error): - debugPrint("An error occurred while purchasing product: \(error.localizedDescription)") - } -} -``` - -You can also use the `async/await` implementation of the `purchase` method. - -```swift - do { - let products = try await Flare.default.purchase(productID: "product_id") - } catch { - debugPrint("An error occurred while purchasing product: \(error.localizedDescription)") - } -``` - -### Refreshing Receipt - -The `refresh` method refreshes the receipt, representing the user's transactions with your app. - -```swift -Flare.default.refresh { result in - switch result { - case let .success(receipt): - debugPrint("A receipt was received: \(receipt)") - case let .failure(error): - debugPrint("An error occurred while fetching receipt: \(error.localizedDescription)") - } -} -``` - -You can also use the `async/await` implementation of the `refresh` method. - -```swift - do { - let receipt = try await Flare.default.refresh() - } catch { - debugPrint("An error occurred while fetching receipt: \(error.localizedDescription)") - } -``` - -### Finishing Transaction - -The `finish` method removes a finished (i.e. failed or completed) transaction from the queue. - -```swift -Flare.default.finish(transaction: ) -``` - -### Setup Observers - -The transactions array will only be synchronized with the server while the queue has observers. These methods may require that the user authenticate. -It is important to set an observer on this queue as early as possible after your app launch. Observer is responsible for processing all events triggered by the queue. - -```swift -// Adds transaction observer to the payment queue and handles payment transactions. -Flare.default.addTransactionObserver { result in - switch result { - case let .success(transaction): - debugPrint("A transaction was received: \(transaction)") - case let .failure(error): - debugPrint("An error occurred while adding transaction observer: \(error.localizedDescription)") - } -} -``` - -```swift -// Removes transaction observer from the payment queue. -Flare.default.removeTransactionObserver() -``` - -## Handling Errors - -### IAPError - -By default, all methods handlers in public interfaces produced the `IAPError` error type. The `IAPError` describes frequently used error types in the app. - -```swift -/// `IAPError` is the error type returned by Flare. -/// It encompasses a few different types of errors, each with their own associated reasons. -public enum IAPError: Swift.Error { - /// The empty array of products were fetched. - case emptyProducts - /// The attempt to fetch products with invalid identifiers. - case invalid(productIds: [String]) - /// The attempt to purchase a product when payments are not allowed. - case paymentNotAllowed - /// The payment was cancelled. - case paymentCancelled - /// The attempt to fetch a product that doesn't available. - case storeProductNotAvailable - /// The `SKPayment` returned unknown error. - case storeTrouble - /// The operation failed with an underlying error. - case with(error: Swift.Error) - /// The App Store receipt wasn't found. - case receiptNotFound - /// The unknown error occurred. - case unknown -} -``` - -If you need a `SKError`, you can simply look at the `plainError` property in the `IAPError`. diff --git a/Gemfile b/Gemfile new file mode 100644 index 000000000..20dff646e --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem 'danger' \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..15dfc08f2 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,66 @@ +GEM + remote: https://rubygems.org/ + specs: + addressable (2.8.5) + public_suffix (>= 2.0.2, < 6.0) + base64 (0.1.1) + claide (1.1.0) + claide-plugins (0.9.2) + cork + nap + open4 (~> 1.3) + colored2 (3.1.2) + cork (0.3.0) + colored2 (~> 3.1) + danger (9.3.2) + claide (~> 1.0) + claide-plugins (>= 0.9.2) + colored2 (~> 3.1) + cork (~> 0.1) + faraday (>= 0.9.0, < 3.0) + faraday-http-cache (~> 2.0) + git (~> 1.13) + kramdown (~> 2.3) + kramdown-parser-gfm (~> 1.0) + no_proxy_fix + octokit (~> 6.0) + terminal-table (>= 1, < 4) + faraday (2.7.11) + base64 + faraday-net_http (>= 2.0, < 3.1) + ruby2_keywords (>= 0.0.4) + faraday-http-cache (2.5.0) + faraday (>= 0.8) + faraday-net_http (3.0.2) + git (1.18.0) + addressable (~> 2.8) + rchardet (~> 1.8) + kramdown (2.4.0) + rexml + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) + nap (1.1.0) + no_proxy_fix (0.1.2) + octokit (6.1.1) + faraday (>= 1, < 3) + sawyer (~> 0.9) + open4 (1.3.4) + public_suffix (5.0.3) + rchardet (1.8.0) + rexml (3.2.6) + ruby2_keywords (0.0.5) + sawyer (0.9.2) + addressable (>= 2.3.5) + faraday (>= 0.17.3, < 3) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + unicode-display_width (2.5.0) + +PLATFORMS + x86_64-darwin-22 + +DEPENDENCIES + danger + +BUNDLED WITH + 2.4.10 diff --git a/Makefile b/Makefile index 856d64b45..37e687ede 100644 --- a/Makefile +++ b/Makefile @@ -16,4 +16,19 @@ lint: fmt: mint run swiftformat Sources Tests -.PHONY: all bootstrap hook mint lint fmt \ No newline at end of file +strings: + swiftgen + +generate: + xcodegen generate + +setup_build_tools: + sh scripts/setup_build_tools.sh + +build: + swift build -c release --target Flare + +test: + xcodebuild test -scheme "Flare" -destination "$(destination)" -testPlan AllTests clean -enableCodeCoverage YES + +.PHONY: all bootstrap hook mint lint fmt generate setup_build_tools strings build test \ No newline at end of file diff --git a/Mintfile b/Mintfile index 1f32d3389..e2cdefabc 100644 --- a/Mintfile +++ b/Mintfile @@ -1,2 +1,2 @@ -nicklockwood/SwiftFormat@0.47.12 -realm/SwiftLint@0.47.1 \ No newline at end of file +nicklockwood/SwiftFormat@0.52.7 +realm/SwiftLint@0.53.0 \ No newline at end of file diff --git a/Package.resolved b/Package.resolved index 24dde42a0..afc089a5b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,16 +1,50 @@ { - "object": { - "pins": [ - { - "package": "Concurrency", - "repositoryURL": "https://github.com/space-code/concurrency", - "state": { - "branch": null, - "revision": "f9611694f77f64e43d9467a16b2f5212cd04099b", - "version": "0.0.1" - } + "pins" : [ + { + "identity" : "atomic", + "kind" : "remoteSourceControl", + "location" : "https://github.com/space-code/atomic.git", + "state" : { + "revision" : "6a1473440c31c6debf1de2404265949ed7892b14", + "version" : "1.0.1" } - ] - }, - "version": 1 + }, + { + "identity" : "concurrency", + "kind" : "remoteSourceControl", + "location" : "https://github.com/space-code/concurrency", + "state" : { + "revision" : "f9611694f77f64e43d9467a16b2f5212cd04099b", + "version" : "0.0.1" + } + }, + { + "identity" : "log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/space-code/log", + "state" : { + "revision" : "d99fff5656c31ef7e604965b90a50ec10539c98f", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + } + ], + "version" : 2 } diff --git a/Package.swift b/Package.swift index cec0ab93a..0291e2f6c 100644 --- a/Package.swift +++ b/Package.swift @@ -1,8 +1,11 @@ -// swift-tools-version: 5.5 +// swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. +// swiftlint:disable all import PackageDescription +let visionOSSetting: SwiftSetting = .define("VISION_OS", .when(platforms: [.visionOS])) + let package = Package( name: "Flare", platforms: [ @@ -10,19 +13,27 @@ let package = Package( .iOS(.v13), .watchOS(.v7), .tvOS(.v13), + .visionOS(.v1), ], products: [ .library(name: "Flare", targets: ["Flare"]), ], dependencies: [ .package(url: "https://github.com/space-code/concurrency.git", .upToNextMajor(from: "0.0.1")), + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.1.0"), + .package(url: "https://github.com/space-code/log.git", .upToNextMajor(from: "1.1.0")), + .package(url: "https://github.com/space-code/atomic.git", .upToNextMajor(from: "1.0.0")), ], targets: [ .target( name: "Flare", dependencies: [ + .product(name: "Atomic", package: "atomic"), .product(name: "Concurrency", package: "concurrency"), - ] + .product(name: "Log", package: "log"), + ], + resources: [.process("Resources")], + swiftSettings: [visionOSSetting] ), .testTarget( name: "FlareTests", diff --git a/Package@swift-5.7.swift b/Package@swift-5.7.swift new file mode 100644 index 000000000..693b183aa --- /dev/null +++ b/Package@swift-5.7.swift @@ -0,0 +1,42 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. +// swiftlint:disable all + +import PackageDescription + +let package = Package( + name: "Flare", + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .watchOS(.v7), + .tvOS(.v13), + ], + products: [ + .library(name: "Flare", targets: ["Flare"]), + ], + dependencies: [ + .package(url: "https://github.com/space-code/concurrency.git", .upToNextMajor(from: "0.0.1")), + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.1.0"), + .package(url: "https://github.com/space-code/log.git", .upToNextMajor(from: "1.1.0")), + .package(url: "https://github.com/space-code/atomic.git", .upToNextMajor(from: "1.0.0")), + ], + targets: [ + .target( + name: "Flare", + dependencies: [ + .product(name: "Atomic", package: "atomic"), + .product(name: "Concurrency", package: "concurrency"), + .product(name: "Log", package: "log"), + ], + resources: [.process("Resources")] + ), + .testTarget( + name: "FlareTests", + dependencies: [ + "Flare", + .product(name: "TestConcurrency", package: "concurrency"), + ] + ), + ] +) diff --git a/Package@swift-5.8.swift b/Package@swift-5.8.swift new file mode 100644 index 000000000..87183860a --- /dev/null +++ b/Package@swift-5.8.swift @@ -0,0 +1,42 @@ +// swift-tools-version: 5.8 +// The swift-tools-version declares the minimum version of Swift required to build this package. +// swiftlint:disable all + +import PackageDescription + +let package = Package( + name: "Flare", + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .watchOS(.v7), + .tvOS(.v13), + ], + products: [ + .library(name: "Flare", targets: ["Flare"]), + ], + dependencies: [ + .package(url: "https://github.com/space-code/concurrency.git", .upToNextMajor(from: "0.0.1")), + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.1.0"), + .package(url: "https://github.com/space-code/log.git", .upToNextMajor(from: "1.1.0")), + .package(url: "https://github.com/space-code/atomic.git", .upToNextMajor(from: "1.0.0")), + ], + targets: [ + .target( + name: "Flare", + dependencies: [ + .product(name: "Atomic", package: "atomic"), + .product(name: "Concurrency", package: "concurrency"), + .product(name: "Log", package: "log"), + ], + resources: [.process("Resources")] + ), + .testTarget( + name: "FlareTests", + dependencies: [ + "Flare", + .product(name: "TestConcurrency", package: "concurrency"), + ] + ), + ] +) diff --git a/README.md b/README.md index c8654d0d0..63b899b10 100644 --- a/README.md +++ b/README.md @@ -3,20 +3,21 @@

flare

-Liscence -Platform -Swift5.5 +Licence +Swift Compatibility +Platform Compatibility CI CodeCov -CI +GitHub release; latest by date +GitHub commit activity

## Description Flare is a framework written in Swift that makes it easy for you to work with in-app purchases and subscriptions. - [Features](#features) -- [Documentaton](#documentation) +- [Documentation](#documentation) - [Requirements](#requirements) - [Installation](#installation) - [Communication](#communication) @@ -27,16 +28,17 @@ Flare is a framework written in Swift that makes it easy for you to work with in ## Features - [x] Support Consumable & Non-Consumable Purchases - [x] Support Subscription Purchase -- [x] Refresh Receipt -- [x] Complete Unit Test Coverage +- [x] Support Promotional & Introductory Offers +- [x] iOS, tvOS, watchOS, macOS, and visionOS compatible +- [x] Complete Unit & Integration Test Coverage ## Documentation -Check out [flare documentation](https://github.com/space-code/flare/blob/main/Documentation/Usage.md). +Check out the [flare documentation](https://space-code.github.io/flare/documentation/flare/). ## Requirements -- iOS 13.0+ / macOS 10.15+ / tvOS 13.0+ / watchOS 7.0+ +- iOS 13.0+ / macOS 10.15+ / tvOS 13.0+ / watchOS 7.0+ / visionOS 1.0+ - Xcode 14.0 -- Swift 5.5 +- Swift 5.7 ## Installation ### Swift Package Manager @@ -69,4 +71,4 @@ Please feel free to help out with this project! If you see something that could Nikita Vasilev, nv3212@gmail.com ## License -flare is available under the MIT license. See the LICENSE file for more info. \ No newline at end of file +flare is available under the MIT license. See the LICENSE file for more info. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..20dffca0b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,7 @@ +# Reporting Security Vulnerabilities + +This software is built with security and data privacy in mind to ensure your data is safe. We are grateful for security researchers and users reporting a vulnerability to us, first. To ensure that your request is handled in a timely manner and non-disclosure of vulnerabilities can be assured, please follow the below guideline. + +**Please do not report security vulnerabilities directly on GitHub. GitHub Issues can be publicly seen and therefore would result in a direct disclosure.** + +* Please address questions about data privacy, security concepts, and other media requests to the nv3212@gmail.com mailbox. \ No newline at end of file diff --git a/Sources/Flare/Classes/Common/Logger.swift b/Sources/Flare/Classes/Common/Logger.swift new file mode 100644 index 000000000..9fa71ee7a --- /dev/null +++ b/Sources/Flare/Classes/Common/Logger.swift @@ -0,0 +1,78 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import Log + +// MARK: - Logger + +enum Logger { + // MARK: Properties + + private static var defaultLogLevel: LogLevel { + #if DEBUG + return .debug + #endif + return .info + } + + private static let `default`: Log.Logger = .init( + printers: [ + ConsolePrinter(formatters: Self.formatters), + OSPrinter(subsystem: .subsystem, category: .category, formatters: Self.formatters), + ], + logLevel: Self.defaultLogLevel + ) + + private static var formatters: [ILogFormatter] { + [ + PrefixLogFormatter(name: .subsystem), + TimestampLogFormatter(dateFormat: "dd.MM.yyyy hh:mm:ss"), + ] + } + + static var logLevel: LogLevel { + get { Self.default.logLevel } + set { Self.default.logLevel = newValue } + } + + // MARK: Static Public Methods + + static func debug(message: @autoclosure () -> String) { + log(level: .debug, message: message()) + } + + static func info(message: @autoclosure () -> String) { + log(level: .info, message: message()) + } + + static func error(message: @autoclosure () -> String) { + log(level: .fault, message: message()) + } + + // MARK: Private + + private static func log(level: LogLevel, message: @autoclosure () -> String) { + switch level { + case .debug: + Self.default.debug(message: message()) + case .info: + Self.default.info(message: message()) + case .fault: + Self.default.fault(message: message()) + case .error: + Self.default.error(message: message()) + default: + break + } + } +} + +// MARK: - Constants + +private extension String { + static let subsystem = "Flare" + static let category = "iap" +} diff --git a/Sources/Flare/Classes/Common/Types.swift b/Sources/Flare/Classes/Common/Types.swift index 6d71b8f40..74d4d65d2 100644 --- a/Sources/Flare/Classes/Common/Types.swift +++ b/Sources/Flare/Classes/Common/Types.swift @@ -7,3 +7,5 @@ import Foundation public typealias Closure = (T) -> Void public typealias Closure2 = (T, U) -> Void + +public typealias SendableClosure = @Sendable (T) -> Void diff --git a/Sources/Flare/Classes/DI/FlareDependencies.swift b/Sources/Flare/Classes/DI/FlareDependencies.swift new file mode 100644 index 000000000..f97ae841c --- /dev/null +++ b/Sources/Flare/Classes/DI/FlareDependencies.swift @@ -0,0 +1,77 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Concurrency +import Foundation +import StoreKit + +/// The package's dependencies. +final class FlareDependencies: IFlareDependencies { + // MARK: Internal + + lazy var iapProvider: IIAPProvider = IAPProvider( + paymentQueue: SKPaymentQueue.default(), + productProvider: cachingProductProviderDecorator, + purchaseProvider: purchaseProvider, + receiptRefreshProvider: receiptRefreshProvider, + refundProvider: refundProvider, + eligibilityProvider: eligibilityProvider, + redeemCodeProvider: redeemCodeProvider + ) + + lazy var configurationProvider: IConfigurationProvider = ConfigurationProvider() + + // MARK: Private + + private var cachingProductProviderDecorator: ICachingProductsProviderDecorator { + CachingProductsProviderDecorator( + productProvider: productProvider, + configurationProvider: configurationProvider + ) + } + + private var productProvider: IProductProvider { + ProductProvider( + dispatchQueueFactory: DispatchQueueFactory() + ) + } + + private var purchaseProvider: IPurchaseProvider { + PurchaseProvider( + paymentProvider: paymentProvider, + configurationProvider: configurationProvider + ) + } + + private var paymentProvider: IPaymentProvider { + PaymentProvider( + paymentQueue: SKPaymentQueue.default(), + dispatchQueueFactory: DispatchQueueFactory() + ) + } + + private var receiptRefreshProvider: IReceiptRefreshProvider { + ReceiptRefreshProvider( + dispatchQueueFactory: DispatchQueueFactory(), + receiptRefreshRequestFactory: ReceiptRefreshRequestFactory() + ) + } + + private var refundProvider: IRefundProvider { + RefundProvider( + systemInfoProvider: SystemInfoProvider() + ) + } + + private var eligibilityProvider: IEligibilityProvider { + EligibilityProvider() + } + + private var redeemCodeProvider: IRedeemCodeProvider { + RedeemCodeProvider(systemInfoProvider: systemInfoProvider) + } + + private lazy var systemInfoProvider: ISystemInfoProvider = SystemInfoProvider() +} diff --git a/Sources/Flare/Classes/DI/IFlareDependencies.swift b/Sources/Flare/Classes/DI/IFlareDependencies.swift new file mode 100644 index 000000000..e962f74f3 --- /dev/null +++ b/Sources/Flare/Classes/DI/IFlareDependencies.swift @@ -0,0 +1,14 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// The package's dependencies. +protocol IFlareDependencies { + /// The IAP provider. + var iapProvider: IIAPProvider { get } + /// The configuration provider. + var configurationProvider: IConfigurationProvider { get } +} diff --git a/Sources/Flare/Classes/Extensions/Formatters/NumberFormatter+.swift b/Sources/Flare/Classes/Extensions/Formatters/NumberFormatter+.swift new file mode 100644 index 000000000..c044972af --- /dev/null +++ b/Sources/Flare/Classes/Extensions/Formatters/NumberFormatter+.swift @@ -0,0 +1,23 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +extension NumberFormatter { + static func numberFormatter(with locale: Locale) -> NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.locale = locale + return formatter + } + + func numberFormatter(with currencyCode: String, locale: Locale = .autoupdatingCurrent) -> NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.locale = locale + formatter.currencyCode = currencyCode + return formatter + } +} diff --git a/Sources/Flare/Classes/Extensions/Locale/Locale+CurrencyCode.swift b/Sources/Flare/Classes/Extensions/Locale/Locale+CurrencyCode.swift new file mode 100644 index 000000000..c54b4cccc --- /dev/null +++ b/Sources/Flare/Classes/Extensions/Locale/Locale+CurrencyCode.swift @@ -0,0 +1,20 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +extension Locale { + var currencyCodeID: String? { + #if swift(>=5.9) + if #available(macOS 13, iOS 16, tvOS 16, watchOS 9, visionOS 1.0, *) { + return self.currency?.identifier + } else { + return currencyCode + } + #else + return currencyCode + #endif + } +} diff --git a/Sources/Flare/Classes/Extensions/ProductType+.swift b/Sources/Flare/Classes/Extensions/ProductType+.swift new file mode 100644 index 000000000..73c936ca6 --- /dev/null +++ b/Sources/Flare/Classes/Extensions/ProductType+.swift @@ -0,0 +1,40 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +extension ProductType { + init(_ type: StoreKit.Product.ProductType) { + switch type { + case .consumable: + self = .consumable + case .nonConsumable: + self = .nonConsumable + case .nonRenewable: + self = .nonRenewableSubscription + case .autoRenewable: + self = .autoRenewableSubscription + default: + self = .nonConsumable + } + } +} + +extension ProductType { + var productCategory: ProductCategory { + switch self { + case .consumable: + return .nonSubscription + case .nonConsumable: + return .nonSubscription + case .nonRenewableSubscription: + return .subscription + case .autoRenewableSubscription: + return .subscription + } + } +} diff --git a/Sources/Flare/Classes/Flare.swift b/Sources/Flare/Classes/Flare.swift new file mode 100644 index 000000000..9ea74d1f4 --- /dev/null +++ b/Sources/Flare/Classes/Flare.swift @@ -0,0 +1,187 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import struct Log.LogLevel +import StoreKit + +#if os(iOS) || VISION_OS + import UIKit +#endif + +// MARK: - Flare + +/// The class creates and manages in-app purchases. +public final class Flare { + // MARK: Properties + + /// The in-app purchase provider. + private let iapProvider: IIAPProvider + + /// The configuration provider. + private let configurationProvider: IConfigurationProvider + + /// The singleton instance. + private static let flare: Flare = .init() + + /// Returns a shared `Flare` object. + public static var shared: IFlare { flare } + + /// The log level. + public var logLevel: LogLevel { + get { Logger.logLevel } + set { Logger.logLevel = newValue } + } + + // MARK: Initialization + + /// Creates a new `Flare` instance. + /// + /// - Parameters: + /// - dependencies: The package's dependencies. + /// - configurationProvider: The configuration provider. + init(dependencies: IFlareDependencies = FlareDependencies()) { + iapProvider = dependencies.iapProvider + configurationProvider = dependencies.configurationProvider + } + + // MARK: Public + + /// Configures the Flare package with the provided configuration. + /// + /// - Parameters: + /// - configuration: The configuration object containing settings for Flare. + public static func configure(with configuration: Configuration) { + flare.configurationProvider.configure(with: configuration) + } +} + +// MARK: IFlare + +extension Flare: IFlare { + public func fetch(productIDs: Set, completion: @escaping Closure>) { + iapProvider.fetch(productIDs: productIDs, completion: completion) + } + + public func fetch(productIDs: Set) async throws -> [StoreProduct] { + try await iapProvider.fetch(productIDs: productIDs) + } + + public func purchase( + product: StoreProduct, + promotionalOffer: PromotionalOffer?, + completion: @escaping Closure> + ) { + guard checkIfUserCanMakePayments() else { + completion(.failure(.paymentNotAllowed)) + return + } + + iapProvider.purchase(product: product, promotionalOffer: promotionalOffer) { result in + switch result { + case let .success(transaction): + completion(.success(transaction)) + case let .failure(error): + completion(.failure(error)) + } + } + } + + public func purchase(product: StoreProduct, promotionalOffer: PromotionalOffer?) async throws -> StoreTransaction { + guard checkIfUserCanMakePayments() else { throw IAPError.paymentNotAllowed } + return try await iapProvider.purchase(product: product, promotionalOffer: promotionalOffer) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + public func purchase( + product: StoreProduct, + options: Set, + promotionalOffer: PromotionalOffer?, + completion: @escaping SendableClosure> + ) { + guard checkIfUserCanMakePayments() else { + completion(.failure(.paymentNotAllowed)) + return + } + iapProvider.purchase(product: product, options: options, promotionalOffer: promotionalOffer, completion: completion) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + public func purchase( + product: StoreProduct, + options: Set, + promotionalOffer: PromotionalOffer? + ) async throws -> StoreTransaction { + guard checkIfUserCanMakePayments() else { throw IAPError.paymentNotAllowed } + return try await iapProvider.purchase(product: product, options: options, promotionalOffer: promotionalOffer) + } + + public func receipt(completion: @escaping Closure>) { + iapProvider.refreshReceipt { result in + switch result { + case let .success(receipt): + completion(.success(receipt)) + case let .failure(error): + completion(.failure(error)) + } + } + } + + public func receipt() async throws -> String { + try await iapProvider.refreshReceipt() + } + + public func finish(transaction: StoreTransaction, completion: (@Sendable () -> Void)?) { + iapProvider.finish(transaction: transaction, completion: completion) + } + + public func addTransactionObserver(fallbackHandler: Closure>?) { + iapProvider.addTransactionObserver(fallbackHandler: fallbackHandler) + } + + public func removeTransactionObserver() { + iapProvider.removeTransactionObserver() + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + public func checkEligibility(productIDs: Set) async throws -> [String: SubscriptionEligibility] { + try await iapProvider.checkEligibility(productIDs: productIDs) + } + + #if os(iOS) || VISION_OS + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + public func beginRefundRequest(productID: String) async throws -> RefundRequestStatus { + try await iapProvider.beginRefundRequest(productID: productID) + } + + @available(iOS 14.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + public func presentCodeRedemptionSheet() { + iapProvider.presentCodeRedemptionSheet() + } + + @available(iOS 16.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + public func presentOfferCodeRedeemSheet() async throws { + try await iapProvider.presentOfferCodeRedeemSheet() + } + #endif + + // MARK: Private + + private func checkIfUserCanMakePayments() -> Bool { + guard iapProvider.canMakePayments else { + Logger.error(message: L10n.Purchase.cannotPurcaseProduct) + return false + } + return true + } +} diff --git a/Sources/Flare/Classes/Foundation/UserDefaults/IUserDefaults.swift b/Sources/Flare/Classes/Foundation/UserDefaults/IUserDefaults.swift new file mode 100644 index 000000000..dfbd224b4 --- /dev/null +++ b/Sources/Flare/Classes/Foundation/UserDefaults/IUserDefaults.swift @@ -0,0 +1,24 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Protocol for managing `UserDefaults` operations. +protocol IUserDefaults { + /// Sets a `Codable` value in `UserDefaults` for a given key. + /// + /// - Parameters: + /// - key: The key to associate with the Codable value. + /// + /// - codable: The Codable value to be stored. + func set(key: String, codable: T) + + /// Retrieves a `Codable` value from `UserDefaults` for a given key. + /// + /// - Parameter key: The key associated with the desired Codable value. + /// + /// - Returns: The Codable value stored for the given key, or nil if not found. + func get(key: String) -> T? +} diff --git a/Sources/Flare/Classes/Foundation/UserDefaults/UserDefaults.swift b/Sources/Flare/Classes/Foundation/UserDefaults/UserDefaults.swift new file mode 100644 index 000000000..e280286e2 --- /dev/null +++ b/Sources/Flare/Classes/Foundation/UserDefaults/UserDefaults.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +extension UserDefaults: IUserDefaults { + func set(key: String, codable: T) { + guard let value = try? JSONEncoder().encode(codable) else { return } + set(value, forKey: key) + } + + func get(key: String) -> T? { + let data = object(forKey: key) as? Data + guard let data = data, let value = try? JSONDecoder().decode(T.self, from: data) else { return nil } + return value + } +} diff --git a/Sources/Flare/Classes/Generated/Strings.swift b/Sources/Flare/Classes/Generated/Strings.swift new file mode 100644 index 000000000..5d56fbcbe --- /dev/null +++ b/Sources/Flare/Classes/Generated/Strings.swift @@ -0,0 +1,213 @@ +// swiftlint:disable all +// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen + +import Foundation + +// swiftlint:disable superfluous_disable_command file_length implicit_return prefer_self_in_static_references + +// MARK: - Strings + +// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length +// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces +internal enum L10n { + internal enum Error { + internal enum FailedToDecodeSignature { + /// Decoding the signature has failed. The signature: %s + internal static func description(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "error.failed_to_decode_signature.description", p1, fallback: "Decoding the signature has failed. The signature: %s") + } + } + internal enum InvalidProductIds { + /// Invalid product IDs: %@ + internal static func description(_ p1: Any) -> String { + return L10n.tr("Localizable", "error.invalid_product_ids.description", String(describing: p1), fallback: "Invalid product IDs: %@") + } + } + internal enum PaymentCancelled { + /// The payment was canceled by the user. + internal static let description = L10n.tr("Localizable", "error.payment_cancelled.description", fallback: "The payment was canceled by the user.") + } + internal enum PaymentDefferred { + /// The purchase is pending, and requires action from the customer. + internal static let description = L10n.tr("Localizable", "error.payment_defferred.description", fallback: "The purchase is pending, and requires action from the customer.") + } + internal enum PaymentNotAllowed { + /// The current user is not eligible to make payments. + internal static let description = L10n.tr("Localizable", "error.payment_not_allowed.description", fallback: "The current user is not eligible to make payments.") + /// The payment card may have purchase restrictions, such as set limits or unavailability for online shopping. + internal static let failureReason = L10n.tr("Localizable", "error.payment_not_allowed.failure_reason", fallback: "The payment card may have purchase restrictions, such as set limits or unavailability for online shopping.") + /// Please check the payment card purchase restrictions. + internal static let recoverySuggestion = L10n.tr("Localizable", "error.payment_not_allowed.recovery_suggestion", fallback: "Please check the payment card purchase restrictions.") + } + internal enum Receipt { + /// The receipt could not be found. + internal static let description = L10n.tr("Localizable", "error.receipt.description", fallback: "The receipt could not be found.") + } + internal enum Refund { + /// The error occurred during the refund: %s + internal static func description(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "error.refund.description", p1, fallback: "The error occurred during the refund: %s") + } + } + internal enum StoreProductNotAvailable { + /// The store product is currently unavailable. + internal static let description = L10n.tr("Localizable", "error.store_product_not_available.description", fallback: "The store product is currently unavailable.") + /// Make sure to create a product with the given identifier in App Store Connect. + internal static let recoverySuggestion = L10n.tr("Localizable", "error.store_product_not_available.recovery_suggestion", fallback: "Make sure to create a product with the given identifier in App Store Connect.") + } + internal enum TransactionNotFound { + /// Transaction for productID: %s couldn't be found. + internal static func description(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "error.transaction_not_found.description", p1, fallback: "Transaction for productID: %s couldn't be found.") + } + } + internal enum Unknown { + /// The SKPayment returned unknown error. + internal static let description = L10n.tr("Localizable", "error.unknown.description", fallback: "The SKPayment returned unknown error.") + } + internal enum Verification { + /// The verification has failed with the following error: %s + internal static func description(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "error.verification.description", p1, fallback: "The verification has failed with the following error: %s") + } + } + internal enum With { + /// The error occurred: %s + internal static func description(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "error.with.description", p1, fallback: "The error occurred: %s") + } + } + } + internal enum Flare { + /// Flare configured with configuration: + /// %@ + internal static func initWithConfiguration(_ p1: Any) -> String { + return L10n.tr("Localizable", "flare.init_with_configuration", String(describing: p1), fallback: "Flare configured with configuration:\n%@") + } + } + internal enum Payment { + /// Adding payment for product: %s. %i transactions already in the queue + internal static func paymentQueueAddingPayment(_ p1: UnsafePointer, _ p2: Int) -> String { + return L10n.tr("Localizable", "payment.payment_queue_adding_payment", p1, p2, fallback: "Adding payment for product: %s. %i transactions already in the queue") + } + } + internal enum Products { + /// Requested products %@ not found. + internal static func requestedProductsNotFound(_ p1: Any) -> String { + return L10n.tr("Localizable", "products.requested_products_not_found", String(describing: p1), fallback: "Requested products %@ not found.") + } + /// Requested products %@ have been received + internal static func requestedProductsReceived(_ p1: Any) -> String { + return L10n.tr("Localizable", "products.requested_products_received", String(describing: p1), fallback: "Requested products %@ have been received") + } + } + internal enum Purchase { + /// This device is not able or allowed to make payments. + internal static let cannotPurcaseProduct = L10n.tr("Localizable", "purchase.cannot_purcase_product", fallback: "This device is not able or allowed to make payments.") + /// An error occurred while listening for transactions: %s + internal static func errorUpdatingTransaction(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "purchase.error_updating_transaction", p1, fallback: "An error occurred while listening for transactions: %s") + } + /// Finishing transaction %s for product identifier: %s + internal static func finishingTransaction(_ p1: UnsafePointer, _ p2: UnsafePointer) -> String { + return L10n.tr("Localizable", "purchase.finishing_transaction", p1, p2, fallback: "Finishing transaction %s for product identifier: %s") + } + /// Product purchase for %s failed with error: %s + internal static func productPurchaseFailed(_ p1: UnsafePointer, _ p2: UnsafePointer) -> String { + return L10n.tr("Localizable", "purchase.product_purchase_failed", p1, p2, fallback: "Product purchase for %s failed with error: %s") + } + /// Purchased product: %s + internal static func purchasedProduct(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "purchase.purchased_product", p1, fallback: "Purchased product: %s") + } + /// Purchasing product: %s + internal static func purchasingProduct(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "purchase.purchasing_product", p1, fallback: "Purchasing product: %s") + } + /// Purchasing product %s with offer %s + internal static func purchasingProductWithOffer(_ p1: UnsafePointer, _ p2: UnsafePointer) -> String { + return L10n.tr("Localizable", "purchase.purchasing_product_with_offer", p1, p2, fallback: "Purchasing product %s with offer %s") + } + /// Transaction for productID: %s not found. + internal static func transactionNotFound(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "purchase.transaction_not_found", p1, fallback: "Transaction for productID: %s not found.") + } + /// Transaction for productID: %s is unverified by the App Store. Verification error: %s. + internal static func transactionUnverified(_ p1: UnsafePointer, _ p2: UnsafePointer) -> String { + return L10n.tr("Localizable", "purchase.transaction_unverified", p1, p2, fallback: "Transaction for productID: %s is unverified by the App Store. Verification error: %s.") + } + } + internal enum Receipt { + /// Refreshed receipt. Request id: %s. + internal static func refreshedReceipt(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "receipt.refreshed_receipt", p1, fallback: "Refreshed receipt. Request id: %s.") + } + /// Refreshing receipt. Request id: %s. + internal static func refreshingReceipt(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "receipt.refreshing_receipt", p1, fallback: "Refreshing receipt. Request id: %s.") + } + /// Refreshing receipt failed with error: %s. Request id: %s. + internal static func refreshingReceiptFailed(_ p1: UnsafePointer, _ p2: UnsafePointer) -> String { + return L10n.tr("Localizable", "receipt.refreshing_receipt_failed", p1, p2, fallback: "Refreshing receipt failed with error: %s. Request id: %s.") + } + } + internal enum Redeem { + /// Presenting code redemption sheet. + internal static let presentingCodeRedemptionSheet = L10n.tr("Localizable", "redeem.presenting_code_redemption_sheet", fallback: "Presenting code redemption sheet.") + /// Presenting offer code redeem sheet + internal static let presentingOfferCodeRedeemSheet = L10n.tr("Localizable", "redeem.presenting_offer_code_redeem_sheet", fallback: "Presenting offer code redeem sheet") + /// Unable to present offer code redeem sheet due to unexpected error: %s + internal static func unableToPresentOfferCodeRedeemSheet(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "redeem.unable_to_present_offer_code_redeem_sheet", p1, fallback: "Unable to present offer code redeem sheet due to unexpected error: %s") + } + } + internal enum Refund { + /// Refund has already requested for this product: %s + internal static func duplicateRefundRequest(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "refund.duplicate_refund_request", p1, fallback: "Refund has already requested for this product: %s") + } + /// Refund request submission failed: %s + internal static func failedRefundRequest(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "refund.failed_refund_request", p1, fallback: "Refund request submission failed: %s") + } + } + internal enum RefundError { + internal enum DuplicateRequest { + /// The request has been duplicated. + internal static let description = L10n.tr("Localizable", "refund_error.duplicate_request.description", fallback: "The request has been duplicated.") + } + internal enum Failed { + /// The refund request failed. + internal static let description = L10n.tr("Localizable", "refund_error.failed.description", fallback: "The refund request failed.") + } + } + internal enum VerificationError { + /// Transaction for productID: %s is unverified by the App Store. Verification error: %s. + internal static func unverified(_ p1: UnsafePointer, _ p2: UnsafePointer) -> String { + return L10n.tr("Localizable", "verification_error.unverified", p1, p2, fallback: "Transaction for productID: %s is unverified by the App Store. Verification error: %s.") + } + } +} +// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length +// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces + +// MARK: - Implementation Details + +extension L10n { + private static func tr(_ table: String, _ key: String, _ args: CVarArg..., fallback value: String) -> String { + let format = BundleToken.bundle.localizedString(forKey: key, value: value, table: table) + return String(format: format, locale: Locale.current, arguments: args) + } +} + +// swiftlint:disable convenience_type +private final class BundleToken { + static let bundle: Bundle = { + #if SWIFT_PACKAGE + return Bundle.module + #else + return Bundle(for: BundleToken.self) + #endif + }() +} +// swiftlint:enable convenience_type diff --git a/Sources/Flare/Classes/Helpers/Async/AsyncHandler.swift b/Sources/Flare/Classes/Helpers/Async/AsyncHandler.swift new file mode 100644 index 000000000..f819b1bf5 --- /dev/null +++ b/Sources/Flare/Classes/Helpers/Async/AsyncHandler.swift @@ -0,0 +1,22 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) +enum AsyncHandler { + static func call( + completion: @escaping (Result) -> Void, + asyncMethod method: @escaping () async throws -> T + ) { + _ = Task { + do { + try completion(.success(await method())) + } catch { + completion(.failure(error)) + } + } + } +} diff --git a/Sources/Flare/Classes/Helpers/AsyncSequence/AsyncSequence+Stream.swift b/Sources/Flare/Classes/Helpers/AsyncSequence/AsyncSequence+Stream.swift new file mode 100644 index 000000000..ef1f60823 --- /dev/null +++ b/Sources/Flare/Classes/Helpers/AsyncSequence/AsyncSequence+Stream.swift @@ -0,0 +1,15 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +extension AsyncSequence { + func toAsyncStream() -> AsyncStream { + var asyncIterator = makeAsyncIterator() + return AsyncStream { + try? await asyncIterator.next() + } + } +} diff --git a/Sources/Flare/Classes/Helpers/PaymentQueue/PaymentQueue.swift b/Sources/Flare/Classes/Helpers/PaymentQueue/PaymentQueue.swift index 6bcfe38e6..5b2783f3f 100644 --- a/Sources/Flare/Classes/Helpers/PaymentQueue/PaymentQueue.swift +++ b/Sources/Flare/Classes/Helpers/PaymentQueue/PaymentQueue.swift @@ -35,4 +35,10 @@ public protocol PaymentQueue: AnyObject { /// Remove a finished (i.e. failed or completed) transaction from the queue. /// Attempting to finish a purchasing transaction will throw an exception. func finishTransaction(_ transaction: SKPaymentTransaction) + + #if os(iOS) || VISION_OS + // Call this method to have StoreKit present a sheet enabling the user to redeem codes provided by your app. + @available(iOS 14.0, *) + func presentCodeRedemptionSheet() + #endif } diff --git a/Sources/Flare/Classes/Helpers/PaymentTransaction/PaymentTransaction.swift b/Sources/Flare/Classes/Helpers/PaymentTransaction/PaymentTransaction.swift index ff71880d3..fde5d04da 100644 --- a/Sources/Flare/Classes/Helpers/PaymentTransaction/PaymentTransaction.swift +++ b/Sources/Flare/Classes/Helpers/PaymentTransaction/PaymentTransaction.swift @@ -77,6 +77,10 @@ public struct PaymentTransaction: Equatable { return skTransaction.error } + public var transactionDate: Date? { + skTransaction.transactionDate + } + /// A `Bool` value indicating that the user canceled a payment request. public var isCancelled: Bool { (skTransaction.error as? SKError)?.code == SKError.Code.paymentCancelled diff --git a/Sources/Flare/Classes/Helpers/ProcessInfo/ProcessInfo+.swift b/Sources/Flare/Classes/Helpers/ProcessInfo/ProcessInfo+.swift new file mode 100644 index 000000000..155c22950 --- /dev/null +++ b/Sources/Flare/Classes/Helpers/ProcessInfo/ProcessInfo+.swift @@ -0,0 +1,29 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +#if DEBUG + extension ProcessInfo { + static var isRunningUnitTests: Bool { + self[.XCTestConfigurationFile] != nil + } + } + + // MARK: - Extensions + + extension ProcessInfo { + static subscript(key: String) -> String? { + processInfo.environment[key] + } + } + + // MARK: - Constants + + private extension String { + static let XCTestConfigurationFile = "XCTestConfigurationFilePath" + } + +#endif diff --git a/Sources/Flare/Classes/Helpers/ScenesHolder/IScenesHolder.swift b/Sources/Flare/Classes/Helpers/ScenesHolder/IScenesHolder.swift new file mode 100644 index 000000000..118d26a5d --- /dev/null +++ b/Sources/Flare/Classes/Helpers/ScenesHolder/IScenesHolder.swift @@ -0,0 +1,22 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +#if canImport(UIKit) + import UIKit +#endif + +// MARK: - IScenesHolder + +/// A type that holds all connected scenes. +protocol IScenesHolder { + #if os(iOS) || VISION_OS + /// The scenes that are connected to the app. + var connectedScenes: Set { get } + #endif +} + +#if os(iOS) || VISION_OS + extension UIApplication: IScenesHolder {} +#endif diff --git a/Sources/Flare/Classes/IFlare.swift b/Sources/Flare/Classes/IFlare.swift new file mode 100644 index 000000000..21856a111 --- /dev/null +++ b/Sources/Flare/Classes/IFlare.swift @@ -0,0 +1,252 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import struct Log.LogLevel +import StoreKit + +// MARK: - IFlare + +/// `Flare` creates and manages in-app purchases. +public protocol IFlare { + /// The log level. + var logLevel: Log.LogLevel { get set } + + /// Retrieves localized information from the App Store about a specified list of products. + /// + /// - Parameters: + /// - productIDs: The list of product identifiers for which you wish to retrieve descriptions. + /// - completion: The completion containing the response of retrieving products. + func fetch(productIDs: Set, completion: @escaping Closure>) + + /// Retrieves localized information from the App Store about a specified list of products. + /// + /// - Parameter productIDs: The list of product identifiers for which you wish to retrieve descriptions. + /// + /// - Throws: `IAPError(error:)` if the request did fail with error. + /// + /// - Returns: An array of products. + func fetch(productIDs: Set) async throws -> [StoreProduct] + + /// Performs a purchase of a product. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - promotionalOffer: The promotional offer. + /// - completion: The closure to be executed once the purchase is complete. + func purchase( + product: StoreProduct, + promotionalOffer: PromotionalOffer?, + completion: @escaping Closure> + ) + + /// Purchases a product. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - promotionalOffer: The promotional offer. + /// + /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. + /// + /// - Returns: A payment transaction. + func purchase(product: StoreProduct, promotionalOffer: PromotionalOffer?) async throws -> StoreTransaction + + /// Purchases a product. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - options: The optional settings for a product purchase. + /// - promotionalOffer: The promotional offer. + /// - completion: The closure to be executed once the purchase is complete. + /// + /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. + /// + /// - Returns: A payment transaction. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set, + promotionalOffer: PromotionalOffer?, + completion: @escaping SendableClosure> + ) + + /// Purchases a product. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - options: The optional settings for a product purchase. + /// - promotionalOffer: The promotional offer. + /// + /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. + /// + /// - Returns: A payment transaction. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set, + promotionalOffer: PromotionalOffer? + ) async throws -> StoreTransaction + + /// Refreshes the receipt, representing the user's transactions with your app. + /// + /// - Parameter completion: The closure to be executed when the refresh operation ends. + func receipt(completion: @escaping Closure>) + + /// Refreshes the receipt, representing the user's transactions with your app. + /// + /// `IAPError(error:)` if the request did fail with error. + /// + /// - Returns: A receipt. + func receipt() async throws -> String + + /// Removes a finished (i.e. failed or completed) transaction from the queue. + /// Attempting to finish a purchasing transaction will throw an exception. + /// + /// - Parameters: + /// - transaction: An object in the payment queue. + /// - completion: If a completion closure is provided, call it after finishing the transaction. + func finish(transaction: StoreTransaction, completion: (@Sendable () -> Void)?) + + /// The transactions array will only be synchronized with the server while the queue has observers. + /// + /// - Note: This may require that the user authenticate. + func addTransactionObserver(fallbackHandler: Closure>?) + + /// Removes transaction observer from the payment queue. + /// The transactions array will only be synchronized with the server while the queue has observers. + /// + /// - Note: This may require that the user authenticate. + func removeTransactionObserver() + + /// Checks whether products are eligible for promotional offers + /// + /// - Parameter productIDs: The list of product identifiers for which you wish to check eligibility. + /// + /// - Returns: An array that contains information about the eligibility of products. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func checkEligibility(productIDs: Set) async throws -> [String: SubscriptionEligibility] + + #if os(iOS) || VISION_OS + /// Present the refund request sheet for the specified transaction in a window scene. + /// + /// - Parameter productID: The identifier of the transaction the user is requesting a refund for. + /// + /// - Returns: The result of the refund request. + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func beginRefundRequest(productID: String) async throws -> RefundRequestStatus + + /// Displays a sheet that enables users to redeem subscription offer codes that you configure in App Store Connect. + @available(iOS 14.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func presentCodeRedemptionSheet() + + /// Displays a sheet in the window scene that enables users to redeem + /// a subscription offer code that you configure in App Store + /// Connect. + @available(iOS 16.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func presentOfferCodeRedeemSheet() async throws + #endif +} + +public extension IFlare { + /// Performs a purchase of a product. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - completion: The closure to be executed once the purchase is complete. + func purchase( + product: StoreProduct, + completion: @escaping Closure> + ) { + purchase(product: product, promotionalOffer: nil, completion: completion) + } + + /// Purchases a product. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameter product: The product to be purchased. + /// + /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. + /// + /// - Returns: A payment transaction. + func purchase(product: StoreProduct) async throws -> StoreTransaction { + try await purchase(product: product, promotionalOffer: nil) + } + + /// Purchases a product. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - options: The optional settings for a product purchase. + /// - completion: The closure to be executed once the purchase is complete. + /// + /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. + /// + /// - Returns: A payment transaction. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set, + completion: @escaping SendableClosure> + ) { + purchase(product: product, options: options, promotionalOffer: nil, completion: completion) + } + + /// Purchases a product. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - options: The optional settings for a product purchase. + /// + /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. + /// + /// - Returns: A payment transaction. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set + ) async throws -> StoreTransaction { + try await purchase(product: product, options: options, promotionalOffer: nil) + } +} diff --git a/Sources/Flare/Classes/Listeners/TransactionListener/ITransactionListener.swift b/Sources/Flare/Classes/Listeners/TransactionListener/ITransactionListener.swift new file mode 100644 index 000000000..095c12af9 --- /dev/null +++ b/Sources/Flare/Classes/Listeners/TransactionListener/ITransactionListener.swift @@ -0,0 +1,23 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +/// Protocol for objects that listen for transactions. +protocol ITransactionListener: Sendable { + /// Listen for incoming transactions asynchronously. + func listenForTransaction() async + + /// Handle the purchase result asynchronously. + /// + /// - Parameters: + /// - purchaseResult: The result of a StoreKit product purchase. + /// - Returns: An optional `StoreTransaction` if handling is successful. + /// + /// - Note: Available on iOS 15.0+, tvOS 15.0+, macOS 12.0+, watchOS 8.0+. + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func handle(purchaseResult: StoreKit.Product.PurchaseResult) async throws -> StoreTransaction? +} diff --git a/Sources/Flare/Classes/Listeners/TransactionListener/TransactionListener.swift b/Sources/Flare/Classes/Listeners/TransactionListener/TransactionListener.swift new file mode 100644 index 000000000..1edd3e410 --- /dev/null +++ b/Sources/Flare/Classes/Listeners/TransactionListener/TransactionListener.swift @@ -0,0 +1,88 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - TransactionListener + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +actor TransactionListener { + // MARK: Types + + typealias TransactionResult = StoreKit.VerificationResult + + // MARK: Private + + private let updates: AsyncStream + private var task: Task? + + // MARK: Initialization + + init(updates: S) where S.Element == TransactionResult { + self.updates = updates.toAsyncStream() + } + + // MARK: Private + + private func handle( + transactionResult: TransactionResult, + fromTransactionUpdate _: Bool + ) async throws -> StoreTransaction { + switch transactionResult { + case let .verified(transaction): + return StoreTransaction( + transaction: transaction, + jwtRepresentation: transactionResult.jwsRepresentation + ) + case let .unverified(transaction, verificationError): + Logger.info( + message: L10n.Purchase.transactionUnverified( + transaction.productID, + verificationError.localizedDescription + ) + ) + + throw IAPError.verification( + error: .unverified(productID: transaction.productID, error: verificationError) + ) + } + } +} + +// MARK: ITransactionListener + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +extension TransactionListener: ITransactionListener { + func listenForTransaction() async { + task?.cancel() + task = Task(priority: .utility) { [weak self] in + guard let self = self else { return } + + for await update in self.updates { + Task.detached { + do { + _ = try await self.handle(transactionResult: update, fromTransactionUpdate: true) + } catch { + Logger.error(message: L10n.Purchase.errorUpdatingTransaction(error.localizedDescription)) + } + } + } + } + } + + func handle(purchaseResult: Product.PurchaseResult) async throws -> StoreTransaction? { + switch purchaseResult { + case let .success(verificationResult): + return try await handle(transactionResult: verificationResult, fromTransactionUpdate: false) + case .userCancelled: + throw IAPError.paymentCancelled + case .pending: + throw IAPError.paymentDefferred + @unknown default: + throw IAPError.unknown + } + } +} diff --git a/Sources/Flare/Classes/Models/Configuration.swift b/Sources/Flare/Classes/Models/Configuration.swift new file mode 100644 index 000000000..ea7ab1da2 --- /dev/null +++ b/Sources/Flare/Classes/Models/Configuration.swift @@ -0,0 +1,36 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +public struct Configuration: Sendable { + // MARK: Properties + + // swiftlint:disable:next line_length + // https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase/subscriptions_and_offers/implementing_promotional_offers_in_your_app + + /// A string that associates the transaction with a user account on your service. + /// + /// - Important: You must set `applicationUsername` to be the same as the one used to generate the signature. + public let applicationUsername: String + + /// The cache policy for fetching products. + public let fetchCachePolicy: FetchCachePolicy + + // MARK: Initialization + + /// Creates a `Configuration` instance. + /// + /// - Parameters: + /// - applicationUsername: A string that associates the transaction with a user account on your service. + /// - fetchCachePolicy: The cache policy for fetching products. + public init( + applicationUsername: String, + fetchCachePolicy: FetchCachePolicy = .cachedOrFetch + ) { + self.applicationUsername = applicationUsername + self.fetchCachePolicy = fetchCachePolicy + } +} diff --git a/Sources/Flare/Classes/Models/DiscountType.swift b/Sources/Flare/Classes/Models/DiscountType.swift new file mode 100644 index 000000000..37f49187f --- /dev/null +++ b/Sources/Flare/Classes/Models/DiscountType.swift @@ -0,0 +1,53 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - DiscountType + +/// The type of discount offer. +public enum DiscountType: Int, Sendable { + /// Introductory offer + case introductory = 0 + /// Promotional offer for subscriptions + case promotional = 1 +} + +extension DiscountType { + /// Creates a ``DiscountType`` instance. + /// + /// - Parameter productDiscount: The details of an introductory offer or a promotional + /// offer for an auto-renewable subscription. + /// + /// - Returns: A discount type. + static func from(productDiscount: SKProductDiscount) -> Self? { + switch productDiscount.type { + case .introductory: + return .introductory + case .subscription: + return .promotional + @unknown default: + return nil + } + } + + /// Creates a ``DiscountType`` instance. + /// + /// - Parameter discount: Information about a subscription offer that you configure in App Store Connect. + /// + /// - Returns: A discount type. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + static func from(discount: Product.SubscriptionOffer) -> Self? { + switch discount.type { + case Product.SubscriptionOffer.OfferType.introductory: + return .introductory + case Product.SubscriptionOffer.OfferType.promotional: + return .promotional + default: + return nil + } + } +} diff --git a/Sources/Flare/Classes/Models/FetchCachePolicy.swift b/Sources/Flare/Classes/Models/FetchCachePolicy.swift new file mode 100644 index 000000000..bfb267c2e --- /dev/null +++ b/Sources/Flare/Classes/Models/FetchCachePolicy.swift @@ -0,0 +1,18 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Enum representing different cache policies for fetching data. +public enum FetchCachePolicy: Sendable, Codable { + /// Fetch the current data without using the cache. + case fetch + + /// Use the cached data if available; otherwise, fetch the data. + case cachedOrFetch + + /// The default cache policy, set to use cached data if available; otherwise, fetch the data. + static let `default`: FetchCachePolicy = .cachedOrFetch +} diff --git a/Sources/Flare/Classes/Models/IAPError.swift b/Sources/Flare/Classes/Models/IAPError.swift index 65fae3136..0de5a9262 100644 --- a/Sources/Flare/Classes/Models/IAPError.swift +++ b/Sources/Flare/Classes/Models/IAPError.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import StoreKit @@ -10,28 +10,64 @@ import StoreKit /// `IAPError` is the error type returned by Flare. /// It encompasses a few different types of errors, each with their own associated reasons. public enum IAPError: Swift.Error { - /// The empty array of products were fetched. - case emptyProducts /// The attempt to fetch products with invalid identifiers. - case invalid(productIds: [String]) + case invalid(productIDs: [String]) /// The attempt to purchase a product when payments are not allowed. case paymentNotAllowed /// The payment was cancelled. case paymentCancelled /// The attempt to fetch a product that doesn't available. case storeProductNotAvailable - /// The `SKPayment` returned unknown error. - case storeTrouble /// The operation failed with an underlying error. case with(error: Swift.Error) /// The App Store receipt wasn't found. case receiptNotFound + /// The transaction wasn't found. + case transactionNotFound(productID: String) + /// The refund error. + case refund(error: RefundError) + /// The verification error. + /// + /// - Note: This is only available for StoreKit 2 transactions. + case verification(error: VerificationError) + /// The purchase is pending, and requires action from the customer. + /// + /// - Note: This is only available for StoreKit 2 transactions. + case paymentDefferred + /// The decoding signature is failed. + /// + /// - Note: This is only available for StoreKit 2 transactions. + case failedToDecodeSignature(signature: String) /// The unknown error occurred. case unknown } extension IAPError { init(error: Swift.Error?) { + if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) { + if let storeKitError = error as? StoreKitError { + self.init(storeKitError: storeKitError) + } else { + self.init(error) + } + } else { + self.init(error) + } + } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + private init(storeKitError: StoreKit.StoreKitError) { + switch storeKitError { + case .unknown: + self = .unknown + case .userCancelled: + self = .paymentCancelled + default: + self = .with(error: storeKitError) + } + } + + private init(_ error: Swift.Error?) { switch (error as? SKError)?.code { case .paymentNotAllowed: self = .paymentNotAllowed @@ -40,7 +76,7 @@ extension IAPError { case .storeProductNotAvailable: self = .storeProductNotAvailable case .unknown: - self = .storeTrouble + self = .unknown default: if let error = error { self = .with(error: error) @@ -66,8 +102,6 @@ extension IAPError { extension IAPError: Equatable { public static func == (lhs: IAPError, rhs: IAPError) -> Bool { switch (lhs, rhs) { - case (.emptyProducts, .emptyProducts): - return true case let (.invalid(lhs), .invalid(rhs)): return lhs == rhs case (.paymentNotAllowed, .paymentNotAllowed): @@ -76,16 +110,71 @@ extension IAPError: Equatable { return true case (.storeProductNotAvailable, .storeProductNotAvailable): return true - case (.storeTrouble, .storeTrouble): - return true case let (.with(lhs), .with(rhs)): return (lhs as NSError) == (rhs as NSError) case (.receiptNotFound, .receiptNotFound): return true + case let (.refund(lhs), .refund(rhs)): + return lhs == rhs case (.unknown, .unknown): return true + case let (.failedToDecodeSignature(lhs), .failedToDecodeSignature(rhs)): + return lhs == rhs default: return false } } } + +// MARK: LocalizedError + +extension IAPError: LocalizedError { + public var errorDescription: String? { + switch self { + case let .invalid(productIDs): + return L10n.Error.InvalidProductIds.description(productIDs) + case .paymentNotAllowed: + return L10n.Error.PaymentNotAllowed.description + case .paymentCancelled: + return L10n.Error.PaymentCancelled.description + case .storeProductNotAvailable: + return L10n.Error.StoreProductNotAvailable.description + case let .with(error): + return L10n.Error.With.description(error.localizedDescription) + case .receiptNotFound: + return L10n.Error.Receipt.description + case let .transactionNotFound(productID): + return L10n.Error.TransactionNotFound.description(productID) + case let .refund(error): + return L10n.Error.Refund.description(error.localizedDescription) + case let .verification(error): + return L10n.Error.Verification.description(error.localizedDescription) + case .paymentDefferred: + return L10n.Error.PaymentDefferred.description + case let .failedToDecodeSignature(signature): + return L10n.Error.FailedToDecodeSignature.description(signature) + case .unknown: + return L10n.Error.Unknown.description + } + } + + public var failureReason: String? { + switch self { + case .paymentNotAllowed: + return L10n.Error.PaymentNotAllowed.failureReason + default: + return nil + } + } + + public var recoverySuggestion: String? { + switch self { + case .paymentNotAllowed: + return L10n.Error.PaymentNotAllowed.recoverySuggestion + case .storeProductNotAvailable: + return L10n.Error.StoreProductNotAvailable.recoverySuggestion + default: + return nil + } + } +} diff --git a/Sources/Flare/Classes/Models/Internal/ProductsRequest.swift b/Sources/Flare/Classes/Models/Internal/ProductsRequest.swift new file mode 100644 index 000000000..21be0b315 --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/ProductsRequest.swift @@ -0,0 +1,39 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +/// A class that represents a request to the App Store. +final class ProductsRequest: ISKRequest { + // MARK: Properties + + /// The request. + private let request: SKRequest + + /// The request’s identifier. + var id: String { request.id } + + // MARK: Initialization + + /// Creates a `ProductsRequest` instance. + /// + /// - Parameter request: The request. + init(_ request: SKRequest) { + self.request = request + } + + // MARK: Hashable + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + // MARK: Equatable + + static func == (lhs: ProductsRequest, rhs: ProductsRequest) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift b/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift new file mode 100644 index 000000000..d649481ef --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift @@ -0,0 +1,45 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Protocol representing a Store Kit product. +protocol ISKProduct { + /// A localized description of the product. + var localizedDescription: String { get } + + /// A localized title or name of the product. + var localizedTitle: String { get } + + /// The currency code for the product's price. + var currencyCode: String? { get } + + /// The price of the product in decimal format. + var price: Decimal { get } + + /// A localized string representing the price of the product. + var localizedPriceString: String? { get } + + /// The unique identifier for the product. + var productIdentifier: String { get } + + /// The type of product (e.g., consumable, non-consumable). + var productType: ProductType? { get } + + /// The category to which the product belongs. + var productCategory: ProductCategory? { get } + + /// The subscription period for the product, if applicable. + var subscriptionPeriod: SubscriptionPeriod? { get } + + /// The details of an introductory offer for an auto-renewable subscription. + var introductoryDiscount: StoreProductDiscount? { get } + + /// The details of promotional offers for an auto-renewable subscription. + var discounts: [StoreProductDiscount] { get } + + /// The subscription group identifier. + var subscriptionGroupIdentifier: String? { get } +} diff --git a/Sources/Flare/Classes/Models/Internal/Protocols/ISKRequest.swift b/Sources/Flare/Classes/Models/Internal/Protocols/ISKRequest.swift new file mode 100644 index 000000000..5ec7b4fc1 --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/Protocols/ISKRequest.swift @@ -0,0 +1,12 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Type that represents a request to the App Store. +protocol ISKRequest: Hashable { + /// The request’s identifier. + var id: String { get } +} diff --git a/Sources/Flare/Classes/Models/Internal/Protocols/IStoreProductDiscount.swift b/Sources/Flare/Classes/Models/Internal/Protocols/IStoreProductDiscount.swift new file mode 100644 index 000000000..903ba0cfa --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/Protocols/IStoreProductDiscount.swift @@ -0,0 +1,32 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - IStoreProductDiscount Protocol + +/// A protocol representing a discount information for a store product. +protocol IStoreProductDiscount: Sendable { + /// A unique identifier for the discount offer. + var offerIdentifier: String? { get } + + /// The currency code for the discount amount. + var currencyCode: String? { get } + + /// The discounted price in the specified currency. + var price: Decimal { get } + + /// The payment mode associated with the discount (e.g., freeTrial, payUpFront, payAsYouGo). + var paymentMode: PaymentMode { get } + + /// The period for which the discount is applicable in a subscription. + var subscriptionPeriod: SubscriptionPeriod { get } + + /// The number of subscription periods for which the discount is applied. + var numberOfPeriods: Int { get } + + /// The type of discount (e.g., introductory, promotional). + var type: DiscountType { get } +} diff --git a/Sources/Flare/Classes/Models/Internal/Protocols/IStoreTransaction.swift b/Sources/Flare/Classes/Models/Internal/Protocols/IStoreTransaction.swift new file mode 100644 index 000000000..4a346da3d --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/Protocols/IStoreTransaction.swift @@ -0,0 +1,32 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// A type that represents a store transaction. +protocol IStoreTransaction { + /// The unique identifier for the product. + var productIdentifier: String { get } + /// The date when the transaction occurred. + var purchaseDate: Date { get } + /// A boolean indicating whether the purchase date is known. + var hasKnownPurchaseDate: Bool { get } + /// A unique identifier for the transaction. + var transactionIdentifier: String { get } + /// A boolean indicating whether the transaction identifier is known. + var hasKnownTransactionIdentifier: Bool { get } + /// The quantity of the product involved in the transaction. + var quantity: Int { get } + + /// The raw JWS representation 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 } +} diff --git a/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift b/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift new file mode 100644 index 000000000..44722d38a --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift @@ -0,0 +1,80 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - SK1StoreProduct + +final class SK1StoreProduct { + // MARK: Properties + + /// The store kit product. + let product: SKProduct + + /// The price formatter. + private lazy var numberFormatter: NumberFormatter = .numberFormatter(with: self.product.priceLocale) + + // MARK: Initialization + + init(_ product: SKProduct) { + self.product = product + } +} + +// MARK: ISKProduct + +extension SK1StoreProduct: ISKProduct { + var localizedDescription: String { + product.localizedDescription + } + + var localizedTitle: String { + product.localizedTitle + } + + var currencyCode: String? { + product.priceLocale.currencyCodeID + } + + var price: Decimal { + product.price as Decimal + } + + var localizedPriceString: String? { + numberFormatter.string(from: product.price) + } + + var productIdentifier: String { + product.productIdentifier + } + + var productType: ProductType? { + nil + } + + var productCategory: ProductCategory? { + subscriptionPeriod == nil ? .nonSubscription : .subscription + } + + var subscriptionPeriod: SubscriptionPeriod? { + guard let subscriptionPeriod = product.subscriptionPeriod, subscriptionPeriod.numberOfUnits > 0 else { + return nil + } + return SubscriptionPeriod.from(subscriptionPeriod: subscriptionPeriod) + } + + var introductoryDiscount: StoreProductDiscount? { + product.introductoryPrice.flatMap { StoreProductDiscount(skProductDiscount: $0) } + } + + var discounts: [StoreProductDiscount] { + product.discounts.compactMap { StoreProductDiscount(skProductDiscount: $0) } + } + + var subscriptionGroupIdentifier: String? { + product.subscriptionGroupIdentifier + } +} diff --git a/Sources/Flare/Classes/Models/Internal/SK1StoreProductDiscount.swift b/Sources/Flare/Classes/Models/Internal/SK1StoreProductDiscount.swift new file mode 100644 index 000000000..263bee9d2 --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/SK1StoreProductDiscount.swift @@ -0,0 +1,60 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +/// The details of an introductory offer or a promotional offer for an auto-renewable subscription. +struct SK1StoreProductDiscount: IStoreProductDiscount { + // MARK: Properties + + private let productDiscount: SKProductDiscount + + /// A unique identifier for the discount offer. + let offerIdentifier: String? + + /// The currency code for the discount amount. + let currencyCode: String? + + /// The discounted price in the specified currency. + let price: Decimal + + /// The payment mode associated with the discount (e.g., freeTrial, payUpFront, payAsYouGo). + let paymentMode: PaymentMode + + /// The period for which the discount is applicable in a subscription. + let subscriptionPeriod: SubscriptionPeriod + + /// The number of subscription periods for which the discount is applied. + let numberOfPeriods: Int + + /// The type of discount (e.g., introductory, promotional). + let type: DiscountType + + // MARK: Initialization + + /// Creates a `SK1StoreProductDiscount` instance. + /// + /// - Parameter productDiscount: The details of an introductory offer or a promotional + /// offer for an auto-renewable subscription. + init?(productDiscount: SKProductDiscount) { + guard let paymentMode = PaymentMode.from(productDiscount: productDiscount), + let discountType = DiscountType.from(productDiscount: productDiscount), + let subscriptionPeriod = SubscriptionPeriod.from(subscriptionPeriod: productDiscount.subscriptionPeriod) + else { + return nil + } + + self.productDiscount = productDiscount + + offerIdentifier = productDiscount.identifier + currencyCode = productDiscount.priceLocale.currencyCodeID + price = productDiscount.price as Decimal + self.paymentMode = paymentMode + self.subscriptionPeriod = subscriptionPeriod + numberOfPeriods = productDiscount.numberOfPeriods + type = discountType + } +} diff --git a/Sources/Flare/Classes/Models/Internal/SK1StoreTransaction.swift b/Sources/Flare/Classes/Models/Internal/SK1StoreTransaction.swift new file mode 100644 index 000000000..e7f02aa29 --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/SK1StoreTransaction.swift @@ -0,0 +1,65 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import StoreKit + +// MARK: - SK1StoreTransaction + +/// A struct representing the first version of the transaction. +struct SK1StoreTransaction { + // MARK: Properties + + /// The StoreKit transaction. + let transaction: PaymentTransaction + + // MARK: Initialization + + /// Creates a new `SK1StoreTransaction` instance. + /// + /// - Parameter transaction: The StoreKit transaction. + init(transaction: PaymentTransaction) { + self.transaction = transaction + } +} + +// MARK: IStoreTransaction + +extension SK1StoreTransaction: IStoreTransaction { + var productIdentifier: String { + transaction.productIdentifier + } + + var purchaseDate: Date { + guard let date = transaction.transactionDate else { + return Date(timeIntervalSince1970: 0) + } + return date + } + + var hasKnownPurchaseDate: Bool { + transaction.transactionDate != nil + } + + var transactionIdentifier: String { + transaction.transactionIdentifier ?? "" + } + + var hasKnownTransactionIdentifier: Bool { + transaction.transactionIdentifier != nil + } + + var quantity: Int { + let payment = transaction.skTransaction.payment + return payment.quantity + } + + var jwsRepresentation: String? { + nil + } + + var environment: StoreEnvironment? { + nil + } +} diff --git a/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift b/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift new file mode 100644 index 000000000..ad99a8987 --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift @@ -0,0 +1,88 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - SK2StoreProduct + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +final class SK2StoreProduct { + // MARK: Properties + + /// The store kit product. + let product: StoreKit.Product + + /// The currency format. + private var currencyFormat: Decimal.FormatStyle.Currency { + product.priceFormatStyle + } + + // MARK: Initialization + + init(_ product: StoreKit.Product) { + self.product = product + } +} + +// MARK: ISKProduct + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +extension SK2StoreProduct: ISKProduct { + var localizedDescription: String { + product.description + } + + var localizedTitle: String { + product.displayName + } + + var currencyCode: String? { + currencyFormat.currencyCode + } + + var price: Decimal { + product.price + } + + var localizedPriceString: String? { + product.displayPrice + } + + var productIdentifier: String { + product.id + } + + var productType: ProductType? { + ProductType(product.type) + } + + var productCategory: ProductCategory? { + productType?.productCategory + } + + var subscriptionPeriod: SubscriptionPeriod? { + guard let subscriptionPeriod = product.subscription?.subscriptionPeriod else { + return nil + } + return SubscriptionPeriod.from(subscriptionPeriod: subscriptionPeriod) + } + + var introductoryDiscount: StoreProductDiscount? { + product.subscription?.introductoryOffer.flatMap { + StoreProductDiscount(discount: $0, currencyCode: self.currencyCode) + } + } + + var discounts: [StoreProductDiscount] { + product.subscription?.promotionalOffers.compactMap { + StoreProductDiscount(discount: $0, currencyCode: self.currencyCode) + } ?? [] + } + + var subscriptionGroupIdentifier: String? { + product.subscription?.subscriptionGroupID + } +} diff --git a/Sources/Flare/Classes/Models/Internal/SK2StoreProductDiscount.swift b/Sources/Flare/Classes/Models/Internal/SK2StoreProductDiscount.swift new file mode 100644 index 000000000..f38ba0cef --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/SK2StoreProductDiscount.swift @@ -0,0 +1,62 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +/// The details of an introductory offer or a promotional offer for an auto-renewable subscription. +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +struct SK2StoreProductDiscount: IStoreProductDiscount, Sendable { + // MARK: Properties + + private let subscriptionOffer: StoreKit.Product.SubscriptionOffer + + /// A unique identifier for the discount offer. + let offerIdentifier: String? + + /// The currency code for the discount amount. + let currencyCode: String? + + /// The discounted price in the specified currency. + let price: Decimal + + /// The payment mode associated with the discount (e.g., freeTrial, payUpFront, payAsYouGo). + let paymentMode: PaymentMode + + /// The period for which the discount is applicable in a subscription. + let subscriptionPeriod: SubscriptionPeriod + + /// The number of subscription periods for which the discount is applied. + let numberOfPeriods: Int + + /// The type of discount (e.g., introductory, promotional). + let type: DiscountType + + // MARK: Initialization + + /// Creates a `SK2StoreProductDiscount` instance. + /// + /// - Parameters: + /// - subscriptionOffer: Information about a subscription offer that you configure in App Store Connect. + /// - currencyCode: The currency code for the discount amount. + init?(subscriptionOffer: StoreKit.Product.SubscriptionOffer, currencyCode: String?) { + guard let paymentMode = PaymentMode.from(discount: subscriptionOffer), + let discountType = DiscountType.from(discount: subscriptionOffer), + let subscriptionPeriod = SubscriptionPeriod.from(subscriptionPeriod: subscriptionOffer.period) + else { + return nil + } + + self.subscriptionOffer = subscriptionOffer + + offerIdentifier = subscriptionOffer.id + self.currencyCode = currencyCode + price = subscriptionOffer.price + self.paymentMode = paymentMode + self.subscriptionPeriod = subscriptionPeriod + numberOfPeriods = subscriptionOffer.periodCount + type = discountType + } +} diff --git a/Sources/Flare/Classes/Models/Internal/SK2StoreTransaction.swift b/Sources/Flare/Classes/Models/Internal/SK2StoreTransaction.swift new file mode 100644 index 000000000..75871ca13 --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/SK2StoreTransaction.swift @@ -0,0 +1,69 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - SK2StoreTransaction + +/// A struct representing the second version of the transaction. +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +struct SK2StoreTransaction { + // MARK: Properties + + /// The StoreKit transaction. + let transaction: StoreKit.Transaction + /// The raw JWS representation of the transaction. + private let _jwsRepresentation: String? + + // MARK: Initialization + + /// Creates a new `SK1StoreTransaction` instance. + /// + /// - Parameters: + /// - transaction: The StoreKit transaction. + /// - jwsRepresentation: The raw JWS representation of the transaction. + init(transaction: StoreKit.Transaction, jwsRepresentation: String) { + self.transaction = transaction + _jwsRepresentation = jwsRepresentation + } +} + +// MARK: IStoreTransaction + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +extension SK2StoreTransaction: IStoreTransaction { + var productIdentifier: String { + transaction.productID + } + + var purchaseDate: Date { + transaction.purchaseDate + } + + var hasKnownPurchaseDate: Bool { + true + } + + var transactionIdentifier: String { + String(transaction.id) + } + + var hasKnownTransactionIdentifier: Bool { + true + } + + var quantity: Int { + transaction.purchasedQuantity + } + + var jwsRepresentation: String? { + _jwsRepresentation + } + + var environment: StoreEnvironment? { + StoreEnvironment(transaction: transaction) + } +} diff --git a/Sources/Flare/Classes/Models/Internal/StoreEnvironment.swift b/Sources/Flare/Classes/Models/Internal/StoreEnvironment.swift new file mode 100644 index 000000000..0744220ac --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/StoreEnvironment.swift @@ -0,0 +1,59 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - StoreEnvironment + +enum StoreEnvironment { + case production + case sandbox + case xcode +} + +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: + return nil + } + } + + init?(environment: String) { + switch environment { + case "Production": + self = .production + case "Sandbox": + self = .sandbox + case "Xcode": + self = .xcode + default: + return nil + } + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + init?(transaction: StoreKit.Transaction) { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + self.init(environment: transaction.environment) + } else { + #if VISION_OS + self.init(environment: transaction.environment) + #else + self.init( + environment: transaction.environmentStringRepresentation + ) + #endif + } + } +} diff --git a/Sources/Flare/Classes/Models/PaymentMode.swift b/Sources/Flare/Classes/Models/PaymentMode.swift new file mode 100644 index 000000000..963694926 --- /dev/null +++ b/Sources/Flare/Classes/Models/PaymentMode.swift @@ -0,0 +1,59 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - PaymentMode + +/// The offer's payment mode. +public enum PaymentMode: Int, Sendable { + /// 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 +} + +extension PaymentMode { + /// Creates a ``PaymentMode`` instance. + /// + /// - Parameter productDiscount: The details of an introductory offer or a promotional + /// offer for an auto-renewable subscription. + /// + /// - Returns: A payment mode. + static func from(productDiscount: SKProductDiscount) -> Self? { + switch productDiscount.paymentMode { + case .payAsYouGo: + return .payAsYouGo + case .payUpFront: + return .payUpFront + case .freeTrial: + return .freeTrial + @unknown default: + return nil + } + } + + /// Creates a ``PaymentMode`` instance. + /// + /// - Parameter discount: Information about a subscription offer that you configure in App Store Connect. + /// + /// - Returns: A payment mode. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + static func from(discount: Product.SubscriptionOffer) -> Self? { + switch discount.paymentMode { + case Product.SubscriptionOffer.PaymentMode.freeTrial: + return .freeTrial + case Product.SubscriptionOffer.PaymentMode.payAsYouGo: + return .payAsYouGo + case Product.SubscriptionOffer.PaymentMode.payUpFront: + return .payUpFront + default: + return nil + } + } +} diff --git a/Sources/Flare/Classes/Models/ProductCategory.swift b/Sources/Flare/Classes/Models/ProductCategory.swift new file mode 100644 index 000000000..cf7dc9e8f --- /dev/null +++ b/Sources/Flare/Classes/Models/ProductCategory.swift @@ -0,0 +1,15 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Enumeration representing different categories of products in an app. +public enum ProductCategory: Int { + /// A non-renewable or auto-renewable subscription. + case subscription + + /// A consumable or non-consumable in-app purchase. + case nonSubscription +} diff --git a/Sources/Flare/Classes/Models/ProductType.swift b/Sources/Flare/Classes/Models/ProductType.swift new file mode 100644 index 000000000..482b86073 --- /dev/null +++ b/Sources/Flare/Classes/Models/ProductType.swift @@ -0,0 +1,21 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +/// The type of product, equivalent to StoreKit's `Product.ProductType`. +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 +} diff --git a/Sources/Flare/Classes/Models/PromotionalOffer.swift b/Sources/Flare/Classes/Models/PromotionalOffer.swift new file mode 100644 index 000000000..c5a279c5c --- /dev/null +++ b/Sources/Flare/Classes/Models/PromotionalOffer.swift @@ -0,0 +1,116 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - PromotionalOffer + +/// A class representing a promotional offer. +public final class PromotionalOffer: NSObject, Sendable { + // MARK: Properties + + /// The details of an introductory offer or a promotional offer for an auto-renewable subscription. + public let discount: StoreProductDiscount + /// The signed discount applied to a payment. + public let signedData: SignedData + + // MARK: Initialization + + /// Creates a `PromotionalOffer` instance. + /// + /// - Parameters: + /// - discount: The details of an introductory offer or a promotional offer for an auto-renewable subscription. + /// - signedData: The signed discount applied to a payment. + public init(discount: StoreProductDiscount, signedData: SignedData) { + self.discount = discount + self.signedData = signedData + } +} + +// MARK: PromotionalOffer.SignedData + +public extension PromotionalOffer { + /// The signed discount applied to a payment. + final class SignedData: NSObject, Sendable { + // MARK: Properties + + /// The identifier agreed upon with the App Store for a discount of your choosing. + public let identifier: String + /// The identifier of the public/private key pair agreed upon with the App Store when the keys were generated. + public let keyIdentifier: String + /// One-time use random entropy-adding value for security. + public let nonce: UUID + /// The cryptographic signature generated by your private key. + public let signature: String + /// Timestamp of when the signature is created. + public let timestamp: Int + + /// Creates a `SignedData` instance. + /// + /// - Parameters: + /// - identifier: The identifier agreed upon with the App Store for a discount of your choosing. + /// - keyIdentifier: The identifier of the public/private key pair agreed upon + /// with the App Store when the keys were generated. + /// - nonce: One-time use random entropy-adding value for security. + /// - signature: The cryptographic signature generated by your private key. + /// - timestamp: Timestamp of when the signature is created. + public 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 + } + } +} + +// MARK: - Convenience Initializators + +extension PromotionalOffer.SignedData { + /// Creates a `SignedData` instance. + /// + /// - Parameter paymentDiscount: The signed discount applied to a payment. + convenience init(paymentDiscount: SKPaymentDiscount) { + self.init( + identifier: paymentDiscount.identifier, + keyIdentifier: paymentDiscount.keyIdentifier, + nonce: paymentDiscount.nonce, + signature: paymentDiscount.signature, + timestamp: paymentDiscount.timestamp.intValue + ) + } +} + +// MARK: - Helpers + +extension PromotionalOffer.SignedData { + var skPromotionalOffer: SKPaymentDiscount { + SKPaymentDiscount( + identifier: identifier, + keyIdentifier: keyIdentifier, + nonce: nonce, + signature: signature, + timestamp: .init(integerLiteral: timestamp) + ) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + var promotionalOffer: Product.PurchaseOption { + get throws { + guard let data = Data(base64Encoded: signature) else { + throw IAPError.failedToDecodeSignature(signature: signature) + } + + return .promotionalOffer( + offerID: identifier, + keyID: keyIdentifier, + nonce: nonce, + signature: data, + timestamp: timestamp + ) + } + } +} diff --git a/Sources/Flare/Classes/Models/RefundError.swift b/Sources/Flare/Classes/Models/RefundError.swift new file mode 100644 index 000000000..1db81dfc7 --- /dev/null +++ b/Sources/Flare/Classes/Models/RefundError.swift @@ -0,0 +1,29 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - RefundError + +/// It encompasses all types of refund errors. +public enum RefundError: Error, Equatable { + /// The duplicate refund request. + case duplicateRequest + /// The refund request failed. + case failed +} + +// MARK: LocalizedError + +extension RefundError: LocalizedError { + public var errorDescription: String? { + switch self { + case .duplicateRequest: + return L10n.RefundError.DuplicateRequest.description + case .failed: + return L10n.RefundError.Failed.description + } + } +} diff --git a/Sources/Flare/Classes/Models/RefundRequestStatus.swift b/Sources/Flare/Classes/Models/RefundRequestStatus.swift new file mode 100644 index 000000000..e7c69cf8f --- /dev/null +++ b/Sources/Flare/Classes/Models/RefundRequestStatus.swift @@ -0,0 +1,18 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +/// It encompasses all refund request states. +public enum RefundRequestStatus: Sendable { + /// A user cancelled the refund request. + case userCancelled + /// The request completed successfully. + case success + /// The refund request failed with an error. + case failed(error: Error) + /// The unknown error occurred. + case unknown +} diff --git a/Sources/Flare/Classes/Models/StoreProduct.swift b/Sources/Flare/Classes/Models/StoreProduct.swift new file mode 100644 index 000000000..3a2c4a7d8 --- /dev/null +++ b/Sources/Flare/Classes/Models/StoreProduct.swift @@ -0,0 +1,100 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - StoreProduct + +/// An object represents a StoreKit product. +public final class StoreProduct: NSObject { + // MARK: Properties + + /// Protocol representing a Store Kit product. + let product: ISKProduct + + /// <#Description#> + var underlyingProduct: ISKProduct { product } + + // MARK: Initialization + + /// Creates a new `StoreProduct` instance. + /// + /// - Parameter product: The StoreKit product. + init(_ product: ISKProduct) { + self.product = product + } +} + +// MARK: - Convenience Initializators + +public extension StoreProduct { + /// Creates a new `StoreProduct` instance. + /// + /// - Parameter skProduct: The StoreKit product. + convenience init(skProduct: SKProduct) { + self.init(SK1StoreProduct(skProduct)) + } + + /// Creates a new `StoreProduct` instance. + /// + /// - Parameter product: The StoreKit product. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + convenience init(product: StoreKit.Product) { + self.init(SK2StoreProduct(product)) + } +} + +// MARK: ISKProduct + +extension StoreProduct: ISKProduct { + public var localizedDescription: String { + product.localizedDescription + } + + public var localizedTitle: String { + product.localizedTitle + } + + public var currencyCode: String? { + product.currencyCode + } + + public var price: Decimal { + product.price + } + + public var localizedPriceString: String? { + product.localizedPriceString + } + + public var productIdentifier: String { + product.productIdentifier + } + + public var productType: ProductType? { + product.productType + } + + public var productCategory: ProductCategory? { + product.productCategory + } + + public var subscriptionPeriod: SubscriptionPeriod? { + product.subscriptionPeriod + } + + public var introductoryDiscount: StoreProductDiscount? { + product.introductoryDiscount + } + + public var discounts: [StoreProductDiscount] { + product.discounts + } + + public var subscriptionGroupIdentifier: String? { + product.subscriptionGroupIdentifier + } +} diff --git a/Sources/Flare/Classes/Models/StoreProductDiscount.swift b/Sources/Flare/Classes/Models/StoreProductDiscount.swift new file mode 100644 index 000000000..871755058 --- /dev/null +++ b/Sources/Flare/Classes/Models/StoreProductDiscount.swift @@ -0,0 +1,82 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - StoreProductDiscount + +/// The details of an introductory offer or a promotional offer for an auto-renewable subscription. +public final class StoreProductDiscount { + // MARK: Properties + + /// The details of an introductory offer or a promotional offer for an auto-renewable subscription. + private let discount: IStoreProductDiscount + + // MARK: Initialization + + /// Creates a `StoreProductDiscount` instance. + /// + /// - Parameter discount: The details of an introductory offer or a promotional offer for an auto-renewable subscription. + init(discount: IStoreProductDiscount) { + self.discount = discount + } +} + +// MARK: - Convenience Initializators + +public extension StoreProductDiscount { + /// Creates a new `StoreProductDiscount` instance. + /// + /// - Parameter skProductDiscount: The details of an introductory offer or a promotional + /// offer for an auto-renewable subscription. + convenience init?(skProductDiscount: SKProductDiscount) { + guard let discount = SK1StoreProductDiscount(productDiscount: skProductDiscount) else { return nil } + self.init(discount: discount) + } + + /// Creates a new `StoreProductDiscount` instance. + /// + /// - Parameters: + /// - subscriptionOffer: Information about a subscription offer that you configure in App Store Connect. + /// - currencyCode: The currency code for the discount amount. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + convenience init?(discount: StoreKit.Product.SubscriptionOffer, currencyCode: String?) { + guard let discount = SK2StoreProductDiscount(subscriptionOffer: discount, currencyCode: currencyCode) else { return nil } + self.init(discount: discount) + } +} + +// MARK: IStoreProductDiscount + +extension StoreProductDiscount: IStoreProductDiscount { + public var offerIdentifier: String? { + discount.offerIdentifier + } + + public var currencyCode: String? { + discount.currencyCode + } + + public var price: Decimal { + discount.price + } + + public var paymentMode: PaymentMode { + discount.paymentMode + } + + public var subscriptionPeriod: SubscriptionPeriod { + discount.subscriptionPeriod + } + + public var numberOfPeriods: Int { + discount.numberOfPeriods + } + + public var type: DiscountType { + discount.type + } +} diff --git a/Sources/Flare/Classes/Models/StoreTransaction.swift b/Sources/Flare/Classes/Models/StoreTransaction.swift new file mode 100644 index 000000000..0a777b45c --- /dev/null +++ b/Sources/Flare/Classes/Models/StoreTransaction.swift @@ -0,0 +1,91 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - StoreTransaction + +/// A class represent a StoreKit transaction. +public final class StoreTransaction { + // MARK: Properties + + /// The StoreKit transaction. + let storeTransaction: IStoreTransaction + + // MARK: Initialization + + /// Creates a new `StoreTransaction` instance. + /// + /// - Parameter storeTransaction: The StoreKit transaction. + init(storeTransaction: IStoreTransaction) { + self.storeTransaction = storeTransaction + } +} + +// MARK: - Convenience Initializators + +extension StoreTransaction { + /// Creates a new `StoreTransaction` instance. + /// + /// - Parameter paymentTransaction: The StoreKit transaction. + convenience init(paymentTransaction: PaymentTransaction) { + self.init(storeTransaction: SK1StoreTransaction(transaction: paymentTransaction)) + } + + /// Creates a new `StoreTransaction` instance. + /// + /// - Parameters: + /// - transaction: The StoreKit transaction. + /// - jwtRepresentation: The server environment where the receipt was generated. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + convenience init(transaction: StoreKit.Transaction, jwtRepresentation: String) { + self.init(storeTransaction: SK2StoreTransaction(transaction: transaction, jwsRepresentation: jwtRepresentation)) + } +} + +// MARK: IStoreTransaction + +extension StoreTransaction: IStoreTransaction { + var productIdentifier: String { + storeTransaction.productIdentifier + } + + var purchaseDate: Date { + storeTransaction.purchaseDate + } + + var hasKnownPurchaseDate: Bool { + storeTransaction.hasKnownPurchaseDate + } + + var transactionIdentifier: String { + storeTransaction.transactionIdentifier + } + + var hasKnownTransactionIdentifier: Bool { + storeTransaction.hasKnownTransactionIdentifier + } + + var quantity: Int { + storeTransaction.quantity + } + + var jwsRepresentation: String? { + storeTransaction.jwsRepresentation + } + + var environment: StoreEnvironment? { + storeTransaction.environment + } +} + +// MARK: Equatable + +extension StoreTransaction: Equatable { + public static func == (lhs: StoreTransaction, rhs: StoreTransaction) -> Bool { + lhs.transactionIdentifier == rhs.transactionIdentifier + } +} diff --git a/Sources/Flare/Classes/Models/SubscriptionEligibility.swift b/Sources/Flare/Classes/Models/SubscriptionEligibility.swift new file mode 100644 index 000000000..362884ace --- /dev/null +++ b/Sources/Flare/Classes/Models/SubscriptionEligibility.swift @@ -0,0 +1,18 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// Enumeration defining the eligibility status for a subscription +public enum SubscriptionEligibility: Int, Sendable { + // Represents that the subscription is eligible for an offer + case eligible + + // Represents that the subscription is not eligible for an offer + case nonEligible + + // Represents that there is no offer available for the subscription + case noOffer +} diff --git a/Sources/Flare/Classes/Models/SubscriptionPeriod.swift b/Sources/Flare/Classes/Models/SubscriptionPeriod.swift new file mode 100644 index 000000000..3a8ef32cc --- /dev/null +++ b/Sources/Flare/Classes/Models/SubscriptionPeriod.swift @@ -0,0 +1,122 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - SubscriptionPeriod + +/// A class representing a subscription period with a specific value and unit. +public final class SubscriptionPeriod: NSObject, Sendable { + // MARK: Types + + public enum Unit: Int, Sendable { + /// 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 + } + + // MARK: Properties + + /// The numeric value of the subscription period. + public let value: Int + /// The unit of the subscription period (day, week, month, year). + public let unit: Unit + + // MARK: Initialization + + /// Initializes a new `SubscriptionPeriod` instance. + /// + /// - Parameters: + /// - value: The numeric value of the subscription period. + /// - unit: The unit of the subscription period. + public init(value: Int, unit: Unit) { + self.value = value + self.unit = unit + } + + override public func isEqual(_ object: Any?) -> Bool { + guard let other = object as? SubscriptionPeriod else { return false } + return value == other.value && unit == other.unit + } + + override public var hash: Int { + var hasher = Hasher() + hasher.combine(value) + hasher.combine(unit) + + return hasher.finalize() + } +} + +// MARK: - Helpers + +extension SubscriptionPeriod { + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + static func from(subscriptionPeriod: SKProductSubscriptionPeriod) -> SubscriptionPeriod? { + guard let unit = SubscriptionPeriod.Unit.from(unit: subscriptionPeriod.unit) else { + return nil + } + return SubscriptionPeriod(value: subscriptionPeriod.numberOfUnits, unit: unit) + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8, *) + static func from(subscriptionPeriod: StoreKit.Product.SubscriptionPeriod) -> SubscriptionPeriod? { + guard let unit = SubscriptionPeriod.Unit.from(unit: subscriptionPeriod.unit) else { + return nil + } + return SubscriptionPeriod(value: subscriptionPeriod.value, unit: unit) + } +} + +// MARK: - Extensions + +private extension SubscriptionPeriod.Unit { + /// Creates a ``SubscriptionPeriod.Unit`` instance. + /// + /// - Parameter unit: Values representing the duration of an interval, from a day up to a year. + /// + /// - Returns: A subscription unit. + static func from(unit: SKProduct.PeriodUnit) -> Self? { + switch unit { + case .day: + return .day + case .week: + return .week + case .month: + return .month + case .year: + return .year + @unknown default: + return nil + } + } + + /// Creates a ``SubscriptionPeriod.Unit`` instance. + /// + /// - Parameter unit: Units of time that describe subscription periods. + /// + /// - Returns: A subscription unit. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8, *) + static func from(unit: StoreKit.Product.SubscriptionPeriod.Unit) -> Self? { + switch unit { + case .day: + return .day + case .week: + return .week + case .month: + return .month + case .year: + return .year + @unknown default: + return nil + } + } +} diff --git a/Sources/Flare/Classes/Models/VerificationError.swift b/Sources/Flare/Classes/Models/VerificationError.swift new file mode 100644 index 000000000..fc661b2fa --- /dev/null +++ b/Sources/Flare/Classes/Models/VerificationError.swift @@ -0,0 +1,25 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - VerificationError + +/// Enumeration representing errors that can occur during verification. +public enum VerificationError: Error { + // Case for unverified product with associated productID and error details. + case unverified(productID: String, error: Error) +} + +// MARK: LocalizedError + +extension VerificationError: LocalizedError { + public var errorDescription: String? { + switch self { + case let .unverified(productID, error): + return L10n.VerificationError.unverified(productID, error.localizedDescription) + } + } +} diff --git a/Sources/Flare/Classes/Providers/CacheProvider/CacheProvider.swift b/Sources/Flare/Classes/Providers/CacheProvider/CacheProvider.swift new file mode 100644 index 000000000..a28ef627f --- /dev/null +++ b/Sources/Flare/Classes/Providers/CacheProvider/CacheProvider.swift @@ -0,0 +1,37 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - CacheProvider + +/// A class provides caching functionality. +final class CacheProvider { + // MARK: Properties + + /// The user defaults. + private let userDefaults: IUserDefaults + + // MARK: Initialization + + /// Creates a `CacheProvider` instance. + /// + /// - Parameter userDefaults: The user defaults. + init(userDefaults: IUserDefaults = UserDefaults.standard) { + self.userDefaults = userDefaults + } +} + +// MARK: ICacheProvider + +extension CacheProvider: ICacheProvider { + func read(key: String) -> T? { + userDefaults.get(key: key) + } + + func write(key: String, value: T) { + userDefaults.set(key: key, codable: value) + } +} diff --git a/Sources/Flare/Classes/Providers/CacheProvider/ICacheProvider.swift b/Sources/Flare/Classes/Providers/CacheProvider/ICacheProvider.swift new file mode 100644 index 000000000..9c5480dec --- /dev/null +++ b/Sources/Flare/Classes/Providers/CacheProvider/ICacheProvider.swift @@ -0,0 +1,23 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Type for a cache provider that supports reading and writing Codable values. +protocol ICacheProvider { + /// Reads a Codable value from the cache using the specified key. + /// + /// - Parameters: + /// - key: The key associated with the value in the cache. + /// - Returns: The Codable value associated with the key, or nil if not found. + func read(key: String) -> T? + + /// Writes a Codable value to the cache using the specified key. + /// + /// - Parameters: + /// - key: The key to associate with the value in the cache. + /// - value: The Codable value to be stored in the cache. + func write(key: String, value: T) +} diff --git a/Sources/Flare/Classes/Providers/ConfigurationProvider/ConfigurationProvider.swift b/Sources/Flare/Classes/Providers/ConfigurationProvider/ConfigurationProvider.swift new file mode 100644 index 000000000..66ae0d47e --- /dev/null +++ b/Sources/Flare/Classes/Providers/ConfigurationProvider/ConfigurationProvider.swift @@ -0,0 +1,50 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - ConfigurationProvider + +/// A class responsible for providing configuration settings, utilizing a cache provider. +final class ConfigurationProvider { + // MARK: Properties + + /// The cache provider used to store and retrieve configuration settings. + private let cacheProvider: ICacheProvider + + /// The cache policy for fetching products. + private(set) var fetchCachePolicy: FetchCachePolicy = .default + + // MARK: Initialization + + /// Initializes a ConfigurationProvider with a specified cache provider. + /// + /// - Parameter cacheProvider: The cache provider to use. Defaults to an instance of + /// `CacheProvider` with standard UserDefaults. + init(cacheProvider: ICacheProvider = CacheProvider(userDefaults: UserDefaults.standard)) { + self.cacheProvider = cacheProvider + } +} + +// MARK: IConfigurationProvider + +extension ConfigurationProvider: IConfigurationProvider { + var applicationUsername: String? { + cacheProvider.read(key: .applicationUsername) + } + + func configure(with configuration: Configuration) { + cacheProvider.write(key: .applicationUsername, value: configuration.applicationUsername) + fetchCachePolicy = configuration.fetchCachePolicy + Logger.debug(message: L10n.Flare.initWithConfiguration(configuration)) + } +} + +// MARK: - Constants + +private extension String { + static let applicationUsername = "flare.configuration.application_username" + static let fetchCachePolicy = "flare.configuration.fetch_cache_policy" +} diff --git a/Sources/Flare/Classes/Providers/ConfigurationProvider/IConfigurationProvider.swift b/Sources/Flare/Classes/Providers/ConfigurationProvider/IConfigurationProvider.swift new file mode 100644 index 000000000..e5e2649b2 --- /dev/null +++ b/Sources/Flare/Classes/Providers/ConfigurationProvider/IConfigurationProvider.swift @@ -0,0 +1,20 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Type for providing configuration settings to an application. +protocol IConfigurationProvider { + /// The application username. + var applicationUsername: String? { get } + + /// <#Description#> + var fetchCachePolicy: FetchCachePolicy { get } + + /// Configures the provider with the specified configuration settings. + /// + /// - Parameter configuration: The configuration settings to apply. + func configure(with configuration: Configuration) +} diff --git a/Sources/Flare/Classes/Providers/EligibilityProvider/EligibilityProvider.swift b/Sources/Flare/Classes/Providers/EligibilityProvider/EligibilityProvider.swift new file mode 100644 index 000000000..ddaa1be78 --- /dev/null +++ b/Sources/Flare/Classes/Providers/EligibilityProvider/EligibilityProvider.swift @@ -0,0 +1,33 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - EligibilityProvider + +/// A class that provides eligibility checking functionality. +final class EligibilityProvider {} + +// MARK: IEligibilityProvider + +extension EligibilityProvider: IEligibilityProvider { + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func checkEligibility(products: [StoreProduct]) async throws -> [String: SubscriptionEligibility] { + let underlyingProducts = products.compactMap { $0.underlyingProduct as? SK2StoreProduct } + + var result: [String: SubscriptionEligibility] = [:] + + for product in underlyingProducts { + if let subscription = product.product.subscription, subscription.introductoryOffer != nil { + let isEligible = await subscription.isEligibleForIntroOffer + result[product.productIdentifier] = isEligible ? .eligible : .nonEligible + } else { + result[product.productIdentifier] = .noOffer + } + } + + return result + } +} diff --git a/Sources/Flare/Classes/Providers/EligibilityProvider/IEligibilityProvider.swift b/Sources/Flare/Classes/Providers/EligibilityProvider/IEligibilityProvider.swift new file mode 100644 index 000000000..40ad67997 --- /dev/null +++ b/Sources/Flare/Classes/Providers/EligibilityProvider/IEligibilityProvider.swift @@ -0,0 +1,17 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Type that provides eligibility checking functionality. +protocol IEligibilityProvider { + /// Checks whether products are eligible for promotional offers + /// + /// - Parameter products: The products to be checked. + /// + /// - Returns: An array that contains information about the eligibility of products. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func checkEligibility(products: [StoreProduct]) async throws -> [String: SubscriptionEligibility] +} diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift index 001843387..bb386a7bf 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift @@ -1,30 +1,57 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import StoreKit +/// A class that provides in-app purchase functionality. final class IAPProvider: IIAPProvider { // MARK: Properties + /// The queue of payment transactions to be processed by the App Store. private let paymentQueue: PaymentQueue + /// The provider is responsible for fetching StoreKit products. private let productProvider: IProductProvider - private let paymentProvider: IPaymentProvider + /// The provider is responsible for purchasing products. + private let purchaseProvider: IPurchaseProvider + /// The provider is responsible for refreshing receipts. private let receiptRefreshProvider: IReceiptRefreshProvider + /// The provider is responsible for refunding purchases + private let refundProvider: IRefundProvider + /// The provider is responsible for eligibility checking. + private let eligibilityProvider: IEligibilityProvider + /// The provider is tasked with handling code redemption. + private let redeemCodeProvider: IRedeemCodeProvider // MARK: Initialization + /// Creates a new `IAPProvider` instance. + /// + /// - Parameters: + /// - paymentQueue: The queue of payment transactions to be processed by the App Store. + /// - productProvider: The provider is responsible for fetching StoreKit products. + /// - purchaseProvider: The provider is respinsible for purchasing StoreKit product. + /// - receiptRefreshProvider: The provider is responsible for refreshing receipts. + /// - refundProvider: The provider is responsible for refunding purchases. + /// - eligibilityProvider: The provider is responsible for eligibility checking. + /// - redeemCodeProvider: The provider is tasked with handling code redemption. init( - paymentQueue: PaymentQueue = SKPaymentQueue.default(), - productProvider: IProductProvider = ProductProvider(), - paymentProvider: IPaymentProvider = PaymentProvider(), - receiptRefreshProvider: IReceiptRefreshProvider = ReceiptRefreshProvider() + paymentQueue: PaymentQueue, + productProvider: IProductProvider, + purchaseProvider: IPurchaseProvider, + receiptRefreshProvider: IReceiptRefreshProvider, + refundProvider: IRefundProvider, + eligibilityProvider: IEligibilityProvider, + redeemCodeProvider: IRedeemCodeProvider ) { self.paymentQueue = paymentQueue self.productProvider = productProvider - self.paymentProvider = paymentProvider + self.purchaseProvider = purchaseProvider self.receiptRefreshProvider = receiptRefreshProvider + self.refundProvider = refundProvider + self.eligibilityProvider = eligibilityProvider + self.redeemCodeProvider = redeemCodeProvider } // MARK: Internal @@ -33,15 +60,31 @@ final class IAPProvider: IIAPProvider { paymentQueue.canMakePayments } - func fetch(productIDs: Set, completion: @escaping Closure>) { - productProvider.fetch( - productIDs: productIDs, - requestID: UUID().uuidString, - completion: completion - ) + func fetch(productIDs: Set, completion: @escaping Closure>) { + if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { + AsyncHandler.call( + completion: { (result: Result<[StoreProduct], Error>) in + switch result { + case let .success(products): + completion(.success(products)) + case let .failure(error): + completion(.failure(.with(error: error))) + } + }, + asyncMethod: { + try await self.productProvider.fetch(productIDs: productIDs) + } + ) + } else { + productProvider.fetch( + productIDs: productIDs, + requestID: UUID().uuidString, + completion: completion + ) + } } - func fetch(productIDs: Set) async throws -> [SKProduct] { + func fetch(productIDs: Set) async throws -> [StoreProduct] { try await withCheckedThrowingContinuation { continuation in self.fetch(productIDs: productIDs) { result in continuation.resume(with: result) @@ -49,34 +92,47 @@ final class IAPProvider: IIAPProvider { } } - func purchase(productID: String, completion: @escaping Closure>) { - productProvider.fetch(productIDs: [productID], requestID: UUID().uuidString) { result in + func purchase( + product: StoreProduct, + promotionalOffer: PromotionalOffer?, + completion: @escaping Closure> + ) { + purchaseProvider.purchase(product: product, promotionalOffer: promotionalOffer) { result in switch result { - case let .success(products): - guard let product = products.first else { - completion(.failure(.storeProductNotAvailable)) - return - } - - let payment = SKPayment(product: product) - - self.paymentProvider.add(payment: payment) { _, result in - switch result { - case let .success(transaction): - completion(.success(PaymentTransaction(transaction))) - case let .failure(error): - completion(.failure(error)) - } - } + case let .success(transaction): + completion(.success(transaction)) case let .failure(error): completion(.failure(error)) } } } - func purchase(productID: String) async throws -> PaymentTransaction { + func purchase(product: StoreProduct, promotionalOffer: PromotionalOffer?) async throws -> StoreTransaction { + try await withCheckedThrowingContinuation { continuation in + self.purchase(product: product, promotionalOffer: promotionalOffer) { result in + continuation.resume(with: result) + } + } + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set, + promotionalOffer: PromotionalOffer?, + completion: @escaping SendableClosure> + ) { + purchaseProvider.purchase(product: product, options: options, promotionalOffer: promotionalOffer, completion: completion) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set, + promotionalOffer: PromotionalOffer? + ) async throws -> StoreTransaction { try await withCheckedThrowingContinuation { continuation in - purchase(productID: productID) { result in + purchase(product: product, options: options, promotionalOffer: promotionalOffer) { result in continuation.resume(with: result) } } @@ -105,23 +161,48 @@ final class IAPProvider: IIAPProvider { } } - func finish(transaction: PaymentTransaction) { - paymentProvider.finish(transaction: transaction) + func finish(transaction: StoreTransaction, completion: (@Sendable () -> Void)?) { + purchaseProvider.finish(transaction: transaction, completion: completion) } func addTransactionObserver(fallbackHandler: Closure>?) { - paymentProvider.set { _, result in - switch result { - case let .success(transaction): - fallbackHandler?(.success(PaymentTransaction(transaction))) - case let .failure(error): - fallbackHandler?(.failure(error)) - } - } - paymentProvider.addTransactionObserver() + purchaseProvider.addTransactionObserver(fallbackHandler: fallbackHandler) } func removeTransactionObserver() { - paymentProvider.removeTransactionObserver() + purchaseProvider.removeTransactionObserver() + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func checkEligibility(productIDs: Set) async throws -> [String: SubscriptionEligibility] { + let products = try await fetch(productIDs: productIDs) + return try await eligibilityProvider.checkEligibility(products: products) } + + #if os(iOS) || VISION_OS + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func beginRefundRequest(productID: String) async throws -> RefundRequestStatus { + try await refundProvider.beginRefundRequest(productID: productID) + } + + @available(iOS 14.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func presentCodeRedemptionSheet() { + Logger.debug(message: L10n.Redeem.presentingCodeRedemptionSheet) + paymentQueue.presentCodeRedemptionSheet() + } + + @available(iOS 16.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func presentOfferCodeRedeemSheet() async throws { + try await redeemCodeProvider.presentOfferCodeRedeemSheet() + } + #endif } diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift index c4e88043b..3e8899936 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift @@ -1,10 +1,12 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import StoreKit +// MARK: - IIAPProvider + /// Type that provides in-app purchase functionality. public protocol IIAPProvider { /// False if this device is not able or allowed to make payments @@ -15,7 +17,7 @@ public protocol IIAPProvider { /// - Parameters: /// - productIDs: The list of product identifiers for which you wish to retrieve descriptions. /// - completion: The completion containing the response of retrieving products. - func fetch(productIDs: Set, completion: @escaping Closure>) + func fetch(productIDs: Set, completion: @escaping Closure>) /// Retrieves localized information from the App Store about a specified list of products. /// @@ -24,18 +26,38 @@ public protocol IIAPProvider { /// - Throws: `IAPError(error:)` if the request did fail with error. /// /// - Returns: An array of products. - func fetch(productIDs: Set) async throws -> [SKProduct] + func fetch(productIDs: Set) async throws -> [StoreProduct] - /// Performs a purchase of a product with a given ID. + /// Performs a purchase of a product. /// /// - Note: The method automatically checks if the user can purchase a product. /// If the user can't make a payment, the method returns an error /// with the type `IAPError.paymentNotAllowed`. /// /// - Parameters: - /// - productID: The product identifier. + /// - product: The product to be purchased. + /// - promotionalOffer: The promotional offer. /// - completion: The closure to be executed once the purchase is complete. - func purchase(productID: String, completion: @escaping Closure>) + func purchase( + product: StoreProduct, + promotionalOffer: PromotionalOffer?, + completion: @escaping Closure> + ) + + /// Purchases a product. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - promotionalOffer: The promotional offer. + /// + /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. + /// + /// - Returns: A payment transaction. + func purchase(product: StoreProduct, promotionalOffer: PromotionalOffer?) async throws -> StoreTransaction /// Purchases a product with a given ID. /// @@ -43,12 +65,43 @@ public protocol IIAPProvider { /// If the user can't make a payment, the method returns an error /// with the type `IAPError.paymentNotAllowed`. /// - /// - Parameter productID: The product identifier. + /// - Parameters: + /// - product: The product to be purchased. + /// - options: The optional settings for a product purchase. + /// - promotionalOffer: The promotional offer. + /// - completion: The closure to be executed once the purchase is complete. /// /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. /// /// - Returns: A payment transaction. - func purchase(productID: String) async throws -> PaymentTransaction + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set, + promotionalOffer: PromotionalOffer?, + completion: @escaping SendableClosure> + ) + + /// Purchases a product with a given ID. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - options: The optional settings for a product purchase. + /// - promotionalOffer: The promotional offer. + /// + /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. + /// + /// - Returns: A payment transaction. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set, + promotionalOffer: PromotionalOffer? + ) async throws -> StoreTransaction /// Refreshes the receipt, representing the user's transactions with your app. /// @@ -65,8 +118,10 @@ public protocol IIAPProvider { /// Removes a finished (i.e. failed or completed) transaction from the queue. /// Attempting to finish a purchasing transaction will throw an exception. /// - /// - Parameter transaction: An object in the payment queue. - func finish(transaction: PaymentTransaction) + /// - Parameters: + /// - transaction: An object in the payment queue. + /// - completion: If a completion closure is provided, call it after finishing the transaction. + func finish(transaction: StoreTransaction, completion: (@Sendable () -> Void)?) /// Adds transaction observer to the payment queue. /// The transactions array will only be synchronized with the server while the queue has observers. @@ -79,4 +134,118 @@ public protocol IIAPProvider { /// /// - Note: This may require that the user authenticate. func removeTransactionObserver() + + /// Checks whether products are eligible for promotional offers + /// + /// - Parameter productIDs: The list of product identifiers for which you wish to check eligibility. + /// + /// - Returns: An array that contains information about the eligibility of products. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func checkEligibility(productIDs: Set) async throws -> [String: SubscriptionEligibility] + + #if os(iOS) || VISION_OS + /// Present the refund request sheet for the specified transaction in a window scene. + /// + /// - Parameter productID: The identifier of the transaction the user is requesting a refund for. + /// + /// - Returns: The result of the refund request. + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func beginRefundRequest(productID: String) async throws -> RefundRequestStatus + + /// Displays a sheet that enables users to redeem subscription offer codes that you configure in App Store Connect. + @available(iOS 14.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func presentCodeRedemptionSheet() + + /// Displays a sheet in the window scene that enables users to redeem + /// a subscription offer code that you configure in App Store + /// Connect. + @available(iOS 16.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func presentOfferCodeRedeemSheet() async throws + #endif +} + +extension IIAPProvider { + /// Performs a purchase of a product. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - completion: The closure to be executed once the purchase is complete. + func purchase( + product: StoreProduct, + completion: @escaping Closure> + ) { + purchase(product: product, promotionalOffer: nil, completion: completion) + } + + /// Purchases a product. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameter product: The product to be purchased. + /// + /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. + /// + /// - Returns: A payment transaction. + func purchase(product: StoreProduct) async throws -> StoreTransaction { + try await purchase(product: product, promotionalOffer: nil) + } + + /// Purchases a product with a given ID. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - options: The optional settings for a product purchase. + /// - completion: The closure to be executed once the purchase is complete. + /// + /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. + /// + /// - Returns: A payment transaction. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set, + completion: @escaping SendableClosure> + ) { + purchase(product: product, options: options, promotionalOffer: nil, completion: completion) + } + + /// Purchases a product with a given ID. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - options: The optional settings for a product purchase. + /// + /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. + /// + /// - Returns: A payment transaction. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set + ) async throws -> StoreTransaction { + try await purchase(product: product, options: options, promotionalOffer: nil) + } } diff --git a/Sources/Flare/Classes/Providers/PaymentProvider/IPaymentProvider.swift b/Sources/Flare/Classes/Providers/PaymentProvider/IPaymentProvider.swift index 2d511767d..dcc812907 100644 --- a/Sources/Flare/Classes/Providers/PaymentProvider/IPaymentProvider.swift +++ b/Sources/Flare/Classes/Providers/PaymentProvider/IPaymentProvider.swift @@ -6,7 +6,7 @@ import StoreKit /// Type that provides payment functionality. -public protocol IPaymentProvider: AnyObject { +protocol IPaymentProvider: AnyObject { /// False if this device is not able or allowed to make payments var canMakePayments: Bool { get } diff --git a/Sources/Flare/Classes/Providers/PaymentProvider/PaymentProvider.swift b/Sources/Flare/Classes/Providers/PaymentProvider/PaymentProvider.swift index aacf89156..0d681238e 100644 --- a/Sources/Flare/Classes/Providers/PaymentProvider/PaymentProvider.swift +++ b/Sources/Flare/Classes/Providers/PaymentProvider/PaymentProvider.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import Concurrency @@ -8,23 +8,36 @@ import StoreKit // MARK: - PaymentProvider +/// A class provides functionality to make payments. final class PaymentProvider: NSObject { // MARK: Properties + /// The queue of payment transactions to be processed by the App Store. private let paymentQueue: PaymentQueue + /// Dictionary to store payment handlers associated with transaction identifiers. private var paymentHandlers: [String: [PaymentHandler]] = [:] + /// Array to store restore handlers for completed transactions. private var restoreHandlers: [RestoreHandler] = [] + /// Optional fallback handler for handling payments if no specific handler is found. private var fallbackHandler: PaymentHandler? + /// Optional handler to determine whether to add a payment to the App Store. private var shouldAddStorePaymentHandler: ShouldAddStorePaymentHandler? + /// The dispatch queue factory for handling concurrent tasks. private var dispatchQueueFactory: IDispatchQueueFactory + /// Lazy-initialized private dispatch queue for handling tasks related to payment processing. private lazy var privateQueue: IDispatchQueue = dispatchQueueFactory.privateQueue(label: String(describing: self)) // MARK: Initialization + /// Creates a new `PaymentProvider` instance. + /// + /// - Parameters: + /// - paymentQueue: The queue of payment transactions to be processed by the App Store. + /// - dispatchQueueFactory: The dispatch queue factory. init( - paymentQueue: PaymentQueue = SKPaymentQueue.default(), - dispatchQueueFactory: IDispatchQueueFactory = DispatchQueueFactory() + paymentQueue: PaymentQueue, + dispatchQueueFactory: IDispatchQueueFactory ) { self.paymentQueue = paymentQueue self.dispatchQueueFactory = dispatchQueueFactory @@ -50,6 +63,13 @@ extension PaymentProvider: IPaymentProvider { addPaymentHandler(productID: payment.productIdentifier, handler: handler) dispatchQueueFactory.main().async { self.paymentQueue.add(payment) + + Logger.info( + message: L10n.Payment.paymentQueueAddingPayment( + payment.productIdentifier, + self.paymentQueue.transactions.count + ) + ) } } @@ -144,6 +164,13 @@ extension PaymentProvider: SKPaymentTransactionObserver { #endif func finish(transaction: PaymentTransaction) { + Logger.info( + message: L10n.Purchase.finishingTransaction( + transaction.transactionIdentifier ?? "", + transaction.productIdentifier + ) + ) + paymentQueue.finishTransaction(transaction.skTransaction) } diff --git a/Sources/Flare/Classes/Providers/ProductProvider/Decorators/ProductsCacheProviderDecorator/CachingProductsProviderDecorator.swift b/Sources/Flare/Classes/Providers/ProductProvider/Decorators/ProductsCacheProviderDecorator/CachingProductsProviderDecorator.swift new file mode 100644 index 000000000..d2a865962 --- /dev/null +++ b/Sources/Flare/Classes/Providers/ProductProvider/Decorators/ProductsCacheProviderDecorator/CachingProductsProviderDecorator.swift @@ -0,0 +1,160 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Atomic +import Foundation + +// MARK: - CachingProductsProviderDecorator + +/// `CachingProductsProviderDecorator` is a decorator class that adds caching functionality to an `IProductProvider`. +final class CachingProductsProviderDecorator { + // MARK: Properties + + /// Atomic property for thread-safe access to the cache dictionary. + @Atomic + private var cache: [String: StoreProduct] = [:] + + /// The product provider. + private let productProvider: IProductProvider + + /// The configuration provider. + private let configurationProvider: IConfigurationProvider + + // MARK: Initialization + + /// Creates a `CachingProductsProviderDecorator`instance. + /// + /// - Parameter productProvider: The product provider. + init(productProvider: IProductProvider, configurationProvider: IConfigurationProvider) { + self.productProvider = productProvider + self.configurationProvider = configurationProvider + } + + // MARK: Private + + /// Caches the provided array of products. + /// + /// - Parameter products: The array of products to be cached. + private func cache(products: [StoreProduct]) { + products.forEach { _cache.wrappedValue[$0.productIdentifier] = $0 } + } + + /// Retrieves cached products for the given set of product IDs. + /// + /// - Parameter ids: The set of product IDs to retrieve cached products for. + /// + /// - Returns: A dictionary containing cached products for the specified IDs. + private func cachedProducts(ids: Set) -> [String: StoreProduct] { + let cachedProducts = _cache.wrappedValue.filter { ids.contains($0.key) } + return cachedProducts + } + + /// Checks the cache for specified product IDs and fetches missing products from the product provider. + /// + /// - Parameters: + /// - productIDs: The set of product IDs to check the cache for. + /// - fetcher: A closure to fetch missing products from the product provider. + /// - completion: A closure to be called with the fetched products or an error. + private func fetch( + productIDs: Set, + fetcher: (Set, @escaping (Result<[StoreProduct], IAPError>) -> Void) -> Void, + completion: @escaping ProductsHandler + ) { + let cachedProducts = cachedProducts(ids: productIDs) + let missingProducts = productIDs.subtracting(cachedProducts.keys) + + if missingProducts.isEmpty { + completion(.success(Array(cachedProducts.values))) + } else { + fetcher(missingProducts) { [weak self] result in + switch result { + case let .success(products): + self?.cache(products: products) + completion(.success(products)) + case let .failure(error): + completion(.failure(error)) + } + } + } + } + + /// Retrieves localized information from the App Store about a specified list of products. + /// + /// - Parameters: + /// - fetchPolicy: The cache policy for fetching products. + /// - productIDs: The set of product IDs to check the cache for. + /// - fetcher: A closure to fetch missing products from the product provider. + /// - completion: A closure to be called with the fetched products or an error. + private func fetch( + fetchPolicy: FetchCachePolicy, + productIDs: Set, + fetcher: (Set, @escaping (Result<[StoreProduct], IAPError>) -> Void) -> Void, + completion: @escaping ProductsHandler + ) { + switch fetchPolicy { + case .fetch: + fetcher(productIDs, completion) + case .cachedOrFetch: + fetch(productIDs: productIDs, fetcher: fetcher, completion: completion) + } + } + + /// Retrieves localized information from the App Store about a specified list of products. + /// + /// - Parameters: + /// - productIDs: The set of product IDs to check the cache for. + /// - completion: A closure to be called with the fetched products or an error. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + private func fetchSK2Products(productIDs: Set, completion: @escaping ProductsHandler) { + AsyncHandler.call( + completion: { result in + switch result { + case let .success(products): + completion(.success(products)) + case let .failure(error): + completion(.failure(IAPError.with(error: error))) + } + }, + asyncMethod: { + try await self.productProvider.fetch(productIDs: productIDs) + } + ) + } +} + +// MARK: ICachingProductsProviderDecorator + +extension CachingProductsProviderDecorator: ICachingProductsProviderDecorator { + func fetch(productIDs: Set, requestID: String, completion: @escaping ProductsHandler) { + fetch( + fetchPolicy: configurationProvider.fetchCachePolicy, + productIDs: productIDs, + fetcher: { [weak self] ids, completion in + self?.productProvider.fetch(productIDs: ids, requestID: requestID, completion: completion) + }, completion: completion + ) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func fetch(productIDs: Set) async throws -> [StoreProduct] { + try await withCheckedThrowingContinuation { [weak self] continuation in + guard let self = self else { + continuation.resume(throwing: IAPError.unknown) + return + } + + self.fetch( + fetchPolicy: self.configurationProvider.fetchCachePolicy, + productIDs: productIDs, + fetcher: { [weak self] _, completion in + self?.fetchSK2Products(productIDs: productIDs, completion: completion) + }, + completion: { result in + continuation.resume(with: result) + } + ) + } + } +} diff --git a/Sources/Flare/Classes/Providers/ProductProvider/Decorators/ProductsCacheProviderDecorator/ICachingProductsProviderDecorator.swift b/Sources/Flare/Classes/Providers/ProductProvider/Decorators/ProductsCacheProviderDecorator/ICachingProductsProviderDecorator.swift new file mode 100644 index 000000000..b53e6d4d7 --- /dev/null +++ b/Sources/Flare/Classes/Providers/ProductProvider/Decorators/ProductsCacheProviderDecorator/ICachingProductsProviderDecorator.swift @@ -0,0 +1,9 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Type that caches retrieved products. +protocol ICachingProductsProviderDecorator: IProductProvider {} diff --git a/Sources/Flare/Classes/Providers/ProductProvider/IProductProvider.swift b/Sources/Flare/Classes/Providers/ProductProvider/IProductProvider.swift index f9e5376ba..1e82f3158 100644 --- a/Sources/Flare/Classes/Providers/ProductProvider/IProductProvider.swift +++ b/Sources/Flare/Classes/Providers/ProductProvider/IProductProvider.swift @@ -1,20 +1,20 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import StoreKit -public typealias ProductHandler = (_ result: Result<[SKProduct], IAPError>) -> Void -public typealias PaymentHandler = (_ queue: PaymentQueue, _ result: Result) -> Void -public typealias RestoreHandler = (_ queue: SKPaymentQueue, _ error: IAPError?) -> Void -public typealias ShouldAddStorePaymentHandler = (_ queue: SKPaymentQueue, _ payment: SKPayment, _ product: SKProduct) -> Bool -public typealias ReceiptRefreshHandler = (Result) -> Void +typealias PaymentHandler = (_ queue: PaymentQueue, _ result: Result) -> Void +typealias RestoreHandler = (_ queue: SKPaymentQueue, _ error: IAPError?) -> Void +typealias ShouldAddStorePaymentHandler = (_ queue: SKPaymentQueue, _ payment: SKPayment, _ product: SKProduct) -> Bool +typealias ReceiptRefreshHandler = (Result) -> Void // MARK: - IProductProvider -public protocol IProductProvider { - typealias ProductsHandler = Closure> +/// A type that is responsible for retrieving StoreKit products. +protocol IProductProvider { + typealias ProductsHandler = Closure> /// Retrieves localized information from the App Store about a specified list of products. /// @@ -23,4 +23,14 @@ public protocol IProductProvider { /// - requestID: The request identifier. /// - completion: The completion containing the response of retrieving products. func fetch(productIDs: Set, requestID: String, completion: @escaping ProductsHandler) + + /// Retrieves localized information from the App Store about a specified list of products. + /// + /// - Note:This method utilizes the new `StoreKit2` API. + /// + /// - Parameter productIDs: The list of product identifiers for which you wish to retrieve descriptions. + /// + /// - Returns: The requested products. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func fetch(productIDs: Set) async throws -> [StoreProduct] } diff --git a/Sources/Flare/Classes/Providers/ProductProvider/ProductProvider.swift b/Sources/Flare/Classes/Providers/ProductProvider/ProductProvider.swift index c9f3dc6b1..57a6ad833 100644 --- a/Sources/Flare/Classes/Providers/ProductProvider/ProductProvider.swift +++ b/Sources/Flare/Classes/Providers/ProductProvider/ProductProvider.swift @@ -1,16 +1,37 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // +import Atomic import Concurrency import StoreKit // MARK: - ProductProvider +/// A class is responsible for fetching StoreKit products. +/// +/// This implementation supports two ways of fetching products using the old way API and the new StoreKit2 API. +/// +/// Example: +/// +/// ``` +/// let productProvider = ProductProvider() +/// productProvider.fetch(productIDs: ["productID"], requestID: UUID().uuidString) { result in +/// switch result { +/// case let .success(products): +/// // The `products` array contains all fetched products with the given IDs. +/// case let .failure(error): +/// // An error occurred; you can handle it here. +/// } +/// } +/// ``` final class ProductProvider: NSObject, IProductProvider { // MARK: Lifecycle + /// Creates a new `ProductProvider` instance. + /// + /// - Parameter dispatchQueueFactory: The dispatch queue factory. init(dispatchQueueFactory: IDispatchQueueFactory = DispatchQueueFactory()) { self.dispatchQueueFactory = dispatchQueueFactory } @@ -22,13 +43,27 @@ final class ProductProvider: NSObject, IProductProvider { fetch(request: request, completion: completion) } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func fetch(productIDs ids: Set) async throws -> [StoreProduct] { + try await StoreKit.Product.products(for: ids).map { StoreProduct(product: $0) } + } + // MARK: Private - private var handlers: [String: ProductsHandler] = [:] + /// Dictionary to store request handlers with their corresponding request IDs. + private var handlers: [ProductsRequest: ProductsHandler] = [:] + /// The dispatch queue factory for handling concurrent tasks. private let dispatchQueueFactory: IDispatchQueueFactory + /// Lazy-initialized private dispatch queue for handling tasks related to product fetching. private lazy var dispatchQueue: IDispatchQueue = dispatchQueueFactory.privateQueue(label: String(describing: self)) + /// Creates a StoreKit product request with the specified product IDs and request ID. + /// + /// - Parameters: + /// - ids: The set of product IDs to include in the request. + /// - requestID: The identifier for the request. + /// - Returns: An instance of `SKProductsRequest`. private func makeRequest(ids: Set, requestID: String) -> SKProductsRequest { let request = SKProductsRequest(productIdentifiers: ids) request.id = requestID @@ -36,14 +71,35 @@ final class ProductProvider: NSObject, IProductProvider { return request } + /// Initiates the product fetch request and handles the associated completion closure. + /// + /// - Parameters: + /// - request: The `SKProductsRequest` to be initiated. + /// - completion: A closure to be called upon completion with the fetched products. private func fetch(request: SKProductsRequest, completion: @escaping ProductsHandler) { dispatchQueue.async { - self.handlers[request.id] = completion + self.handlers[request.request] = completion self.dispatchQueueFactory.main().async { request.start() } } } + + private func handleFetchResult( + result: Result<[T], E>, + _ completion: @escaping (Result<[StoreProduct], IAPError>) -> Void + ) { + switch result { + case let .success(products): + completion(.success(products.map { StoreProduct($0) })) + case let .failure(error): + if let iapError = error as? IAPError { + completion(.failure(iapError)) + } else { + completion(.failure(IAPError(error: error))) + } + } + } } // MARK: SKProductsRequestDelegate @@ -51,7 +107,8 @@ final class ProductProvider: NSObject, IProductProvider { extension ProductProvider: SKProductsRequestDelegate { func request(_ request: SKRequest, didFailWithError error: Error) { dispatchQueue.async { - let handler = self.handlers.removeValue(forKey: request.id) + let handler = self.handlers.removeValue(forKey: request.request) + self.dispatchQueueFactory.main().async { handler?(.failure(IAPError(error: error))) } @@ -60,18 +117,29 @@ extension ProductProvider: SKProductsRequestDelegate { func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { dispatchQueue.async { - let handler = self.handlers.removeValue(forKey: request.id) + let handler = self.handlers.removeValue(forKey: request.request) guard response.invalidProductIdentifiers.isEmpty else { self.dispatchQueueFactory.main().async { - handler?(.failure(.invalid(productIds: response.invalidProductIdentifiers))) + handler?(.failure(.invalid(productIDs: response.invalidProductIdentifiers))) + Logger.error(message: L10n.Products.requestedProductsNotFound(response.invalidProductIdentifiers)) } return } + Logger.debug(message: L10n.Products.requestedProductsReceived(response.products.map(\.productIdentifier))) + self.dispatchQueueFactory.main().async { - handler?(.success(response.products)) + handler?(.success(response.products.map { StoreProduct(skProduct: $0) })) } } } } + +// MARK: - Helpers + +private extension SKRequest { + var request: ProductsRequest { + ProductsRequest(self) + } +} diff --git a/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift b/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift new file mode 100644 index 000000000..2fb931923 --- /dev/null +++ b/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift @@ -0,0 +1,89 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +public typealias PurchaseCompletionHandler = @MainActor @Sendable (Result) -> Void + +// MARK: - IPurchaseProvider + +protocol IPurchaseProvider { + /// Removes a finished (i.e. failed or completed) transaction from the queue. + /// Attempting to finish a purchasing transaction will throw an exception. + /// + /// - Parameters: + /// - transaction: An object in the payment queue. + /// - completion: If a completion closure is provided, call it after finishing the transaction. + func finish(transaction: StoreTransaction, completion: (@Sendable () -> Void)?) + + /// Adds transaction observer to the payment queue. + /// The transactions array will only be synchronized with the server while the queue has observers. + /// + /// - Note: This may require that the user authenticate. + func addTransactionObserver(fallbackHandler: Closure>?) + + /// Removes transaction observer from the payment queue. + /// The transactions array will only be synchronized with the server while the queue has observers. + /// + /// - Note: This may require that the user authenticate. + func removeTransactionObserver() + + /// Purchases a product. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - promotionalOffer: The promotional offer. + /// - completion: The closure to be executed once the purchase is complete. + func purchase( + product: StoreProduct, + promotionalOffer: PromotionalOffer?, + completion: @escaping PurchaseCompletionHandler + ) + + /// Purchases a product. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - options: The optional settings for a product purchase. + /// - promotionalOffer: The promotional offer. + /// - completion: The closure to be executed once the purchase is complete. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set, + promotionalOffer: PromotionalOffer?, + completion: @escaping PurchaseCompletionHandler + ) +} + +extension IPurchaseProvider { + /// Purchases a product. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - completion: The closure to be executed once the purchase is complete. + func purchase( + product: StoreProduct, + completion: @escaping PurchaseCompletionHandler + ) { + purchase(product: product, promotionalOffer: nil, completion: completion) + } + + /// Purchases a product. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - options: The optional settings for a product purchase. + /// - completion: The closure to be executed once the purchase is complete. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set, + completion: @escaping PurchaseCompletionHandler + ) { + purchase(product: product, options: options, promotionalOffer: nil, completion: completion) + } +} diff --git a/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift new file mode 100644 index 000000000..b78680ba9 --- /dev/null +++ b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift @@ -0,0 +1,209 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - PurchaseProvider + +final class PurchaseProvider { + // MARK: Properties + + /// The provider is responsible for making in-app payments. + private let paymentProvider: IPaymentProvider + /// The transaction listener. + private let transactionListener: ITransactionListener? + /// The configuration provider. + private let configurationProvider: IConfigurationProvider + + // MARK: Initialization + + /// Creates a new `PurchaseProvider` instance. + /// + /// - Parameters: + /// - paymentProvider: The provider is responsible for purchasing products. + /// - transactionListener: The transaction listener. + /// - configurationProvider: The configuration provider. + init( + paymentProvider: IPaymentProvider, + transactionListener: ITransactionListener? = nil, + configurationProvider: IConfigurationProvider + ) { + self.paymentProvider = paymentProvider + self.configurationProvider = configurationProvider + + if let transactionListener = transactionListener { + self.transactionListener = transactionListener + } else if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { + self.transactionListener = TransactionListener(updates: StoreKit.Transaction.updates) + } else { + self.transactionListener = nil + } + } + + // MARK: Private + + private func purchase( + sk1StoreProduct: SK1StoreProduct, + promotionalOffer: PromotionalOffer?, + completion: @escaping @MainActor (Result) -> Void + ) { + let payment = SKMutablePayment(product: sk1StoreProduct.product) + payment.applicationUsername = configurationProvider.applicationUsername + payment.paymentDiscount = promotionalOffer?.signedData.skPromotionalOffer + paymentProvider.add(payment: payment) { _, result in + Task { + switch result { + case let .success(transaction): + await completion(.success(StoreTransaction(paymentTransaction: PaymentTransaction(transaction)))) + Logger.info(message: L10n.Purchase.purchasedProduct(sk1StoreProduct.productIdentifier)) + case let .failure(error): + await completion(.failure(error)) + self.log(error: error, productID: sk1StoreProduct.productIdentifier) + } + } + } + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + private func purchase( + sk2StoreProduct: SK2StoreProduct, + options: Set? = nil, + promotionalOffer: PromotionalOffer?, + completion: @escaping @MainActor (Result) -> Void + ) { + AsyncHandler.call(completion: { (result: Result) in + Task { + switch result { + case let .success(result): + if let transaction = try await self.transactionListener?.handle(purchaseResult: result) { + await completion(.success(transaction)) + Logger.info(message: L10n.Purchase.purchasedProduct(sk2StoreProduct.productIdentifier)) + } else { + await completion(.failure(IAPError.unknown)) + self.log(error: IAPError.unknown, productID: sk2StoreProduct.productIdentifier) + } + case let .failure(error): + await completion(.failure(IAPError(error: error))) + } + } + }, asyncMethod: { + var options: Set = options ?? [] + try self.configure(options: &options, promotionalOffer: promotionalOffer) + return try await sk2StoreProduct.product.purchase(options: options) + }) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + private func configure(options: inout Set, promotionalOffer: PromotionalOffer?) throws { + if let promotionalOffer { + try options.insert(promotionalOffer.signedData.promotionalOffer) + } + + if let applicationUsername = configurationProvider.applicationUsername, let uuid = UUID(uuidString: applicationUsername) { + // If options contain an app account token, the next line of code doesn't affect it. + options.insert(.appAccountToken(uuid)) + } + } + + private func log(error: Error, productID: String) { + Logger.error(message: L10n.Purchase.productPurchaseFailed(productID, error.localizedDescription)) + } + + private func logPurchase(productID: String, promotionalOffer: PromotionalOffer?) { + if let offerID = promotionalOffer?.discount.offerIdentifier { + Logger.info( + message: L10n.Purchase.purchasingProductWithOffer(productID, offerID) + ) + } else { + Logger.info(message: L10n.Purchase.purchasingProduct(productID)) + } + } +} + +// MARK: IPurchaseProvider + +extension PurchaseProvider: IPurchaseProvider { + func purchase( + product: StoreProduct, + promotionalOffer: PromotionalOffer?, + completion: @escaping PurchaseCompletionHandler + ) { + logPurchase(productID: product.productIdentifier, promotionalOffer: promotionalOffer) + + if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *), + let sk2Product = product.underlyingProduct as? SK2StoreProduct + { + self.purchase(sk2StoreProduct: sk2Product, promotionalOffer: promotionalOffer, completion: completion) + } else if let sk1Product = product.underlyingProduct as? SK1StoreProduct { + purchase(sk1StoreProduct: sk1Product, promotionalOffer: promotionalOffer, completion: completion) + } + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set, + promotionalOffer: PromotionalOffer?, + completion: @escaping PurchaseCompletionHandler + ) { + logPurchase(productID: product.productIdentifier, promotionalOffer: promotionalOffer) + + if let sk2Product = product.underlyingProduct as? SK2StoreProduct { + purchase( + sk2StoreProduct: sk2Product, + options: options, + promotionalOffer: promotionalOffer, + completion: completion + ) + } else { + Task { + await completion(.failure(.unknown)) + self.log(error: IAPError.unknown, productID: product.productIdentifier) + } + } + } + + func finish(transaction: StoreTransaction, completion: (@Sendable () -> Void)?) { + if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *), + let sk2Transaction = transaction.storeTransaction as? SK2StoreTransaction + { + AsyncHandler.call( + completion: { _ in + completion?() + }, + asyncMethod: { + await sk2Transaction.transaction.finish() + + Logger.info( + message: L10n.Purchase.finishingTransaction( + sk2Transaction.transactionIdentifier, + sk2Transaction.productIdentifier + ) + ) + } + ) + } else if let sk1Transaction = transaction.storeTransaction as? SK1StoreTransaction { + paymentProvider.finish(transaction: sk1Transaction.transaction) + completion?() + } + } + + func addTransactionObserver(fallbackHandler: Closure>?) { + paymentProvider.set { _, result in + switch result { + case let .success(transaction): + fallbackHandler?(.success(PaymentTransaction(transaction))) + case let .failure(error): + fallbackHandler?(.failure(error)) + } + } + paymentProvider.addTransactionObserver() + } + + func removeTransactionObserver() { + paymentProvider.removeTransactionObserver() + } +} diff --git a/Sources/Flare/Classes/Providers/ReceiptRefreshProvider/IReceiptRefreshProvider.swift b/Sources/Flare/Classes/Providers/ReceiptRefreshProvider/IReceiptRefreshProvider.swift index 44c9c1c9a..9344e93ff 100644 --- a/Sources/Flare/Classes/Providers/ReceiptRefreshProvider/IReceiptRefreshProvider.swift +++ b/Sources/Flare/Classes/Providers/ReceiptRefreshProvider/IReceiptRefreshProvider.swift @@ -6,7 +6,7 @@ import StoreKit /// A type that can refresh the bundle's App Store receipt. -public protocol IReceiptRefreshProvider { +protocol IReceiptRefreshProvider { /// The bundle’s App Store receipt. var receipt: String? { get } diff --git a/Sources/Flare/Classes/Providers/ReceiptRefreshProvider/ReceiptRefreshProvider.swift b/Sources/Flare/Classes/Providers/ReceiptRefreshProvider/ReceiptRefreshProvider.swift index fcfdc6fd0..f5ec09957 100644 --- a/Sources/Flare/Classes/Providers/ReceiptRefreshProvider/ReceiptRefreshProvider.swift +++ b/Sources/Flare/Classes/Providers/ReceiptRefreshProvider/ReceiptRefreshProvider.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import Concurrency @@ -9,25 +9,39 @@ import StoreKit // MARK: - ReceiptRefreshProvider +/// A class that can refresh the bundle's App Store receipt. final class ReceiptRefreshProvider: NSObject { // MARK: Properties + /// The dispatch queue factory. private let dispatchQueueFactory: IDispatchQueueFactory + /// A convenient interface to the contents of the file system, and the primary means of interacting with it. private let fileManager: IFileManager + /// The type that retrieves the App Store receipt URL. private let appStoreReceiptProvider: IAppStoreReceiptProvider + /// The receipt refresh request factory. private let receiptRefreshRequestFactory: IReceiptRefreshRequestFactory + /// Collection of handlers for receipt refresh requests. private var handlers: [String: ReceiptRefreshHandler] = [:] + /// Lazy-initialized private dispatch queue for handling tasks related to refreshing receipts. private lazy var dispatchQueue: IDispatchQueue = dispatchQueueFactory.privateQueue(label: String(describing: self)) // MARK: Initialization + /// Creates a new `ReceiptRefreshProvider` instance. + /// + /// - Parameters: + /// - dispatchQueueFactory: The dispatch queue factory. + /// - fileManager: A convenient interface to the contents of the file system, and the primary means of interacting with it. + /// - appStoreReceiptProvider: The type that retrieves the App Store receipt URL. + /// - receiptRefreshRequestFactory: The receipt refresh request factory. init( - dispatchQueueFactory: IDispatchQueueFactory = DispatchQueueFactory(), + dispatchQueueFactory: IDispatchQueueFactory, fileManager: IFileManager = FileManager.default, appStoreReceiptProvider: IAppStoreReceiptProvider = Bundle.main, - receiptRefreshRequestFactory: IReceiptRefreshRequestFactory = ReceiptRefreshRequestFactory() + receiptRefreshRequestFactory: IReceiptRefreshRequestFactory ) { self.dispatchQueueFactory = dispatchQueueFactory self.fileManager = fileManager @@ -37,6 +51,7 @@ final class ReceiptRefreshProvider: NSObject { // MARK: Internal + /// Computed property to retrieve the base64-encoded app store receipt string. var receipt: String? { if let appStoreReceiptURL = appStoreReceiptProvider.appStoreReceiptURL, fileManager.fileExists(atPath: appStoreReceiptURL.path) @@ -50,10 +65,20 @@ final class ReceiptRefreshProvider: NSObject { // MARK: Private + /// Creates a refresh receipt request. + /// + /// - Parameter id: The request identifier. + /// + /// - Returns: A receipt refresh request. private func makeRequest(id: String) -> IReceiptRefreshRequest { receiptRefreshRequestFactory.make(requestID: id, delegate: self) } + /// Fetches receipt information using a refresh request. + /// + /// - Parameters: + /// - request: The refresh request. + /// - handler: The closure to be executed once the refresh is complete. private func fetch(request: IReceiptRefreshRequest, handler: @escaping ReceiptRefreshHandler) { dispatchQueue.async { self.handlers[request.id] = handler @@ -68,6 +93,8 @@ final class ReceiptRefreshProvider: NSObject { extension ReceiptRefreshProvider: IReceiptRefreshProvider { func refresh(requestID: String, handler: @escaping ReceiptRefreshHandler) { + Logger.info(message: L10n.Receipt.refreshingReceipt(requestID)) + let request = makeRequest(id: requestID) fetch(request: request, handler: handler) } @@ -85,6 +112,8 @@ extension ReceiptRefreshProvider: IReceiptRefreshProvider { extension ReceiptRefreshProvider: SKRequestDelegate { func request(_ request: SKRequest, didFailWithError error: Error) { + Logger.error(message: L10n.Receipt.refreshingReceiptFailed(request.id, error.localizedDescription)) + dispatchQueue.async { let handler = self.handlers.removeValue(forKey: request.id) self.dispatchQueueFactory.main().async { @@ -94,6 +123,8 @@ extension ReceiptRefreshProvider: SKRequestDelegate { } func requestDidFinish(_ request: SKRequest) { + Logger.info(message: L10n.Receipt.refreshedReceipt(request.id)) + dispatchQueue.async { let handler = self.handlers.removeValue(forKey: request.id) self.dispatchQueueFactory.main().async { diff --git a/Sources/Flare/Classes/Providers/RedeemCodeProvider/IRedeemCodeProvider.swift b/Sources/Flare/Classes/Providers/RedeemCodeProvider/IRedeemCodeProvider.swift new file mode 100644 index 000000000..03981bec2 --- /dev/null +++ b/Sources/Flare/Classes/Providers/RedeemCodeProvider/IRedeemCodeProvider.swift @@ -0,0 +1,24 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Protocol defining the requirements for a redeem code provider. +protocol IRedeemCodeProvider { + #if os(iOS) || VISION_OS + /// Displays a sheet in the window scene that enables users to redeem + /// a subscription offer code configured in App Store Connect. + /// + /// - Important: This method is available starting from iOS 16.0. + /// - Note: This method is not available on macOS, watchOS, or tvOS. + /// + /// - Throws: An error if there is an issue with presenting the redeem code sheet. + @available(iOS 16.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func presentOfferCodeRedeemSheet() async throws + #endif +} diff --git a/Sources/Flare/Classes/Providers/RedeemCodeProvider/RedeemCodeProvider.swift b/Sources/Flare/Classes/Providers/RedeemCodeProvider/RedeemCodeProvider.swift new file mode 100644 index 000000000..6358aeb69 --- /dev/null +++ b/Sources/Flare/Classes/Providers/RedeemCodeProvider/RedeemCodeProvider.swift @@ -0,0 +1,49 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - RedeemCodeProvider + +/// A final class responsible for providing functionality related to redeeming offer codes. +final class RedeemCodeProvider { + // MARK: Properties + + /// An instance of a system information provider conforming to the `ISystemInfoProvider` + private let systemInfoProvider: ISystemInfoProvider + + // MARK: Initialization + + /// Initializes a `RedeemCodeProvider` instance with an optional system information provider. + /// + /// - Parameter systemInfoProvider: An instance of a system information provider. + /// Defaults to a new instance of `SystemInfoProvider` if not provided. + init(systemInfoProvider: ISystemInfoProvider = SystemInfoProvider()) { + self.systemInfoProvider = systemInfoProvider + } +} + +// MARK: IRedeemCodeProvider + +extension RedeemCodeProvider: IRedeemCodeProvider { + #if os(iOS) || VISION_OS + @available(iOS 16.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @MainActor + func presentOfferCodeRedeemSheet() async throws { + let windowScene = try systemInfoProvider.currentScene + do { + Logger.debug(message: L10n.Redeem.presentingOfferCodeRedeemSheet) + try await AppStore.presentOfferCodeRedeemSheet(in: windowScene) + } catch { + Logger.error(message: L10n.Redeem.unableToPresentOfferCodeRedeemSheet(error.localizedDescription)) + throw error + } + } + #endif +} diff --git a/Sources/Flare/Classes/Providers/RefundProvider/IRefundProvider.swift b/Sources/Flare/Classes/Providers/RefundProvider/IRefundProvider.swift new file mode 100644 index 000000000..350c1a3f7 --- /dev/null +++ b/Sources/Flare/Classes/Providers/RefundProvider/IRefundProvider.swift @@ -0,0 +1,21 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +/// A type that can refund purchases. +protocol IRefundProvider { + #if os(iOS) || VISION_OS + /// Present the refund request sheet for the specified transaction in a window scene. + /// + /// - Parameter productID: The identifier of the transaction the user is requesting a refund for. + /// + /// - Returns: The result of the refund request. + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @MainActor + func beginRefundRequest(productID: String) async throws -> RefundRequestStatus + #endif +} diff --git a/Sources/Flare/Classes/Providers/RefundProvider/RefundProvider.swift b/Sources/Flare/Classes/Providers/RefundProvider/RefundProvider.swift new file mode 100644 index 000000000..d77bf4f0a --- /dev/null +++ b/Sources/Flare/Classes/Providers/RefundProvider/RefundProvider.swift @@ -0,0 +1,85 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +#if canImport(UIKit) + import UIKit +#endif +import StoreKit + +// MARK: - RefundProvider + +final class RefundProvider { + // MARK: Properties + + private let systemInfoProvider: ISystemInfoProvider + private let refundRequestProvider: IRefundRequestProvider + + // MARK: Initialization + + init( + systemInfoProvider: ISystemInfoProvider = SystemInfoProvider(), + refundRequestProvider: IRefundRequestProvider = RefundRequestProvider() + ) { + self.systemInfoProvider = systemInfoProvider + self.refundRequestProvider = refundRequestProvider + } + + // MARK: Private + + #if os(iOS) || VISION_OS + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @MainActor + private func initRefundRequest( + transactionID: UInt64, + windowScene: UIWindowScene + ) async throws -> RefundRequestStatus { + let status = try await refundRequestProvider.beginRefundRequest( + transactionID: transactionID, + windowScene: windowScene + ) + return mapStatus(status) + } + + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + private func mapStatus(_ status: Result) -> RefundRequestStatus { + switch status { + case let .success(status): + switch status { + case .success: + return .success + case .userCancelled: + return .userCancelled + @unknown default: + return .unknown + } + case let .failure(error): + return .failed(error: error) + } + } + #endif +} + +// MARK: IRefundProvider + +extension RefundProvider: IRefundProvider { + #if os(iOS) || VISION_OS + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @MainActor + func beginRefundRequest(productID: String) async throws -> RefundRequestStatus { + let windowScene = try systemInfoProvider.currentScene + let transactionID = try await refundRequestProvider.verifyTransaction(productID: productID) + return try await initRefundRequest(transactionID: transactionID, windowScene: windowScene) + } + #endif +} diff --git a/Sources/Flare/Classes/Providers/RefundRequestProvider/IRefundRequestProvider.swift b/Sources/Flare/Classes/Providers/RefundRequestProvider/IRefundRequestProvider.swift new file mode 100644 index 000000000..87b84c2d2 --- /dev/null +++ b/Sources/Flare/Classes/Providers/RefundRequestProvider/IRefundRequestProvider.swift @@ -0,0 +1,47 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +#if canImport(UIKit) + import UIKit +#endif + +// MARK: - IRefundRequestProvider + +/// A type can create a refund request. +protocol IRefundRequestProvider { + /// Present the refund request sheet for the specified transaction in a window scene. + /// + /// - Parameters: + /// - transactionID: The identifier of the transaction the user is requesting a refund for. + /// - windowScene: The UIWindowScene that the system displays the sheet on. + /// + /// - Returns: The result of the refund request. + #if os(iOS) || VISION_OS + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @MainActor + func beginRefundRequest( + transactionID: UInt64, + windowScene: UIWindowScene + ) async throws -> Result + + /// Verifies the latest user's transaction. + /// + /// - Parameter productID: The product identifier. + /// + /// - Returns: The identifier of the transaction. + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @MainActor + func verifyTransaction(productID: String) async throws -> UInt64 + #endif +} diff --git a/Sources/Flare/Classes/Providers/RefundRequestProvider/RefundRequestProvider.swift b/Sources/Flare/Classes/Providers/RefundRequestProvider/RefundRequestProvider.swift new file mode 100644 index 000000000..bdc650a6d --- /dev/null +++ b/Sources/Flare/Classes/Providers/RefundRequestProvider/RefundRequestProvider.swift @@ -0,0 +1,75 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +#if canImport(UIKit) + import UIKit +#endif +import StoreKit + +// MARK: - RefundRequestProvider + +final class RefundRequestProvider {} + +// MARK: IRefundRequestProvider + +extension RefundRequestProvider: IRefundRequestProvider { + #if os(iOS) || VISION_OS + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @MainActor + func beginRefundRequest( + transactionID: UInt64, + windowScene: UIWindowScene + ) async throws -> Result { + do { + let status = try await StoreKit.Transaction.beginRefundRequest(for: transactionID, in: windowScene) + return .success(status) + } catch { + return .failure(mapError(error)) + } + } + + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func verifyTransaction(productID: String) async throws -> UInt64 { + guard let state = await StoreKit.Transaction.latest(for: productID) else { + Logger.error(message: L10n.Purchase.transactionNotFound(productID)) + throw IAPError.transactionNotFound(productID: productID) + } + + switch state { + case let .verified(transaction): + return transaction.id + case let .unverified(_, result): + Logger.error(message: L10n.Purchase.transactionUnverified(productID, result.localizedDescription)) + throw result + } + } + + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + private func mapError(_ error: Error) -> IAPError { + if let skError = error as? StoreKit.Transaction.RefundRequestError { + switch skError { + case .duplicateRequest: + Logger.error(message: L10n.Refund.duplicateRefundRequest(skError.localizedDescription)) + return .refund(error: .duplicateRequest) + case .failed: + Logger.error(message: L10n.Refund.failedRefundRequest(skError.localizedDescription)) + return .refund(error: .failed) + @unknown default: + return .unknown + } + } + return .unknown + } + #endif +} diff --git a/Sources/Flare/Classes/Providers/SystemInfoProvider/ISystemInfoProvider.swift b/Sources/Flare/Classes/Providers/SystemInfoProvider/ISystemInfoProvider.swift new file mode 100644 index 000000000..3006113e7 --- /dev/null +++ b/Sources/Flare/Classes/Providers/SystemInfoProvider/ISystemInfoProvider.swift @@ -0,0 +1,18 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +#if canImport(UIKit) + import UIKit +#endif + +// MARK: - ISystemInfoProvider + +/// A type that provides the system info. +protocol ISystemInfoProvider { + #if os(iOS) || VISION_OS + /// The current window scene. + var currentScene: UIWindowScene { get throws } + #endif +} diff --git a/Sources/Flare/Classes/Providers/SystemInfoProvider/SystemInfoProvider.swift b/Sources/Flare/Classes/Providers/SystemInfoProvider/SystemInfoProvider.swift new file mode 100644 index 000000000..6f7c5b417 --- /dev/null +++ b/Sources/Flare/Classes/Providers/SystemInfoProvider/SystemInfoProvider.swift @@ -0,0 +1,54 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +#if canImport(UIKit) + import UIKit +#endif + +// MARK: - SystemInfoProvider + +final class SystemInfoProvider { + // MARK: Properties + + #if os(iOS) || VISION_OS + private let scenesHolder: IScenesHolder + + // MARK: Initialization + + init(scenesHolder: IScenesHolder = UIApplication.shared) { + self.scenesHolder = scenesHolder + } + #endif +} + +// MARK: ISystemInfoProvider + +extension SystemInfoProvider: ISystemInfoProvider { + #if os(iOS) || VISION_OS + @available(iOS 13.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @MainActor + var currentScene: UIWindowScene { + get throws { + var scenes = scenesHolder.connectedScenes + .filter { $0.activationState == .foregroundActive } + + #if DEBUG && targetEnvironment(simulator) + if scenes.isEmpty, ProcessInfo.isRunningUnitTests { + scenes = scenesHolder.connectedScenes + } + #endif + + guard let windowScene = scenes.first as? UIWindowScene else { + throw IAPError.unknown + } + + return windowScene + } + } + #endif +} diff --git a/Sources/Flare/Flare.docc/Articles/logging.md b/Sources/Flare/Flare.docc/Articles/logging.md new file mode 100644 index 000000000..6df6914d4 --- /dev/null +++ b/Sources/Flare/Flare.docc/Articles/logging.md @@ -0,0 +1,19 @@ +# logging + +Learn how to log important events. + +## Overview + +The `Flare` supports logging out of the box. It has a set of methods to facilitate logging, each accompanied by a detailed description. + +### Enabling Logging + +> important: `Flare` uses the `log` package for logging functionality. See [Log Package](https://github.com/space-code/log) for more info. + +By default, `Flare` logs only `debug` or `info` events based on the package building scheme. The special logging level can be forced by setting ``IFlare/logLevel`` to Flare. + +```swift +Flare.shared.logLevel = .all +``` + +The logging can be turned off by setting ``IFlare/logLevel`` to `off`. diff --git a/Sources/Flare/Flare.docc/Articles/perform-purchase.md b/Sources/Flare/Flare.docc/Articles/perform-purchase.md new file mode 100644 index 000000000..87dd4ea88 --- /dev/null +++ b/Sources/Flare/Flare.docc/Articles/perform-purchase.md @@ -0,0 +1,96 @@ +# Perform Purchase + +Learn how to perform a purchase. + +## Setup Observers + +> tip: This step isn't required if the app uses system higher than iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0. + +The transactions array will only be synchronized with the server while the queue has observers. These methods may require that the user authenticate. It is important to set an observer on this queue as early as possible after your app launch. Observer is responsible for processing all events triggered by the queue. + +```swift +// Adds transaction observer to the payment queue and handles payment transactions. +Flare.shared.addTransactionObserver { result in + switch result { + case let .success(transaction): + debugPrint("A transaction was received: \(transaction)") + case let .failure(error): + debugPrint("An error occurred while adding transaction observer: \(error.localizedDescription)") + } +} +``` + +```swift +// Removes transaction observer from the payment queue. +Flare.shared.removeTransactionObserver() +``` + +## Getting Products + +The fetch method sends a request to the App Store, which retrieves the products if they are available. The productIDs parameter takes the product ids, which should be given from the App Store. + +> important: Before attempting to add a payment always check if the user can actually make payments. The Flare does it under the hood, if a user cannot make payments, you will get an ``IAPError`` with the value ``IAPError/paymentNotAllowed``. + +```swift +Flare.shared.fetch(productIDs: ["product_id"]) { result in + switch result { + case let .success(products): + debugPrint("Fetched products: \(products)") + case let .failure(error): + debugPrint("An error occurred while fetching products: \(error.localizedDescription)") + } +} +``` + +Additionally, there are versions of both fetch that provide an `async` method, allowing the use of await. + +```swift +do { + let products = try await Flare.shared.fetch(productIDs: Set(arrayLiteral: ["product_id"])) +} catch { + debugPrint("An error occurred while fetching products: \(error.localizedDescription)") +} +``` + +> note: Products are cached by default. If caching is not possible for specific usecases, set ``Configuration/fetchCachePolicy`` to ``FetchCachePolicy/fetch``. + +## Purchasing Product + +Flare provides a few methods to perform a purchase: + +- ``IFlare/purchase(product:completion:)`` +- ``IFlare/purchase(product:)`` +- ``IFlare/purchase(product:options:)`` +- ``IFlare/purchase(product:options:completion:)`` + +The method accepts a product parameter which represents a product: + +```swift +Flare.shared.purchase(product: product) { result in + switch result { + case let .success(transaction): + debugPrint("A transaction was received: \(transaction)") + case let .failure(error): + debugPrint("An error occurred while purchasing product: \(error.localizedDescription)") + } +} +``` + +If your app has a deployment target higher than iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, you can pass a set of [`options`](https://developer.apple.com/documentation/storekit/product/purchaseoption) along with a purchase request. + +```swift +let transaction = try await Flare.shared.purchase(product: product, options: [.appAccountToken(UUID())]) +``` + +## Finishing Transaction + +Finishing a transaction tells StoreKit that your app completed its workflow to make a purchase complete. Unfinished transactions remain in the queue until they’re finished, so be sure to add the transaction queue observer every time your app launches, to enable your app to finish the transactions. Your app needs to finish each transaction, whether it succeeds or fails. + +To finish the transaction, call the ``IFlare/finish(transaction:completion:)`` method. + +```swift +Flare.shared.finish(transaction: transaction, completion: nil) +``` + +> important: Don’t call the ``IFlare/finish(transaction:completion:)`` method before the transaction is actually complete and attempt to use some other mechanism in your app to track the transaction as unfinished. StoreKit doesn’t function that way, and doing that prevents your app from downloading Apple-hosted content and can lead to other issues. + diff --git a/Sources/Flare/Flare.docc/Articles/promotional-offers.md b/Sources/Flare/Flare.docc/Articles/promotional-offers.md new file mode 100644 index 000000000..fd5641f41 --- /dev/null +++ b/Sources/Flare/Flare.docc/Articles/promotional-offers.md @@ -0,0 +1,94 @@ +# Promotional Offers + +Learn how to use promotional offers. + +## Overview + +[Promotional offers](https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase/subscriptions_and_offers/implementing_promotional_offers_in_your_app) can be effective in winning back lapsed subscribers or retaining current subscribers. You can provide lapsed or current subscribers a limited-time offer of a discounted or free period of service for auto-renewable subscriptions on macOS, iOS, and tvOS. + +[Introductory offers](https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase/subscriptions_and_offers/implementing_introductory_offers_in_your_app#2940726) can offer a discounted introductory price, including a free trial, to eligible users. You can make introductory offers to customers who haven’t previously received an introductory offer for the given product, or for any products in the same subscription group. + +> note: To implement the offers, first complete the setup on App Store Connect, including generating a private key. See [Setting up promotional offers](https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase/subscriptions_and_offers/setting_up_promotional_offers) for more details. + +## Introductory Offers + +> important: Do not show a subscription offer to users if they are not eligible for it. It’s very important to check this beforehand. + +First, check if the user is eligible for an introductory offer. + +> tip: For this purpose can be used ``IFlare/checkEligibility(productIDs:)`` method. This method requires iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0. Otherwise, see [Determine Eligibility](https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase/subscriptions_and_offers/implementing_introductory_offers_in_your_app#2940726). + +```swift +func isEligibleForIntroductoryOffer(productID: String) async -> Bool { + let dict = await Flare.shared.checkEligibility(productIDs: [productID]) + return dict[productID] == .eligible +} +``` + +Second, proceed with the purchase as usual. See [Perform Purchase]() + +## Promotional Offers + +### Configuration + +Configure ``IFlare`` with a ``Configuration``. + +```swift +Flare.configure(configuration: Configuration(applicationUsername: "username")) +``` + +### Creating & Requesting a Signature + +> important: You need to fetch the signature from your server. See [Generation a signature for promotional offers](https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase/subscriptions_and_offers/generating_a_signature_for_promotional_offers) for more information. + +Request a signature from your server and prepare the discount offer. + +```swift +func prepareOffer(username: String, productID: String, offerID: String, completion: @escaping (PromotionalOffer.SignedData) -> Void) { + YourServer.fetchOfferDetails( + username: username, + productIdentifier: productID, + offerIdentifier: offerID, + completion: { (nonce: UUID, timestamp: NSNumber, keyIdentifier: String, signature: String) in + let signedData = PromotionalOffer.SignedData( + identifier: offerID, + keyIdentifier: keyIdentifier, + nonce: nonce, + signature: signature, + timestamp: timestamp + ) + + completion(signedData) + } +} +``` + +### Perform a Purchase with the Promotional Offer + +Complete the purchase with the promotional offer. + +```swift +func purchase(product: StoreProduct, discount: StoreProductDiscount, signedData: SignedData) { + let promotionalOffer = PromotionalOffer(discount: discount, signedData: signedData) + + Flare.default.purchase(product: product, promotionalOffer: promotionalOffer) { result in + switch result { + case let .success(transaction): + break + case let .failure(error): + break + } + } + + // Or using async/await + let transaction = Flare.shared.purchase(product: product, promotionalOffer: promotionalOffer) +} +``` + +### Finish the Transaction + +Complete the transaction after purchasing. + +```swift +Flare.default.finish(transaction: transaction) +``` diff --git a/Sources/Flare/Flare.docc/Articles/refund-purchase.md b/Sources/Flare/Flare.docc/Articles/refund-purchase.md new file mode 100644 index 000000000..46ab24f6d --- /dev/null +++ b/Sources/Flare/Flare.docc/Articles/refund-purchase.md @@ -0,0 +1,17 @@ +# Refund Purchase + +Learn how to process a refund through an iOS app. + +## Refund a Purchase + +Starting with iOS 15, Flare now includes support for refunding purchases as part of StoreKit 2. Under the hood, `Flare` obtains the active window scene and displays the sheets on it. You can read more about the refunding process in the official [Apple documentation](https://developer.apple.com/documentation/storekit/transaction/3803220-beginrefundrequest/). + +Flare suggest to use ``IFlare/beginRefundRequest(productID:)`` for refunding purchase. + +```swift +let status = try await Flare.shared.beginRefundRequest(productID: "product_id") +``` + +> important: If an issue occurs during the refund process, this method throws an ``IAPError/refund(error:)`` error. + +Call this function from account settings or a help menu to enable customers to request a refund for an in-app purchase within your app. When you call this function, the system displays a refund sheet with the customer’s purchase details and list of reason codes for the customer to choose from. diff --git a/Sources/Flare/Flare.docc/Articles/restore-purchase.md b/Sources/Flare/Flare.docc/Articles/restore-purchase.md new file mode 100644 index 000000000..1164954b4 --- /dev/null +++ b/Sources/Flare/Flare.docc/Articles/restore-purchase.md @@ -0,0 +1,36 @@ +# Restore Purchase + +Learn how to restore a purchase. + +## Overview + +Users sometimes need to restore purchased content, such as when they upgrade to a new phone. Include some mechanism in your app, such as a Restore Purchases button, to let them restore their purchases. + +## Refresh the app receipt + +A request to the App Store to get the app receipt, which represents the user’s transactions with your app. + +> note: The receipt isn’t necessary if you use StoreKit2. Only use the receipt if your app supports deployment target is lower than iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0. + +Use this API to request a new app receipt from the App Store if the receipt is invalid or missing from its expected location. To request the receipt using the ``IFlare/receipt(completion:)``. + +> important: The receipt refresh request displays a system prompt that asks users to authenticate with their App Store credentials. For a better user experience, initiate the request after an explicit user action, like tapping or clicking a button. + +```swift +Flare.shared.receipt { result in + switch result { + case let .success(receipt): + // Handle a receipt + case let .failure(error): + // Handle an error + } +} +``` + +> important: If a receipt isn't found, Flare throws an ``IAPError/receiptNotFound`` error. + +There is an ``IFlare/receipt()`` method for obtaining a receipt using async/await. + +```swift +let receipt = try await Flare.shared.receipt() +``` diff --git a/Sources/Flare/Flare.docc/Flare.md b/Sources/Flare/Flare.docc/Flare.md new file mode 100644 index 000000000..196c2ac5f --- /dev/null +++ b/Sources/Flare/Flare.docc/Flare.md @@ -0,0 +1,68 @@ +# ``Flare`` + +Flare provides an elegant interface for In-App Purchases, supporting non-consumable and consumable purchases as well as subscriptions. + +## Overview + +Flare provides a clear and convenient API for making in-app purchases. + +```swift +import Flare + +/// Fetch a product with the given id +guard let product = try await Flare.shared.products(productIDs: ["product_identifier"]) else { return } +/// Purchase a product +let transaction = try await Flare.shared.purchase(product: product) +/// Finish a transaction +Flare.shared.finish(transaction: transaction, completion: nil) +``` + +Flare supports both StoreKit and StoreKit2; it decides which one to use under the hood based on the operating system version. Flare provides two ways to work with in-app purchases (IAP): it supports the traditional closure-based syntax and the modern async/await approach. + +```swift +import Flare + +/// Fetch a product with the given id +Flare.shared.products(productIDs: ["product_identifier"]) { result in + switch result { + case let .success(products): + // Purchase a product + case let .failure(error): + // Handler an error + } +} +``` + +## Minimum Requirements + +| Flare | Date | Swift | Xcode | Platforms | +|-------|------------|-------|---------|-------------------------------------------------------------| +| 3.0 | unreleased | 5.7 | 14.1 | iOS 13.0, watchOS 6.0, macOS 10.15, tvOS 13.0, visionOS 1.0 | +| 2.0 | 14/09/2023 | 5.7 | 14.1 | iOS 13.0, watchOS 6.0, macOS 10.15, tvOS 13.0, visionOS 1.0 | +| 1.0 | 21/01/2023 | 5.5 | 13.4.1 | iOS 13.0, watchOS 6.0, macOS 10.15, tvOS 13.0 | + +## License + +flare is available under the MIT license. See the LICENSE file for more info. + +## Topics + +### Essentials + +- ``IFlare`` +- ``IIAPProvider`` + +### Misc + +- ``IAPError`` +- ``ProductType`` +- ``StoreProduct`` +- ``StoreTransaction`` + +### Articles + +- +- +- +- +- diff --git a/Sources/Flare/Flare.swift b/Sources/Flare/Flare.swift deleted file mode 100644 index e733b19e5..000000000 --- a/Sources/Flare/Flare.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// Flare -// Copyright © 2023 Space Code. All rights reserved. -// - -import StoreKit - -// MARK: - Flare - -public final class Flare { - // MARK: Initialization - - init(iapProvider: IIAPProvider = IAPProvider()) { - self.iapProvider = iapProvider - } - - // MARK: Public - - public static let `default`: IFlare = Flare() - - // MARK: Private - - private let iapProvider: IIAPProvider -} - -// MARK: IFlare - -extension Flare: IFlare { - public func fetch(productIDs: Set, completion: @escaping Closure>) { - iapProvider.fetch(productIDs: productIDs, completion: completion) - } - - public func fetch(productIDs: Set) async throws -> [SKProduct] { - try await iapProvider.fetch(productIDs: productIDs) - } - - public func purchase(productID: String, completion: @escaping Closure>) { - guard iapProvider.canMakePayments else { - completion(.failure(.paymentNotAllowed)) - return - } - - iapProvider.purchase(productID: productID) { result in - switch result { - case let .success(transaction): - completion(.success(transaction)) - case let .failure(error): - completion(.failure(error)) - } - } - } - - public func purchase(productID: String) async throws -> PaymentTransaction { - guard iapProvider.canMakePayments else { throw IAPError.paymentNotAllowed } - return try await iapProvider.purchase(productID: productID) - } - - public func receipt(completion: @escaping Closure>) { - iapProvider.refreshReceipt { result in - switch result { - case let .success(receipt): - completion(.success(receipt)) - case let .failure(error): - completion(.failure(error)) - } - } - } - - public func receipt() async throws -> String { - try await iapProvider.refreshReceipt() - } - - public func finish(transaction: PaymentTransaction) { - iapProvider.finish(transaction: transaction) - } - - public func addTransactionObserver(fallbackHandler: Closure>?) { - iapProvider.addTransactionObserver(fallbackHandler: fallbackHandler) - } - - public func removeTransactionObserver() { - iapProvider.removeTransactionObserver() - } -} diff --git a/Sources/Flare/IFlare.swift b/Sources/Flare/IFlare.swift deleted file mode 100644 index 36676fa09..000000000 --- a/Sources/Flare/IFlare.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// Flare -// Copyright © 2023 Space Code. All rights reserved. -// - -import Foundation -import StoreKit - -/// `Flare` creates and manages in-app purchases. -public protocol IFlare { - /// Retrieves localized information from the App Store about a specified list of products. - /// - /// - Parameters: - /// - productIDs: The list of product identifiers for which you wish to retrieve descriptions. - /// - completion: The completion containing the response of retrieving products. - func fetch(productIDs: Set, completion: @escaping Closure>) - - /// Retrieves localized information from the App Store about a specified list of products. - /// - /// - Parameter productIDs: The list of product identifiers for which you wish to retrieve descriptions. - /// - /// - Throws: `IAPError(error:)` if the request did fail with error. - /// - /// - Returns: An array of products. - func fetch(productIDs: Set) async throws -> [SKProduct] - - /// Performs a purchase of a product with a given ID. - /// - /// - Note: The method automatically checks if the user can purchase a product. - /// If the user can't make a payment, the method returns an error - /// with the type `IAPError.paymentNotAllowed`. - /// - /// - Parameters: - /// - productID: The product identifier. - /// - completion: The closure to be executed once the purchase is complete. - func purchase(productID: String, completion: @escaping Closure>) - - /// Purchases a product with a given ID. - /// - /// - Note: The method automatically checks if the user can purchase a product. - /// If the user can't make a payment, the method returns an error - /// with the type `IAPError.paymentNotAllowed`. - /// - /// - Parameter productID: The product identifier. - /// - /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. - /// - /// - Returns: A payment transaction. - func purchase(productID: String) async throws -> PaymentTransaction - - /// Refreshes the receipt, representing the user's transactions with your app. - /// - /// - Parameter completion: The closure to be executed when the refresh operation ends. - func receipt(completion: @escaping Closure>) - - /// Refreshes the receipt, representing the user's transactions with your app. - /// - /// `IAPError(error:)` if the request did fail with error. - /// - /// - Returns: A receipt. - func receipt() async throws -> String - - /// Removes a finished (i.e. failed or completed) transaction from the queue. - /// Attempting to finish a purchasing transaction will throw an exception. - /// - /// - Parameter transaction: An object in the payment queue. - func finish(transaction: PaymentTransaction) - - /// The transactions array will only be synchronized with the server while the queue has observers. - /// - /// - Note: This may require that the user authenticate. - func addTransactionObserver(fallbackHandler: Closure>?) - - /// Removes transaction observer from the payment queue. - /// The transactions array will only be synchronized with the server while the queue has observers. - /// - /// - Note: This may require that the user authenticate. - func removeTransactionObserver() -} diff --git a/Sources/Flare/Resources/Localizable.strings b/Sources/Flare/Resources/Localizable.strings new file mode 100644 index 000000000..127895aa8 --- /dev/null +++ b/Sources/Flare/Resources/Localizable.strings @@ -0,0 +1,77 @@ +"purchase.transaction_unverified" = "Transaction for productID: %s is unverified by the App Store. Verification error: %s."; + +"purchase.finishing_transaction" = "Finishing transaction %s for product identifier: %s"; + +"purchase.cannot_purcase_product" = "This device is not able or allowed to make payments."; + +"purchase.transaction_not_found" = "Transaction for productID: %s not found."; + +"purchase.error_updating_transaction" = "An error occurred while listening for transactions: %s"; + +"flare.init_with_configuration" = "Flare configured with configuration:\n%@"; + +"payment.payment_queue_adding_payment" = "Adding payment for product: %s. %i transactions already in the queue"; + +"purchase.purchased_product" = "Purchased product: %s"; + +"purchase.purchasing_product" = "Purchasing product: %s"; + +"purchase.purchasing_product_with_offer" = "Purchasing product %s with offer %s"; + +"purchase.product_purchase_failed" = "Product purchase for %s failed with error: %s"; + +"refund.failed_refund_request" = "Refund request submission failed: %s"; + +"refund.duplicate_refund_request" = "Refund has already requested for this product: %s"; + +"redeem.presenting_code_redemption_sheet" = "Presenting code redemption sheet."; + +"redeem.presenting_offer_code_redeem_sheet" = "Presenting offer code redeem sheet"; + +"redeem.unable_to_present_offer_code_redeem_sheet" = "Unable to present offer code redeem sheet due to unexpected error: %s"; + +"products.requested_products_not_found" = "Requested products %@ not found."; + +"products.requested_products_received" = "Requested products %@ have been received"; + +"receipt.refreshing_receipt" = "Refreshing receipt. Request id: %s."; + +"receipt.refreshed_receipt" = "Refreshed receipt. Request id: %s."; + +"receipt.refreshing_receipt_failed" = "Refreshing receipt failed with error: %s. Request id: %s."; + +"error.invalid_product_ids.description" = "Invalid product IDs: %@"; + +"error.payment_not_allowed.description" = "The current user is not eligible to make payments."; + +"error.payment_not_allowed.failure_reason" = "The payment card may have purchase restrictions, such as set limits or unavailability for online shopping."; + +"error.payment_not_allowed.recovery_suggestion" = "Please check the payment card purchase restrictions."; + +"error.payment_cancelled.description" = "The payment was canceled by the user."; + +"error.store_product_not_available.description" = "The store product is currently unavailable."; + +"error.store_product_not_available.recovery_suggestion" = "Make sure to create a product with the given identifier in App Store Connect."; + +"error.unknown.description" = "The SKPayment returned unknown error."; + +"error.with.description" = "The error occurred: %s"; + +"error.receipt.description" = "The receipt could not be found."; + +"error.transaction_not_found.description" = "Transaction for productID: %s couldn't be found."; + +"error.refund.description" = "The error occurred during the refund: %s"; + +"error.verification.description" = "The verification has failed with the following error: %s"; + +"error.failed_to_decode_signature.description" = "Decoding the signature has failed. The signature: %s"; + +"error.payment_defferred.description" = "The purchase is pending, and requires action from the customer."; + +"refund_error.duplicate_request.description" = "The request has been duplicated."; + +"refund_error.failed.description" = "The refund request failed."; + +"verification_error.unverified" = "Transaction for productID: %s is unverified by the App Store. Verification error: %s."; diff --git a/Tests/FlareTests/Mocks/IAPProviderMock.swift b/Tests/FlareTests/Mocks/IAPProviderMock.swift deleted file mode 100644 index a6ea5b23a..000000000 --- a/Tests/FlareTests/Mocks/IAPProviderMock.swift +++ /dev/null @@ -1,140 +0,0 @@ -// -// Flare -// Copyright © 2023 Space Code. All rights reserved. -// - -@testable import Flare -import StoreKit - -final class IAPProviderMock: IIAPProvider { - var invokedCanMakePayments = false - var invokedCanMakePaymentsCount = 0 - var stubbedCanMakePayments: Bool = false - - var canMakePayments: Bool { - invokedCanMakePayments = true - invokedCanMakePaymentsCount += 1 - return stubbedCanMakePayments - } - - var invokedFetch = false - var invokedFetchCount = 0 - var invokedFetchParameters: (productIDs: Set, completion: Closure>)? - var invokedFetchParametersList = [(productIDs: Set, completion: Closure>)]() - - func fetch(productIDs: Set, completion: @escaping Closure>) { - invokedFetch = true - invokedFetchCount += 1 - invokedFetchParameters = (productIDs, completion) - invokedFetchParametersList.append((productIDs, completion)) - } - - var invokedPurchase = false - var invokedPurchaseCount = 0 - var invokedPurchaseParameters: (productID: String, completion: Closure>)? - var invokedPurchaseParametersList = [(productID: String, completion: Closure>)]() - - func purchase(productID: String, completion: @escaping Closure>) { - invokedPurchase = true - invokedPurchaseCount += 1 - invokedPurchaseParameters = (productID, completion) - invokedPurchaseParametersList.append((productID, completion)) - } - - var invokedRefreshReceipt = false - var invokedRefreshReceiptCount = 0 - var invokedRefreshReceiptParameters: (completion: Closure>, Void)? - var invokedRefreshReceiptParametersList = [(completion: Closure>, Void)]() - var stubbedRefreshReceiptResult: Result? - - func refreshReceipt(completion: @escaping Closure>) { - invokedRefreshReceipt = true - invokedRefreshReceiptCount += 1 - invokedRefreshReceiptParameters = (completion, ()) - invokedRefreshReceiptParametersList.append((completion, ())) - - if let result = stubbedRefreshReceiptResult { - completion(result) - } - } - - var invokedFinishTransaction = false - var invokedFinishTransactionCount = 0 - var invokedFinishTransactionParameters: (PaymentTransaction, Void)? - var invokedFinishTransactionParanetersList = [(PaymentTransaction, Void)]() - - func finish(transaction: PaymentTransaction) { - invokedFinishTransaction = true - invokedFinishTransactionCount += 1 - invokedFinishTransactionParameters = (transaction, ()) - invokedFinishTransactionParanetersList.append((transaction, ())) - } - - var invokedAddTransactionObserver = false - var invokedAddTransactionObserverCount = 0 - var invokedAddTransactionObserverParameters: (fallbackHandler: Closure>?, Void)? - var invokedAddTransactionObserverParametersList = [(fallbackHandler: Closure>?, Void)]() - - func addTransactionObserver(fallbackHandler: Closure>?) { - invokedAddTransactionObserver = true - invokedAddTransactionObserverCount += 1 - invokedAddTransactionObserverParameters = (fallbackHandler, ()) - invokedAddTransactionObserverParametersList.append((fallbackHandler, ())) - } - - var invokedRemoveTransactionObserver = false - var invokedRemoveTransactionObserverCount = 0 - - func removeTransactionObserver() { - invokedRemoveTransactionObserver = true - invokedRemoveTransactionObserverCount += 1 - } - - var invokedFetchAsync = false - var invokedFetchAsyncCount = 0 - var invokedFetchAsyncParameters: (productIDs: Set, Void)? - var invokedFetchAsyncParametersList = [(productIDs: Set, Void)]() - var fetchAsyncResult: [SKProduct] = [] - - func fetch(productIDs: Set) async throws -> [SKProduct] { - invokedFetchAsync = true - invokedFetchAsyncCount += 1 - invokedFetchAsyncParameters = (productIDs, ()) - invokedFetchAsyncParametersList.append((productIDs, ())) - return fetchAsyncResult - } - - var invokedAsyncPurchase = false - var invokedAsyncPurchaseCount = 0 - var invokedAsyncPurchaseParameters: (productID: String, Void)? - var invokedAsyncPurchaseParametersList = [(productID: String, Void)?]() - var stubbedAsyncPurchase: PaymentTransaction! - - func purchase(productID: String) async throws -> PaymentTransaction { - invokedAsyncPurchase = true - invokedAsyncPurchaseCount += 1 - invokedAsyncPurchaseParameters = (productID, ()) - invokedAsyncPurchaseParametersList.append((productID, ())) - return stubbedAsyncPurchase - } - - var invokedAsyncRefreshReceipt = false - var invokedAsyncRefreshReceiptCounter = 0 - var stubbedRefreshReceiptAsyncResult: Result! - - func refreshReceipt() async throws -> String { - invokedAsyncRefreshReceipt = true - invokedAsyncRefreshReceiptCounter += 1 - - let result = stubbedRefreshReceiptAsyncResult - - switch result { - case let .success(receipt): - return receipt - case let .failure(error): - throw error - default: - fatalError("An unknown type") - } - } -} diff --git a/Tests/FlareTests/Mocks/ProductProviderMock.swift b/Tests/FlareTests/Mocks/ProductProviderMock.swift deleted file mode 100644 index 5480426d4..000000000 --- a/Tests/FlareTests/Mocks/ProductProviderMock.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Flare -// Copyright © 2023 Space Code. All rights reserved. -// - -@testable import Flare -import class StoreKit.SKProduct - -final class ProductProviderMock: IProductProvider { - var invokedFetch = false - var invokedFetchCount = 0 - var invokedFetchParameters: (productIDs: Set, requestID: String, completion: ProductsHandler)? - var invokedFetchParamtersList = [(productIDs: Set, requestID: String, completion: ProductsHandler)]() - var stubbedFetchResult: Result<[SKProduct], IAPError>? - - func fetch(productIDs: Set, requestID: String, completion: @escaping ProductsHandler) { - invokedFetch = true - invokedFetchCount += 1 - invokedFetchParameters = (productIDs, requestID, completion) - invokedFetchParamtersList.append((productIDs, requestID, completion)) - - if let result = stubbedFetchResult { - completion(result) - } - } -} diff --git a/Tests/FlareTests/UnitTests/FlareTests.swift b/Tests/FlareTests/UnitTests/FlareTests.swift index 01e403dbd..6998e78bf 100644 --- a/Tests/FlareTests/UnitTests/FlareTests.swift +++ b/Tests/FlareTests/UnitTests/FlareTests.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // @testable import Flare @@ -12,20 +12,29 @@ import XCTest class FlareTests: XCTestCase { // MARK: - Properties + private var dependenciesMock: FlareDependenciesMock! private var iapProviderMock: IAPProviderMock! - private var flare: Flare! + private var configurationProviderMock: ConfigurationProviderMock! + + private var sut: Flare! // MARK: - XCTestCase override func setUp() { super.setUp() iapProviderMock = IAPProviderMock() - flare = Flare(iapProvider: iapProviderMock) + dependenciesMock = FlareDependenciesMock() + configurationProviderMock = ConfigurationProviderMock() + dependenciesMock.stubbedIapProvider = iapProviderMock + dependenciesMock.stubbedConfigurationProvider = configurationProviderMock + sut = Flare(dependencies: dependenciesMock) } override func tearDown() { + configurationProviderMock = nil + dependenciesMock = nil iapProviderMock = nil - flare = nil + sut = nil super.tearDown() } @@ -33,7 +42,7 @@ class FlareTests: XCTestCase { func test_thatFlareFetchesProductsWithGivenProductIDs() { // when - flare.fetch(productIDs: .ids, completion: { _ in }) + sut.fetch(productIDs: .ids, completion: { _ in }) // then XCTAssertTrue(iapProviderMock.invokedFetch) @@ -41,11 +50,15 @@ class FlareTests: XCTestCase { func test_thatFlareFetchesProductsWithGivenProductIDs() async throws { // given - let productMocks = [ProductMock(), ProductMock(), ProductMock()] + let productMocks = [ + StoreProduct(skProduct: ProductMock()), + StoreProduct(skProduct: ProductMock()), + StoreProduct(skProduct: ProductMock()), + ] iapProviderMock.fetchAsyncResult = productMocks // when - let products = try await flare.fetch(productIDs: .ids) + let products = try await sut.fetch(productIDs: .ids) // then XCTAssertEqual(products, productMocks) @@ -56,74 +69,67 @@ class FlareTests: XCTestCase { iapProviderMock.stubbedCanMakePayments = true // when - flare.purchase(productID: .productID, completion: { _ in }) + sut.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { _ in }) // then - XCTAssertTrue(iapProviderMock.invokedPurchase) - XCTAssertEqual(iapProviderMock.invokedPurchaseParameters?.productID, .productID) + XCTAssertTrue(iapProviderMock.invokedPurchaseWithPromotionalOffer) + XCTAssertEqual(iapProviderMock.invokedPurchaseWithPromotionalOfferParameters?.product.productIdentifier, .productID) } - func test_thatFlareDoesNotPurchaseAProduct_whenUserCannotMakePayments() { + func test_thatFlareThrowsAnError_whenUserCannotMakePayments() { // given iapProviderMock.stubbedCanMakePayments = false // when - flare.purchase(productID: .productID, completion: { _ in }) + sut.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { _ in }) // then XCTAssertFalse(iapProviderMock.invokedPurchase) } - func test_thatFlarePurchasesAProduct_whenRequestCompletedSuccessfully() { + func test_thatFlarePurchasesAProduct_whenRequestCompleted() { // given - let paymentTransaction = PaymentTransaction(PaymentTransactionMock()) + let paymentTransaction = StoreTransaction(storeTransaction: StoreTransactionStub()) iapProviderMock.stubbedCanMakePayments = true + iapProviderMock.stubbedPurchaseWithPromotionalOffer = .success(paymentTransaction) // when - var transaction: PaymentTransaction? - flare.purchase(productID: .productID, completion: { result in - if case let .success(result) = result { - transaction = result - } + var transaction: IStoreTransaction? + sut.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { result in + transaction = result.success }) iapProviderMock.invokedPurchaseParameters?.completion(.success(paymentTransaction)) // then - XCTAssertTrue(iapProviderMock.invokedPurchase) - XCTAssertEqual(transaction, paymentTransaction) + XCTAssertTrue(iapProviderMock.invokedPurchaseWithPromotionalOffer) + XCTAssertEqual(transaction?.productIdentifier, paymentTransaction.productIdentifier) } - func test_thatFlareDoesNotPurchaseAProduct_whenUnknownErrorOccurred() { + func test_thatFlareDoesNotPurchaseAProduct_whenPurchaseReturnsUnkownError() { // given let errorMock = IAPError.paymentNotAllowed iapProviderMock.stubbedCanMakePayments = true + iapProviderMock.stubbedPurchaseWithPromotionalOffer = .failure(errorMock) // when var error: IAPError? - flare.purchase(productID: .productID, completion: { result in - if case let .failure(result) = result { - error = result - } + sut.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { result in + error = result.error }) iapProviderMock.invokedPurchaseParameters?.completion(.failure(errorMock)) // then - XCTAssertTrue(iapProviderMock.invokedPurchase) + XCTAssertTrue(iapProviderMock.invokedPurchaseWithPromotionalOffer) XCTAssertEqual(error, errorMock) } func test_thatFlareDoesNotPurchaseAProduct_whenUserCannotMakePayments() async { // given iapProviderMock.stubbedCanMakePayments = false - iapProviderMock.stubbedAsyncPurchase = PaymentTransaction(PaymentTransactionMock()) + iapProviderMock.stubbedAsyncPurchase = StoreTransaction(storeTransaction: StoreTransactionStub()) // when - var iapError: IAPError? - do { - _ = try await flare.purchase(productID: .productID) - } catch { - iapError = error as? IAPError - } + let iapError: IAPError? = await error(for: { try await sut.purchase(product: .fake(skProduct: .fake(id: .productID))) }) // then XCTAssertFalse(iapProviderMock.invokedAsyncPurchase) @@ -132,34 +138,24 @@ class FlareTests: XCTestCase { func test_thatFlareDoesNotPurchaseAProduct_whenUnknownErrorOccurred() async { // given - let transactionMock = PaymentTransaction(PaymentTransactionMock()) + let transactionMock = StoreTransaction(storeTransaction: StoreTransactionStub()) iapProviderMock.stubbedCanMakePayments = true - iapProviderMock.stubbedAsyncPurchase = transactionMock + iapProviderMock.stubbedPurchaseAsyncWithPromotionalOffer = transactionMock // when - var transaction: PaymentTransaction? - var iapError: IAPError? - do { - transaction = try await flare.purchase(productID: .productID) - } catch { - iapError = error as? IAPError - } + let transaction = await value(for: { try await sut.purchase(product: .fake(skProduct: .fake(id: .productID))) }) // then - XCTAssertTrue(iapProviderMock.invokedAsyncPurchase) - XCTAssertNil(iapError) - XCTAssertEqual(transaction, transactionMock) + XCTAssertTrue(iapProviderMock.invokedPurchaseAsyncWithPromotionalOffer) + XCTAssertEqual(transaction?.productIdentifier, transactionMock.productIdentifier) } - func test_thatFlareFetchesReceipt_whenRequestCompletedSuccessfully() { + func test_thatFlareFetchesReceipt_whenRequestCompleted() { // when var receipt: String? - flare.receipt(completion: { result in - if case let .success(result) = result { - receipt = result - } - }) + sut.receipt { receipt = $0.success } + iapProviderMock.invokedRefreshReceiptParameters?.completion(.success(.receipt)) // then @@ -170,11 +166,8 @@ class FlareTests: XCTestCase { func test_thatFlareDoesNotFetchReceipt_whenRequestFailed() { // when var error: IAPError? - flare.receipt(completion: { result in - if case let .failure(result) = result { - error = result - } - }) + sut.receipt { error = $0.error } + iapProviderMock.invokedRefreshReceiptParameters?.completion(.failure(.paymentNotAllowed)) // then @@ -184,18 +177,18 @@ class FlareTests: XCTestCase { func test_thatFlareRemovesTransactionObserver() { // when - flare.removeTransactionObserver() + sut.removeTransactionObserver() // then XCTAssertTrue(iapProviderMock.invokedRemoveTransactionObserver) } - func test_thatFlareFetchesReceipt_whenRequestCompletedSuccessfully() async throws { + func test_thatFlareFetchesReceipt_whenRequestCompleted() async throws { // given iapProviderMock.stubbedRefreshReceiptAsyncResult = .success(.receipt) // when - let receipt = try await flare.receipt() + let receipt = try await sut.receipt() // then XCTAssertEqual(receipt, .receipt) @@ -206,15 +199,10 @@ class FlareTests: XCTestCase { iapProviderMock.stubbedRefreshReceiptAsyncResult = .failure(.paymentNotAllowed) // when - var iapError: IAPError? - do { - _ = try await flare.receipt() - } catch { - iapError = error as? IAPError - } + let error: IAPError? = await self.error(for: { try await sut.receipt() }) // then - XCTAssertEqual(iapError, .paymentNotAllowed) + XCTAssertEqual(error, .paymentNotAllowed) } func test_thatFlareFinishesTransaction() { @@ -222,7 +210,7 @@ class FlareTests: XCTestCase { let transaction = PaymentTransaction(PaymentTransactionMock()) // when - flare.finish(transaction: transaction) + sut.finish(transaction: StoreTransaction(paymentTransaction: transaction), completion: nil) // then XCTAssertTrue(iapProviderMock.invokedFinishTransaction) @@ -230,11 +218,24 @@ class FlareTests: XCTestCase { func test_thatFlareAddsTransactionObserver() { // when - flare.addTransactionObserver(fallbackHandler: { _ in }) + sut.addTransactionObserver(fallbackHandler: { _ in }) // then XCTAssertTrue(iapProviderMock.invokedAddTransactionObserver) } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func test_thatFlareChecksEligibility() async throws { + // given + iapProviderMock.stubbedCheckEligibility = [.productID: .eligible] + + // when + let _ = try await sut.checkEligibility(productIDs: [.productID]) + + // then + XCTAssertEqual(iapProviderMock.invokedCheckEligibilityCount, 1) + XCTAssertEqual(iapProviderMock.invokedCheckEligibilityParameters?.productIDs, [.productID]) + } } // MARK: - Constants diff --git a/Tests/FlareTests/UnitTests/Helpers/ProcessInfoTests.swift b/Tests/FlareTests/UnitTests/Helpers/ProcessInfoTests.swift new file mode 100644 index 000000000..e98c6f155 --- /dev/null +++ b/Tests/FlareTests/UnitTests/Helpers/ProcessInfoTests.swift @@ -0,0 +1,13 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +import XCTest + +final class ProcessInfoTests: XCTestCase { + func test_thatProcessInfoReturnsSsRunningUnitTestsEqualsToTrue_whenRuggingUnderTests() { + XCTAssertTrue(ProcessInfo.isRunningUnitTests) + } +} diff --git a/Tests/FlareTests/UnitTests/Models/IAPErrorTests.swift b/Tests/FlareTests/UnitTests/Models/IAPErrorTests.swift new file mode 100644 index 000000000..e46c73d32 --- /dev/null +++ b/Tests/FlareTests/UnitTests/Models/IAPErrorTests.swift @@ -0,0 +1,51 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +import StoreKit +import XCTest + +final class IAPErrorTests: XCTestCase { + func test_thatIAPErrorInstantiatesANewInstanceFromSkError_whenCodeIsEqualToPaymentNotAllowed() { + // given + let skError = SKError(SKError.Code.paymentNotAllowed) + + // when + let error = IAPError(error: skError) + + // then + XCTAssertEqual(error, IAPError.paymentNotAllowed) + } + + func test_thatIAPErrorInstantiatesANewInstanceFromSkError_whenCodeIsEqualToPaymentCancelled() { + // given + let skError = SKError(SKError.Code.paymentCancelled) + + // when + let error = IAPError(error: skError) + + // then + XCTAssertEqual(error, IAPError.paymentCancelled) + } + + func test_thatIAPErrorInstantiatesANewInstanceFromSkError_whenCodeIsEqualToStoreProductNotAvailable() { + // given + let skError = SKError(SKError.Code.storeProductNotAvailable) + + // when + let error = IAPError(error: skError) + + // then + XCTAssertEqual(error, IAPError.storeProductNotAvailable) + } + + func test_thatIAPErrorInstantiatesANewInstanceFromSkError_whenErrorIsNil() { + // when + let error = IAPError(error: nil) + + // then + XCTAssertEqual(error, IAPError.unknown) + } +} diff --git a/Tests/FlareTests/UnitTests/Models/PromotionalOfferTests.swift b/Tests/FlareTests/UnitTests/Models/PromotionalOfferTests.swift new file mode 100644 index 000000000..e172dc4ab --- /dev/null +++ b/Tests/FlareTests/UnitTests/Models/PromotionalOfferTests.swift @@ -0,0 +1,56 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import StoreKit +import XCTest + +// MARK: - PromotionalOfferTests + +final class PromotionalOfferTests: XCTestCase { + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func test_purchaseOptions() throws { + let option = try PromotionalOffer.SignedData.randomOffer.promotionalOffer + let expected: Product.PurchaseOption = .promotionalOffer( + offerID: PromotionalOffer.SignedData.randomOffer.identifier, + keyID: PromotionalOffer.SignedData.randomOffer.keyIdentifier, + nonce: PromotionalOffer.SignedData.randomOffer.nonce, + signature: Data(), + timestamp: PromotionalOffer.SignedData.randomOffer.timestamp + ) + + XCTAssertEqual(expected, option) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func test_purchaseOptionWithInvalidSignatureThrows() throws { + do { + _ = try PromotionalOffer.SignedData.invalidOffer.promotionalOffer + } catch { + let error = try XCTUnwrap(error as? IAPError) + XCTAssertEqual(error, IAPError.failedToDecodeSignature(signature: PromotionalOffer.SignedData.invalidOffer.signature)) + } + } +} + +// MARK: - Constants + +private extension PromotionalOffer.SignedData { + static let randomOffer: PromotionalOffer.SignedData = .init( + identifier: "identifier \(Int.random(in: 0 ..< 1000))", + keyIdentifier: "key identifier \(Int.random(in: 0 ..< 1000))", + nonce: .init(), + signature: "signature \(Int.random(in: 0 ..< 1000))".asData.base64EncodedString(), + timestamp: Int.random(in: 0 ..< 1000) + ) + + static let invalidOffer: PromotionalOffer.SignedData = .init( + identifier: "identifier \(Int.random(in: 0 ..< 1000))", + keyIdentifier: "key identifier \(Int.random(in: 0 ..< 1000))", + nonce: .init(), + signature: "signature \(Int.random(in: 0 ..< 1000))", + timestamp: Int.random(in: 0 ..< 1000) + ) +} diff --git a/Tests/FlareTests/UnitTests/Providers/CachingProductsProviderDecoratorTests.swift b/Tests/FlareTests/UnitTests/Providers/CachingProductsProviderDecoratorTests.swift new file mode 100644 index 000000000..c3c779602 --- /dev/null +++ b/Tests/FlareTests/UnitTests/Providers/CachingProductsProviderDecoratorTests.swift @@ -0,0 +1,83 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import XCTest + +// MARK: - CachingProductsProviderDecoratorTests + +final class CachingProductsProviderDecoratorTests: XCTestCase { + // MARK: Properties + + private var productProviderMock: ProductProviderMock! + private var configurationProviderMock: ConfigurationProviderMock! + + private var sut: CachingProductsProviderDecorator! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + productProviderMock = ProductProviderMock() + configurationProviderMock = ConfigurationProviderMock() + sut = CachingProductsProviderDecorator( + productProvider: productProviderMock, + configurationProvider: configurationProviderMock + ) + } + + override func tearDown() { + productProviderMock = nil + configurationProviderMock = nil + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatProviderFetchesCachedProducts_whenFetchCachePolicyIsCachedOrFetch() { + // given + configurationProviderMock.stubbedFetchCachePolicy = .cachedOrFetch + productProviderMock.stubbedFetchResult = .success([StoreProduct.fake()]) + + // when + sut.fetch(productIDs: [.productID], requestID: "", completion: { _ in }) + sut.fetch(productIDs: [.productID], requestID: "", completion: { _ in }) + + // then + XCTAssertEqual(productProviderMock.invokedFetchCount, 1) + } + + func test_thatProviderFetchesProducts_whenFetchCachePolicyIsFetch() { + // given + configurationProviderMock.stubbedFetchCachePolicy = .fetch + productProviderMock.stubbedFetchResult = .success([StoreProduct.fake()]) + + // when + sut.fetch(productIDs: [.productID], requestID: "", completion: { _ in }) + sut.fetch(productIDs: [.productID], requestID: "", completion: { _ in }) + + // then + XCTAssertEqual(productProviderMock.invokedFetchCount, 2) + } + + func test_thatProviderThrowsAnError_whenFetchDidFail() { + // given + configurationProviderMock.stubbedFetchCachePolicy = .cachedOrFetch + productProviderMock.stubbedFetchResult = .failure(.unknown) + + // when + sut.fetch(productIDs: [.productID], requestID: "", completion: { XCTAssertEqual($0.error, .unknown) }) + + // then + XCTAssertEqual(productProviderMock.invokedFetchCount, 1) + } +} + +// MARK: - Constants + +private extension String { + static let productID = "product_id" +} diff --git a/Tests/FlareTests/UnitTests/Providers/ConfigurationProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/ConfigurationProviderTests.swift new file mode 100644 index 000000000..634f230be --- /dev/null +++ b/Tests/FlareTests/UnitTests/Providers/ConfigurationProviderTests.swift @@ -0,0 +1,67 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import XCTest + +// MARK: - ConfigurationProviderTests + +final class ConfigurationProviderTests: XCTestCase { + // MARK: Properties + + private var cacheProviderMock: CacheProviderMock! + + private var sut: ConfigurationProvider! + + // MARK: Initialization + + override func setUp() { + super.setUp() + cacheProviderMock = CacheProviderMock() + sut = ConfigurationProvider( + cacheProvider: cacheProviderMock + ) + } + + override func tearDown() { + cacheProviderMock = nil + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatCacheProviderReturnsApplicationUsername_whenUsernameExists() { + // given + cacheProviderMock.stubbedReadResult = String.applicationUsername + + // when + let applicationUsername = sut.applicationUsername + + // then + XCTAssertEqual(cacheProviderMock.invokedReadParameters?.key, .applicationUsernameKey) + XCTAssertEqual(applicationUsername, .applicationUsername) + } + + func test_thatCacheProviderConfigures() { + // given + let configurationFake = Configuration.fake() + + // when + sut.configure(with: configurationFake) + + // then + XCTAssertEqual(cacheProviderMock.invokedWriteParameters?.key, .applicationUsernameKey) + XCTAssertEqual(cacheProviderMock.invokedWriteParameters?.value as? String, configurationFake.applicationUsername) + } +} + +// MARK: - Constants + +private extension String { + static let applicationUsername = "application_username" + + static let applicationUsernameKey = "flare.configuration.application_username" +} diff --git a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift index b97b668fb..ace7cd0e8 100644 --- a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // @testable import Flare @@ -14,9 +14,11 @@ class IAPProviderTests: XCTestCase { private var paymentQueueMock: PaymentQueueMock! private var productProviderMock: ProductProviderMock! - private var paymentProviderMock: PaymentProviderMock! + private var purchaseProvider: PurchaseProviderMock! private var receiptRefreshProviderMock: ReceiptRefreshProviderMock! - private var iapProvider: IIAPProvider! + private var refundProviderMock: RefundProviderMock! + + private var sut: IIAPProvider! // MARK: - XCTestCase @@ -24,22 +26,27 @@ class IAPProviderTests: XCTestCase { super.setUp() paymentQueueMock = PaymentQueueMock() productProviderMock = ProductProviderMock() - paymentProviderMock = PaymentProviderMock() + purchaseProvider = PurchaseProviderMock() receiptRefreshProviderMock = ReceiptRefreshProviderMock() - iapProvider = IAPProvider( + refundProviderMock = RefundProviderMock() + sut = IAPProvider( paymentQueue: paymentQueueMock, productProvider: productProviderMock, - paymentProvider: paymentProviderMock, - receiptRefreshProvider: receiptRefreshProviderMock + purchaseProvider: purchaseProvider, + receiptRefreshProvider: receiptRefreshProviderMock, + refundProvider: refundProviderMock, + eligibilityProvider: EligibilityProviderMock(), + redeemCodeProvider: RedeemCodeProviderMock() ) } override func tearDown() { paymentQueueMock = nil productProviderMock = nil - paymentProviderMock = nil + purchaseProvider = nil receiptRefreshProviderMock = nil - iapProvider = nil + refundProviderMock = nil + sut = nil super.tearDown() } @@ -50,12 +57,14 @@ class IAPProviderTests: XCTestCase { paymentQueueMock.stubbedCanMakePayments = true // then - XCTAssertTrue(iapProvider.canMakePayments) + XCTAssertTrue(sut.canMakePayments) } func test_thatIAPProviderFetchesProducts() throws { + try AvailabilityChecker.iOS15APINotAvailableOrSkipTest() + // when - iapProvider.fetch(productIDs: .productIDs, completion: { _ in }) + sut.fetch(productIDs: .productIDs, completion: { _ in }) // then let parameters = try XCTUnwrap(productProviderMock.invokedFetchParameters) @@ -65,15 +74,15 @@ class IAPProviderTests: XCTestCase { func test_thatIAPProviderPurchasesProduct() throws { // when - iapProvider.purchase(productID: .productID, completion: { _ in }) + sut.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { _ in }) // then - XCTAssertTrue(productProviderMock.invokedFetch) + XCTAssertTrue(purchaseProvider.invokedPurchase) } func test_thatIAPProviderRefreshesReceipt() { // when - iapProvider.refreshReceipt(completion: { _ in }) + sut.refreshReceipt(completion: { _ in }) // then XCTAssertTrue(receiptRefreshProviderMock.invokedRefresh) @@ -84,168 +93,92 @@ class IAPProviderTests: XCTestCase { let transaction = PurchaseManagerTestHelper.makePaymentTransaction(state: .purchased) // when - iapProvider.finish(transaction: PaymentTransaction(transaction)) + sut.finish(transaction: StoreTransaction(paymentTransaction: PaymentTransaction(transaction)), completion: nil) // then - XCTAssertTrue(paymentProviderMock.invokedFinishTransaction) + XCTAssertTrue(purchaseProvider.invokedFinish) } func test_thatIAPProviderAddsTransactionObserver() { // when - iapProvider.addTransactionObserver(fallbackHandler: { _ in }) + sut.addTransactionObserver(fallbackHandler: { _ in }) // then - XCTAssertTrue(paymentProviderMock.invokedFallbackHandler) - XCTAssertTrue(paymentProviderMock.invokedAddTransactionObserver) + XCTAssertTrue(purchaseProvider.invokedAddTransactionObserver) } func test_thatIAPProviderRemovesTransactionObserver() { // when - iapProvider.removeTransactionObserver() + sut.removeTransactionObserver() // then - XCTAssertTrue(paymentProviderMock.invokedRemoveTransactionObserver) + XCTAssertTrue(purchaseProvider.invokedRemoveTransactionObserver) } - func test_thatIAPProviderFetchesProducts_whenProducts() async throws { + // FIXME: Update test + func test_thatIAPProviderFetchesSK1Products_whenProductsAvailable() async throws { + try AvailabilityChecker.iOS15APINotAvailableOrSkipTest() + // given - let productsMock = [SKProduct(), SKProduct(), SKProduct()] + let productsMock = [0 ... 2].map { _ in StoreProduct(SK1StoreProduct(ProductMock())) } productProviderMock.stubbedFetchResult = .success(productsMock) // when - let products = try await iapProvider.fetch(productIDs: .productIDs) + let products = try await sut.fetch(productIDs: .productIDs) // then - XCTAssertEqual(productsMock, products) + XCTAssertEqual(productsMock.count, products.count) } - func test_thatIAPProviderThrowsNoProductsError_whenProductsProductProviderReturnsError() async { + func test_thatIAPProviderThrowsNoProductsError_whenProductsProductProviderReturnsError() async throws { + try AvailabilityChecker.iOS15APINotAvailableOrSkipTest() + // given productProviderMock.stubbedFetchResult = .failure(IAPError.unknown) // when - var errorResult: Error? - do { - _ = try await iapProvider.fetch(productIDs: .productIDs) - } catch { - errorResult = error - } + let errorResult: Error? = await error(for: { try await sut.fetch(productIDs: .productIDs) }) // then XCTAssertEqual(errorResult as? NSError, IAPError.unknown as NSError) } - func test_thatIAPProviderThrowsStoreProductNotAvailableError_whenProductProviderDoesNotHaveProducts() { - // given - productProviderMock.stubbedFetchResult = .success([]) - - // when - var error: Error? - iapProvider.purchase(productID: .productID) { result in - if case let .failure(result) = result { - error = result - } - } - - // then - XCTAssertEqual(error as? NSError, IAPError.storeProductNotAvailable as NSError) - } - - func test_thatIAPProviderReturnsPaymentTransaction_whenProductsExist() { - // given - let paymentTransactionMock = PaymentTransactionMock() - productProviderMock.stubbedFetchResult = .success([ProductMock()]) - paymentProviderMock.stubbedAddResult = (paymentQueueMock, .success(paymentTransactionMock)) - - // when - var transactionResult: PaymentTransaction? - iapProvider.purchase(productID: .productID) { result in - if case let .success(transaction) = result { - transactionResult = transaction - } - } - - // then - XCTAssertEqual(transactionResult?.skTransaction, paymentTransactionMock) - } - func test_thatIAPProviderReturnsError_whenAddingPaymentFailed() { // given - productProviderMock.stubbedFetchResult = .success([ProductMock()]) - paymentProviderMock.stubbedAddResult = (paymentQueueMock, .failure(.unknown)) + productProviderMock.stubbedFetchResult = .success([StoreProduct(SK1StoreProduct(ProductMock()))]) + purchaseProvider.stubbedPurchaseCompletionResult = (.failure(.unknown), ()) // when - var errorResult: Error? - iapProvider.purchase(productID: .productID) { result in - if case let .failure(error) = result { - errorResult = error - } - } + var error: Error? + sut.purchase(product: .fake(skProduct: .fake(id: .productID))) { error = $0.error } // then - XCTAssertEqual(errorResult as? NSError, IAPError.unknown as NSError) + XCTAssertEqual(error as? NSError, IAPError.unknown as NSError) } func test_thatIAPProviderReturnsError_whenFetchRequestFailed() { // given - productProviderMock.stubbedFetchResult = .failure(.storeProductNotAvailable) - - // when - var errorResult: Error? - iapProvider.purchase(productID: .productID) { result in - if case let .failure(error) = result { - errorResult = error - } - } - - // then - XCTAssertEqual(errorResult as? NSError, IAPError.storeProductNotAvailable as NSError) - } - - func test_thatIAPProviderThrowsStoreProductNotAvailableError_whenProductsDoNotExist() async throws { - // given - productProviderMock.stubbedFetchResult = .success([]) + purchaseProvider.stubbedPurchaseCompletionResult = (.failure(IAPError.unknown), ()) // when - var errorResult: Error? - do { - _ = try await iapProvider.purchase(productID: .productID) - } catch { - errorResult = error - } - - // then - XCTAssertEqual(errorResult as? NSError, IAPError.storeProductNotAvailable as NSError) - } - - func test_thatIAPProviderPurchasesForAProduct_whenProductsExist() async throws { - // given - let transactionMock = SKPaymentTransaction() - productProviderMock.stubbedFetchResult = .success([ProductMock()]) - paymentProviderMock.stubbedAddResult = (paymentQueueMock, .success(transactionMock)) - - // when - let transactionResult = try await iapProvider.purchase(productID: .productID) + var error: Error? + sut.purchase(product: .fake(skProduct: .fake(id: .productID))) { error = $0.error } // then - XCTAssertEqual(transactionMock, transactionResult.skTransaction) + XCTAssertEqual(error as? NSError, IAPError.unknown as NSError) } - func test_thatIAPProviderRefreshesReceipt_when() { + func test_thatIAPProviderRefreshesReceipt_whenReceiptExist() { // given receiptRefreshProviderMock.stubbedReceipt = .receipt receiptRefreshProviderMock.stubbedRefreshResult = .success(()) // when - var receiptResult: String? - iapProvider.refreshReceipt { result in - if case let .success(receipt) = result { - receiptResult = receipt - } - } + var receipt: String? + sut.refreshReceipt { receipt = $0.success } // then - XCTAssertEqual(receiptResult, .receipt) + XCTAssertEqual(receipt, .receipt) } func test_thatIAPProviderDoesNotRefreshReceipt_whenRequestFailed() { @@ -254,15 +187,11 @@ class IAPProviderTests: XCTestCase { receiptRefreshProviderMock.stubbedRefreshResult = .failure(.receiptNotFound) // when - var errorResult: Error? - iapProvider.refreshReceipt { result in - if case let .failure(error) = result { - errorResult = error - } - } + var error: Error? + sut.refreshReceipt { error = $0.error } // then - XCTAssertEqual(errorResult as? NSError, IAPError.receiptNotFound as NSError) + XCTAssertEqual(error as? NSError, IAPError.receiptNotFound as NSError) } func test_thatIAPProviderReturnsReceiptNotFoundError_whenReceiptIsNil() { @@ -271,15 +200,11 @@ class IAPProviderTests: XCTestCase { receiptRefreshProviderMock.stubbedRefreshResult = .success(()) // when - var errorResult: Error? - iapProvider.refreshReceipt { result in - if case let .failure(error) = result { - errorResult = error - } - } + var error: Error? + sut.refreshReceipt { error = $0.error } // then - XCTAssertEqual(errorResult as? NSError, IAPError.receiptNotFound as NSError) + XCTAssertEqual(error as? NSError, IAPError.receiptNotFound as NSError) } func test_thatIAPProviderRefreshesReceipt_whenReceiptIsNotNil() async throws { @@ -288,7 +213,7 @@ class IAPProviderTests: XCTestCase { receiptRefreshProviderMock.stubbedRefreshResult = .success(()) // when - let receipt = try await iapProvider.refreshReceipt() + let receipt = try await sut.refreshReceipt() // then XCTAssertEqual(receipt, .receipt) @@ -300,49 +225,11 @@ class IAPProviderTests: XCTestCase { receiptRefreshProviderMock.stubbedRefreshResult = .success(()) // when - var errorResult: Error? - do { - _ = try await iapProvider.refreshReceipt() - } catch { - errorResult = error - } + let errorResult: Error? = await error(for: { try await sut.refreshReceipt() }) // then XCTAssertEqual(errorResult as? NSError, IAPError.receiptNotFound as NSError) } - - func test_thatIAPProviderReturnsTransaction() { - // given - let transactionMock = SKPaymentTransaction() - paymentProviderMock.stubbedFallbackHandlerResult = (paymentQueueMock, .success(transactionMock)) - - // when - var transactionResult: PaymentTransaction? - iapProvider.addTransactionObserver { result in - if case let .success(transaction) = result { - transactionResult = transaction - } - } - - // then - XCTAssertEqual(transactionResult?.skTransaction, transactionMock) - } - - func test_thatIAPProviderReturnsError() { - // given - paymentProviderMock.stubbedFallbackHandlerResult = (paymentQueueMock, .failure(.unknown)) - - // when - var errorResult: Error? - iapProvider.addTransactionObserver { result in - if case let .failure(error) = result { - errorResult = error - } - } - - // then - XCTAssertEqual(errorResult as? NSError, IAPError.unknown as NSError) - } } // MARK: - Constants @@ -350,6 +237,7 @@ class IAPProviderTests: XCTestCase { private extension String { static let receipt = "receipt" static let productID = "product_identifier" + static let transactionID = "transaction_identifier" } private extension Set where Element == String { diff --git a/Tests/FlareTests/UnitTests/Providers/ProductProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/ProductProviderTests.swift index c2f1ada71..497601be2 100644 --- a/Tests/FlareTests/UnitTests/Providers/ProductProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/ProductProviderTests.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import Concurrency @@ -11,12 +11,13 @@ import XCTest // MARK: - ProductProviderTests -class ProductProviderTests: XCTestCase { +final class ProductProviderTests: XCTestCase { // MARK: - Properties private var testDispatchQueue: TestDispatchQueue! private var dispatchQueueFactory: IDispatchQueueFactory! - private var productProvider: ProductProvider! + + private var sut: ProductProvider! // MARK: - XCTestCase @@ -24,21 +25,21 @@ class ProductProviderTests: XCTestCase { super.setUp() testDispatchQueue = TestDispatchQueue() dispatchQueueFactory = TestDispatchQueueFactory(testQueue: testDispatchQueue) - productProvider = ProductProvider(dispatchQueueFactory: dispatchQueueFactory) + sut = ProductProvider(dispatchQueueFactory: dispatchQueueFactory) } override func tearDown() { testDispatchQueue = nil dispatchQueueFactory = nil - productProvider = nil + sut = nil super.tearDown() } // MARK: - Tests - func test_thatProductProviderReturnsInvalidProductIDs_whenRequestProductsWithInvalidIDs() { + func test_thatProductProviderReturnsInvalidProductIDs_whenRequestProductsAreFetchedWithInvalidIDs() { // given - var fetchResult: Result<[SKProduct], IAPError>? + var fetchResult: Result<[StoreProduct], IAPError>? let completionHandler: IProductProvider.ProductsHandler = { result in fetchResult = result } let request = PurchaseManagerTestHelper.makeRequest(with: .requestID) let response = ProductResponseMock() @@ -46,8 +47,8 @@ class ProductProviderTests: XCTestCase { response.stubbedInvokedInvalidProductsIdentifiers = [.productID] // when - productProvider.fetch(productIDs: .productIDs, requestID: .requestID, completion: completionHandler) - productProvider.productsRequest(request, didReceive: response) + sut.fetch(productIDs: .productIDs, requestID: .requestID, completion: completionHandler) + sut.productsRequest(request, didReceive: response) // then if case let .failure(error) = fetchResult, case let .invalid(products) = error { @@ -57,49 +58,41 @@ class ProductProviderTests: XCTestCase { } } - func test_thatProductProviderReturnsProducts_whenRequestProductsWithValidProductIDs() { + func test_thatProductProviderReturnsProducts_whenRequestProductsAreFetchedWithValidProductIDs() { // given - var fetchResult: Result<[SKProduct], IAPError>? - let completionHandler: IProductProvider.ProductsHandler = { result in fetchResult = result } + var products: [StoreProduct]? = [] + let completionHandler: IProductProvider.ProductsHandler = { products = $0.success } let request = PurchaseManagerTestHelper.makeRequest(with: .requestID) let response = ProductResponseMock() // when - productProvider.fetch(productIDs: .productIDs, requestID: .requestID, completion: completionHandler) - productProvider.productsRequest(request, didReceive: response) + sut.fetch(productIDs: .productIDs, requestID: .requestID, completion: completionHandler) + sut.productsRequest(request, didReceive: response) // then - if case let .success(products) = fetchResult { - XCTAssertEqual(products, response.products) - } else { - XCTFail() - } + XCTAssertEqual(products?.compactMap { $0.product as? SK1StoreProduct }.map(\.product), response.products) } func test_thatProductProviderHandlesError_whenRequestDidFailWithError() { // given - var fetchResult: Result<[SKProduct], IAPError>? - let completionHandler: IProductProvider.ProductsHandler = { result in fetchResult = result } + var error: IAPError? + let completionHandler: IProductProvider.ProductsHandler = { error = $0.error } let request = PurchaseManagerTestHelper.makeRequest(with: .requestID) - let error = IAPError.emptyProducts + let errorStub = IAPError.unknown // when - productProvider.fetch(productIDs: .productIDs, requestID: .requestID, completion: completionHandler) - productProvider.request(request, didFailWithError: error) + sut.fetch(productIDs: .productIDs, requestID: .requestID, completion: completionHandler) + sut.request(request, didFailWithError: errorStub) // then - if case let .failure(resultError) = fetchResult { - XCTAssertEqual(resultError.plainError as NSError, error.plainError as NSError) - } else { - XCTFail() - } + XCTAssertEqual(error?.plainError as? NSError, errorStub.plainError as NSError) } } // MARK: - Constants private extension String { - static let productID = "product_ID" + static let productID = "com.flare.test_purchase_1" static let requestID = "request_identifier" } diff --git a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift new file mode 100644 index 000000000..dee0878b7 --- /dev/null +++ b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift @@ -0,0 +1,107 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import StoreKit +import StoreKitTest +import XCTest + +// MARK: - PurchaseProviderTests + +final class PurchaseProviderTests: XCTestCase { + // MARK: Properties + + private var paymentQueueMock: PaymentQueueMock! + private var paymentProviderMock: PaymentProviderMock! + + private var sut: PurchaseProvider! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + paymentQueueMock = PaymentQueueMock() + paymentProviderMock = PaymentProviderMock() + sut = PurchaseProvider( + paymentProvider: paymentProviderMock, + configurationProvider: ConfigurationProviderMock() + ) + } + + override func tearDown() { + paymentQueueMock = nil + paymentProviderMock = nil + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatPurchaseProviderReturnsPaymentTransaction_whenSK1ProductExist() { + // given + let productMock = StoreProduct(skProduct: ProductMock()) + let paymentTransaction = SKPaymentTransaction() + let storeTransaction = StoreTransaction(paymentTransaction: PaymentTransaction(paymentTransaction)) + + paymentProviderMock.stubbedAddResult = (paymentQueueMock, .success(paymentTransaction)) + + // when + sut.purchase(product: productMock) { result in + if case let .success(transaction) = result { + XCTAssertEqual(transaction, storeTransaction) + } else { + XCTFail("The products' ids must be equal") + } + } + } + + func test_thatPurchaseProviderFinishesTransaction() { + // given + let transaction = PurchaseManagerTestHelper.makePaymentTransaction(state: .purchased) + + // when + sut.finish(transaction: StoreTransaction(paymentTransaction: PaymentTransaction(transaction)), completion: nil) + + // then + XCTAssertTrue(paymentProviderMock.invokedFinishTransaction) + } + + func test_thatPurchaseProviderAddsTransactionObserver_whenPaymentDidSuccess() { + // given + let paymentTransactionMock = SKPaymentTransaction() + paymentProviderMock.stubbedFallbackHandlerResult = (paymentQueueMock, .success(paymentTransactionMock)) + + // when + sut.addTransactionObserver(fallbackHandler: { result in + if case let .success(transaction) = result { + XCTAssertTrue(transaction.productIdentifier.isEmpty) + } else { + XCTFail("The products' ids must be equal") + } + }) + } + + func test_thatPurchaseProviderThrowsAnError_whenTransactionObserverDidFail() { + // given + paymentProviderMock.stubbedFallbackHandlerResult = (paymentQueueMock, .failure(IAPError.unknown)) + + // when + sut.addTransactionObserver(fallbackHandler: { result in + if case let .failure(error) = result { + XCTAssertEqual(error, .unknown) + } else { + XCTFail("The errors' types must be equal") + } + }) + } + + func test_thatIAPProviderRemovesTransactionObserver() { + // when + sut.removeTransactionObserver() + + // then + XCTAssertTrue(paymentProviderMock.invokedRemoveTransactionObserver) + } +} diff --git a/Tests/FlareTests/UnitTests/Providers/ReceiptRefreshProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/ReceiptRefreshProviderTests.swift index cd3b4c67e..61b25272c 100644 --- a/Tests/FlareTests/UnitTests/Providers/ReceiptRefreshProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/ReceiptRefreshProviderTests.swift @@ -15,11 +15,12 @@ class ReceiptRefreshProviderTests: XCTestCase { private var testDispatchQueue: TestDispatchQueue! private var dispatchQueueFactory: TestDispatchQueueFactory! - private var receiptRefreshProvider: ReceiptRefreshProvider! private var appStoreReceiptProviderMock: AppStoreReceiptProviderMock! private var fileManagerMock: FileManagerMock! private var receiptRefreshRequestFactoryMock: ReceiptRefreshRequestFactoryMock! + private var sut: ReceiptRefreshProvider! + // MARK: - XCTestCase override func setUp() { @@ -29,7 +30,7 @@ class ReceiptRefreshProviderTests: XCTestCase { dispatchQueueFactory = TestDispatchQueueFactory(testQueue: testDispatchQueue) fileManagerMock = FileManagerMock() receiptRefreshRequestFactoryMock = ReceiptRefreshRequestFactoryMock() - receiptRefreshProvider = ReceiptRefreshProvider( + sut = ReceiptRefreshProvider( dispatchQueueFactory: dispatchQueueFactory, fileManager: fileManagerMock, appStoreReceiptProvider: appStoreReceiptProviderMock, @@ -40,7 +41,7 @@ class ReceiptRefreshProviderTests: XCTestCase { override func tearDown() { testDispatchQueue = nil dispatchQueueFactory = nil - receiptRefreshProvider = nil + sut = nil appStoreReceiptProviderMock = nil fileManagerMock = nil receiptRefreshRequestFactoryMock = nil @@ -59,8 +60,8 @@ class ReceiptRefreshProviderTests: XCTestCase { let error = IAPError.paymentCancelled // when - receiptRefreshProvider.refresh(requestID: .requestID, handler: handler) - receiptRefreshProvider.request(request, didFailWithError: error) + sut.refresh(requestID: .requestID, handler: handler) + sut.request(request, didFailWithError: error) // then if case let .failure(resultError) = result { @@ -77,13 +78,11 @@ class ReceiptRefreshProviderTests: XCTestCase { let handler: ReceiptRefreshHandler = { result = $0 } // when - receiptRefreshProvider.refresh(requestID: .requestID, handler: handler) - receiptRefreshProvider.requestDidFinish(request) + sut.refresh(requestID: .requestID, handler: handler) + sut.requestDidFinish(request) // then - if case .failure = result { - XCTFail() - } + if case .failure = result { XCTFail("The result must be `success`") } } func test_thatReceiptRefreshProviderLoadsAppStoreReceipt_whenReceiptExists() { @@ -92,7 +91,7 @@ class ReceiptRefreshProviderTests: XCTestCase { fileManagerMock.stubbedFileExistsResult = true // when - let receipt = receiptRefreshProvider.receipt + let receipt = sut.receipt // then XCTAssertNotNil(receipt) @@ -104,7 +103,7 @@ class ReceiptRefreshProviderTests: XCTestCase { fileManagerMock.stubbedFileExistsResult = false // when - let receipt = receiptRefreshProvider.receipt + let receipt = sut.receipt // then XCTAssertNil(receipt) @@ -116,20 +115,14 @@ class ReceiptRefreshProviderTests: XCTestCase { receiptRefreshRequestFactoryMock.stubbedMakeResult = request request.stubbedStartAction = { - self.receiptRefreshProvider.request( + self.sut.request( self.makeSKRequest(id: request.id), didFailWithError: IAPError.paymentNotAllowed ) } // when - var iapError: IAPError? - - do { - try await receiptRefreshProvider.refresh(requestID: .requestID) - } catch { - iapError = error as? IAPError - } + let iapError: IAPError? = await error(for: { try await sut.refresh(requestID: .requestID) }) // then XCTAssertEqual(iapError, IAPError(error: IAPError.paymentNotAllowed)) @@ -143,17 +136,11 @@ class ReceiptRefreshProviderTests: XCTestCase { receiptRefreshRequestFactoryMock.stubbedMakeResult = request request.stubbedStartAction = { - self.receiptRefreshProvider.requestDidFinish(self.makeSKRequest(id: .requestID)) + self.sut.requestDidFinish(self.makeSKRequest(id: .requestID)) } // when - var iapError: IAPError? - - do { - try await receiptRefreshProvider.refresh(requestID: .requestID) - } catch { - iapError = error as? IAPError - } + let iapError: IAPError? = await error(for: { try await sut.refresh(requestID: .requestID) }) // then XCTAssertNil(iapError) diff --git a/Tests/FlareTests/UnitTests/Providers/RefundProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/RefundProviderTests.swift new file mode 100644 index 000000000..4dbe95d6e --- /dev/null +++ b/Tests/FlareTests/UnitTests/Providers/RefundProviderTests.swift @@ -0,0 +1,106 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +#if os(iOS) || VISION_OS + @testable import Flare + import XCTest + + @available(iOS 15.0, *) + class RefundProviderTests: XCTestCase { + // MARK: - Properties + + private var systemInfoProviderMock: SystemInfoProviderMock! + private var refundRequestProviderMock: RefundRequestProviderMock! + + private var sut: RefundProvider! + + // MARK: - XCTestCase + + override func setUp() { + super.setUp() + refundRequestProviderMock = RefundRequestProviderMock() + systemInfoProviderMock = SystemInfoProviderMock() + sut = RefundProvider(systemInfoProvider: systemInfoProviderMock, refundRequestProvider: refundRequestProviderMock) + } + + override func tearDown() { + refundRequestProviderMock = nil + systemInfoProviderMock = nil + sut = nil + super.tearDown() + } + + // MARK: - Tests + + func testThatRefundProviderThrowsAnError_whenVerificationDidFail() async throws { + // given + refundRequestProviderMock.stubbedVerifyTransaction = nil + systemInfoProviderMock.stubbedCurrentScene = .failure(IAPError.unknown) + + // when + let error: Error? = await error(for: { try await sut.beginRefundRequest(productID: .productID) }) + + // then + XCTAssertEqual(error as? NSError, IAPError.unknown as NSError) + } + +// func testThatRefundProviderThrowsAnErrorWhenRefundRequestDidFail() async throws { +// // given +// refundRequestProviderMock.stubbedVerifyTransaction = .transactionID +// refundRequestProviderMock.stubbedBeginRefundRequest = .failure(IAPError.unknown) +// systemInfoProviderMock.stubbedCurrentScene = .success(WindowSceneFactory.makeWindowScene()) +// +// // when +// let status: RefundRequestStatus? = try await sut.beginRefundRequest(productID: .productID) +// +// // then +// if case .failed = status {} +// else { XCTFail("The status must be `failed`") } +// XCTAssertEqual(refundRequestProviderMock.invokedBeginRefundRequestCount, 1) +// } +// +// func testThatRefundProviderReturnsSuccessStatusWhenRefundRequestCompleted() async throws { +// // given +// refundRequestProviderMock.stubbedVerifyTransaction = .transactionID +// refundRequestProviderMock.stubbedBeginRefundRequest = .success(.success) +// systemInfoProviderMock.stubbedCurrentScene = .success(WindowSceneFactory.makeWindowScene()) +// +// // when +// let status: RefundRequestStatus? = try await sut.beginRefundRequest(productID: .productID) +// +// // then +// if case .success = status {} +// else { XCTFail("The status must be `success`") } +// XCTAssertEqual(refundRequestProviderMock.invokedBeginRefundRequestCount, 1) +// } +// +// func testThatRefundProviderReturnsUserCancelledStatusWhenUserCancelledRequest() async throws { +// // given +// refundRequestProviderMock.stubbedVerifyTransaction = .transactionID +// refundRequestProviderMock.stubbedBeginRefundRequest = .success(.userCancelled) +// systemInfoProviderMock.stubbedCurrentScene = .success(WindowSceneFactory.makeWindowScene()) +// +// // when +// let status: RefundRequestStatus? = try await sut.beginRefundRequest(productID: .productID) +// +// // then +// if case .userCancelled = status {} +// else { XCTFail("The status must be `userCancelled`") } +// XCTAssertEqual(refundRequestProviderMock.invokedBeginRefundRequestCount, 1) +// } + } + + // MARK: - Constants + +// +// private extension UInt64 { +// static let transactionID: UInt64 = 5 +// } +// + private extension String { + static let productID: String = "product_id" + } + +#endif diff --git a/Tests/FlareTests/UnitTests/Providers/RefundRequestProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/RefundRequestProviderTests.swift new file mode 100644 index 000000000..ef6acb2e8 --- /dev/null +++ b/Tests/FlareTests/UnitTests/Providers/RefundRequestProviderTests.swift @@ -0,0 +1,63 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import StoreKit +import StoreKitTest +import XCTest + +#if os(iOS) || VISION_OS + + @available(iOS 15.0, *) + final class RefundRequestProviderTests: XCTestCase { + // MARK: Properties + + private var sut: RefundRequestProvider! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + sut = RefundRequestProvider() + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + // MARK: Tests + +// @MainActor +// func test_thatRefundRequestProviderThrowsAnUnknownError_whenRequestDidFailed() async throws { +// // given +// let windowScene = WindowSceneFactory.makeWindowScene() +// +// // when +// let status = try await sut.beginRefundRequest( +// transactionID: .transactionID, +// windowScene: windowScene +// ) +// +// // then +// if case let .failure(error) = status { +// XCTAssertEqual(error as NSError, IAPError.refund(error: .failed) as NSError) +// } else { +// XCTFail("state must be `failure`") +// } +// } + } + + // MARK: - Constants + + private extension UInt64 { + static let transactionID: UInt64 = 0 + } + + private extension String { + static let productID: String = "com.flare.test_purchase_1" + } + +#endif diff --git a/Tests/FlareTests/UnitTests/Providers/SystemInfoProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/SystemInfoProviderTests.swift new file mode 100644 index 000000000..5d92412bb --- /dev/null +++ b/Tests/FlareTests/UnitTests/Providers/SystemInfoProviderTests.swift @@ -0,0 +1,55 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +#if os(iOS) || VISION_OS + @testable import Flare + import XCTest + + final class SystemInfoProviderTests: XCTestCase { + // MARK: Properties + + private var scenesHolderMock: ScenesHolderMock! + + private var sut: SystemInfoProvider! + + // MARK: Initialization + + override func setUp() { + super.setUp() + scenesHolderMock = ScenesHolderMock() + sut = SystemInfoProvider(scenesHolder: scenesHolderMock) + } + + override func tearDown() { + scenesHolderMock = nil + sut = nil + super.tearDown() + } + + // MARK: Tests + +// @MainActor +// func test_thatScenesHolderReturnsCurrentScene() throws { +// // given +// let windowScene = WindowSceneFactory.makeWindowScene() +// scenesHolderMock.stubbedConnectedScenes = Set(arrayLiteral: windowScene) +// +// // when +// let scene = try sut.currentScene +// +// // then +// XCTAssertEqual(windowScene, scene) +// } +// +// @MainActor +// func test_thatScenesHolderThrowsAnErrorWhenThereIsNoActiveWindowScene() async throws { +// // when +// let error: Error? = await self.error(for: { try sut.currentScene }) +// +// // then +// XCTAssertEqual(error as? NSError, IAPError.unknown as NSError) +// } + } +#endif diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Extensions/Result+.swift b/Tests/FlareTests/UnitTests/TestHelpers/Extensions/Result+.swift new file mode 100644 index 000000000..9c779804a --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Extensions/Result+.swift @@ -0,0 +1,26 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +extension Result { + var error: Failure? { + switch self { + case let .failure(error): + return error + default: + return nil + } + } + + var success: Success? { + switch self { + case let .success(value): + return value + default: + return nil + } + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Extensions/String+Data.swift b/Tests/FlareTests/UnitTests/TestHelpers/Extensions/String+Data.swift new file mode 100644 index 000000000..50643fa69 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Extensions/String+Data.swift @@ -0,0 +1,12 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +extension String { + var asData: Data { + Data(utf8) + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Extensions/XCTestCase+.swift b/Tests/FlareTests/UnitTests/TestHelpers/Extensions/XCTestCase+.swift new file mode 100644 index 000000000..8d55a0f50 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Extensions/XCTestCase+.swift @@ -0,0 +1,35 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import XCTest + +extension XCTestCase { + func value(for closure: () async throws -> U) async -> U? { + do { + let value = try await closure() + return value + } catch { + return nil + } + } + + func error(for closure: () async throws -> U) async -> T? { + do { + _ = try await closure() + return nil + } catch { + return error as? T + } + } + + func result(for closure: () async throws -> U) async -> Result { + do { + let value = try await closure() + return .success(value) + } catch { + return .failure(error as! T) + } + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Fakes/Configuration+Fake.swift b/Tests/FlareTests/UnitTests/TestHelpers/Fakes/Configuration+Fake.swift new file mode 100644 index 000000000..7ffdd13d5 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Fakes/Configuration+Fake.swift @@ -0,0 +1,13 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import Foundation + +extension Configuration { + static func fake(applicationUsername: String = "username") -> Configuration { + Configuration(applicationUsername: applicationUsername) + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Fakes/SKProduct+Fake.swift b/Tests/FlareTests/UnitTests/TestHelpers/Fakes/SKProduct+Fake.swift new file mode 100644 index 000000000..a748b6270 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Fakes/SKProduct+Fake.swift @@ -0,0 +1,15 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +extension SKProduct { + static func fake(id: String) -> SKProduct { + let product = ProductMock() + product.stubbedProductIdentifier = id + return product + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Fakes/StoreProduct+Fake.swift b/Tests/FlareTests/UnitTests/TestHelpers/Fakes/StoreProduct+Fake.swift new file mode 100644 index 000000000..4f93ff255 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Fakes/StoreProduct+Fake.swift @@ -0,0 +1,13 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Flare +import StoreKit + +extension StoreProduct { + static func fake(skProduct: SKProduct = ProductMock()) -> StoreProduct { + StoreProduct(skProduct: skProduct) + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Fakes/StoreTransactionFake.swift b/Tests/FlareTests/UnitTests/TestHelpers/Fakes/StoreTransactionFake.swift new file mode 100644 index 000000000..002b7311b --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Fakes/StoreTransactionFake.swift @@ -0,0 +1,12 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare + +extension StoreTransaction { + static func fakeSK1(storeTransaction: IStoreTransaction? = nil) -> StoreTransaction { + StoreTransaction(storeTransaction: storeTransaction ?? StoreTransactionStub()) + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Helpers/AvailabilityChecker.swift b/Tests/FlareTests/UnitTests/TestHelpers/Helpers/AvailabilityChecker.swift new file mode 100644 index 000000000..7cec99218 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Helpers/AvailabilityChecker.swift @@ -0,0 +1,14 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import XCTest + +enum AvailabilityChecker { + static func iOS15APINotAvailableOrSkipTest() throws { + if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) { + throw XCTSkip("Test only for older devices") + } + } +} diff --git a/Tests/FlareTests/Helpers/PurchaseManagerTestHelper.swift b/Tests/FlareTests/UnitTests/TestHelpers/Helpers/PurchaseManagerTestHelper.swift similarity index 100% rename from Tests/FlareTests/Helpers/PurchaseManagerTestHelper.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Helpers/PurchaseManagerTestHelper.swift diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Helpers/WindowSceneFactory.swift b/Tests/FlareTests/UnitTests/TestHelpers/Helpers/WindowSceneFactory.swift new file mode 100644 index 000000000..4c21b2c38 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Helpers/WindowSceneFactory.swift @@ -0,0 +1,14 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +#if os(iOS) || VISION_OS + import UIKit + + final class WindowSceneFactory { + static func makeWindowScene() -> UIWindowScene { + UIApplication.shared.connectedScenes.first as! UIWindowScene + } + } +#endif diff --git a/Tests/FlareTests/Mocks/AppStoreReceiptProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/AppStoreReceiptProviderMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/AppStoreReceiptProviderMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/AppStoreReceiptProviderMock.swift diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/CacheProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/CacheProviderMock.swift new file mode 100644 index 000000000..413504174 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/CacheProviderMock.swift @@ -0,0 +1,35 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import Foundation + +final class CacheProviderMock: ICacheProvider { + var invokedRead = false + var invokedReadCount = 0 + var invokedReadParameters: (key: String, Void)? + var invokedReadParametersList = [(key: String, Void)]() + var stubbedReadResult: Any! + + func read(key: String) -> T? { + invokedRead = true + invokedReadCount += 1 + invokedReadParameters = (key, ()) + invokedReadParametersList.append((key, ())) + return stubbedReadResult as? T + } + + var invokedWrite = false + var invokedWriteCount = 0 + var invokedWriteParameters: (key: String, value: Any)? + var invokedWriteParametersList = [(key: String, value: Any)]() + + func write(key: String, value: T) { + invokedWrite = true + invokedWriteCount += 1 + invokedWriteParameters = (key, value) + invokedWriteParametersList.append((key, value)) + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/CacheProviderTests.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/CacheProviderTests.swift new file mode 100644 index 000000000..55ae8c599 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/CacheProviderTests.swift @@ -0,0 +1,61 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import XCTest + +// MARK: - CacheProviderTests + +final class CacheProviderTests: XCTestCase { + // MARK: Properties + + private var userDefaultsMock: UserDefaultsMock! + + private var sut: CacheProvider! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + userDefaultsMock = UserDefaultsMock() + sut = CacheProvider(userDefaults: userDefaultsMock) + } + + override func tearDown() { + userDefaultsMock = nil + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_write() { + // when + sut.write(key: .key, value: String.value) + + // then + XCTAssertEqual(userDefaultsMock.invokedSetParameters?.key, .key) + XCTAssertEqual(userDefaultsMock.invokedSetParameters?.codable as? String, String.value) + } + + func test_read() { + // given + userDefaultsMock.stubbedGetResult = String.value + + // when + let value: String? = sut.read(key: .key) + + // then + XCTAssertEqual(userDefaultsMock.invokedGetParameters?.key, .key) + XCTAssertEqual(value, String.value) + } +} + +// MARK: - Constants + +private extension String { + static let key = "key" + static let value = "value" +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ConfigurationProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ConfigurationProviderMock.swift new file mode 100644 index 000000000..679845da5 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ConfigurationProviderMock.swift @@ -0,0 +1,41 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import Foundation + +final class ConfigurationProviderMock: IConfigurationProvider { + var invokedApplicationUsernameGetter = false + var invokedApplicationUsernameGetterCount = 0 + var stubbedApplicationUsername: String! + + var applicationUsername: String? { + invokedApplicationUsernameGetter = true + invokedApplicationUsernameGetterCount += 1 + return stubbedApplicationUsername + } + + var invokedFetchCachePolicyGetter = false + var invokedFetchCachePolicyGetterCount = 0 + var stubbedFetchCachePolicy: FetchCachePolicy! + + var fetchCachePolicy: FetchCachePolicy { + invokedFetchCachePolicyGetter = true + invokedFetchCachePolicyGetterCount += 1 + return stubbedFetchCachePolicy + } + + var invokedConfigure = false + var invokedConfigureCount = 0 + var invokedConfigureParameters: (configuration: Configuration, Void)? + var invokedConfigureParametersList = [(configuration: Configuration, Void)]() + + func configure(with configuration: Configuration) { + invokedConfigure = true + invokedConfigureCount += 1 + invokedConfigureParameters = (configuration, ()) + invokedConfigureParametersList.append((configuration, ())) + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/EligibilityProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/EligibilityProviderMock.swift new file mode 100644 index 000000000..94578e24b --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/EligibilityProviderMock.swift @@ -0,0 +1,23 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import Foundation + +final class EligibilityProviderMock: IEligibilityProvider { + var invokedCheckEligibility = false + var invokedCheckEligibilityCount = 0 + var invokedCheckEligibilityParameters: (products: [StoreProduct], Void)? + var invokedCheckEligibilityParametersList = [(products: [StoreProduct], Void)]() + var stubbedCheckEligibility: [String: SubscriptionEligibility] = [:] + + func checkEligibility(products: [StoreProduct]) async throws -> [String: SubscriptionEligibility] { + invokedCheckEligibility = true + invokedCheckEligibilityCount += 1 + invokedCheckEligibilityParameters = (products, ()) + invokedCheckEligibilityParametersList.append((products, ())) + return stubbedCheckEligibility + } +} diff --git a/Tests/FlareTests/Mocks/FileManagerMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/FileManagerMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/FileManagerMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/FileManagerMock.swift diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/FlareDependenciesMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/FlareDependenciesMock.swift new file mode 100644 index 000000000..ac2886dba --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/FlareDependenciesMock.swift @@ -0,0 +1,29 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import Foundation + +final class FlareDependenciesMock: IFlareDependencies { + var invokedIapProviderGetter = false + var invokedIapProviderGetterCount = 0 + var stubbedIapProvider: IIAPProvider! + + var iapProvider: IIAPProvider { + invokedIapProviderGetter = true + invokedIapProviderGetterCount += 1 + return stubbedIapProvider + } + + var invokedConfigurationProviderGetter = false + var invokedConfigurationProviderGetterCount = 0 + var stubbedConfigurationProvider: IConfigurationProvider! + + var configurationProvider: IConfigurationProvider { + invokedConfigurationProviderGetter = true + invokedConfigurationProviderGetterCount += 1 + return stubbedConfigurationProvider + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift new file mode 100644 index 000000000..1d05ea36c --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift @@ -0,0 +1,287 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import StoreKit + +final class IAPProviderMock: IIAPProvider { + var invokedCanMakePayments = false + var invokedCanMakePaymentsCount = 0 + var stubbedCanMakePayments: Bool = false + + var canMakePayments: Bool { + invokedCanMakePayments = true + invokedCanMakePaymentsCount += 1 + return stubbedCanMakePayments + } + + var invokedFetch = false + var invokedFetchCount = 0 + var invokedFetchParameters: (productIDs: Set, completion: Closure>)? + var invokedFetchParametersList = [(productIDs: Set, completion: Closure>)]() + + func fetch(productIDs: Set, completion: @escaping Closure>) { + invokedFetch = true + invokedFetchCount += 1 + invokedFetchParameters = (productIDs, completion) + invokedFetchParametersList.append((productIDs, completion)) + } + + var invokedPurchase = false + var invokedPurchaseCount = 0 + var invokedPurchaseParameters: (product: StoreProduct, completion: Closure>)? + var invokedPurchaseParametersList = [(product: StoreProduct, completion: Closure>)]() + + func purchase(product: StoreProduct, completion: @escaping Closure>) { + invokedPurchase = true + invokedPurchaseCount += 1 + invokedPurchaseParameters = (product, completion) + invokedPurchaseParametersList.append((product, completion)) + } + + var invokedRefreshReceipt = false + var invokedRefreshReceiptCount = 0 + var invokedRefreshReceiptParameters: (completion: Closure>, Void)? + var invokedRefreshReceiptParametersList = [(completion: Closure>, Void)]() + var stubbedRefreshReceiptResult: Result? + + func refreshReceipt(completion: @escaping Closure>) { + invokedRefreshReceipt = true + invokedRefreshReceiptCount += 1 + invokedRefreshReceiptParameters = (completion, ()) + invokedRefreshReceiptParametersList.append((completion, ())) + + if let result = stubbedRefreshReceiptResult { + completion(result) + } + } + + var invokedFinishTransaction = false + var invokedFinishTransactionCount = 0 + var invokedFinishTransactionParameters: (StoreTransaction, Void)? + var invokedFinishTransactionParanetersList = [(StoreTransaction, Void)]() + + func finish(transaction: StoreTransaction, completion _: (@Sendable () -> Void)?) { + invokedFinishTransaction = true + invokedFinishTransactionCount += 1 + invokedFinishTransactionParameters = (transaction, ()) + invokedFinishTransactionParanetersList.append((transaction, ())) + } + + var invokedAddTransactionObserver = false + var invokedAddTransactionObserverCount = 0 + var invokedAddTransactionObserverParameters: (fallbackHandler: Closure>?, Void)? + var invokedAddTransactionObserverParametersList = [(fallbackHandler: Closure>?, Void)]() + + func addTransactionObserver(fallbackHandler: Closure>?) { + invokedAddTransactionObserver = true + invokedAddTransactionObserverCount += 1 + invokedAddTransactionObserverParameters = (fallbackHandler, ()) + invokedAddTransactionObserverParametersList.append((fallbackHandler, ())) + } + + var invokedRemoveTransactionObserver = false + var invokedRemoveTransactionObserverCount = 0 + + func removeTransactionObserver() { + invokedRemoveTransactionObserver = true + invokedRemoveTransactionObserverCount += 1 + } + + var invokedFetchAsync = false + var invokedFetchAsyncCount = 0 + var invokedFetchAsyncParameters: (productIDs: Set, Void)? + var invokedFetchAsyncParametersList = [(productIDs: Set, Void)]() + var fetchAsyncResult: [StoreProduct] = [] + + func fetch(productIDs: Set) async throws -> [StoreProduct] { + invokedFetchAsync = true + invokedFetchAsyncCount += 1 + invokedFetchAsyncParameters = (productIDs, ()) + invokedFetchAsyncParametersList.append((productIDs, ())) + return fetchAsyncResult + } + + var invokedAsyncPurchase = false + var invokedAsyncPurchaseCount = 0 + var invokedAsyncPurchaseParameters: (product: StoreProduct, Void)? + var invokedAsyncPurchaseParametersList = [(product: StoreProduct, Void)?]() + var stubbedAsyncPurchase: StoreTransaction! + + func purchase(product: StoreProduct) async throws -> StoreTransaction { + invokedAsyncPurchase = true + invokedAsyncPurchaseCount += 1 + invokedAsyncPurchaseParameters = (product, ()) + invokedAsyncPurchaseParametersList.append((product, ())) + return stubbedAsyncPurchase + } + + var invokedAsyncRefreshReceipt = false + var invokedAsyncRefreshReceiptCounter = 0 + var stubbedRefreshReceiptAsyncResult: Result! + + func refreshReceipt() async throws -> String { + invokedAsyncRefreshReceipt = true + invokedAsyncRefreshReceiptCounter += 1 + + let result = stubbedRefreshReceiptAsyncResult + + switch result { + case let .success(receipt): + return receipt + case let .failure(error): + throw error + default: + fatalError("An unknown type") + } + } + + var invokedBeginRefundRequest = false + var invokedBeginRefundRequestCount = 0 + var invokedBeginRefundRequestParameters: (productID: String, Void)? + var invokedBeginRefundRequestParametersList = [(productID: String, Void)]() + var stubbedBeginRefundRequest: RefundRequestStatus! + func beginRefundRequest(productID: String) async throws -> RefundRequestStatus { + invokedBeginRefundRequest = true + invokedBeginRefundRequestCount += 1 + invokedBeginRefundRequestParameters = (productID, ()) + invokedBeginRefundRequestParametersList.append((productID, ())) + return stubbedBeginRefundRequest + } + + var invokedPurchaseWithOptions = false + var invokedPurchaseWithOptionsCount = 0 + var invokedPurchaseWithOptionsParameters: (product: StoreProduct, options: Any)? + var invokedPurchaseWithOptionsParametersList = [(product: StoreProduct, options: Any)]() + var stubbedPurchaseWithOptionsResult: Result? + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set, + completion: @escaping SendableClosure> + ) { + invokedPurchaseWithOptions = true + invokedPurchaseWithOptionsCount += 1 + invokedPurchaseWithOptionsParameters = (product, options) + invokedPurchaseWithOptionsParametersList.append((product, options)) + + if let result = stubbedPurchaseWithOptionsResult { + completion(result) + } + } + + var invokedAsyncPurchaseWithOptions = false + var invokedAsyncPurchaseWithOptionsCount = 0 + var invokedAsyncPurchaseWithOptionsParameters: (product: StoreProduct, options: Any)? + var invokedAsyncPurchaseWithOptionsParametersList = [(product: StoreProduct, options: Any)]() + var stubbedAsyncPurchaseWithOptions: StoreTransaction! + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set + ) async throws -> StoreTransaction { + invokedAsyncPurchaseWithOptions = true + invokedAsyncPurchaseWithOptionsCount += 1 + invokedAsyncPurchaseWithOptionsParameters = (product, options) + invokedAsyncPurchaseWithOptionsParametersList.append((product, options)) + return stubbedAsyncPurchaseWithOptions + } + + var invokedPurchaseWithPromotionalOffer = false + var invokedPurchaseWithPromotionalOfferCount = 0 + var invokedPurchaseWithPromotionalOfferParameters: (product: StoreProduct, promotionalOffer: PromotionalOffer?)? + var invokedPurchaseWithPromotionalOfferParametersList = [(product: StoreProduct, VpromotionalOffer: PromotionalOffer?)]() + var stubbedPurchaseWithPromotionalOffer: Result? + + func purchase( + product: StoreProduct, + promotionalOffer: PromotionalOffer?, + completion: @escaping Closure> + ) { + invokedPurchaseWithPromotionalOffer = true + invokedPurchaseWithPromotionalOfferCount += 1 + invokedPurchaseWithPromotionalOfferParameters = (product, promotionalOffer) + invokedPurchaseWithPromotionalOfferParametersList.append((product, promotionalOffer)) + + if let result = stubbedPurchaseWithPromotionalOffer { + completion(result) + } + } + + var invokedPurchaseAsyncWithPromotionalOffer = false + var invokedPurchaseAsyncWithPromotionalOfferCount = 0 + var invokedPurchaseAsyncWithPromotionalOfferParameters: (product: StoreProduct, promotionalOffer: PromotionalOffer?)? + var invokedPurchaseAsyncWithPromotionalOfferParametersList = [(product: StoreProduct, VpromotionalOffer: PromotionalOffer?)]() + var stubbedPurchaseAsyncWithPromotionalOffer: StoreTransaction! + + func purchase( + product: StoreProduct, + promotionalOffer: PromotionalOffer? + ) async throws -> StoreTransaction { + invokedPurchaseAsyncWithPromotionalOffer = true + invokedPurchaseAsyncWithPromotionalOfferCount += 1 + invokedPurchaseAsyncWithPromotionalOfferParameters = (product, promotionalOffer) + invokedPurchaseAsyncWithPromotionalOfferParametersList.append((product, promotionalOffer)) + return stubbedPurchaseAsyncWithPromotionalOffer + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product _: StoreProduct, + options _: Set, + promotionalOffer _: PromotionalOffer?, + completion _: @escaping SendableClosure> + ) {} + + var invokedPurchaseWithOptionsAndPromotionalOffer = false + var invokedPurchaseWithOptionsAndPromotionalOfferCount = 0 + var stubbedPurchaseWithOptionsAndPromotionalOffer: StoreTransaction! + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product _: StoreProduct, + options _: Set, + promotionalOffer _: PromotionalOffer? + ) async throws -> StoreTransaction { + invokedPurchaseWithOptionsAndPromotionalOffer = true + invokedPurchaseWithOptionsAndPromotionalOfferCount += 1 + return stubbedPurchaseWithOptionsAndPromotionalOffer + } + + var invokedCheckEligibility = false + var invokedCheckEligibilityCount = 0 + var invokedCheckEligibilityParameters: (productIDs: Set, Void)? + var invokedCheckEligibilityParametersList = [(productIDs: Set, Void)]() + var stubbedCheckEligibility: [String: SubscriptionEligibility] = [:] + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func checkEligibility(productIDs: Set) async throws -> [String: SubscriptionEligibility] { + invokedCheckEligibility = true + invokedCheckEligibilityCount += 1 + invokedCheckEligibilityParameters = (productIDs, ()) + invokedCheckEligibilityParametersList = [(productIDs, ())] + return stubbedCheckEligibility + } + + var invokedPresentCodeRedemptionSheet = false + var invokedPresentCodeRedemptionSheetCount = 0 + + @available(iOS 14.0, *) + func presentCodeRedemptionSheet() { + invokedPresentCodeRedemptionSheet = true + invokedPresentCodeRedemptionSheetCount += 1 + } + + var invokedPresentOfferCodeRedeemSheet = false + var invokedPresentOfferCodeRedeemSheetCount = 0 + + @available(iOS 16.0, *) + func presentOfferCodeRedeemSheet() async throws { + invokedPresentOfferCodeRedeemSheet = true + invokedPresentOfferCodeRedeemSheetCount += 1 + } +} diff --git a/Tests/FlareTests/Mocks/PaymentProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PaymentProviderMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/PaymentProviderMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/PaymentProviderMock.swift diff --git a/Tests/FlareTests/Mocks/PaymentQueueMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PaymentQueueMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/PaymentQueueMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/PaymentQueueMock.swift diff --git a/Tests/FlareTests/Mocks/PaymentTransactionMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PaymentTransactionMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/PaymentTransactionMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/PaymentTransactionMock.swift diff --git a/Tests/FlareTests/Mocks/ProductMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductMock.swift similarity index 83% rename from Tests/FlareTests/Mocks/ProductMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductMock.swift index e15937e91..9d765f02e 100644 --- a/Tests/FlareTests/Mocks/ProductMock.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductMock.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import StoreKit @@ -8,7 +8,7 @@ import StoreKit final class ProductMock: SKProduct { var invokedProductIdentifier = false var invokedProductIdentifierCount = 0 - var stubbedProductIdentifier: String = "" + var stubbedProductIdentifier: String = "product_id" override var productIdentifier: String { invokedProductIdentifier = true diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductProviderMock.swift new file mode 100644 index 000000000..489118ce7 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductProviderMock.swift @@ -0,0 +1,49 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import StoreKit + +final class ProductProviderMock: IProductProvider { + var invokedFetch = false + var invokedFetchCount = 0 + var invokedFetchParameters: (productIDs: Set, requestID: String, completion: ProductsHandler)? + var invokedFetchParamtersList = [(productIDs: Set, requestID: String, completion: ProductsHandler)]() + var stubbedFetchResult: Result<[StoreProduct], IAPError>? + + func fetch(productIDs: Set, requestID: String, completion: @escaping ProductsHandler) { + invokedFetch = true + invokedFetchCount += 1 + invokedFetchParameters = (productIDs, requestID, completion) + invokedFetchParamtersList.append((productIDs, requestID, completion)) + + if let result = stubbedFetchResult { + completion(result) + } + } + + var invokedAsyncFetch = false + var invokedAsyncFetchCount = 0 + var invokedAsyncFetchParameters: (productIDs: Set, Void)? + var invokedAsyncFetchParamtersList = [(productIDs: Set, Void)]() + var stubbedAsyncFetchResult: Result<[StoreProduct], Error>? + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func fetch(productIDs: Set) async throws -> [StoreProduct] { + invokedAsyncFetch = true + invokedAsyncFetchCount += 1 + invokedAsyncFetchParameters = (productIDs, ()) + invokedAsyncFetchParamtersList.append((productIDs, ())) + + switch stubbedAsyncFetchResult { + case let .success(products): + return products + case let .failure(error): + throw error + default: + return [] + } + } +} diff --git a/Tests/FlareTests/Mocks/ProductResponseMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductResponseMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/ProductResponseMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductResponseMock.swift diff --git a/Tests/FlareTests/Mocks/ProductsRequestMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductsRequestMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/ProductsRequestMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductsRequestMock.swift diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift new file mode 100644 index 000000000..7ccf9b076 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift @@ -0,0 +1,82 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import StoreKit + +final class PurchaseProviderMock: IPurchaseProvider { + var invokedFinish = false + var invokedFinishCount = 0 + var invokedFinishParameters: (transaction: StoreTransaction, Void)? + var invokedFinishParametersList = [(transaction: StoreTransaction, Void)]() + + func finish(transaction: StoreTransaction, completion _: (@Sendable () -> Void)?) { + invokedFinish = true + invokedFinishCount += 1 + invokedFinishParameters = (transaction, ()) + invokedFinishParametersList.append((transaction, ())) + } + + var invokedAddTransactionObserver = false + var invokedAddTransactionObserverCount = 0 + var invokedAddTransactionObserverParameters: (fallbackHandler: Closure>?, Void)? + var invokedAddTransactionObserverParametersList = [(fallbackHandler: Closure>?, Void)]() + + func addTransactionObserver(fallbackHandler: Closure>?) { + invokedAddTransactionObserver = true + invokedAddTransactionObserverCount += 1 + invokedAddTransactionObserverParameters = (fallbackHandler, ()) + invokedAddTransactionObserverParametersList.append((fallbackHandler, ())) + } + + var invokedRemoveTransactionObserver = false + var invokedRemoveTransactionObserverCount = 0 + + func removeTransactionObserver() { + invokedRemoveTransactionObserver = true + invokedRemoveTransactionObserverCount += 1 + } + + var invokedPurchase = false + var invokedPurchaseCount = 0 + var invokedPurchaseParameters: (product: StoreProduct, promotionalOffer: PromotionalOffer?)? + var invokedPurchaseParametersList = [(product: StoreProduct, promotionalOffer: PromotionalOffer?)]() + var stubbedPurchaseCompletionResult: (Result, Void)? + + @MainActor + func purchase(product: StoreProduct, promotionalOffer: PromotionalOffer?, completion: @escaping PurchaseCompletionHandler) { + invokedPurchase = true + invokedPurchaseCount += 1 + invokedPurchaseParameters = (product, promotionalOffer) + invokedPurchaseParametersList.append((product, promotionalOffer)) + if let result = stubbedPurchaseCompletionResult { + completion(result.0) + } + } + + var invokedPurchaseWithOptions = false + var invokedPurchaseWithOptionsCount = 0 + var invokedPurchaseWithOptionsParameters: (product: StoreProduct, Any, promotionalOffer: PromotionalOffer?)? + var invokedPurchaseWithOptionsParametersList = [(product: StoreProduct, Any, promotionalOffer: PromotionalOffer?)]() + var stubbedinvokedPurchaseWithOptionsCompletionResult: (Result, Void)? + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + @MainActor + func purchase( + product: StoreProduct, + options: Set, + promotionalOffer: PromotionalOffer?, + completion: @escaping PurchaseCompletionHandler + ) { + invokedPurchaseWithOptions = true + invokedPurchaseWithOptionsCount += 1 + invokedPurchaseWithOptionsParameters = (product, options, promotionalOffer) + invokedPurchaseWithOptionsParametersList.append((product, options, promotionalOffer)) + + if let result = stubbedinvokedPurchaseWithOptionsCompletionResult { + completion(result.0) + } + } +} diff --git a/Tests/FlareTests/Mocks/ReceiptRefreshProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ReceiptRefreshProviderMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/ReceiptRefreshProviderMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/ReceiptRefreshProviderMock.swift diff --git a/Tests/FlareTests/Mocks/ReceiptRefreshRequestFactory.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ReceiptRefreshRequestFactory.swift similarity index 100% rename from Tests/FlareTests/Mocks/ReceiptRefreshRequestFactory.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/ReceiptRefreshRequestFactory.swift diff --git a/Tests/FlareTests/Mocks/ReceiptRefreshRequestMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ReceiptRefreshRequestMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/ReceiptRefreshRequestMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/ReceiptRefreshRequestMock.swift diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/RedeemCodeProvider.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/RedeemCodeProvider.swift new file mode 100644 index 000000000..0f3f03a5f --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/RedeemCodeProvider.swift @@ -0,0 +1,17 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import Foundation + +final class RedeemCodeProviderMock: IRedeemCodeProvider { + var invokedPresentOfferCodeRedeemSheet = false + var invokedPresentOfferCodeRedeemSheetCount = 0 + + func presentOfferCodeRedeemSheet() async { + invokedPresentOfferCodeRedeemSheet = true + invokedPresentOfferCodeRedeemSheetCount += 1 + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/RefundProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/RefundProviderMock.swift new file mode 100644 index 000000000..c6a1872ac --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/RefundProviderMock.swift @@ -0,0 +1,22 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare + +final class RefundProviderMock: IRefundProvider { + var invokedBeginRefundRequest = false + var invokedBeginRefundRequestCount = 0 + var invokedBeginRefundRequestParameters: (productID: String, Void)? + var invokedBeginRefundRequestParametersList = [(productID: String, Void)]() + var stubbedBeginRefundRequest: RefundRequestStatus! + + func beginRefundRequest(productID: String) async throws -> RefundRequestStatus { + invokedBeginRefundRequest = true + invokedBeginRefundRequestCount += 1 + invokedBeginRefundRequestParameters = (productID, ()) + invokedBeginRefundRequestParametersList.append((productID, ())) + return stubbedBeginRefundRequest + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/RefundRequestProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/RefundRequestProviderMock.swift new file mode 100644 index 000000000..7639d7aa0 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/RefundRequestProviderMock.swift @@ -0,0 +1,67 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +#if os(iOS) || VISION_OS + @testable import Flare + import StoreKit + import UIKit + + @available(iOS 15.0, macCatalyst 15.0, *) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + final class RefundRequestProviderMock: IRefundRequestProvider { + var invokedBeginRefundRequest = false + var invokedBeginRefundRequestCount = 0 + var invokedBeginRefundRequestParameters: (transactionID: UInt64, windowScene: UIWindowScene)? + var invokedBeginRefundRequestParametersList = [(transactionID: UInt64, windowScene: UIWindowScene)]() + var stubbedBeginRefundRequest: Result! + + func beginRefundRequest( + transactionID: UInt64, + windowScene: UIWindowScene + ) async throws -> Result { + invokedBeginRefundRequest = true + invokedBeginRefundRequestCount += 1 + invokedBeginRefundRequestParameters = (transactionID, windowScene) + invokedBeginRefundRequestParametersList.append((transactionID, windowScene)) + + switch stubbedBeginRefundRequest { + case let .success(status): + return .success(mapToSkStatus(status)) + case let .failure(error): + return .failure(error) + case .none: + fatalError() + } + } + + var invokedVerifyTransaction = false + var invokedVerifyTransactionCount = 0 + var invokedVerifyTransactionParameters: (productID: String, Void)? + var invokedVerifyTransactionParametersList = [(productID: String, Void)]() + var stubbedVerifyTransaction: UInt64! + + func verifyTransaction(productID: String) async throws -> UInt64 { + invokedVerifyTransaction = true + invokedVerifyTransactionCount += 1 + invokedVerifyTransactionParameters = (productID, ()) + invokedVerifyTransactionParametersList.append((productID, ())) + return stubbedVerifyTransaction + } + + // MARK: Private + + private func mapToSkStatus(_ status: RefundRequestStatus) -> StoreKit.Transaction.RefundRequestStatus { + switch status { + case .success: + return .success + case .userCancelled: + return .userCancelled + default: + fatalError() + } + } + } +#endif diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ScenesHolderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ScenesHolderMock.swift new file mode 100644 index 000000000..2330df924 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ScenesHolderMock.swift @@ -0,0 +1,25 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +#if canImport(UIKit) + import UIKit +#endif + +// MARK: - ScenesHolderMock + +final class ScenesHolderMock: IScenesHolder { + #if os(iOS) || VISION_OS + var invokedConnectedScenesGetter = false + var invokedConnectedScenesGetterCount = 0 + var stubbedConnectedScenes: Set! = [] + + var connectedScenes: Set { + invokedConnectedScenesGetter = true + invokedConnectedScenesGetterCount += 1 + return stubbedConnectedScenes + } + #endif +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/StoreTransactionMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/StoreTransactionMock.swift new file mode 100644 index 000000000..225e403ef --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/StoreTransactionMock.swift @@ -0,0 +1,89 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +import Foundation + +final class StoreTransactionMock: IStoreTransaction { + var invokedProductIdentifierGetter = false + var invokedProductIdentifierGetterCount = 0 + var stubbedProductIdentifier: String! = "" + + var productIdentifier: String { + invokedProductIdentifierGetter = true + invokedProductIdentifierGetterCount += 1 + return stubbedProductIdentifier + } + + var invokedPurchaseDateGetter = false + var invokedPurchaseDateGetterCount = 0 + var stubbedPurchaseDate: Date! + + var purchaseDate: Date { + invokedPurchaseDateGetter = true + invokedPurchaseDateGetterCount += 1 + return stubbedPurchaseDate + } + + var invokedHasKnownPurchaseDateGetter = false + var invokedHasKnownPurchaseDateGetterCount = 0 + var stubbedHasKnownPurchaseDate: Bool! = false + + var hasKnownPurchaseDate: Bool { + invokedHasKnownPurchaseDateGetter = true + invokedHasKnownPurchaseDateGetterCount += 1 + return stubbedHasKnownPurchaseDate + } + + var invokedTransactionIdentifierGetter = false + var invokedTransactionIdentifierGetterCount = 0 + var stubbedTransactionIdentifier: String! = "" + + var transactionIdentifier: String { + invokedTransactionIdentifierGetter = true + invokedTransactionIdentifierGetterCount += 1 + return stubbedTransactionIdentifier + } + + var invokedHasKnownTransactionIdentifierGetter = false + var invokedHasKnownTransactionIdentifierGetterCount = 0 + var stubbedHasKnownTransactionIdentifier: Bool! = false + + var hasKnownTransactionIdentifier: Bool { + invokedHasKnownTransactionIdentifierGetter = true + invokedHasKnownTransactionIdentifierGetterCount += 1 + return stubbedHasKnownTransactionIdentifier + } + + var invokedQuantityGetter = false + var invokedQuantityGetterCount = 0 + var stubbedQuantity: Int! = 0 + + var quantity: Int { + invokedQuantityGetter = true + invokedQuantityGetterCount += 1 + return stubbedQuantity + } + + var invokedJwsRepresentationGetter = false + var invokedJwsRepresentationGetterCount = 0 + var stubbedJwsRepresentation: String! + + var jwsRepresentation: String? { + invokedJwsRepresentationGetter = true + invokedJwsRepresentationGetterCount += 1 + return stubbedJwsRepresentation + } + + var invokedEnvironmentGetter = false + var invokedEnvironmentGetterCount = 0 + var stubbedEnvironment: StoreEnvironment! + + var environment: StoreEnvironment? { + invokedEnvironmentGetter = true + invokedEnvironmentGetterCount += 1 + return stubbedEnvironment + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/SystemInfoProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/SystemInfoProviderMock.swift new file mode 100644 index 000000000..6f7268a55 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/SystemInfoProviderMock.swift @@ -0,0 +1,34 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +#if canImport(UIKit) + import UIKit +#endif + +// MARK: - SystemInfoProviderMock + +final class SystemInfoProviderMock: ISystemInfoProvider { + #if os(iOS) || VISION_OS + var invokedCurrentSceneGetter = false + var invokedCurrentSceneGetterCount = 0 + var stubbedCurrentScene: Result! + + var currentScene: UIWindowScene { + get throws { + invokedCurrentSceneGetter = true + invokedCurrentSceneGetterCount += 1 + switch stubbedCurrentScene { + case let .success(scene): + return scene + case let .failure(error): + throw error + default: + fatalError() + } + } + } + #endif +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/UserDefaultsMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/UserDefaultsMock.swift new file mode 100644 index 000000000..f1a341fec --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/UserDefaultsMock.swift @@ -0,0 +1,35 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import Foundation + +final class UserDefaultsMock: IUserDefaults { + var invokedSet = false + var invokedSetCount = 0 + var invokedSetParameters: (key: String, codable: Any)? + var invokedSetParametersList = [(key: String, codable: Any)]() + + func set(key: String, codable: T) { + invokedSet = true + invokedSetCount += 1 + invokedSetParameters = (key, codable) + invokedSetParametersList.append((key, codable)) + } + + var invokedGet = false + var invokedGetCount = 0 + var invokedGetParameters: (key: String, Void)? + var invokedGetParametersList = [(key: String, Void)]() + var stubbedGetResult: Any! + + func get(key: String) -> T? { + invokedGet = true + invokedGetCount += 1 + invokedGetParameters = (key, ()) + invokedGetParametersList.append((key, ())) + return stubbedGetResult as? T + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Stubs/StoreTransactionStub.swift b/Tests/FlareTests/UnitTests/TestHelpers/Stubs/StoreTransactionStub.swift new file mode 100644 index 000000000..7b8a0c7cb --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Stubs/StoreTransactionStub.swift @@ -0,0 +1,57 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +import Foundation + +final class StoreTransactionStub: IStoreTransaction { + var stubbedProductIdentifier: String! = UUID().uuidString + + var productIdentifier: String { + stubbedProductIdentifier + } + + var stubbedPurchaseDate: Date! + + var purchaseDate: Date { + stubbedPurchaseDate + } + + var stubbedHasKnownPurchaseDate: Bool! = false + + var hasKnownPurchaseDate: Bool { + stubbedHasKnownPurchaseDate + } + + var stubbedTransactionIdentifier: String! = "" + + var transactionIdentifier: String { + stubbedTransactionIdentifier + } + + var stubbedHasKnownTransactionIdentifier: Bool! = false + + var hasKnownTransactionIdentifier: Bool { + stubbedHasKnownTransactionIdentifier + } + + var stubbedQuantity: Int! = 0 + + var quantity: Int { + stubbedQuantity + } + + var stubbedJwsRepresentation: String! + + var jwsRepresentation: String? { + stubbedJwsRepresentation + } + + var stubbedEnvironment: StoreEnvironment! + + var environment: StoreEnvironment? { + stubbedEnvironment + } +} diff --git a/Tests/IntegrationTests/Flare.storekit b/Tests/IntegrationTests/Flare.storekit new file mode 100644 index 000000000..23d0dd415 --- /dev/null +++ b/Tests/IntegrationTests/Flare.storekit @@ -0,0 +1,242 @@ +{ + "identifier" : "95D98A48", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "0.99", + "familyShareable" : false, + "internalID" : "1CBF43E6", + "localizations" : [ + { + "description" : "com.flare.test_non_consumable_purchase_1", + "displayName" : "com.flare.test_non_consumable_", + "locale" : "en_US" + } + ], + "productID" : "com.flare.test_non_consumable_purchase_1", + "referenceName" : null, + "type" : "NonConsumable" + } + ], + "settings" : { + "_failTransactionsEnabled" : false, + "_locale" : "en_US", + "_storefront" : "USA", + "_storeKitErrors" : [ + { + "current" : null, + "enabled" : false, + "name" : "Load Products" + }, + { + "current" : null, + "enabled" : false, + "name" : "Purchase" + }, + { + "current" : null, + "enabled" : false, + "name" : "Verification" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Store Sync" + }, + { + "current" : null, + "enabled" : false, + "name" : "Subscription Status" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Transaction" + }, + { + "current" : null, + "enabled" : false, + "name" : "Manage Subscriptions Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Refund Request Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Offer Code Redeem Sheet" + } + ] + }, + "subscriptionGroups" : [ + { + "id" : "C3C61FEC", + "localizations" : [ + + ], + "name" : "subscription_group", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "1.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "138CEE33", + "introductoryOffer" : { + "displayPrice" : "0.99", + "internalID" : "970CA16D", + "numberOfPeriods" : 1, + "paymentMode" : "payAsYouGo", + "subscriptionPeriod" : "P1M" + }, + "localizations" : [ + { + "description" : "", + "displayName" : "", + "locale" : "en_US" + } + ], + "productID" : "com.flare.monthly_1.99_week_intro", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Subscription with Introductory Offer", + "subscriptionGroupID" : "C3C61FEC", + "type" : "RecurringSubscription" + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "0.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "9CB5F7A9", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "", + "displayName" : "", + "locale" : "en_US" + } + ], + "productID" : "com.flare.monthly_0.99", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Subscription Without Offers", + "subscriptionGroupID" : "C3C61FEC", + "type" : "RecurringSubscription" + }, + { + "adHocOffers" : [ + { + "internalID" : "479390B8", + "offerID" : "subscription_3_offer", + "paymentMode" : "free", + "referenceName" : "subscription_3_offer", + "subscriptionPeriod" : "P2W" + } + ], + "codeOffers" : [ + + ], + "displayPrice" : "0.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "8D29D6BD", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "", + "displayName" : "", + "locale" : "en_US" + } + ], + "productID" : "com.flare.monthly_1.99_two_weeks_offer.free", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Subscription with Promotional Offer", + "subscriptionGroupID" : "C3C61FEC", + "type" : "RecurringSubscription" + }, + { + "adHocOffers" : [ + { + "displayPrice" : "0.99", + "internalID" : "A05B39A3", + "numberOfPeriods" : 1, + "offerID" : "com.flare.monthly_0.99.1_week_intro", + "paymentMode" : "payAsYouGo", + "referenceName" : "com.flare.monthly_0.99.1_week_intro", + "subscriptionPeriod" : "P1M" + }, + { + "displayPrice" : "1.99", + "internalID" : "79BD229A", + "offerID" : "com.flare.monthly_0.99.1_week_intro", + "paymentMode" : "payUpFront", + "referenceName" : "com.flare.monthly_0.99.1_week_intro", + "subscriptionPeriod" : "P1M" + }, + { + "internalID" : "C181C3BF", + "offerID" : "com.flare.monthly_0.99.1_week_intro", + "paymentMode" : "free", + "referenceName" : "com.flare.monthly_0.99.1_week_intro", + "subscriptionPeriod" : "P1W" + } + ], + "codeOffers" : [ + { + "displayPrice" : "0.99", + "eligibility" : [ + "existing", + "expired", + "new" + ], + "internalID" : "A9D00827", + "isStackable" : true, + "paymentMode" : "payUpFront", + "referenceName" : "offer", + "subscriptionPeriod" : "P1M" + } + ], + "displayPrice" : "0.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "7867CD16", + "introductoryOffer" : { + "displayPrice" : "0.99", + "internalID" : "0A94C45A", + "paymentMode" : "payUpFront", + "subscriptionPeriod" : "P1M" + }, + "localizations" : [ + { + "description" : "Subscription", + "displayName" : "Subscription", + "locale" : "en_US" + } + ], + "productID" : "com.flare.monthly_0.99.1_week_intro", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Subscription Full", + "subscriptionGroupID" : "C3C61FEC", + "type" : "RecurringSubscription" + } + ] + } + ], + "version" : { + "major" : 3, + "minor" : 0 + } +} diff --git a/Tests/IntegrationTests/Helpers/Extensions/AsyncSequence+.swift b/Tests/IntegrationTests/Helpers/Extensions/AsyncSequence+.swift new file mode 100644 index 000000000..b16130182 --- /dev/null +++ b/Tests/IntegrationTests/Helpers/Extensions/AsyncSequence+.swift @@ -0,0 +1,16 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) +extension AsyncSequence { + /// Returns the elements of the asynchronous sequence. + func extractValues() async rethrows -> [Element] { + try await reduce(into: []) { + $0.append($1) + } + } +} diff --git a/Tests/IntegrationTests/Helpers/Extensions/Result+.swift b/Tests/IntegrationTests/Helpers/Extensions/Result+.swift new file mode 100644 index 000000000..9c779804a --- /dev/null +++ b/Tests/IntegrationTests/Helpers/Extensions/Result+.swift @@ -0,0 +1,26 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +extension Result { + var error: Failure? { + switch self { + case let .failure(error): + return error + default: + return nil + } + } + + var success: Success? { + switch self { + case let .success(value): + return value + default: + return nil + } + } +} diff --git a/Tests/IntegrationTests/Helpers/Extensions/XCTestCase+.swift b/Tests/IntegrationTests/Helpers/Extensions/XCTestCase+.swift new file mode 100644 index 000000000..8d55a0f50 --- /dev/null +++ b/Tests/IntegrationTests/Helpers/Extensions/XCTestCase+.swift @@ -0,0 +1,35 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import XCTest + +extension XCTestCase { + func value(for closure: () async throws -> U) async -> U? { + do { + let value = try await closure() + return value + } catch { + return nil + } + } + + func error(for closure: () async throws -> U) async -> T? { + do { + _ = try await closure() + return nil + } catch { + return error as? T + } + } + + func result(for closure: () async throws -> U) async -> Result { + do { + let value = try await closure() + return .success(value) + } catch { + return .failure(error as! T) + } + } +} diff --git a/Tests/IntegrationTests/Helpers/Providers/ProductProviderHelper.swift b/Tests/IntegrationTests/Helpers/Providers/ProductProviderHelper.swift new file mode 100644 index 000000000..4e35afd6d --- /dev/null +++ b/Tests/IntegrationTests/Helpers/Providers/ProductProviderHelper.swift @@ -0,0 +1,56 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import StoreKit + +// MARK: - ProductProviderHelper + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +enum ProductProviderHelper { + static var purchases: [StoreKit.Product] { + get async throws { + try await StoreKit.Product.products(for: [.testNonConsumableID]) + } + } + + static var subscriptions: [StoreKit.Product] { + get async throws { + try await subscriptionsWithIntroductoryOffer + subscriptionsWithoutOffers + subscriptonsWithOffers + } + } + + static var subscriptionsWithIntroductoryOffer: [StoreKit.Product] { + get async throws { + try await StoreKit.Product.products(for: [.subscription1ID]) + } + } + + static var subscriptionsWithoutOffers: [StoreKit.Product] { + get async throws { + try await StoreKit.Product.products(for: [.subscription2ID]) + } + } + + static var subscriptonsWithOffers: [StoreKit.Product] { + get async throws { + try await StoreKit.Product.products(for: [.subscription3ID]) + } + } +} + +// MARK: - Constants + +private extension String { + static let testNonConsumableID = "com.flare.test_non_consumable_purchase_1" + + /// The subscription's id with introductory offer + static let subscription1ID = "com.flare.monthly_1.99_week_intro" + + /// The subscription's id without introductory offer + static let subscription2ID = "com.flare.monthly_0.99" + + /// The subscription's id with promotional offer + static let subscription3ID = "com.flare.monthly_1.99_two_weeks_offer.free" +} diff --git a/Tests/IntegrationTests/Helpers/StoreSessionTestCase/StoreSessionTestCase.swift b/Tests/IntegrationTests/Helpers/StoreSessionTestCase/StoreSessionTestCase.swift new file mode 100644 index 000000000..d0923294a --- /dev/null +++ b/Tests/IntegrationTests/Helpers/StoreSessionTestCase/StoreSessionTestCase.swift @@ -0,0 +1,87 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import StoreKitTest +import XCTest + +// MARK: - StoreSessionTestCase + +@available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) +class StoreSessionTestCase: XCTestCase { + // MARK: Properties + + var session: SKTestSession? + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + + session = try? SKTestSession(configurationFileNamed: "Flare") + session?.resetToDefaultState() + session?.askToBuyEnabled = false + session?.disableDialogs = true + } + + override func tearDown() { + session?.clearTransactions() + session = nil + super.tearDown() + } +} + +@available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) +extension StoreSessionTestCase { + func expireSubscription(product: StoreProduct) { + do { + try session?.expireSubscription(productIdentifier: product.productIdentifier) + } catch { + debugPrint(error.localizedDescription) + } + } + + @available(iOS 15.2, tvOS 15.2, macOS 12.1, watchOS 8.3, *) + func findTransaction(for productID: String) async throws -> Transaction { + let transactions: [Transaction] = await Transaction.currentEntitlements + .compactMap { result in + switch result { + case let .verified(transaction): + return transaction + case .unverified: + return nil + } + } + .filter { (transaction: Transaction) in + transaction.productID == productID + } + .extractValues() + + return try XCTUnwrap(transactions.first) + } + + @available(iOS 15.2, tvOS 15.2, macOS 12.1, watchOS 8.3, *) + func latestTransaction(for productID: String) async throws -> Transaction { + let result: Transaction? = await Transaction.latest(for: productID) + .flatMap { result -> Transaction? in + switch result { + case let .verified(transaction): + return transaction + case .unverified: + return nil + } + } + + return try XCTUnwrap(result) + } + + func clearTransactions() { + session?.clearTransactions() + } + + func forceRenewalOfSubscription(for productIdentifier: String) throws { + try session?.forceRenewalOfSubscription(productIdentifier: productIdentifier) + } +} diff --git a/Tests/IntegrationTests/Tests/EligibilityProviderTests.swift b/Tests/IntegrationTests/Tests/EligibilityProviderTests.swift new file mode 100644 index 000000000..ec7e48152 --- /dev/null +++ b/Tests/IntegrationTests/Tests/EligibilityProviderTests.swift @@ -0,0 +1,50 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import XCTest + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +final class EligibilityProviderTests: StoreSessionTestCase { + // MARK: Properties + + private var sut: EligibilityProvider! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + sut = EligibilityProvider() + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatProviderReturnsNoOffer_whenProductDoesNotHaveIntroductoryOffer() async throws { + // given + let product = try await ProductProviderHelper.subscriptionsWithoutOffers.randomElement()! + + // when + let result = try await sut.checkEligibility(products: [StoreProduct(product: product)]) + + // then + XCTAssertEqual(result[product.id], .noOffer) + } + + func test_thatProviderReturnsEligible_whenProductHasIntroductoryOffer() async throws { + // given + let product = try await ProductProviderHelper.subscriptionsWithIntroductoryOffer.randomElement()! + + // when + let result = try await sut.checkEligibility(products: [StoreProduct(product: product)]) + + // then + XCTAssertEqual(result[product.id], .eligible) + } +} diff --git a/Tests/IntegrationTests/Tests/FlareTests.swift b/Tests/IntegrationTests/Tests/FlareTests.swift new file mode 100644 index 000000000..1fc5b9b87 --- /dev/null +++ b/Tests/IntegrationTests/Tests/FlareTests.swift @@ -0,0 +1,206 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import StoreKit +import XCTest + +// MARK: - FlareTests + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +final class FlareTests: StoreSessionTestCase { + // MARK: - Properties + + private var sut: Flare! + + // MARK: - XCTestCase + + override func setUp() { + super.setUp() + sut = Flare() + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatFlarePurchasesAProductWithCompletion_whenPurchaseCompleted() async throws { + try await test_purchaseWithOptions( + options: [], + expectedResult: .success(()) + ) + } + + func test_thatFlarePurchasesAProductWithCompletion_whenUnkownErrorOccurred() async throws { + // given + session?.failTransactionsEnabled = true + session?.failureError = .unknown + + // when + try await test_purchaseWithOptions( + options: [], + expectedResult: .failure(.unknown) + ) + } + + func test_thatFlarePurchasesAProductWithOptions_whenPurchaseCompleted() async throws { + try await test_purchaseWithOptionsAndCompletion( + expectedResult: .success(()) + ) + } + + func test_thatFlarePurchaseThrowsAnError_whenUnkownErrorOccurred() async throws { + // given + session?.failTransactionsEnabled = true + session?.failureError = .unknown + + // when + try await test_purchaseWithOptionsAndCompletion( + expectedResult: .failure(IAPError.unknown) + ) + } + + func test_thatFlarePurchasesAsyncAProductWithOptionsAndCompletionHandler_whenPurchaseCompleted() async throws { + try await test_purchaseWithOptions( + expectedResult: .success(()) + ) + } + + func test_thatFlarePurchaseAsyncThrowsAnError_whenUnkownErrorOccurred() async throws { + // given + session?.failTransactionsEnabled = true + session?.failureError = .unknown + + // when + try await test_purchaseWithOptions( + expectedResult: .failure(IAPError.unknown) + ) + } + + @available(iOS 15.2, tvOS 15.2, macOS 12.1, watchOS 8.3, *) + func test_thatPurchaseIntorudctoryOffer() async throws { + // 1. Fetch a product + let product = try await ProductProviderHelper.subscriptionsWithIntroductoryOffer.randomElement()! + let storeProduct = StoreProduct(product: product) + + // 2. Checking eligibility for a product + var eligibleResult = try await Flare.shared.checkEligibility(productIDs: [product.id])[product.id] + XCTAssertEqual(eligibleResult, .eligible) + + // 3. Purchase the product + let purchaseTransaction = try await sut.purchase(product: storeProduct) + + // 5. Retrieve a transaction + var transaction = try await findTransaction(for: product.id) + + // 6. Checking transaction + XCTAssertEqual(transaction.productID, product.id) + XCTAssertEqual(transaction.offerType, .introductory) + + // 7. Finish the transaction + let expectation = XCTestExpectation(description: "Finishing the transaction") + sut.finish(transaction: purchaseTransaction) { expectation.fulfill() } + + #if swift(>=5.9) + await fulfillment(of: [expectation]) + #else + wait(for: [expectation], timeout: .second) + #endif + + // 8. Checking eligibility for the purchased product + eligibleResult = try await Flare.shared.checkEligibility(productIDs: [product.id])[product.id] + XCTAssertEqual(eligibleResult, .nonEligible) + + // 9. Expire subscription + expireSubscription(product: storeProduct) + + // 10. Purchase the same product again + _ = try await sut.purchase(product: storeProduct) + + // 11. Retrieve a transaction + transaction = try await latestTransaction(for: product.id) + + // 12. Checking the transaction + XCTAssertEqual(transaction.productID, product.id) + XCTAssertEqual(transaction.offerType, nil) + } + + // MARK: Private + + private func test_purchaseWithOptionsAndCompletion( + expectedResult: Result + ) async throws { + // given + let product = try await ProductProviderHelper.purchases.randomElement() + + // when + let result: Result = await result(for: { + try await sut.purchase( + product: StoreProduct(product: product!), + options: [.simulatesAskToBuyInSandbox(false)] + ) + }) + + // then + switch expectedResult { + case .success: + XCTAssertEqual(result.success?.productIdentifier, product?.id) + case let .failure(error): + XCTAssertEqual(error, result.error) + } + } + + private func test_purchaseWithOptions( + options: Set = [.simulatesAskToBuyInSandbox(true)], + expectedResult: Result + ) async throws { + // given + let expectation = XCTestExpectation(description: "Purchase a product") + + let product = try await ProductProviderHelper.purchases.randomElement() + + // when + let handler: Closure> = { result in + switch expectedResult { + case .success: + XCTAssertEqual(result.success?.productIdentifier, product?.id) + case let .failure(error): + XCTAssertEqual(error, result.error) + } + expectation.fulfill() + } + + if options.isEmpty { + sut.purchase(product: StoreProduct(product: product!)) { handler($0) } + } else { + sut.purchase( + product: StoreProduct(product: product!), + options: options + ) { [handler] result in + Task { handler(result) } + } + } + + // then + #if swift(>=5.9) + await fulfillment(of: [expectation]) + #else + wait(for: [expectation], timeout: .second) + #endif + } +} + +// MARK: - Constants + +private extension TimeInterval { + static let second: CGFloat = 1.0 +} + +private extension String { + static let productID = "com.flare.test_purchase_2" +} diff --git a/Tests/IntegrationTests/Tests/StoreProductTests.swift b/Tests/IntegrationTests/Tests/StoreProductTests.swift new file mode 100644 index 000000000..91432b7e2 --- /dev/null +++ b/Tests/IntegrationTests/Tests/StoreProductTests.swift @@ -0,0 +1,173 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import StoreKit +import XCTest + +// MARK: - StoreProductTests + +@available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) +final class StoreProductTests: StoreSessionTestCase { + // MARK: Private + + private var provider: IProductProvider! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + provider = ProductProvider() + } + + override func tearDown() { + provider = nil + super.tearDown() + } + + // MARK: - Tests + + func test_sk1ProductWrapsCorrectly() async throws { + // given + let expectation = XCTestExpectation(description: "Purchase a product") + + // when + var products: [StoreProduct] = [] + provider.fetch(productIDs: [String.productID], requestID: UUID().uuidString) { result in + switch result { + case let .success(skProducts): + products = skProducts.map { StoreProduct($0) } + case .failure: + break + } + expectation.fulfill() + } + + #if swift(>=5.9) + await fulfillment(of: [expectation]) + #else + wait(for: [expectation], timeout: .seconds) + #endif + + // then + let storeProduct = try XCTUnwrap(products.first) + + // then + XCTAssertEqual(storeProduct.productIdentifier, .productID) + XCTAssertEqual(storeProduct.productCategory, .subscription) + XCTAssertEqual(storeProduct.productType, nil) + XCTAssertEqual(storeProduct.localizedDescription, "Subscription") + XCTAssertEqual(storeProduct.localizedTitle, "Subscription") + XCTAssertEqual(storeProduct.currencyCode, "USD") + XCTAssertEqual(storeProduct.price.description, "0.99") + XCTAssertEqual(storeProduct.localizedPriceString, "$0.99") + XCTAssertEqual(storeProduct.subscriptionGroupIdentifier, "C3C61FEC") + + XCTAssertEqual(storeProduct.subscriptionPeriod?.unit, .month) + XCTAssertEqual(storeProduct.subscriptionPeriod?.value, 1) + + let intro = try XCTUnwrap(storeProduct.introductoryDiscount) + + XCTAssertEqual(intro.price, 0.99) + XCTAssertEqual(intro.paymentMode, .payUpFront) + XCTAssertEqual(intro.type, .introductory) + XCTAssertEqual(intro.offerIdentifier, nil) + XCTAssertEqual(intro.subscriptionPeriod, SubscriptionPeriod(value: 1, unit: .month)) + + let offers = try XCTUnwrap(storeProduct.discounts) + XCTAssertEqual(offers.count, 3) + + XCTAssertEqual(offers[0].price, 0.99) + XCTAssertEqual(offers[0].paymentMode, .payAsYouGo) + XCTAssertEqual(offers[0].type, .promotional) + XCTAssertEqual(offers[0].offerIdentifier, "com.flare.monthly_0.99.1_week_intro") + XCTAssertEqual(offers[0].subscriptionPeriod, SubscriptionPeriod(value: 1, unit: .month)) + XCTAssertEqual(offers[0].numberOfPeriods, 1) + XCTAssertEqual(offers[0].currencyCode, "USD") + + XCTAssertEqual(offers[1].price, 1.99) + XCTAssertEqual(offers[1].paymentMode, .payUpFront) + XCTAssertEqual(offers[1].type, .promotional) + XCTAssertEqual(offers[1].offerIdentifier, "com.flare.monthly_0.99.1_week_intro") + XCTAssertEqual(offers[1].subscriptionPeriod, SubscriptionPeriod(value: 1, unit: .month)) + XCTAssertEqual(offers[1].numberOfPeriods, 1) + XCTAssertEqual(offers[1].currencyCode, "USD") + + XCTAssertEqual(offers[2].price, 0) + XCTAssertEqual(offers[2].paymentMode, .freeTrial) + XCTAssertEqual(offers[2].type, .promotional) + XCTAssertEqual(offers[2].offerIdentifier, "com.flare.monthly_0.99.1_week_intro") + XCTAssertEqual(offers[2].subscriptionPeriod, SubscriptionPeriod(value: 1, unit: .week)) + XCTAssertEqual(offers[2].numberOfPeriods, 1) + XCTAssertEqual(offers[2].currencyCode, "USD") + } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func test_sk2ProductWrapsCorrectly() async throws { + // given + let products = try await StoreKit.Product.products(for: [String.productID]) + let product = try XCTUnwrap(products.first) + let storeProduct = StoreProduct(product: product) + + // then + XCTAssertEqual(storeProduct.productIdentifier, .productID) + XCTAssertEqual(storeProduct.productCategory, .subscription) + XCTAssertEqual(storeProduct.productType, .autoRenewableSubscription) + XCTAssertEqual(storeProduct.localizedDescription, "Subscription") + XCTAssertEqual(storeProduct.localizedTitle, "Subscription") + XCTAssertEqual(storeProduct.currencyCode, "USD") + XCTAssertEqual(storeProduct.price.description, "0.99") + XCTAssertEqual(storeProduct.localizedPriceString, "$0.99") + XCTAssertEqual(storeProduct.subscriptionGroupIdentifier, "C3C61FEC") + + XCTAssertEqual(storeProduct.subscriptionPeriod?.unit, .month) + XCTAssertEqual(storeProduct.subscriptionPeriod?.value, 1) + + let intro = try XCTUnwrap(storeProduct.introductoryDiscount) + + XCTAssertEqual(intro.price, 0.99) + XCTAssertEqual(intro.paymentMode, .payUpFront) + XCTAssertEqual(intro.type, .introductory) + XCTAssertEqual(intro.offerIdentifier, nil) + XCTAssertEqual(intro.subscriptionPeriod, SubscriptionPeriod(value: 1, unit: .month)) + + let offers = try XCTUnwrap(storeProduct.discounts) + XCTAssertEqual(offers.count, 3) + + XCTAssertEqual(offers[0].price, 0.99) + XCTAssertEqual(offers[0].paymentMode, .payAsYouGo) + XCTAssertEqual(offers[0].type, .promotional) + XCTAssertEqual(offers[0].offerIdentifier, "com.flare.monthly_0.99.1_week_intro") + XCTAssertEqual(offers[0].subscriptionPeriod, SubscriptionPeriod(value: 1, unit: .month)) + XCTAssertEqual(offers[0].numberOfPeriods, 1) + XCTAssertEqual(offers[0].currencyCode, "USD") + + XCTAssertEqual(offers[1].price, 1.99) + XCTAssertEqual(offers[1].paymentMode, .payUpFront) + XCTAssertEqual(offers[1].type, .promotional) + XCTAssertEqual(offers[1].offerIdentifier, "com.flare.monthly_0.99.1_week_intro") + XCTAssertEqual(offers[1].subscriptionPeriod, SubscriptionPeriod(value: 1, unit: .month)) + XCTAssertEqual(offers[1].numberOfPeriods, 1) + XCTAssertEqual(offers[1].currencyCode, "USD") + + XCTAssertEqual(offers[2].price, 0) + XCTAssertEqual(offers[2].paymentMode, .freeTrial) + XCTAssertEqual(offers[2].type, .promotional) + XCTAssertEqual(offers[2].offerIdentifier, "com.flare.monthly_0.99.1_week_intro") + XCTAssertEqual(offers[2].subscriptionPeriod, SubscriptionPeriod(value: 1, unit: .week)) + XCTAssertEqual(offers[2].numberOfPeriods, 1) + XCTAssertEqual(offers[2].currencyCode, "USD") + } +} + +// MARK: - Constants + +private extension String { + static let productID = "com.flare.monthly_0.99.1_week_intro" +} + +private extension TimeInterval { + static let seconds: CGFloat = 60.0 +} diff --git a/Tests/TestPlans/AllTests.xctestplan b/Tests/TestPlans/AllTests.xctestplan new file mode 100644 index 000000000..259da0494 --- /dev/null +++ b/Tests/TestPlans/AllTests.xctestplan @@ -0,0 +1,44 @@ +{ + "configurations" : [ + { + "id" : "1CA61D79-6551-4DA7-8DEF-CC28563C2658", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : { + "targets" : [ + { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "AAEFF2D6694AA197C07481DA", + "name" : "Flare" + } + ] + }, + "targetForVariableExpansion" : { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "AAEFF2D6694AA197C07481DA", + "name" : "Flare" + } + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "2053CB2B5F4780EC86D0DE04", + "name" : "FlareTests" + } + }, + { + "target" : { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "5A649E8F4319C5B59E9588FD", + "name" : "IntegrationTests" + } + } + ], + "version" : 1 +} diff --git a/Tests/TestPlans/IntegrationTests.xctestplan b/Tests/TestPlans/IntegrationTests.xctestplan new file mode 100644 index 000000000..57f3054e2 --- /dev/null +++ b/Tests/TestPlans/IntegrationTests.xctestplan @@ -0,0 +1,37 @@ +{ + "configurations" : [ + { + "id" : "1CA61D79-6551-4DA7-8DEF-CC28563C2658", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : { + "targets" : [ + { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "AAEFF2D6694AA197C07481DA", + "name" : "Flare" + } + ] + }, + "targetForVariableExpansion" : { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "AAEFF2D6694AA197C07481DA", + "name" : "Flare" + } + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "5A649E8F4319C5B59E9588FD", + "name" : "IntegrationTests" + } + } + ], + "version" : 1 +} diff --git a/Tests/TestPlans/UnitTests.xctestplan b/Tests/TestPlans/UnitTests.xctestplan new file mode 100644 index 000000000..98e230890 --- /dev/null +++ b/Tests/TestPlans/UnitTests.xctestplan @@ -0,0 +1,37 @@ +{ + "configurations" : [ + { + "id" : "1CA61D79-6551-4DA7-8DEF-CC28563C2658", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : { + "targets" : [ + { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "AAEFF2D6694AA197C07481DA", + "name" : "Flare" + } + ] + }, + "targetForVariableExpansion" : { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "AAEFF2D6694AA197C07481DA", + "name" : "Flare" + } + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "2053CB2B5F4780EC86D0DE04", + "name" : "FlareTests" + } + } + ], + "version" : 1 +} diff --git a/Tests/UnitTestHostApp/AppDelegate.swift b/Tests/UnitTestHostApp/AppDelegate.swift new file mode 100644 index 000000000..4f3e9b07c --- /dev/null +++ b/Tests/UnitTestHostApp/AppDelegate.swift @@ -0,0 +1,30 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import SwiftUI + +#if os(macOS) + + import Cocoa + + @main + class AppDelegate: NSObject, NSApplicationDelegate {} + +#elseif os(watchOS) + + @main + struct TestApp: App { + var body: some Scene { + WindowGroup { + Text("Hello World") + } + } + } + +#else + @main + class AppDelegate: UIResponder, UIApplicationDelegate {} + +#endif diff --git a/Tests/UnitTestHostApp/Assets.xcassets/AccentColor.colorset/Contents.json b/Tests/UnitTestHostApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/Tests/UnitTestHostApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/UnitTestHostApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/Tests/UnitTestHostApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..9221b9bb1 --- /dev/null +++ b/Tests/UnitTestHostApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/UnitTestHostApp/Assets.xcassets/Contents.json b/Tests/UnitTestHostApp/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Tests/UnitTestHostApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/UnitTestHostApp/Info.plist b/Tests/UnitTestHostApp/Info.plist new file mode 100644 index 000000000..c468f022f --- /dev/null +++ b/Tests/UnitTestHostApp/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + UnitTestsHostApp + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSupportsIndirectInputEvents + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/codecov.yml b/codecov.yml index b41560430..8bb858ab5 100644 --- a/codecov.yml +++ b/codecov.yml @@ -32,7 +32,7 @@ coverage: target: 85% # Allow coverage to drop by X% - threshold: 5% + threshold: 50% changes: no comment: diff --git a/project.yml b/project.yml new file mode 100644 index 000000000..9f43f179d --- /dev/null +++ b/project.yml @@ -0,0 +1,90 @@ +name: Flare +options: + deploymentTarget: + iOS: 13.0 + macOS: 10.15 + tvOS: 13.0 + watchOS: 7.0 +packages: + # External + + Concurrency: + url: https://github.com/space-code/concurrency.git + from: 0.0.1 + Log: + url: https://github.com/space-code/log.git + from: 1.1.0 + Atomic: + url: https://github.com/space-code/atomic.git + from: 1.0.0 +targets: + UnitTestHostApp: + type: application + supportedDestinations: [iOS, tvOS, macOS] + sources: Tests/UnitTestHostApp + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.spacecode.flare + TARGETED_DEVICE_FAMILY: "1,2,3,4" + SUPPORTED_PLATFORMS: "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator" + scheme: + storeKitConfiguration: "Tests/IntegrationTests/Flare.storekit" + testTargets: + - IntegrationTests + Flare: + type: framework + supportedDestinations: [iOS, tvOS, macOS] + dependencies: + - package: Concurrency + product: Concurrency + - package: Log + product: Log + - package: Atomic + package: Atomic + settings: + base: + GENERATE_INFOPLIST_FILE: YES + TARGETED_DEVICE_FAMILY: "1,2,3,4" + SUPPORTED_PLATFORMS: "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator" + sources: + - path: Sources + scheme: + testPlans: + - path: Tests/TestPlans/AllTests.xctestplan + defaultPlan: true + - path: Tests/TestPlans/UnitTests.xctestplan + - path: Tests/TestPlans/IntegrationTests.xctestplan + gatherCoverageData: true + coverageTargets: + - Flare + FlareTests: + type: bundle.unit-test + supportedDestinations: [iOS, tvOS, macOS] + dependencies: + - package: Concurrency + product: TestConcurrency + - target: Flare + settings: + base: + GENERATE_INFOPLIST_FILE: YES + PRODUCT_BUNDLE_IDENTIFIER: com.spacecode.flare-unit-tests + SUPPORTED_PLATFORMS: "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator" + TARGETED_DEVICE_FAMILY: "1,2,3,4" + sources: + - Tests/FlareTests/UnitTests + IntegrationTests: + type: bundle.unit-test + supportedDestinations: [iOS, tvOS, macOS] + dependencies: + - package: Concurrency + product: TestConcurrency + - target: Flare + - target: UnitTestHostApp + settings: + base: + BUNDLE_LOADER: $(TEST_HOST) + GENERATE_INFOPLIST_FILE: YES + TEST_HOST: $(BUILT_PRODUCTS_DIR)/UnitTestHostApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/UnitTestHostApp + PRODUCT_BUNDLE_IDENTIFIER: com.spacecode.flare.storekit-unit-tests + sources: + - Tests/IntegrationTests diff --git a/scripts/setup_build_tools.sh b/scripts/setup_build_tools.sh new file mode 100644 index 000000000..a0b9e5235 --- /dev/null +++ b/scripts/setup_build_tools.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +which -s xcodegen +if [[ $? != 0 ]] ; then + # Install xcodegen + echo "Installing xcodegen." + brew install xcodegen +fi \ No newline at end of file diff --git a/swiftgen.yml b/swiftgen.yml new file mode 100644 index 000000000..d54965973 --- /dev/null +++ b/swiftgen.yml @@ -0,0 +1,10 @@ +input_dir: Sources/Flare/Resources +output_dir: Sources/Flare/Classes/Generated +strings: + inputs: + - Localizable.strings + outputs: + templateName: structured-swift5 + output: Strings.swift + params: + publicAccess: false \ No newline at end of file