diff --git a/.circleci/config.yml b/.circleci/config.yml index e32b969090..2660b9a9a7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -25,22 +25,22 @@ aliases: android-executor: &android-executor executor: - name: android/android-docker - resource-class: xlarge - tag: 2024.04.1 + name: android/android_docker + resource_class: xlarge + tag: 2025.04.1 environment: JVM_OPTS: -Xmx6g GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.workers.max=2" android-machine-emulator: &android-machine-emulator executor: - name: android/android-machine - resource-class: xlarge - tag: 2024.04.1 + name: android/android_machine + resource_class: xlarge + tag: 2024.11.1 version: 2.1 orbs: - android: circleci/android@2.5.0 + android: circleci/android@3.1.0 gcp-cli: circleci/gcp-cli@3.3.1 revenuecat: revenuecat/sdks-common-config@3.0.0 codecov: codecov/codecov@3.2.4 @@ -73,11 +73,11 @@ commands: android-dependencies: steps: - - android/restore-gradle-cache + - android/restore_gradle_cache - run: name: Download Dependencies command: ./gradlew androidDependencies - - android/save-gradle-cache + - android/save_gradle_cache prepare-signing-key: steps: @@ -131,9 +131,9 @@ commands: build-paywall-tester: steps: - - android/accept-licenses - - android/restore-gradle-cache - - android/restore-build-cache + - android/accept_licenses + - android/restore_gradle_cache + - android/restore_build_cache - run: name: Prepare Keystore working_directory: examples/paywall-tester @@ -152,8 +152,8 @@ commands: bundle exec fastlane android build_paywall_tester_bundle - store_artifacts: path: examples/paywall-tester/build/outputs/bundle/release/paywall-tester-release.aab - - android/save-gradle-cache - - android/save-build-cache + - android/save_gradle_cache + - android/save_build_cache install-ruby: description: "Installs the provided version of Ruby using RVM." @@ -200,13 +200,13 @@ jobs: - install-sdkman - revenuecat/install-gem-unix-dependencies: cache-version: v1 - - android/accept-licenses + - android/accept_licenses - android-dependencies - - android/restore-build-cache + - android/restore_build_cache - run: name: Build sample app command: bundle exec fastlane android build_magic_weather_compose - - android/save-build-cache + - android/save_build_cache assemble-custom-entitlement-computation-sample-app: <<: *android-executor @@ -216,15 +216,15 @@ jobs: - install-sdkman - revenuecat/install-gem-unix-dependencies: cache-version: v1 - - android/accept-licenses + - android/accept_licenses - android-dependencies - - android/restore-build-cache + - android/restore_build_cache - run: name: Build sample app command: bundle exec fastlane android build_custom_entitlement_computation_sample - - android/save-build-cache + - android/save_build_cache - test: + prepare-tests: <<: *android-executor shell: /bin/bash --login -o pipefail steps: @@ -232,42 +232,123 @@ jobs: - checkout-submodule: path: upstream/paywall-preview-resources - install-sdkman - - android/accept-licenses - - android-dependencies - - android/restore-build-cache - - run: - name: Verify purchases-android target SDK compatibility (currently 33) - command: ./gradlew :test-apps:testpurchasesandroidcompatibility:assembleDebug + - android/accept_licenses + - android/restore_gradle_cache - run: - name: Verify purchases-ui target SDK compatibility (currently 34) - command: ./gradlew :test-apps:testpurchasesuiandroidcompatibility:assembleDebug + name: Assemble all variants (warms Gradle cache once) + command: ./gradlew testClasses --parallel --no-daemon + - android/save_gradle_cache + - persist_to_workspace: + root: . + paths: + - . + + test_defaults_debug: + <<: *android-executor + shell: /bin/bash --login -o pipefail + steps: + - checkout + - attach_workspace: + at: . + - android/restore_gradle_cache - run: - name: Run Tests - command: ./gradlew lint test + name: Run Defaults Debug Tests + command: ./gradlew testDefaultsDebugUnitTest --parallel --no-daemon - run: - name: Consolidate artifacts + name: Collect JUnit XMLs command: | - mkdir -p build/test-results/ - find . -type f -regex ".*/build/test-results/.*xml" -exec cp --parents {} build/test-results/ \; + mkdir -p build/test-results-defaults-debug + find . -path ./build/test-results-defaults-debug -prune -o -type f -regex ".*/build/test-results/.*xml" -exec cp --parents {} build/test-results-defaults-debug/ \; + - store_test_results: + path: build/test-results-defaults-debug + - store_artifacts: + path: build/reports + + test_defaults_release: + <<: *android-executor + shell: /bin/bash --login -o pipefail + steps: + - checkout + - attach_workspace: + at: . + - android/restore_gradle_cache + - run: + name: Run Defaults Release Tests + command: ./gradlew testDefaultsReleaseUnitTest --parallel --no-daemon - run: - name: Kover HTML + name: Generate Kover HTML command: ./gradlew purchases:koverHtmlReportDefaultsRelease - run: - name: Kover XML + name: Generate Kover XML command: ./gradlew purchases:koverXmlReportDefaultsRelease - codecov/upload: file: purchases/build/reports/kover/reportDefaultsRelease.xml - - android/save-build-cache + - run: + name: Collect JUnit XMLs + command: | + mkdir -p build/test-results-defaults-release + find . -path ./build/test-results-defaults-release -prune -o -type f -regex ".*/build/test-results/.*xml" -exec cp --parents {} build/test-results-defaults-release/ \; + - store_test_results: + path: build/test-results-defaults-release + - store_artifacts: + path: build/reports + + test_cec_debug: + <<: *android-executor + shell: /bin/bash --login -o pipefail + steps: + - checkout + - attach_workspace: + at: . + - android/restore_gradle_cache + - run: + name: Run CEC Debug Tests + command: ./gradlew testCustomEntitlementComputationDebugUnitTest --parallel --no-daemon + - run: + name: Collect JUnit XMLs + command: | + mkdir -p build/test-results-cec-debug + find . -path ./build/test-results-cec-debug -prune -o -type f -regex ".*/build/test-results/.*xml" -exec cp --parents {} build/test-results-cec-debug/ \; + - store_test_results: + path: build/test-results-cec-debug - store_artifacts: path: build/reports + + test_cec_release: + <<: *android-executor + shell: /bin/bash --login -o pipefail + steps: + - checkout + - attach_workspace: + at: . + - android/restore_gradle_cache - run: - name: Save test results + name: Run CEC Release Tests + command: ./gradlew testCustomEntitlementComputationReleaseUnitTest --parallel --no-daemon + - run: + name: Collect JUnit XMLs command: | - mkdir -p build/test-results - find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} build/test-results \; - when: always + mkdir -p build/test-results-cec-release + find . -path ./build/test-results-cec-release -prune -o -type f -regex ".*/build/test-results/.*xml" -exec cp --parents {} build/test-results-cec-release/ \; + - store_test_results: + path: build/test-results-cec-release + - store_artifacts: + path: build/reports + + test_dokka_hide_internal: + <<: *android-executor + shell: /bin/bash --login -o pipefail + steps: + - attach_workspace: + at: . + - android/restore_gradle_cache + - run: + name: Run dokka-hide-internal Tests + command: ./gradlew :dokka-hide-internal:test --parallel --no-daemon - store_test_results: - path: build/test-results + path: dokka-hide-internal/build/test-results/test + - store_artifacts: + path: build/reports detekt: <<: *android-executor @@ -275,11 +356,11 @@ jobs: steps: - checkout - install-sdkman - - android/restore-gradle-cache + - android/restore_gradle_cache - run: name: Detekt command: ./gradlew detektAll - - android/save-gradle-cache + - android/save_gradle_cache metalava: <<: *android-executor @@ -287,27 +368,24 @@ jobs: steps: - checkout - install-sdkman - - android/restore-gradle-cache + - android/restore_gradle_cache - run: - name: Validate binary compatibility (Defaults) - command: ./gradlew metalavaCheckCompatibilityDefaultsRelease - - run: - name: Validate binary compatibility (CustomEntitlementComputation) - command: ./gradlew metalavaCheckCompatibilityCustomEntitlementComputationRelease - - android/save-gradle-cache + name: Validate binary compatibility + command: ./scripts/api-check.sh + - android/save_gradle_cache docs-deploy: <<: *android-executor steps: - checkout - install-sdkman - - android/restore-gradle-cache - - android/restore-build-cache + - android/restore_gradle_cache + - android/restore_build_cache - run: name: Dokka command: ./gradlew dokkaHtmlMultiModule - - android/save-gradle-cache - - android/save-build-cache + - android/save_gradle_cache + - android/save_build_cache - run: name: Install pip command: sudo apt update && sudo apt install python3-pip @@ -316,7 +394,7 @@ jobs: command: sudo pip install awscli - run: name: Deploy to S3 - command: aws s3 sync ~/project/docs/8.22.0-SNAPSHOT s3://purchases-docs/android/8.22.0-SNAPSHOT --delete + command: aws s3 sync ~/project/docs/8.24.0 s3://purchases-docs/android/8.24.0 --delete - run: name: Update index.html command: aws s3 cp ~/project/docs/index.html s3://purchases-docs/android/index.html @@ -334,14 +412,14 @@ jobs: - revenuecat/install-gem-unix-dependencies: cache-version: v1 - prepare-signing-key - - android/restore-gradle-cache - - android/restore-build-cache + - android/restore_gradle_cache + - android/restore_build_cache - run: name: Deployment command: | bundle exec fastlane android deploy - - android/save-gradle-cache - - android/save-build-cache + - android/save_gradle_cache + - android/save_build_cache prepare-next-version: <<: *android-executor @@ -363,28 +441,28 @@ jobs: - install-sdkman - revenuecat/install-gem-unix-dependencies: cache-version: v1 - - android/restore-gradle-cache: - cache-prefix: v1a - - android/restore-build-cache: - cache-prefix: v1a + - android/restore_gradle_cache: + cache_prefix: v1a + - android/restore_build_cache: + cache_prefix: v1a - prepare-signing-key - run: name: Deployment command: | bundle exec fastlane android deploy_snapshot - - android/save-gradle-cache: - cache-prefix: v1a - - android/save-build-cache: - cache-prefix: v1a + - android/save_gradle_cache: + cache_prefix: v1a + - android/save_build_cache: + cache_prefix: v1a assemble-purchase-tester: <<: *android-executor steps: - checkout - - install-sdkman - - android/accept-licenses - - android/restore-gradle-cache - - android/restore-build-cache + - attach_workspace: + at: . + - android/restore_gradle_cache + - android/restore_build_cache - run: name: Prepare Keystore working_directory: examples/purchase-tester @@ -400,8 +478,8 @@ jobs: path: examples/purchase-tester/build/outputs/apk/release/purchase-tester-release.apk - store_artifacts: path: examples/purchase-tester/build/outputs/apk/debug/purchase-tester-debug.apk - - android/save-gradle-cache - - android/save-build-cache + - android/save_gradle_cache + - android/save_build_cache publish-purchase-tester-release: <<: *android-executor @@ -410,9 +488,9 @@ jobs: - install-sdkman - revenuecat/install-gem-unix-dependencies: cache-version: v1 - - android/accept-licenses - - android/restore-gradle-cache - - android/restore-build-cache + - android/accept_licenses + - android/restore_gradle_cache + - android/restore_build_cache - run: name: Prepare Keystore working_directory: examples/purchase-tester @@ -423,8 +501,8 @@ jobs: bundle exec fastlane android build_purchase_tester_bundle - store_artifacts: path: examples/purchase-tester/build/outputs/bundle/release/purchase-tester-release.aab - - android/save-gradle-cache - - android/save-build-cache + - android/save_gradle_cache + - android/save_build_cache - run: name: Publish aab command: | @@ -465,7 +543,7 @@ jobs: working_directory: integration-tests/src/androidTest/java/com/revenuecat/purchases/integrationtests/ command: | sed -i s/REVENUECAT_API_KEY/$API_KEY/ IntegrationTest.kt - - android/restore-build-cache + - android/restore_build_cache - run: name: Prepare Keystore working_directory: integration-tests @@ -484,7 +562,7 @@ jobs: -PreleaseKeyAlias=$RELEASE_KEY_ALIAS \ -PreleaseKeystorePassword=$RELEASE_KEYSTORE_PASSWORD \ -PreleaseKeyPassword=$RELEASE_KEY_PASSWORD - - android/save-build-cache + - android/save_build_cache - persist_to_workspace: root: . paths: @@ -498,12 +576,12 @@ jobs: - install-sdkman - revenuecat/install-gem-unix-dependencies: cache-version: v1 - - android/restore-build-cache + - android/restore_build_cache - run: name: Create purchases integration tests apks command: | bundle exec fastlane android build_default_purchases_integration_tests - - android/save-build-cache + - android/save_build_cache - persist_to_workspace: root: . paths: @@ -534,12 +612,12 @@ jobs: gcloud_service_key: GCLOUD_SERVICE_KEY google_compute_zone: GOOGLE_COMPUTE_ZONE google_project_id: GOOGLE_PROJECT_ID - - android/restore-build-cache + - android/restore_build_cache - run: name: Build and run load shedder integration tests command: | bundle exec fastlane android run_load_shedder_purchases_integration_tests - - android/save-build-cache + - android/save_build_cache - store_artifacts: path: ~/gsutil/ - store_test_results: @@ -558,12 +636,12 @@ jobs: gcloud_service_key: GCLOUD_SERVICE_KEY google_compute_zone: GOOGLE_COMPUTE_ZONE google_project_id: GOOGLE_PROJECT_ID - - android/restore-build-cache + - android/restore_build_cache - run: name: Build and run custom entitlement computation integration tests command: | bundle exec fastlane android run_custom_entitlement_computation_integration_tests - - android/save-build-cache + - android/save_build_cache - store_artifacts: path: ~/gsutil/ - store_test_results: @@ -579,12 +657,18 @@ jobs: - install-sdkman - revenuecat/install-gem-unix-dependencies: cache-version: v1 - - android/restore-build-cache + - android/restore_build_cache + - attach_workspace: + at: . + - install-sdkman + - revenuecat/install-gem-unix-dependencies: + cache-version: v1 + - android/restore_gradle_cache - run: name: Build and run purchases ui snapshot tests command: | bundle exec fastlane android emerge_purchases_ui_snapshot_tests - - android/save-build-cache + - android/save_build_cache run-firebase-tests: description: "Run integration tests for Android in Firebase. Variant latestDependencies" @@ -602,23 +686,17 @@ jobs: <<: *android-executor steps: - checkout + - attach_workspace: + at: . - install-sdkman - revenuecat/install-gem-unix-dependencies: cache-version: v1 - - android/restore-build-cache + - android/restore_gradle_cache - run: name: Run backend integration tests command: | bundle exec fastlane android run_backend_integration_tests - - android/save-build-cache - - run: - name: Save test results - command: | - mkdir -p build/test-results - find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} build/test-results \; - when: always - - store_test_results: - path: build/test-results + - android/save_build_cache record-and-upload-paparazzi-revenuecatui-snapshots: description: "Record new RevenueCatUI snapshots with Paparazzi and upload them to Emerge" @@ -627,6 +705,8 @@ jobs: - checkout - checkout-submodule: path: upstream/paywall-preview-resources + - attach_workspace: + at: . - install-sdkman # Required by Emerge CLI - install-ruby: @@ -636,7 +716,7 @@ jobs: command: gem install emerge - revenuecat/install-gem-unix-dependencies: cache-version: v1 - - android/restore-build-cache + - android/restore_gradle_cache - revenuecat/setup-git-credentials - run: name: Records new RevenueCat UI snapshots to become the new golden @@ -675,7 +755,7 @@ jobs: --client-library paparazzi \ $PR_ARG \ --project-root . - - android/save-build-cache + - android/save_build_cache record-and-push-paywall-template-screenshots: description: "Record new paywall template screenshots with Paparazzi and push them to the paywall-rendering-validation repository" @@ -690,7 +770,7 @@ jobs: - install-sdkman - revenuecat/install-gem-unix-dependencies: cache-version: v1 - - android/restore-build-cache + - android/restore_build_cache - revenuecat/setup-git-credentials - run: name: Set GitHub token environment variable @@ -700,22 +780,26 @@ jobs: - run: name: Record and push new paywall template screenshots command: bundle exec fastlane android record_and_push_paywall_template_screenshots target_repository_path:../paywall-rendering-validation - - android/save-build-cache + - android/save_build_cache run-revenuecatui-ui-tests: description: "Run RevenueCatUI UI tests for Android in CircleCI" <<: *android-machine-emulator steps: - checkout + - attach_workspace: + at: . - install-sdkman - - android/create-avd: - avd-name: test-revenuecat-ui - system-image: system-images;android-32;google_apis;x86_64 + - android/create_avd: + avd_name: test-revenuecat-ui + system_image: system-images;android-32;google_apis;x86_64 install: true - - android/start-emulator: - avd-name: test-revenuecat-ui - post-emulator-launch-assemble-command: ./gradlew ui:revenuecatui:assembleDebugAndroidTest + - android/start_emulator: + avd_name: test-revenuecat-ui + post_emulator_launch_assemble_command: ./gradlew ui:revenuecatui:assembleDebugAndroidTest + - android/restore_gradle_cache - run: + name: Run RevenueCatUI UI tests for Android in CircleCI command: | ./gradlew ui:revenuecatui:connectedDebugAndroidTest @@ -733,6 +817,33 @@ jobs: name: Update paywall templates command: bundle exec fastlane android update_paywall_preview_resources_submodule + lint: + <<: *android-executor + shell: /bin/bash --login -o pipefail + steps: + - attach_workspace: + at: . + - android/restore_gradle_cache + - run: + name: Run Android Lint + command: ./gradlew lint --parallel --no-daemon + - store_artifacts: + path: build/reports + + verify-compatibility: + <<: *android-executor + shell: /bin/bash --login -o pipefail + steps: + - attach_workspace: + at: . + - android/restore_gradle_cache + - run: + name: Verify purchases-android target SDK (33) + command: ./gradlew :test-apps:testpurchasesandroidcompatibility:assembleDebug --no-daemon --parallel + - run: + name: Verify purchases-ui target SDK (34) + command: ./gradlew :test-apps:testpurchasesuiandroidcompatibility:assembleDebug --no-daemon --parallel + workflows: version: 2 danger: @@ -775,15 +886,53 @@ workflows: equal: [ scheduled_pipeline, << pipeline.trigger_source >> ] - equal: [ "default", << pipeline.parameters.action >> ] jobs: - - test - - detekt - - metalava - - assemble-purchase-tester - - assemble-paywall-tester-release - - run-backend-integration-tests - - record-and-upload-paparazzi-revenuecatui-snapshots - - run-revenuecatui-ui-tests - - emerge_purchases_ui_snapshot_tests + - prepare-tests + - test_defaults_debug: + requires: + - prepare-tests + - test_defaults_release: + requires: + - prepare-tests + - test_cec_debug: + requires: + - prepare-tests + - test_cec_release: + requires: + - prepare-tests + - verify-compatibility: + requires: + - prepare-tests + - lint: + requires: + - prepare-tests + - detekt: + requires: + - prepare-tests + - metalava: + requires: + - prepare-tests + - assemble-purchase-tester: + requires: + - prepare-tests + - assemble-paywall-tester-release: + requires: + - prepare-tests + - test_dokka_hide_internal: + <<: *release-branches + requires: + - prepare-tests + - run-backend-integration-tests: + requires: + - prepare-tests + - record-and-upload-paparazzi-revenuecatui-snapshots: + requires: + - prepare-tests + - run-revenuecatui-ui-tests: + requires: + - prepare-tests + - emerge_purchases_ui_snapshot_tests: + requires: + - prepare-tests - integration-tests-build: *release-branches - purchases-integration-tests-build: *release-branches - run-firebase-tests-purchases-custom-entitlement-computation-integration-test: @@ -801,7 +950,10 @@ workflows: - hold: type: approval requires: - - test + - test_defaults_debug + - test_defaults_release + - test_cec_debug + - test_cec_release - assemble-purchase-tester - assemble-paywall-tester-release - run-backend-integration-tests @@ -810,6 +962,7 @@ workflows: - run-firebase-tests-purchases-integration-test - run-firebase-tests-purchases-load-shedder-integration-test - run-firebase-tests-purchases-custom-entitlement-computation-integration-test + - verify-compatibility <<: *release-branches - revenuecat/tag-current-branch: requires: diff --git a/.version b/.version index 07f0534592..93fc83f8c4 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -8.22.0-SNAPSHOT +8.24.0 diff --git a/CHANGELOG.latest.md b/CHANGELOG.latest.md index 6a3ec7e6b3..447cd0358a 100644 --- a/CHANGELOG.latest.md +++ b/CHANGELOG.latest.md @@ -1,27 +1,27 @@ ## RevenueCat SDK +> [!NOTE] +> This release brings all non-breaking changes between SDK versions 9.4.0 and 9.6.0 to major 8, so developers that don't/can't use major 9, can still get the latest updates. + ### ✨ New Features -* Add `managementURL` to `SubscriptionInfo` (#2468) via Cesar de la Vega (@vegaro) +* Add `RevenueCatBackupAgent` (#2625) via Toni Rico (@tonidero) +* Add preferred UI locale override for RevenueCat UI components (#2620) via Josh Holtz (@joshdholtz) +* Add option to disable automatic ID collection when setting attribution network IDs at configuration time (#2643) via Toni Rico (@tonidero) ### 🐞 Bugfixes -* Use subscription's managementURL instead of CustomerInfo's in Customer Center (#2473) via Cesar de la Vega (@vegaro) -* Issue when loading promotional offers when products are missing base plans (#2471) via Cesar de la Vega (@vegaro) -* Purchases the right package when a purchase button is inside a package component (#2469) via JayShortway (@JayShortway) -* fix potential infinite loop in paywalls (#2462) via Antonio Pallares (@ajpallares) +* Handle payment pending errors when restoring properly (#2635) via Toni Rico (@tonidero) ## RevenueCatUI SDK -### Customer Center +### Paywallv2 #### ✨ New Features -* Support multi purchases in Customer Center (#2431) via Cesar de la Vega (@vegaro) +* MON-1193 Support delayed close button (Component Transitions) (#2623) via Jacob Rakidzich (@JZDesign) #### 🐞 Bugfixes -* Use Material TopAppBar in Customer Center (#2459) via Cesar de la Vega (@vegaro) -* Redo navigation in Customer Center (#2458) via Cesar de la Vega (@vegaro) +* Fix PaywallDialog going over screen size on Android 35+ (#2642) via Toni Rico (@tonidero) +### Customer Center +#### ✨ New Features +* Add button_text to ScreenOffering (#2638) via Facundo Menzella (@facumenzella) ### 🔄 Other Changes -* Migrates publishing to Maven Central Portal. (#2476) via JayShortway (@JayShortway) -* Introduce the Compose compiler metrics option for the ui package (#2474) via Jaewoong Eum (@skydoves) -* Update workflows/issue-notifications.yml@v2 (#2475) via Josh Holtz (@joshdholtz) -* Configure Renovate (#2470) via RevenueCat Git Bot (@RCGitBot) -* Add GitHub Issue Action Ack (#2467) via Josh Holtz (@joshdholtz) -* [AUTOMATIC][Paywalls V2] Updates paywall-preview-resources submodule (#2448) via RevenueCat Git Bot (@RCGitBot) -* Improve paywall error logs (#2461) via Toni Rico (@tonidero) -* [EXTERNAL] Improve logger performance with message lambdas (#2456) via @landarskiy (#2460) via JayShortway (@JayShortway) -* [Paywalls] Ignores `sheet.background` property in schema (#2451) via JayShortway (@JayShortway) +* Fix CoroutineCreationDuringComposition lint error on AGP 8.13.0 (#2659) via Cesar de la Vega (@vegaro) +* Support setting null offering id on PaywallView (#2658) via Toni Rico (@tonidero) +* Improve thread safety of setting paywalls preferred locale (#2655) via Josh Holtz (@joshdholtz) +* Remove validation for no packages on paywalls (#2653) via Josh Holtz (@joshdholtz) +* MON-1193 flatten Transition JSON structure after chatting more thoroughly with team (#2641) via Jacob Rakidzich (@JZDesign) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7703214aeb..d43bbc74c7 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,124 @@ +## 8.24.0 +## RevenueCat SDK +> [!NOTE] +> This release brings all non-breaking changes between SDK versions 9.4.0 and 9.6.0 to major 8, so developers that don't/can't use major 9, can still get the latest updates. + +### ✨ New Features +* Add `RevenueCatBackupAgent` (#2625) via Toni Rico (@tonidero) +* Add preferred UI locale override for RevenueCat UI components (#2620) via Josh Holtz (@joshdholtz) +* Add option to disable automatic ID collection when setting attribution network IDs at configuration time (#2643) via Toni Rico (@tonidero) +### 🐞 Bugfixes +* Handle payment pending errors when restoring properly (#2635) via Toni Rico (@tonidero) + +## RevenueCatUI SDK +### Paywallv2 +#### ✨ New Features +* MON-1193 Support delayed close button (Component Transitions) (#2623) via Jacob Rakidzich (@JZDesign) +#### 🐞 Bugfixes +* Fix PaywallDialog going over screen size on Android 35+ (#2642) via Toni Rico (@tonidero) +### Customer Center +#### ✨ New Features +* Add button_text to ScreenOffering (#2638) via Facundo Menzella (@facumenzella) + +### 🔄 Other Changes +* Fix CoroutineCreationDuringComposition lint error on AGP 8.13.0 (#2659) via Cesar de la Vega (@vegaro) +* Support setting null offering id on PaywallView (#2658) via Toni Rico (@tonidero) +* Improve thread safety of setting paywalls preferred locale (#2655) via Josh Holtz (@joshdholtz) +* Remove validation for no packages on paywalls (#2653) via Josh Holtz (@joshdholtz) +* MON-1193 flatten Transition JSON structure after chatting more thoroughly with team (#2641) via Jacob Rakidzich (@JZDesign) + +## 8.23.0 +> [!NOTE] +> This release brings all non-breaking changes between SDK versions 9.0.0 and 9.3.2 to major 8, so developers that don't/can't use major 9, can still get the latest updates. + +## RevenueCat SDK +### ✨ New Features +* Virtual Currency Support (#2519) via Will Taylor (@fire-at-will) +### 🐞 Bugfixes +* Use `Block store` to backup anonymous user ids across installations (#2595) via Toni Rico (@tonidero) + +## RevenueCatUI SDK +### Paywallv2 +#### ✨ New Features +* Add support for az-AZ locale (#2575) via Franco Correa (@francocorreasosa) +* PaywallActivityLauncher: Add `edgeToEdge` parameter to display paywall in full screen (#2530) via Toni Rico (@tonidero) +#### 🐞 Bugfixes +* Fix embedded font family loading (#2624) via Toni Rico (@tonidero) +* Fixes price formatting discrepancies on Paywalls for `{{ product.price_per_[day|week|month|year] }}` (#2604) via JayShortway (@JayShortway) +* Remove logic to avoid repurchasing already subscribed products (#2492) via Toni Rico (@tonidero) +* Make PaywallActivity not consume insets in Android 16+ (#2520) via Toni Rico (@tonidero) + +### Customer Center +#### ✨ New Features +* Show a subscribe button in customer center when there are no subscriptions (#2596) via Facundo Menzella (@facumenzella) +* Introduce custom actions for customer center (#2572) via Facundo Menzella (@facumenzella) +#### 🐞 Bugfixes +* Fix title and price of non-Google purchases in Customer Center (#2576) via Cesar de la Vega (@vegaro) +* Add lifetime badge to customer center (#2565) via Cesar de la Vega (@vegaro) +* Display latest expired subscription if no active subscriptions (#2564) via Cesar de la Vega (@vegaro) +* Resubscribe if cancelled (#2487) via Cesar de la Vega (@vegaro) + +### 🔄 Other Changes +* Fix integration tests shared preferences file (#2629) via Toni Rico (@tonidero) +* Migrate RC SharedPreferences data to a separate file (#2621) via Toni Rico (@tonidero) +* Override presented offering context paywalls without offering (#2612) via Toni Rico (@tonidero) +* Add APIs for hybrid SDKs to set presentedOfferingContext (#2610) via Toni Rico (@tonidero) +* Bump Baseline Profiles to 1.4.0 and update profiles (#2611) via Jaewoong Eum (@skydoves) +* Migrate deprecated kotlinOptions to compilerOptions (#2607) via Jaewoong Eum (@skydoves) +* Migrate amazon & debugview modules to KTS (#2327) via Jaewoong Eum (@skydoves) +* Add log when restoring purchases finds no purchases with some troubleshooting (#2599) via Toni Rico (@tonidero) +* Add alias users backend call (#2594) via Toni Rico (@tonidero) +* Rename TestStore to SimulatedStore (#2591) via Toni Rico (@tonidero) +* Fail configure call if using test api key in release builds (#2590) via Toni Rico (@tonidero) +* Support test store (#2554) via Toni Rico (@tonidero) +* Fix insets in Paywall Tester (#2584) via Cesar de la Vega (@vegaro) +* Change metalava job to diff dump generated dump files (#2585) via Toni Rico (@tonidero) +* Expose getStorefront APIs in CustomEntitlementComputation flavor (#2579) via Toni Rico (@tonidero) +* Add missing dokka-hide-internal tests to CI and fix reported number of tests (#2569) via Cesar de la Vega (@vegaro) +* Convert WebBilling products into TestStoreProducts (#2573) via Toni Rico (@tonidero) +* Add WebBillingGetProducts endpoint (#2571) via Toni Rico (@tonidero) +* Remove TestStoreProduct purchase check (#2570) via Toni Rico (@tonidero) +* Parallel test jobs (#2549) via Cesar de la Vega (@vegaro) +* Update VC Caching Log Message (#2552) via Will Taylor (@fire-at-will) +* Update design of No Active subscriptions screen in Customer Center (#2559) via Cesar de la Vega (@vegaro) +* Bump danger from 9.5.1 to 9.5.3 (#2556) via dependabot[bot] (@dependabot[bot]) +* Bump fastlane from 2.227.2 to 2.228.0 (#2557) via dependabot[bot] (@dependabot[bot]) +* Bump fastlane-plugin-revenuecat_internal from `9a29f63` to `7d97553` (#2558) via dependabot[bot] (@dependabot[bot]) +* Bump nokogiri from 1.18.8 to 1.18.9 (#2553) via dependabot[bot] (@dependabot[bot]) +* Fix paywall tester so customer center doesn't close when changing to dark mode (#2550) via Cesar de la Vega (@vegaro) +* Expose Virtual Currency Constructors with @InternalRevenueCatAPI (#2543) via Will Taylor (@fire-at-will) +* Expose API key validation result to BillingFactory (#2542) via Toni Rico (@tonidero) +* Dont run VC tests on load shedder integration tests (#2538) via Will Taylor (@fire-at-will) +* Introduces `CompatComposeView` to handle scenarios where the view tree is not set up (#2527) via JayShortway (@JayShortway) +* Increase compile/target SDK version to 35 (#2525) via Toni Rico (@tonidero) +* Fix snapshot deployment (#2526) via Toni Rico (@tonidero) +* Update CircleCI android orb (#2521) via Toni Rico (@tonidero) +* Fixes compilation of the CEC sample app. (#2512) via JayShortway (@JayShortway) +* Update CustomEntitlementComputation sample app kotlin version (#2510) via Toni Rico (@tonidero) +* Fix `Switch` component previews (#2509) via Toni Rico (@tonidero) + + +## 8.22.1 +### 🔄 Other Changes +* Expose getStorefront APIs in CustomEntitlementComputation flavor (#2579) + +## 8.22.0 +## RevenueCat SDK +### ✨ New Features +* feat(purchases): Add setPostHogUserId() method to Purchases API (#2495) via Hussain Mustafa (@hussain-mustafa990) +### 🐞 Bugfixes +* Improves button progress indicator size calculation. (#2485) via JayShortway (@JayShortway) + +### 🔄 Other Changes +* Revert "BC8 migration (#2477)" (#2501) via Toni Rico (@tonidero) +* Add codelab instructions on README file (#2489) via Jaewoong Eum (@skydoves) +* Use collectAsStateWithLifecycle instead of collectAsState in Compose (#2488) via Jaewoong Eum (@skydoves) +* Improve Composable stabilities (#2478) via Jaewoong Eum (@skydoves) +* [AUTOMATIC][Paywalls V2] Updates paywall-preview-resources submodule (#2486) via RevenueCat Git Bot (@RCGitBot) +* BC8 migration (#2477) via Toni Rico (@tonidero) +* Fixes building sample apps with SNAPSHOT dependencies (#2483) via JayShortway (@JayShortway) +* [AUTOMATIC][Paywalls V2] Updates paywall-preview-resources submodule (#2484) via RevenueCat Git Bot (@RCGitBot) + ## 8.21.0 ## RevenueCat SDK ### ✨ New Features diff --git a/Gemfile.lock b/Gemfile.lock index a3bd5f6ca7..04301eb879 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/RevenueCat/fastlane-plugin-revenuecat_internal - revision: 9a29f638e834a61c2f522df6247d484ad478bba0 + revision: 7d97553e9c5baabcd18286f03d8034797a27dd64 branch: main specs: fastlane-plugin-revenuecat_internal (0.1.0) @@ -14,30 +14,44 @@ GEM base64 nkf rexml + activesupport (7.2.2.2) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) artifactory (3.0.17) atomos (0.1.3) - aws-eventstream (1.3.2) - aws-partitions (1.1098.0) - aws-sdk-core (3.223.0) + aws-eventstream (1.4.0) + aws-partitions (1.1132.0) + aws-sdk-core (3.227.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) base64 jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.100.0) - aws-sdk-core (~> 3, >= 3.216.0) + aws-sdk-kms (1.107.0) + aws-sdk-core (~> 3, >= 3.227.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.185.0) - aws-sdk-core (~> 3, >= 3.216.0) + aws-sdk-s3 (1.194.0) + aws-sdk-core (~> 3, >= 3.227.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.11.0) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) - base64 (0.2.0) + base64 (0.3.0) + benchmark (0.4.1) + bigdecimal (3.2.2) claide (1.1.0) claide-plugins (0.9.2) cork @@ -47,27 +61,30 @@ GEM colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) + concurrent-ruby (1.3.5) + connection_pool (2.5.3) cork (0.3.0) colored2 (~> 3.1) - danger (9.5.1) + danger (9.5.3) base64 (~> 0.2) claide (~> 1.0) claide-plugins (>= 0.9.2) - colored2 (~> 3.1) + colored2 (>= 3.1, < 5) cork (~> 0.1) faraday (>= 0.9.0, < 3.0) faraday-http-cache (~> 2.0) - git (~> 1.13) - kramdown (~> 2.3) + git (>= 1.13, < 3.0) + kramdown (>= 2.5.1, < 3.0) kramdown-parser-gfm (~> 1.0) octokit (>= 4.0) pstore (~> 0.1) - terminal-table (>= 1, < 4) + terminal-table (>= 1, < 5) declarative (0.0.20) digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) domain_name (0.6.20240107) dotenv (2.8.1) + drb (2.2.3) emoji_regex (3.2.3) excon (0.112.0) faraday (1.10.4) @@ -86,12 +103,12 @@ GEM faraday (>= 0.8.0) http-cookie (~> 1.0.0) faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) + faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-http-cache (2.5.1) faraday (>= 0.8) faraday-httpclient (1.0.1) - faraday-multipart (1.1.0) + faraday-multipart (1.1.1) multipart-post (~> 2.0) faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) @@ -101,7 +118,7 @@ GEM faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.4.0) - fastlane (2.227.2) + fastlane (2.228.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -147,8 +164,10 @@ GEM fastlane-sirp (1.0.0) sysrandom (~> 1.0) gh_inspector (1.1.3) - git (1.19.1) + git (2.3.3) + activesupport (>= 5.0) addressable (~> 2.8) + process_executer (~> 1.1) rchardet (~> 1.8) google-apis-androidpublisher_v3 (0.54.0) google-apis-core (>= 0.11.0, < 2.a) @@ -192,48 +211,52 @@ GEM domain_name (~> 0.5) httpclient (2.9.0) mutex_m + i18n (1.14.7) + concurrent-ruby (~> 1.0) jmespath (1.6.2) - json (2.11.3) - jwt (2.10.1) + json (2.13.0) + jwt (2.10.2) base64 kramdown (2.5.1) rexml (>= 3.3.9) kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) logger (1.7.0) - mime-types (3.6.2) + mime-types (3.7.0) logger - mime-types-data (~> 3.2015) - mime-types-data (3.2025.0325) + mime-types-data (~> 3.2025, >= 3.2025.0507) + mime-types-data (3.2025.0715) mini_magick (4.13.2) mini_mime (1.1.5) - mini_portile2 (2.8.8) - multi_json (1.15.0) + mini_portile2 (2.8.9) + minitest (5.25.5) + multi_json (1.17.0) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) nap (1.1.0) - naturally (2.2.1) + naturally (2.3.0) netrc (0.11.0) nkf (0.2.0) - nokogiri (1.18.8) + nokogiri (1.18.9) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.18.8-arm64-darwin) + nokogiri (1.18.9-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.8-x86_64-darwin) + nokogiri (1.18.9-x86_64-darwin) racc (~> 1.4) - octokit (9.2.0) + octokit (10.0.0) faraday (>= 1, < 3) sawyer (~> 0.9) open4 (1.3.4) optparse (0.6.0) os (1.1.4) plist (3.7.2) + process_executer (1.3.0) pstore (0.2.0) public_suffix (6.0.2) racc (1.8.1) - rake (13.2.1) + rake (13.3.0) rchardet (1.9.0) representable (3.2.0) declarative (< 0.1.0) @@ -252,6 +275,7 @@ GEM sawyer (0.9.2) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) + securerandom (0.4.1) security (0.1.5) signet (0.20.0) addressable (~> 2.8) @@ -270,6 +294,8 @@ GEM tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) uber (0.1.0) unicode-display_width (2.6.0) word_wrap (1.0.0) diff --git a/api-tester/build.gradle.kts b/api-tester/build.gradle.kts index 7a7d17d351..8a49de7b80 100644 --- a/api-tester/build.gradle.kts +++ b/api-tester/build.gradle.kts @@ -15,6 +15,7 @@ android { } create("customEntitlementComputation") { dimension = "apis" + missingDimensionStrategy("apis", "defaults") } } @@ -27,10 +28,6 @@ android { targetCompatibility = JavaVersion.VERSION_1_8 } - kotlinOptions { - jvmTarget = "1.8" - } - composeOptions { kotlinCompilerExtensionVersion = "1.4.8" } @@ -51,6 +48,12 @@ android { namespace = "com.revenuecat.api_tester_kotlin" } +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8) + } +} + dependencies { implementation(project(":purchases")) implementation(project(":feature:amazon")) diff --git a/api-tester/src/defaults/java/com/revenuecat/apitester/java/PurchasesAPI.java b/api-tester/src/defaults/java/com/revenuecat/apitester/java/PurchasesAPI.java index 2deafc5fb3..20b4936c59 100644 --- a/api-tester/src/defaults/java/com/revenuecat/apitester/java/PurchasesAPI.java +++ b/api-tester/src/defaults/java/com/revenuecat/apitester/java/PurchasesAPI.java @@ -21,12 +21,13 @@ import com.revenuecat.purchases.customercenter.CustomerCenterListener; import com.revenuecat.purchases.customercenter.CustomerCenterManagementOption; import com.revenuecat.purchases.interfaces.GetAmazonLWAConsentStatusCallback; -import com.revenuecat.purchases.interfaces.GetStorefrontCallback; +import com.revenuecat.purchases.interfaces.GetVirtualCurrenciesCallback; import com.revenuecat.purchases.interfaces.LogInCallback; import com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback; import com.revenuecat.purchases.interfaces.RedeemWebPurchaseListener; import com.revenuecat.purchases.interfaces.SyncAttributesAndOfferingsCallback; import com.revenuecat.purchases.interfaces.SyncPurchasesCallback; +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies; import org.jetbrains.annotations.NotNull; @@ -88,16 +89,6 @@ public void onSuccess(@NonNull AmazonLWAConsentStatus contentStatus) { } }; - final GetStorefrontCallback getStorefrontCallback = new GetStorefrontCallback() { - @Override - public void onReceived(@NonNull String storefrontCountryCode) { - } - - @Override - public void onError(@NonNull PurchasesError error) { - } - }; - purchases.syncAttributesAndOfferingsIfNeeded(syncAttributesAndOfferingsCallback); purchases.syncPurchases(); purchases.syncPurchases(syncPurchasesCallback); @@ -122,13 +113,22 @@ public void onError(@NonNull PurchasesError error) { final Store store = purchases.getStore(); - final String storefrontCountryCode = purchases.getStorefrontCountryCode(); - purchases.getStorefrontCountryCode(getStorefrontCallback); - final PurchasesConfiguration configuration = purchases.getCurrentConfiguration(); final WebPurchaseRedemption webPurchaseRedemption1 = Purchases.parseAsWebPurchaseRedemption(intent); final WebPurchaseRedemption webPurchaseRedemption2 = Purchases.parseAsWebPurchaseRedemption(""); + + final GetVirtualCurrenciesCallback getVirtualCurrenciesCallback = new GetVirtualCurrenciesCallback() { + @Override + public void onReceived(@NonNull VirtualCurrencies virtualCurrencies) {} + + @Override + public void onError(@NonNull PurchasesError error) {} + }; + + purchases.getVirtualCurrencies(getVirtualCurrenciesCallback); + purchases.invalidateVirtualCurrenciesCache(); + VirtualCurrencies cachedVirtualCurrencies = purchases.getCachedVirtualCurrencies(); } static void check(final Purchases purchases, final Map attributes) { diff --git a/api-tester/src/defaults/java/com/revenuecat/apitester/java/revenuecatui/PaywallActivityLauncherAPI.java b/api-tester/src/defaults/java/com/revenuecat/apitester/java/revenuecatui/PaywallActivityLauncherAPI.java index f09ca815e1..1129c8de60 100644 --- a/api-tester/src/defaults/java/com/revenuecat/apitester/java/revenuecatui/PaywallActivityLauncherAPI.java +++ b/api-tester/src/defaults/java/com/revenuecat/apitester/java/revenuecatui/PaywallActivityLauncherAPI.java @@ -37,6 +37,7 @@ static void check( launcher.launch(offering, null, true); launcher.launch(null, fontProvider, true); launcher.launch(null, null, true); + launcher.launch(null, null, true, true); launcher.launchIfNeeded("requiredEntitlementIdentifier"); launcher.launchIfNeeded("requiredEntitlementIdentifier", offering); launcher.launchIfNeeded("requiredEntitlementIdentifier", null); @@ -44,7 +45,8 @@ static void check( launcher.launchIfNeeded("requiredEntitlementIdentifier", offering, null, true); launcher.launchIfNeeded("requiredEntitlementIdentifier", null, fontProvider, true); launcher.launchIfNeeded("requiredEntitlementIdentifier", null, null, true); - launcher.launchIfNeeded("requiredEntitlementIdentifier", offering, fontProvider, true, paywallDisplayCallback); + launcher.launchIfNeeded("requiredEntitlementIdentifier", offering, fontProvider, true, true); + launcher.launchIfNeeded("requiredEntitlementIdentifier", offering, fontProvider, true, true, paywallDisplayCallback); launcher.launchIfNeeded(offering, fontProvider, true, customerInfo -> null); launcher.launchIfNeeded(offering, null, true, customerInfo -> null); launcher.launchIfNeeded(null, fontProvider, true, customerInfo -> null); diff --git a/api-tester/src/defaults/java/com/revenuecat/apitester/java/revenuecatui/PaywallViewAPI.java b/api-tester/src/defaults/java/com/revenuecat/apitester/java/revenuecatui/PaywallViewAPI.java index 8a37bb095f..4c02f5dcb4 100644 --- a/api-tester/src/defaults/java/com/revenuecat/apitester/java/revenuecatui/PaywallViewAPI.java +++ b/api-tester/src/defaults/java/com/revenuecat/apitester/java/revenuecatui/PaywallViewAPI.java @@ -4,6 +4,7 @@ import android.util.AttributeSet; import com.revenuecat.purchases.Offering; +import com.revenuecat.purchases.PresentedOfferingContext; import com.revenuecat.purchases.ui.revenuecatui.PaywallListener; import com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider; import com.revenuecat.purchases.ui.revenuecatui.views.PaywallView; @@ -28,12 +29,14 @@ static void checkConstructors(Context context, PaywallView paywallView8 = new PaywallView(context, offering, listener, fontProvider, shouldDisplayDismissButton, () -> null); } - static void checkMethods(PaywallView paywallView, PaywallListener listener) { + static void checkMethods(PaywallView paywallView, PaywallListener listener, PresentedOfferingContext context) { paywallView.setPaywallListener(null); paywallView.setPaywallListener(listener); paywallView.setDismissHandler(null); paywallView.setDismissHandler(() -> null); paywallView.setOfferingId(null); paywallView.setOfferingId("offeringId"); + paywallView.setOfferingId(null, null); + paywallView.setOfferingId("offeringId", context); } } diff --git a/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/PurchasesAPI.kt b/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/PurchasesAPI.kt index 0cce815577..908bb41cb3 100644 --- a/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/PurchasesAPI.kt +++ b/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/PurchasesAPI.kt @@ -20,11 +20,11 @@ import com.revenuecat.purchases.WebPurchaseRedemption import com.revenuecat.purchases.amazon.AmazonConfiguration import com.revenuecat.purchases.awaitCustomerCenterConfigData import com.revenuecat.purchases.awaitCustomerInfo +import com.revenuecat.purchases.awaitGetVirtualCurrencies import com.revenuecat.purchases.awaitLogIn import com.revenuecat.purchases.awaitLogOut import com.revenuecat.purchases.awaitRestore import com.revenuecat.purchases.awaitRestoreResult -import com.revenuecat.purchases.awaitStorefrontCountryCode import com.revenuecat.purchases.awaitSyncAttributesAndOfferingsIfNeeded import com.revenuecat.purchases.awaitSyncPurchases import com.revenuecat.purchases.customercenter.CustomerCenterConfigData @@ -34,9 +34,9 @@ import com.revenuecat.purchases.data.LogInResult import com.revenuecat.purchases.getAmazonLWAConsentStatus import com.revenuecat.purchases.getAmazonLWAConsentStatusWith import com.revenuecat.purchases.getCustomerInfoWith -import com.revenuecat.purchases.getStorefrontCountryCodeWith +import com.revenuecat.purchases.getVirtualCurrenciesWith import com.revenuecat.purchases.interfaces.GetAmazonLWAConsentStatusCallback -import com.revenuecat.purchases.interfaces.GetStorefrontCallback +import com.revenuecat.purchases.interfaces.GetVirtualCurrenciesCallback import com.revenuecat.purchases.interfaces.LogInCallback import com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback import com.revenuecat.purchases.interfaces.RedeemWebPurchaseListener @@ -47,6 +47,7 @@ import com.revenuecat.purchases.logOutWith import com.revenuecat.purchases.models.BillingFeature import com.revenuecat.purchases.syncAttributesAndOfferingsIfNeededWith import com.revenuecat.purchases.syncPurchasesWith +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies import java.util.concurrent.ExecutorService @Suppress("unused", "UNUSED_VARIABLE", "EmptyFunctionBlock", "DEPRECATION") @@ -78,8 +79,9 @@ private class PurchasesAPI { override fun onSuccess(status: AmazonLWAConsentStatus) {} override fun onError(error: PurchasesError) {} } - val getStorefrontCallback = object : GetStorefrontCallback { - override fun onReceived(storefrontCountryCode: String) {} + + val getVirtualCurrenciesCallback = object : GetVirtualCurrenciesCallback { + override fun onReceived(virtualCurrencies: VirtualCurrencies) {} override fun onError(error: PurchasesError) {} } @@ -108,24 +110,21 @@ private class PurchasesAPI { val store: Store = purchases.store - val countryCode = purchases.storefrontCountryCode - purchases.getStorefrontCountryCode(getStorefrontCallback) - val configuration: PurchasesConfiguration = purchases.currentConfiguration purchases.redeemWebPurchase(webPurchaseRedemption, redeemWebPurchaseListener) val parsedWebPurchaseRedemption: WebPurchaseRedemption? = Purchases.parseAsWebPurchaseRedemption(intent) val parsedWebPurchaseRedemption2: WebPurchaseRedemption? = Purchases.parseAsWebPurchaseRedemption("") + + purchases.getVirtualCurrencies(callback = getVirtualCurrenciesCallback) + purchases.invalidateVirtualCurrenciesCache() + val cachedVirtualCurrencies: VirtualCurrencies? = purchases.cachedVirtualCurrencies } @Suppress("LongMethod", "LongParameterList") fun checkListenerConversions( purchases: Purchases, ) { - purchases.getStorefrontCountryCodeWith( - onError = { _: PurchasesError -> }, - onSuccess = { _: String -> }, - ) purchases.logInWith( "", onError = { _: PurchasesError -> }, @@ -162,13 +161,16 @@ private class PurchasesAPI { onError = { _: PurchasesError -> }, onSuccess = { _: AmazonLWAConsentStatus -> }, ) + purchases.getVirtualCurrenciesWith( + onError = { _: PurchasesError -> }, + onSuccess = { _: VirtualCurrencies -> }, + ) } @OptIn(InternalRevenueCatAPI::class) suspend fun checkCoroutines( purchases: Purchases, ) { - val storefrontCountryCode: String = purchases.awaitStorefrontCountryCode() val customerInfo: CustomerInfo = purchases.awaitCustomerInfo() val customerInfoFetchPolicy: CustomerInfo = purchases.awaitCustomerInfo(fetchPolicy = CacheFetchPolicy.FETCH_CURRENT) @@ -181,6 +183,7 @@ private class PurchasesAPI { var offerings: Offerings = purchases.awaitSyncAttributesAndOfferingsIfNeeded() var consentStatus: AmazonLWAConsentStatus = purchases.getAmazonLWAConsentStatus() var customerCenterConfigData: CustomerCenterConfigData = purchases.awaitCustomerCenterConfigData() + val getVirtualCurrenciesResult: VirtualCurrencies = purchases.awaitGetVirtualCurrencies() } fun check(purchases: Purchases, attributes: Map) { diff --git a/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/revenuecatui/PaywallActivityLauncherAPI.kt b/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/revenuecatui/PaywallActivityLauncherAPI.kt index 493f4d5594..a7cce4f5c8 100644 --- a/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/revenuecatui/PaywallActivityLauncherAPI.kt +++ b/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/revenuecatui/PaywallActivityLauncherAPI.kt @@ -35,6 +35,7 @@ private class PaywallActivityLauncherAPI { offering = offering, fontProvider = fontProvider, shouldDisplayDismissButton = true, + edgeToEdge = true, ) activityLauncher.launch(offeringIdentifier) activityLauncher.launch( @@ -45,6 +46,7 @@ private class PaywallActivityLauncherAPI { offeringIdentifier = offeringIdentifier, fontProvider = fontProvider, shouldDisplayDismissButton = true, + edgeToEdge = true, ) activityLauncher.launchIfNeeded("requiredEntitlementIdentifier") activityLauncher.launchIfNeeded( @@ -61,12 +63,14 @@ private class PaywallActivityLauncherAPI { offering = offering, fontProvider = fontProvider, shouldDisplayDismissButton = true, + edgeToEdge = true, ) activityLauncher.launchIfNeeded( requiredEntitlementIdentifier = "requiredEntitlementIdentifier", offering = offering, fontProvider = fontProvider, shouldDisplayDismissButton = true, + edgeToEdge = true, paywallDisplayCallback = paywallDisplayCallback, ) activityLauncher.launchIfNeeded( @@ -83,12 +87,14 @@ private class PaywallActivityLauncherAPI { offeringIdentifier = offeringIdentifier, fontProvider = fontProvider, shouldDisplayDismissButton = true, + edgeToEdge = true, ) activityLauncher.launchIfNeeded( requiredEntitlementIdentifier = "requiredEntitlementIdentifier", offeringIdentifier = offeringIdentifier, fontProvider = fontProvider, shouldDisplayDismissButton = true, + edgeToEdge = true, paywallDisplayCallback = paywallDisplayCallback, ) activityLauncher.launchIfNeeded { diff --git a/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/revenuecatui/PaywallViewAPI.kt b/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/revenuecatui/PaywallViewAPI.kt index a6caf0ed74..74f4cb62b8 100644 --- a/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/revenuecatui/PaywallViewAPI.kt +++ b/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/revenuecatui/PaywallViewAPI.kt @@ -4,6 +4,7 @@ import android.content.Context import android.util.AttributeSet import androidx.compose.ui.platform.AbstractComposeView import com.revenuecat.purchases.Offering +import com.revenuecat.purchases.PresentedOfferingContext import com.revenuecat.purchases.ui.revenuecatui.PaywallListener import com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider import com.revenuecat.purchases.ui.revenuecatui.views.PaywallView @@ -40,6 +41,7 @@ private class PaywallViewAPI { paywallView: PaywallView, paywallListener: PaywallListener, dismissHandler: () -> Unit, + presentedOfferingContext: PresentedOfferingContext?, ) { paywallView.setPaywallListener(null) paywallView.setPaywallListener(paywallListener) @@ -47,5 +49,7 @@ private class PaywallViewAPI { paywallView.setDismissHandler(dismissHandler) paywallView.setOfferingId(null) paywallView.setOfferingId("offering_id") + paywallView.setOfferingId(null, presentedOfferingContext) + paywallView.setOfferingId("offering_id", presentedOfferingContext) } } diff --git a/api-tester/src/main/java/com/revenuecat/apitester/java/PurchasesCommonAPI.java b/api-tester/src/main/java/com/revenuecat/apitester/java/PurchasesCommonAPI.java index 8aedec9fd6..5adefa9076 100644 --- a/api-tester/src/main/java/com/revenuecat/apitester/java/PurchasesCommonAPI.java +++ b/api-tester/src/main/java/com/revenuecat/apitester/java/PurchasesCommonAPI.java @@ -20,6 +20,7 @@ import com.revenuecat.purchases.PurchasesError; import com.revenuecat.purchases.Store; import com.revenuecat.purchases.interfaces.GetStoreProductsCallback; +import com.revenuecat.purchases.interfaces.GetStorefrontCallback; import com.revenuecat.purchases.interfaces.PurchaseCallback; import com.revenuecat.purchases.interfaces.ReceiveOfferingsCallback; import com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener; @@ -59,6 +60,15 @@ public void onReceived(@NonNull List storeProducts) { public void onError(@NonNull PurchasesError error) { } }; + final GetStorefrontCallback getStorefrontCallback = new GetStorefrontCallback() { + @Override + public void onReceived(@NonNull String storefrontCountryCode) { + } + + @Override + public void onError(@NonNull PurchasesError error) { + } + }; purchases.getOfferings(receiveOfferingsListener); purchases.getProducts(productIds, productResponseListener); @@ -72,6 +82,9 @@ public void onError(@NonNull PurchasesError error) { purchases.setUpdatedCustomerInfoListener((CustomerInfo customerInfo) -> { }); + final String storefrontCountryCode = purchases.getStorefrontCountryCode(); + purchases.getStorefrontCountryCode(getStorefrontCallback); + final List inAppMessageTypeList = new ArrayList<>(); purchases.showInAppMessagesIfNeeded(activity); purchases.showInAppMessagesIfNeeded(activity, inAppMessageTypeList); @@ -150,6 +163,7 @@ static void checkConfiguration(final Context context, .showInAppMessagesAutomatically(true) .store(Store.APP_STORE) .pendingTransactionsForPrepaidPlansEnabled(true) + .automaticDeviceIdentifierCollectionEnabled(true) .build(); final Boolean showInAppMessagesAutomatically = build.getShowInAppMessagesAutomatically(); diff --git a/api-tester/src/main/java/com/revenuecat/apitester/java/RevenueCatBackupAgentAPI.java b/api-tester/src/main/java/com/revenuecat/apitester/java/RevenueCatBackupAgentAPI.java new file mode 100644 index 0000000000..cf5e308c06 --- /dev/null +++ b/api-tester/src/main/java/com/revenuecat/apitester/java/RevenueCatBackupAgentAPI.java @@ -0,0 +1,12 @@ +package com.revenuecat.apitester.java; + +import android.app.backup.BackupAgentHelper; + +import com.revenuecat.purchases.backup.RevenueCatBackupAgent; + +@SuppressWarnings({"unused"}) +final class RevenueCatBackupAgentAPI { + static void check(final RevenueCatBackupAgent agent) { + final BackupAgentHelper backupAgentHelper = agent; + } +} diff --git a/api-tester/src/main/java/com/revenuecat/apitester/java/TestStoreProductAPI.java b/api-tester/src/main/java/com/revenuecat/apitester/java/TestStoreProductAPI.java index 8aeab8a22c..7f5558bd10 100644 --- a/api-tester/src/main/java/com/revenuecat/apitester/java/TestStoreProductAPI.java +++ b/api-tester/src/main/java/com/revenuecat/apitester/java/TestStoreProductAPI.java @@ -1,13 +1,18 @@ package com.revenuecat.apitester.java; +import com.revenuecat.purchases.PresentedOfferingContext; import com.revenuecat.purchases.models.Period; import com.revenuecat.purchases.models.Price; +import com.revenuecat.purchases.models.PricingPhase; import com.revenuecat.purchases.models.StoreProduct; import com.revenuecat.purchases.models.TestStoreProduct; @SuppressWarnings({"unused", "deprecation"}) final class TestStoreProductAPI { - static void checkConstructors(final Price price, final Period period) { + static void checkConstructors(final Price price, + final Period period, + final PresentedOfferingContext presentedOfferingContext) { + PricingPhase pricingPhase = null; new TestStoreProduct( "id", "title", "description", price, period, null, null ); @@ -15,11 +20,14 @@ static void checkConstructors(final Price price, final Period period) { "id", "title", "description", price, period, period, price ); new TestStoreProduct( - "id", "name", "title", "description", price, period, null, null + "id", "name", "title", "description", price, period, pricingPhase, pricingPhase ); new TestStoreProduct( "id", "name", "title", "description", price, period, period, price ); + new TestStoreProduct( + "id", "name", "title", "description", price, period, pricingPhase, pricingPhase, presentedOfferingContext + ); } static void checkTestStoreProductIsStoreProduct(final TestStoreProduct testStoreProduct) { diff --git a/api-tester/src/main/java/com/revenuecat/apitester/java/VirtualCurrenciesAPI.java b/api-tester/src/main/java/com/revenuecat/apitester/java/VirtualCurrenciesAPI.java new file mode 100644 index 0000000000..3d67705345 --- /dev/null +++ b/api-tester/src/main/java/com/revenuecat/apitester/java/VirtualCurrenciesAPI.java @@ -0,0 +1,22 @@ +package com.revenuecat.apitester.java; + +import androidx.annotation.OptIn; + +import com.revenuecat.purchases.InternalRevenueCatAPI; +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies; +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrency; + +import java.util.HashMap; +import java.util.Map; + +final class VirtualCurrenciesAPI { + static void check(final VirtualCurrencies virtualCurrencies){ + final Map all = virtualCurrencies.getAll(); + final VirtualCurrency testGetVC = virtualCurrencies.get("COIN"); + } + + @OptIn(markerClass = InternalRevenueCatAPI.class) + static void checkInternalRevenueCatAPIs() { + final VirtualCurrencies vcs = new VirtualCurrencies(new HashMap()); + } +} diff --git a/api-tester/src/main/java/com/revenuecat/apitester/java/VirtualCurrencyAPI.java b/api-tester/src/main/java/com/revenuecat/apitester/java/VirtualCurrencyAPI.java new file mode 100644 index 0000000000..e13d669899 --- /dev/null +++ b/api-tester/src/main/java/com/revenuecat/apitester/java/VirtualCurrencyAPI.java @@ -0,0 +1,33 @@ +package com.revenuecat.apitester.java; + +import androidx.annotation.OptIn; + +import com.revenuecat.purchases.InternalRevenueCatAPI; +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrency; + +final class VirtualCurrencyAPI { + static void check(final VirtualCurrency virtualCurrency) { + final int balance = virtualCurrency.getBalance(); + final Integer balance2 = virtualCurrency.getBalance(); + final String name = virtualCurrency.getName(); + final String code = virtualCurrency.getCode(); + final String serverDescription = virtualCurrency.getServerDescription(); + } + + @OptIn(markerClass = InternalRevenueCatAPI.class) + static void checkInternalRevenueCatAPIs() { + VirtualCurrency virtualCurrencyWithServerDescription = new VirtualCurrency( + 100, + "Gold", + "GLD", + "This is a test currency." + ); + + VirtualCurrency virtualCurrencyWithoutServerDescription = new VirtualCurrency( + 100, + "Gold", + "GLD", + null + ); + } +} diff --git a/api-tester/src/main/java/com/revenuecat/apitester/kotlin/PurchasesCommonAPI.kt b/api-tester/src/main/java/com/revenuecat/apitester/kotlin/PurchasesCommonAPI.kt index 702c14dd82..7f89e7ecd7 100644 --- a/api-tester/src/main/java/com/revenuecat/apitester/kotlin/PurchasesCommonAPI.kt +++ b/api-tester/src/main/java/com/revenuecat/apitester/kotlin/PurchasesCommonAPI.kt @@ -23,9 +23,12 @@ import com.revenuecat.purchases.awaitOfferings import com.revenuecat.purchases.awaitOfferingsResult import com.revenuecat.purchases.awaitPurchase import com.revenuecat.purchases.awaitPurchaseResult +import com.revenuecat.purchases.awaitStorefrontCountryCode import com.revenuecat.purchases.getOfferingsWith import com.revenuecat.purchases.getProductsWith +import com.revenuecat.purchases.getStorefrontCountryCodeWith import com.revenuecat.purchases.interfaces.GetStoreProductsCallback +import com.revenuecat.purchases.interfaces.GetStorefrontCallback import com.revenuecat.purchases.interfaces.PurchaseCallback import com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback import com.revenuecat.purchases.interfaces.ReceiveOfferingsCallback @@ -61,6 +64,10 @@ private class PurchasesCommonAPI { override fun onReceived(storeProducts: List) {} override fun onError(error: PurchasesError) {} } + val getStorefrontCallback = object : GetStorefrontCallback { + override fun onReceived(storefrontCountryCode: String) {} + override fun onError(error: PurchasesError) {} + } purchases.getOfferings(receiveOfferingsCallback) @@ -71,6 +78,9 @@ private class PurchasesCommonAPI { val appUserID: String = purchases.appUserID + val countryCode = purchases.storefrontCountryCode + purchases.getStorefrontCountryCode(getStorefrontCallback) + purchases.removeUpdatedCustomerInfoListener() purchases.close() @@ -129,6 +139,10 @@ private class PurchasesCommonAPI { purchases: Purchases, purchaseParams: PurchaseParams, ) { + purchases.getStorefrontCountryCodeWith( + onError = { _: PurchasesError -> }, + onSuccess = { _: String -> }, + ) purchases.getOfferingsWith( onError = { _: PurchasesError -> }, onSuccess = { _: Offerings -> }, @@ -163,6 +177,7 @@ private class PurchasesCommonAPI { activity: Activity, packageToPurchase: Package, ) { + val storefrontCountryCode: String = purchases.awaitStorefrontCountryCode() val offerings: Offerings = purchases.awaitOfferings() val purchasePackageBuilder: PurchaseParams.Builder = PurchaseParams.Builder(activity, packageToPurchase) @@ -209,6 +224,7 @@ private class PurchasesCommonAPI { .entitlementVerificationMode(EntitlementVerificationMode.INFORMATIONAL) .store(Store.PLAY_STORE) .pendingTransactionsForPrepaidPlansEnabled(true) + .automaticDeviceIdentifierCollectionEnabled(true) .build() val showInAppMessagesAutomatically: Boolean = build.showInAppMessagesAutomatically diff --git a/api-tester/src/main/java/com/revenuecat/apitester/kotlin/RevenueCatBackupAgentAPI.kt b/api-tester/src/main/java/com/revenuecat/apitester/kotlin/RevenueCatBackupAgentAPI.kt new file mode 100644 index 0000000000..a397c467ab --- /dev/null +++ b/api-tester/src/main/java/com/revenuecat/apitester/kotlin/RevenueCatBackupAgentAPI.kt @@ -0,0 +1,11 @@ +package com.revenuecat.apitester.kotlin + +import android.app.backup.BackupAgentHelper +import com.revenuecat.purchases.backup.RevenueCatBackupAgent + +@Suppress("unused", "UNUSED_VARIABLE") +private class RevenueCatBackupAgentAPI { + fun check(agent: RevenueCatBackupAgent) { + val backupAgentHelper: BackupAgentHelper = agent + } +} diff --git a/api-tester/src/main/java/com/revenuecat/apitester/kotlin/TestStoreProductAPI.kt b/api-tester/src/main/java/com/revenuecat/apitester/kotlin/TestStoreProductAPI.kt index 54ce5ed5b0..a103633297 100644 --- a/api-tester/src/main/java/com/revenuecat/apitester/kotlin/TestStoreProductAPI.kt +++ b/api-tester/src/main/java/com/revenuecat/apitester/kotlin/TestStoreProductAPI.kt @@ -1,5 +1,6 @@ package com.revenuecat.apitester.kotlin +import com.revenuecat.purchases.PresentedOfferingContext import com.revenuecat.purchases.models.Period import com.revenuecat.purchases.models.Price import com.revenuecat.purchases.models.StoreProduct @@ -7,7 +8,7 @@ import com.revenuecat.purchases.models.TestStoreProduct @Suppress("unused", "UNUSED_VARIABLE", "LongMethod", "DEPRECATION") private class TestStoreProductAPI { - fun checkConstructor(price: Price, period: Period?) { + fun checkConstructor(price: Price, period: Period?, presentedOfferingContext: PresentedOfferingContext?) { TestStoreProduct( id = "ID", title = "title", @@ -74,6 +75,15 @@ private class TestStoreProductAPI { period = period, introPrice = price, ) + TestStoreProduct( + id = "ID", + name = "name", + title = "title", + price = price, + description = "description", + period = period, + presentedOfferingContext = presentedOfferingContext, + ) } fun checkTestStoreProductIsStoreProduct(testStoreProduct: TestStoreProduct) { diff --git a/api-tester/src/main/java/com/revenuecat/apitester/kotlin/VirtualCurrenciesAPI.kt b/api-tester/src/main/java/com/revenuecat/apitester/kotlin/VirtualCurrenciesAPI.kt new file mode 100644 index 0000000000..8136a4c612 --- /dev/null +++ b/api-tester/src/main/java/com/revenuecat/apitester/kotlin/VirtualCurrenciesAPI.kt @@ -0,0 +1,21 @@ +package com.revenuecat.apitester.kotlin + +import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrency + +@Suppress("unused", "UNUSED_VARIABLE") +private class VirtualCurrenciesAPI { + fun check(virtualCurrencies: VirtualCurrencies) { + val all: Map = virtualCurrencies.all + val subscriptTest: VirtualCurrency? = virtualCurrencies["COIN"] + val getTest: VirtualCurrency? = virtualCurrencies.get(code = "COIN") + } + + @OptIn(InternalRevenueCatAPI::class) + fun checkInternalRevenueCatAPIs() { + val virtualCurrencies = VirtualCurrencies( + all = emptyMap(), + ) + } +} diff --git a/api-tester/src/main/java/com/revenuecat/apitester/kotlin/VirtualCurrencyAPI.kt b/api-tester/src/main/java/com/revenuecat/apitester/kotlin/VirtualCurrencyAPI.kt new file mode 100644 index 0000000000..f46dd64168 --- /dev/null +++ b/api-tester/src/main/java/com/revenuecat/apitester/kotlin/VirtualCurrencyAPI.kt @@ -0,0 +1,31 @@ +package com.revenuecat.apitester.kotlin + +import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrency + +@Suppress("unused", "UNUSED_VARIABLE") +private class VirtualCurrencyAPI { + fun check(virtualCurrency: VirtualCurrency) { + val balance: Int = virtualCurrency.balance + val name: String = virtualCurrency.name + val code: String = virtualCurrency.code + val serverDescription: String? = virtualCurrency.serverDescription + } + + @OptIn(InternalRevenueCatAPI::class) + fun checkInternalRevenueCatAPIs() { + val virtualCurrencyWithServerDescription = VirtualCurrency( + balance = 100, + name = "Gold", + code = "GLD", + serverDescription = "hello", + ) + + val virtualCurrencyWithoutServerDescription = VirtualCurrency( + balance = 100, + name = "Gold", + code = "GLD", + serverDescription = null, + ) + } +} diff --git a/baselineprofile/build.gradle.kts b/baselineprofile/build.gradle.kts index 66ca6eb6df..0c68c8cadb 100644 --- a/baselineprofile/build.gradle.kts +++ b/baselineprofile/build.gradle.kts @@ -17,10 +17,6 @@ android { targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { - jvmTarget = "11" - } - defaultConfig { minSdk = 28 targetSdk = 34 @@ -65,6 +61,12 @@ android { } } +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } +} + // This is the plugin configuration. Everything is optional. Defaults are in the // comments. In this example, you use the GMD added earlier and disable connected devices. baselineProfile { diff --git a/build.gradle.kts b/build.gradle.kts index 5146be1d26..13a3440cec 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,5 @@ buildscript { - extra["compileVersion"] = 34 + extra["compileVersion"] = 35 extra["minVersion"] = 21 } diff --git a/docs/index.html b/docs/index.html index bbb9a3fb36..3f5175341e 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,7 +1,7 @@ - + diff --git a/dokka-hide-internal/build.gradle.kts b/dokka-hide-internal/build.gradle.kts index d2f3de996f..fb72513595 100644 --- a/dokka-hide-internal/build.gradle.kts +++ b/dokka-hide-internal/build.gradle.kts @@ -5,10 +5,11 @@ plugins { } tasks.withType().configureEach { - kotlinOptions { - jvmTarget = "17" + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } } + tasks.withType().configureEach { targetCompatibility = "17" } diff --git a/examples/CustomEntitlementComputationSample/app/build.gradle.kts b/examples/CustomEntitlementComputationSample/app/build.gradle.kts index 7fadc21e1a..27fb26eb78 100644 --- a/examples/CustomEntitlementComputationSample/app/build.gradle.kts +++ b/examples/CustomEntitlementComputationSample/app/build.gradle.kts @@ -50,15 +50,12 @@ android { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } - kotlinOptions { - jvmTarget = "1.8" - } buildFeatures { compose = true buildConfig = true } composeOptions { - kotlinCompilerExtensionVersion = "1.3.2" + kotlinCompilerExtensionVersion = "1.4.8" } packaging { resources { @@ -67,6 +64,12 @@ android { } } +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8) + } +} + dependencies { implementation(libs.androidx.core) implementation(libs.lifecycle.runtime.ktx) diff --git a/examples/CustomEntitlementComputationSample/gradle/libs.versions.toml b/examples/CustomEntitlementComputationSample/gradle/libs.versions.toml index 56ac8d4f48..b675111f91 100644 --- a/examples/CustomEntitlementComputationSample/gradle/libs.versions.toml +++ b/examples/CustomEntitlementComputationSample/gradle/libs.versions.toml @@ -1,9 +1,7 @@ [versions] agp = "8.1.3" -androidxNavigation = "2.5.3" -kotlin = "1.7.20" -purchases = "8.22.0-SNAPSHOT" -lifecycle = "2.5.0" +kotlin = "1.8.22" +purchases = "8.23.0-SNAPSHOT" androidxCore = "1.10.1" [plugins] @@ -12,14 +10,7 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } [libraries] androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidxCore" } -androidx-lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle" } -androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } -androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidxNavigation" } -androidx-navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidxNavigation" } -kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } - -material = "com.google.android.material:material:1.3.0" lifecycle-runtime-ktx = "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1" activity-compose = "androidx.activity:activity-compose:1.3.1" compose-bom = "androidx.compose:compose-bom:2023.05.01" diff --git a/examples/MagicWeather/app/build.gradle.kts b/examples/MagicWeather/app/build.gradle.kts index ed777fd67d..297b0f0f43 100644 --- a/examples/MagicWeather/app/build.gradle.kts +++ b/examples/MagicWeather/app/build.gradle.kts @@ -41,15 +41,17 @@ android { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } - kotlinOptions { - jvmTarget = "1.8" - } buildFeatures { buildConfig = true } namespace = "com.revenuecat.sample" } +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8) + } +} dependencies { implementation(libs.revenuecat) implementation(libs.revenuecat.amazon) diff --git a/examples/MagicWeather/gradle/libs.versions.toml b/examples/MagicWeather/gradle/libs.versions.toml index 4c30ed3c3e..1c4c4195ac 100644 --- a/examples/MagicWeather/gradle/libs.versions.toml +++ b/examples/MagicWeather/gradle/libs.versions.toml @@ -2,7 +2,7 @@ agp = "8.1.3" androidxNavigation = "2.6.0" kotlin = "1.9.0" -purchases = "8.22.0-SNAPSHOT" +purchases = "8.24.0" lifecycle = "2.6.1" androidxCore = "1.10.1" diff --git a/examples/MagicWeatherCompose/app/build.gradle.kts b/examples/MagicWeatherCompose/app/build.gradle.kts index 1fcb6340b7..fcd3402428 100644 --- a/examples/MagicWeatherCompose/app/build.gradle.kts +++ b/examples/MagicWeatherCompose/app/build.gradle.kts @@ -45,9 +45,6 @@ android { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } - kotlinOptions { - jvmTarget = "1.8" - } buildFeatures { compose = true buildConfig = true @@ -62,6 +59,12 @@ android { } } +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8) + } +} + dependencies { implementation(libs.androidx.core) implementation(libs.lifecycle.runtime.ktx) diff --git a/examples/MagicWeatherCompose/gradle/libs.versions.toml b/examples/MagicWeatherCompose/gradle/libs.versions.toml index 69a3ed698f..8ff1dae360 100644 --- a/examples/MagicWeatherCompose/gradle/libs.versions.toml +++ b/examples/MagicWeatherCompose/gradle/libs.versions.toml @@ -2,7 +2,7 @@ agp = "8.1.3" androidxNavigation = "2.5.3" kotlin = "1.8.22" -purchases = "8.22.0-SNAPSHOT" +purchases = "8.24.0" lifecycle = "2.5.0" androidxCore = "1.10.1" diff --git a/examples/paywall-tester/build.gradle.kts b/examples/paywall-tester/build.gradle.kts index 94a610afd7..3e3478a7ff 100644 --- a/examples/paywall-tester/build.gradle.kts +++ b/examples/paywall-tester/build.gradle.kts @@ -58,10 +58,6 @@ android { targetCompatibility = JavaVersion.VERSION_1_8 } - kotlinOptions { - jvmTarget = "1.8" - } - buildFeatures { compose = true viewBinding = true @@ -78,6 +74,12 @@ android { } } +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8) + } +} + baselineProfile { mergeIntoMain = true diff --git a/examples/paywall-tester/src/main/AndroidManifest.xml b/examples/paywall-tester/src/main/AndroidManifest.xml index 74647621b2..c46ebc92ca 100644 --- a/examples/paywall-tester/src/main/AndroidManifest.xml +++ b/examples/paywall-tester/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ android:icon="@mipmap/icon" android:label="@string/app_name" android:roundIcon="@mipmap/icon_round" + android:backupAgent="com.revenuecat.purchases.backup.RevenueCatBackupAgent" android:supportsRtl="true" android:theme="@style/Theme.Purchasesandroid"> Unit, + modifier: Modifier = Modifier, +) { + var lastCustomAction by remember { mutableStateOf(null) } + + val context = LocalContext.current + + val customerCenterListener = remember { + createCustomerCenterListener { actionIdentifier, purchaseIdentifier -> + val message = "Custom Action: $actionIdentifier" + + if (purchaseIdentifier != null) " (Product: $purchaseIdentifier)" else "" + lastCustomAction = message + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } + } + + CustomerCenter( + modifier = modifier.fillMaxSize(), + options = CustomerCenterOptions.Builder() + .setListener(customerCenterListener) + .build(), + ) { + dismissRequest() + } +} + +private fun createCustomerCenterListener( + onCustomAction: (actionIdentifier: String, purchaseIdentifier: String?) -> Unit = { _, _ -> }, +): CustomerCenterListener { + return object : CustomerCenterListener { + override fun onManagementOptionSelected(action: CustomerCenterManagementOption) { + Log.d(TAG, "Local listener: onManagementOptionSelected called with action: $action") + } + + override fun onRestoreStarted() { + Log.d(TAG, "Local listener: onRestoreStarted called") + } + + override fun onRestoreCompleted(customerInfo: CustomerInfo) { + Log.d( + TAG, + "Local listener: onRestoreCompleted called with customer info: " + + customerInfo.originalAppUserId, + ) + } + + override fun onRestoreFailed(error: PurchasesError) { + Log.d(TAG, "Local listener: onRestoreFailed called with error: ${error.message}") + } + + override fun onShowingManageSubscriptions() { + Log.d(TAG, "Local listener: onShowingManageSubscriptions called") + } + + override fun onFeedbackSurveyCompleted(feedbackSurveyOptionId: String) { + Log.d(TAG, "Local listener: onFeedbackSurveyCompleted called with option ID: $feedbackSurveyOptionId") + } + + override fun onCustomActionSelected(actionIdentifier: String, purchaseIdentifier: String?) { + Log.d( + TAG, + "Local listener: onCustomActionSelected called with action: $actionIdentifier, " + + "purchaseIdentifier: $purchaseIdentifier", + ) + onCustomAction(actionIdentifier, purchaseIdentifier) + } + } +} diff --git a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/MainScreen.kt b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/MainScreen.kt index cdd9a53bd8..8832f92f64 100644 --- a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/MainScreen.kt +++ b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/MainScreen.kt @@ -1,10 +1,10 @@ package com.revenuecat.paywallstester.ui.screens.main +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding -import androidx.compose.material.BottomNavigation -import androidx.compose.material.BottomNavigationItem import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -18,28 +18,33 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.revenuecat.paywallstester.ui.screens.main.appinfo.AppInfoScreen +import com.revenuecat.paywallstester.ui.screens.main.locale.LocaleScreen import com.revenuecat.paywallstester.ui.screens.main.offerings.OfferingsScreen import com.revenuecat.paywallstester.ui.screens.main.paywalls.PaywallsScreen import com.revenuecat.purchases.Offering +@SuppressWarnings("LongParameterList") @Composable fun MainScreen( navigateToPaywallScreen: (Offering?) -> Unit, navigateToPaywallFooterScreen: (Offering?) -> Unit, navigateToPaywallCondensedFooterScreen: (Offering?) -> Unit, navigateToPaywallByPlacementScreen: (String) -> Unit, + navigateToCustomerCenterScreen: () -> Unit, navController: NavHostController = rememberNavController(), ) { Scaffold( bottomBar = { BottomBarNavigation(navController) }, - ) { + ) { paddingValues -> MainNavHost( - navController, - navigateToPaywallScreen, - navigateToPaywallFooterScreen, - navigateToPaywallCondensedFooterScreen, - navigateToPaywallByPlacementScreen, - Modifier.padding(it), + navController = navController, + navigateToPaywallScreen = navigateToPaywallScreen, + navigateToPaywallFooterScreen = navigateToPaywallFooterScreen, + navigateToPaywallCondensedFooterScreen = navigateToPaywallCondensedFooterScreen, + navigateToPaywallByPlacementScreen = navigateToPaywallByPlacementScreen, + navigateToCustomerCenterScreen = navigateToCustomerCenterScreen, + modifier = Modifier.padding(paddingValues) + .consumeWindowInsets(paddingValues), ) } } @@ -52,6 +57,7 @@ fun MainScreenPreview() { navigateToPaywallFooterScreen = {}, navigateToPaywallCondensedFooterScreen = {}, navigateToPaywallByPlacementScreen = {}, + navigateToCustomerCenterScreen = {}, ) } @@ -59,6 +65,7 @@ private val bottomNavigationItems = listOf( Tab.AppInfo, Tab.Paywalls, Tab.Offerings, + Tab.Locale, ) @Suppress("LongParameterList") @@ -69,6 +76,7 @@ private fun MainNavHost( navigateToPaywallFooterScreen: (Offering?) -> Unit, navigateToPaywallCondensedFooterScreen: (Offering?) -> Unit, navigateToPaywallByPlacementScreen: (String) -> Unit, + navigateToCustomerCenterScreen: () -> Unit, modifier: Modifier = Modifier, ) { NavHost( @@ -77,7 +85,9 @@ private fun MainNavHost( modifier = modifier, ) { composable(Tab.AppInfo.route) { - AppInfoScreen() + AppInfoScreen( + tappedOnCustomerCenter = navigateToCustomerCenterScreen, + ) } composable(Tab.Paywalls.route) { PaywallsScreen() @@ -90,6 +100,9 @@ private fun MainNavHost( tappedOnOfferingByPlacement = { placementId -> navigateToPaywallByPlacementScreen(placementId) }, ) } + composable(Tab.Locale.route) { + LocaleScreen() + } } } @@ -97,13 +110,10 @@ private fun MainNavHost( private fun BottomBarNavigation( navController: NavHostController, ) { - BottomNavigation( - backgroundColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, - ) { + NavigationBar { val currentRoute = currentRoute(navController) bottomNavigationItems.forEach { screen -> - BottomNavigationItem( + NavigationBarItem( icon = { Icon( painterResource(id = screen.iconResourceId), diff --git a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/Tab.kt b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/Tab.kt index f3a1fc943d..7e8e9fd40a 100644 --- a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/Tab.kt +++ b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/Tab.kt @@ -6,4 +6,5 @@ sealed class Tab(val route: String, val title: String, val iconResourceId: Int) object AppInfo : Tab("app-info", "App Info", R.drawable.ic_menu_call) object Paywalls : Tab("paywalls", "Paywalls", R.drawable.ic_dialog_map) object Offerings : Tab("offerings", "Offerings", R.drawable.ic_dialog_dialer) + object Locale : Tab("locale", "Locale", R.drawable.ic_menu_edit) } diff --git a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/appinfo/AppInfoScreen.kt b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/appinfo/AppInfoScreen.kt index 5d4dab86c2..8d2d47ef95 100644 --- a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/appinfo/AppInfoScreen.kt +++ b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/appinfo/AppInfoScreen.kt @@ -30,47 +30,25 @@ import androidx.compose.ui.window.Dialog import androidx.lifecycle.viewmodel.compose.viewModel import com.revenuecat.paywallstester.Constants import com.revenuecat.paywallstester.ui.screens.main.appinfo.AppInfoScreenViewModel.UiState -import com.revenuecat.purchases.CustomerInfo -import com.revenuecat.purchases.PurchasesError -import com.revenuecat.purchases.customercenter.CustomerCenterListener -import com.revenuecat.purchases.customercenter.CustomerCenterManagementOption import com.revenuecat.purchases.ui.debugview.DebugRevenueCatBottomSheet -import com.revenuecat.purchases.ui.revenuecatui.customercenter.CustomerCenter -import com.revenuecat.purchases.ui.revenuecatui.customercenter.CustomerCenterOptions import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -private const val TAG = "CustomerCenterTest" - @SuppressWarnings("LongMethod") @Composable fun AppInfoScreen( + modifier: Modifier = Modifier, viewModel: AppInfoScreenViewModel = viewModel( factory = AppInfoScreenViewModelImpl.Factory, ), + tappedOnCustomerCenter: () -> Unit, ) { var isDebugBottomSheetVisible by remember { mutableStateOf(false) } - var isCustomerCenterVisible by remember { mutableStateOf(false) } var showLogInDialog by remember { mutableStateOf(false) } var showApiKeyDialog by remember { mutableStateOf(false) } - // Use remember to cache the listener across recompositions - val customerCenterListener = remember { createCustomerCenterListener() } - - if (isCustomerCenterVisible) { - CustomerCenter( - modifier = Modifier.fillMaxSize(), - options = CustomerCenterOptions.Builder() - .setListener(customerCenterListener) - .build(), - ) { - isCustomerCenterVisible = false - } - return - } - Column( - modifier = Modifier.fillMaxSize(), + modifier = modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -92,7 +70,7 @@ fun AppInfoScreen( Text(text = "Show debug view") } Button(onClick = { - isCustomerCenterVisible = true + tappedOnCustomerCenter() }) { Text(text = "Show customer center") } @@ -218,6 +196,7 @@ fun AppInfoScreenPreview() { override fun logOut() { } override fun switchApiKey(newApiKey: String) { } }, + tappedOnCustomerCenter = {}, ) } @@ -229,35 +208,3 @@ private fun ApiKeyDialog_Preview() { onDismissed = {}, ) } - -private fun createCustomerCenterListener(): CustomerCenterListener { - return object : CustomerCenterListener { - override fun onManagementOptionSelected(action: CustomerCenterManagementOption) { - Log.d(TAG, "Local listener: onManagementOptionSelected called with action: $action") - } - - override fun onRestoreStarted() { - Log.d(TAG, "Local listener: onRestoreStarted called") - } - - override fun onRestoreCompleted(customerInfo: CustomerInfo) { - Log.d( - TAG, - "Local listener: onRestoreCompleted called with customer info: " + - customerInfo.originalAppUserId, - ) - } - - override fun onRestoreFailed(error: PurchasesError) { - Log.d(TAG, "Local listener: onRestoreFailed called with error: ${error.message}") - } - - override fun onShowingManageSubscriptions() { - Log.d(TAG, "Local listener: onShowingManageSubscriptions called") - } - - override fun onFeedbackSurveyCompleted(feedbackSurveyOptionId: String) { - Log.d(TAG, "Local listener: onFeedbackSurveyCompleted called with option ID: $feedbackSurveyOptionId") - } - } -} diff --git a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/locale/LocaleScreen.kt b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/locale/LocaleScreen.kt new file mode 100644 index 0000000000..6451866c98 --- /dev/null +++ b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/locale/LocaleScreen.kt @@ -0,0 +1,205 @@ +package com.revenuecat.paywallstester.ui.screens.main.locale + +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Divider +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.revenuecat.purchases.Purchases + +private const val MESSAGE_HIDE_DELAY = 4000L +private const val LOG_DELAY = 100L + +@Suppress("LongMethod") +@Composable +fun LocaleScreen( + modifier: Modifier = Modifier, +) { + // Initialize selectedLocale with the current preferred locale override + val currentPreferredLocale = Purchases.sharedInstance.preferredUILocaleOverride + var selectedLocale by remember { mutableStateOf(currentPreferredLocale) } + + val commonLocales = listOf( + "en-US" to "English (US)", + "en-GB" to "English (UK)", + "es-ES" to "Spanish (Spain)", + "es-MX" to "Spanish (Mexico)", + "fr-FR" to "French (France)", + "de-DE" to "German", + "it-IT" to "Italian", + "pt-BR" to "Portuguese (Brazil)", + "ja-JP" to "Japanese", + "ko-KR" to "Korean", + "zh-CN" to "Chinese (Simplified)", + "zh-TW" to "Chinese (Traditional)", + "ru-RU" to "Russian", + "ar-SA" to "Arabic", + "hi-IN" to "Hindi", + ) + + // If current locale is custom (not in predefined list), initialize custom input + var customLocaleInput by remember { + mutableStateOf( + if (currentPreferredLocale != null && !commonLocales.any { it.first == currentPreferredLocale }) { + currentPreferredLocale + } else { + "" + }, + ) + } + + Column( + modifier = modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Preferred UI Locale Override", + fontSize = 20.sp, + modifier = Modifier.padding(bottom = 8.dp), + ) + + Text( + text = "Current: ${Purchases.sharedInstance.preferredUILocaleOverride ?: "System default"}", + fontSize = 14.sp, + modifier = Modifier.padding(bottom = 16.dp), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = selectedLocale == null, + onClick = { selectedLocale = null }, + ) + Text("System default") + } + + Divider() + + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(commonLocales) { (localeCode, displayName) -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { selectedLocale = localeCode }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = selectedLocale == localeCode, + onClick = { selectedLocale = localeCode }, + ) + Column { + Text(displayName) + Text( + text = localeCode, + fontSize = 12.sp, + color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + Divider() + + Text("Custom Locale:", modifier = Modifier.padding(top = 8.dp)) + OutlinedTextField( + value = customLocaleInput, + onValueChange = { customLocaleInput = it }, + label = { Text("e.g. en-US, pt-BR") }, + modifier = Modifier.fillMaxWidth(), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = selectedLocale == customLocaleInput && customLocaleInput.isNotEmpty(), + onClick = { + if (customLocaleInput.isNotEmpty()) { + selectedLocale = customLocaleInput + } + }, + ) + Text("Use custom locale") + } + + var statusMessage by remember { mutableStateOf(null) } + var isError by remember { mutableStateOf(false) } + + statusMessage?.let { message -> + Text( + text = message, + color = if (isError) { + androidx.compose.material3.MaterialTheme.colorScheme.error + } else { + androidx.compose.material3.MaterialTheme.colorScheme.primary + }, + fontSize = 12.sp, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + + Button( + onClick = { + Purchases.sharedInstance.overridePreferredUILocale(selectedLocale) + + // Hide message after 4 seconds + Handler(Looper.getMainLooper()).postDelayed({ + statusMessage = null + }, MESSAGE_HIDE_DELAY) + + // Log for debugging + Handler(Looper.getMainLooper()).postDelayed({ + val currentOverride = Purchases.sharedInstance.preferredUILocaleOverride + Log.d( + "LocaleScreen", + "Applied locale override: $selectedLocale, " + + "current value: $currentOverride", + ) + }, LOG_DELAY) + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + ) { + Text("Apply Locale Override") + } + } +} + +@Preview(showBackground = true) +@Composable +private fun LocaleScreenPreview() { + LocaleScreen() +} diff --git a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/offerings/OfferingsScreen.kt b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/offerings/OfferingsScreen.kt index ba8f46348c..6a8ea6f109 100644 --- a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/offerings/OfferingsScreen.kt +++ b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/offerings/OfferingsScreen.kt @@ -15,10 +15,13 @@ import androidx.compose.material.FloatingActionButton import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -49,16 +52,18 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +@SuppressWarnings("LongParameterList") @Composable fun OfferingsScreen( tappedOnOffering: (Offering) -> Unit, tappedOnOfferingFooter: (Offering) -> Unit, tappedOnOfferingCondensedFooter: (Offering) -> Unit, tappedOnOfferingByPlacement: (String) -> Unit, + modifier: Modifier = Modifier, viewModel: OfferingsViewModel = viewModel(), ) { when (val state = viewModel.offeringsState.collectAsStateWithLifecycle().value) { - is OfferingsState.Error -> ErrorOfferingsScreen(errorState = state) + is OfferingsState.Error -> ErrorOfferingsScreen(errorState = state, modifier) is OfferingsState.Loaded -> OfferingsListScreen( offeringsState = state, tappedOnNavigateToOffering = tappedOnOffering, @@ -66,14 +71,20 @@ fun OfferingsScreen( tappedOnNavigateToOfferingCondensedFooter = tappedOnOfferingCondensedFooter, tappedOnNavigateToOfferingByPlacement = tappedOnOfferingByPlacement, tappedOnReloadOfferings = { viewModel.refreshOfferings() }, + onSearchQueryChange = { query -> viewModel.updateSearchQuery(query) }, + modifier, ) - OfferingsState.Loading -> LoadingOfferingsScreen() + OfferingsState.Loading -> LoadingOfferingsScreen(modifier) } } @Composable -private fun ErrorOfferingsScreen(errorState: OfferingsState.Error) { +private fun ErrorOfferingsScreen( + errorState: OfferingsState.Error, + modifier: Modifier = Modifier, +) { Column( + modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -82,8 +93,11 @@ private fun ErrorOfferingsScreen(errorState: OfferingsState.Error) { } @Composable -private fun LoadingOfferingsScreen() { +private fun LoadingOfferingsScreen( + modifier: Modifier = Modifier, +) { Column( + modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -101,14 +115,58 @@ private fun OfferingsListScreen( tappedOnNavigateToOfferingCondensedFooter: (Offering) -> Unit, tappedOnNavigateToOfferingByPlacement: (String) -> Unit, tappedOnReloadOfferings: () -> Unit, + onSearchQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, ) { var dropdownExpandedOffering by remember { mutableStateOf(null) } var displayPaywallDialogOffering by remember { mutableStateOf(null) } val showDialog = remember { mutableStateOf(false) } - Box(modifier = Modifier.fillMaxSize()) { + // Filter offerings based on search query + val filteredOfferings = remember(offeringsState.offerings, offeringsState.searchQuery) { + val query = offeringsState.searchQuery.lowercase().trim() + if (query.isEmpty()) { + offeringsState.offerings.all.values.toList() + } else { + offeringsState.offerings.all.values.filter { offering -> + offering.identifier.lowercase().contains(query) || + offering.paywall?.templateName?.lowercase()?.contains(query) == true || + offering.paywallComponents?.data?.templateName?.lowercase()?.contains(query) == true + } + } + } + + Box(modifier = modifier.fillMaxSize()) { LazyColumn { + // Search bar + item { + OutlinedTextField( + value = offeringsState.searchQuery, + onValueChange = onSearchQueryChange, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + label = { Text("Search offerings...") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + ) + }, + trailingIcon = { + if (offeringsState.searchQuery.isNotEmpty()) { + IconButton(onClick = { onSearchQueryChange("") }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear search", + ) + } + } + }, + ) + } + item { Box(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) { @@ -126,7 +184,7 @@ private fun OfferingsListScreen( } } } - items(offeringsState.offerings.all.values.toList()) { offering -> + items(filteredOfferings) { offering -> Box(modifier = Modifier.fillMaxWidth()) { if (offering == dropdownExpandedOffering) { DisplayOfferingMenu( @@ -278,7 +336,11 @@ private fun DisplayOfferingMenu( ) DropdownMenuItem( text = { Text(text = "Display paywall as activity") }, - onClick = { activity.launchPaywall(offering) }, + onClick = { activity.launchPaywall(offering, edgeToEdge = false) }, + ) + DropdownMenuItem( + text = { Text(text = "Display paywall as activity (edgeToEdge enabled)") }, + onClick = { activity.launchPaywall(offering, edgeToEdge = true) }, ) DropdownMenuItem( text = { Text(text = "Display paywall as view in an activity") }, @@ -315,6 +377,10 @@ fun OfferingsScreenPreview() { override fun refreshOfferings() { // no-op } + + override fun updateSearchQuery(query: String) { + // no-op + } }, ) } diff --git a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/offerings/OfferingsState.kt b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/offerings/OfferingsState.kt index b069f4e5c5..da6a82aa12 100644 --- a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/offerings/OfferingsState.kt +++ b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/offerings/OfferingsState.kt @@ -4,7 +4,7 @@ import com.revenuecat.purchases.Offerings import com.revenuecat.purchases.PurchasesError sealed class OfferingsState { - data class Loaded(val offerings: Offerings) : OfferingsState() + data class Loaded(val offerings: Offerings, val searchQuery: String = "") : OfferingsState() object Loading : OfferingsState() data class Error(val purchasesError: PurchasesError) : OfferingsState() } diff --git a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/offerings/OfferingsViewModel.kt b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/offerings/OfferingsViewModel.kt index 5532e82cc2..63bea2d7ac 100644 --- a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/offerings/OfferingsViewModel.kt +++ b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/offerings/OfferingsViewModel.kt @@ -17,6 +17,8 @@ abstract class OfferingsViewModel : ViewModel() { abstract val offeringsState: StateFlow abstract fun refreshOfferings() + + abstract fun updateSearchQuery(query: String) } class OfferingsViewModelImpl : OfferingsViewModel() { @@ -33,7 +35,15 @@ class OfferingsViewModelImpl : OfferingsViewModel() { _offeringsState.update { OfferingsState.Loading } viewModelScope.launch { val offerings = Purchases.sharedInstance.awaitSyncAttributesAndOfferingsIfNeeded() - _offeringsState.update { OfferingsState.Loaded(offerings) } + val currentQuery = (_offeringsState.value as? OfferingsState.Loaded)?.searchQuery ?: "" + _offeringsState.update { OfferingsState.Loaded(offerings, currentQuery) } + } + } + + override fun updateSearchQuery(query: String) { + val currentState = _offeringsState.value + if (currentState is OfferingsState.Loaded) { + _offeringsState.update { currentState.copy(searchQuery = query) } } } diff --git a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/paywalls/PaywallsScreen.kt b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/paywalls/PaywallsScreen.kt index 8d80fb8c6a..52aeb27b7a 100644 --- a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/paywalls/PaywallsScreen.kt +++ b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/paywalls/PaywallsScreen.kt @@ -109,6 +109,7 @@ private class TestAppPurchaseLogicCallbacks : PurchaseLogicWithCallback() { @Suppress("LongMethod") @Composable fun PaywallsScreen( + modifier: Modifier = Modifier, samplePaywallsLoader: SamplePaywallsLoader = SamplePaywallsLoader(), ) { var displayPaywallState by remember { mutableStateOf(DisplayPaywallState.None) } @@ -126,7 +127,7 @@ fun PaywallsScreen( } LazyColumn( - modifier = Modifier.testTag("paywall_screen"), + modifier = modifier.testTag("paywall_screen"), ) { items(SamplePaywalls.SampleTemplate.values()) { template -> val offering = samplePaywallsLoader.offeringForTemplate(template) diff --git a/examples/purchase-tester/build.gradle.kts b/examples/purchase-tester/build.gradle.kts index 5982751af1..d4ba435368 100644 --- a/examples/purchase-tester/build.gradle.kts +++ b/examples/purchase-tester/build.gradle.kts @@ -73,4 +73,5 @@ dependencies { implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.navigation.fragment) implementation(libs.androidx.navigation.ui) + implementation(libs.google.blockstore) } diff --git a/examples/purchase-tester/src/main/AndroidManifest.xml b/examples/purchase-tester/src/main/AndroidManifest.xml index 31db952516..459e989321 100644 --- a/examples/purchase-tester/src/main/AndroidManifest.xml +++ b/examples/purchase-tester/src/main/AndroidManifest.xml @@ -6,10 +6,10 @@ diff --git a/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/OverviewViewModel.kt b/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/OverviewViewModel.kt index 7c8c59c17a..aaef3d8778 100644 --- a/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/OverviewViewModel.kt +++ b/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/OverviewViewModel.kt @@ -1,11 +1,14 @@ package com.revenuecat.purchasetester +import android.content.Context import android.net.Uri import android.util.Log import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.google.android.gms.auth.blockstore.Blockstore +import com.google.android.gms.auth.blockstore.DeleteBytesRequest import com.revenuecat.purchases.CustomerInfo import com.revenuecat.purchases.EntitlementInfo import com.revenuecat.purchases.Purchases @@ -13,9 +16,12 @@ import com.revenuecat.purchases.PurchasesError import com.revenuecat.purchases.PurchasesException import com.revenuecat.purchases.VerificationResult import com.revenuecat.purchases.awaitCustomerInfo +import com.revenuecat.purchases.awaitGetVirtualCurrencies import com.revenuecat.purchases.restorePurchasesWith +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies import kotlinx.coroutines.launch +@Suppress("TooManyFunctions") class OverviewViewModel(private val interactionHandler: OverviewInteractionHandler) : ViewModel() { val customerInfo: MutableLiveData by lazy { @@ -34,6 +40,8 @@ class OverviewViewModel(private val interactionHandler: OverviewInteractionHandl val verificationResult = MediatorLiveData() + val formattedVirtualCurrencies = MutableLiveData() + init { activeEntitlements.addSource(customerInfo) { info -> info?.entitlements?.active?.values?.let { @@ -70,6 +78,16 @@ class OverviewViewModel(private val interactionHandler: OverviewInteractionHandl }) } + fun onBlockStoreClearClicked(context: Context) { + val blockstoreClient = Blockstore.getClient(context) + val request = DeleteBytesRequest.Builder() + .setDeleteAll(true) + .build() + blockstoreClient.deleteBytes(request) + .addOnSuccessListener { Log.d("PurchaseTester", "Blockstore cleared") } + .addOnFailureListener { Log.e("PurchaseTester", "Blockstore failed to clear: $it") } + } + fun onCardClicked() = interactionHandler.toggleCard() fun onCopyClicked() { @@ -103,9 +121,52 @@ class OverviewViewModel(private val interactionHandler: OverviewInteractionHandl interactionHandler.syncAttributes() } + fun onFetchVCsClicked() { + viewModelScope.launch { + val virtualCurrencies: VirtualCurrencies = Purchases.sharedInstance.awaitGetVirtualCurrencies() + val formatted = formatVirtualCurrencies(virtualCurrencies = virtualCurrencies) + formattedVirtualCurrencies.value = formatted + Log.i("PurchaseTester", formatted) + } + } + + fun onInvalidateVirtualCurrenciesCache() { + Purchases.sharedInstance.invalidateVirtualCurrenciesCache() + } + + fun onFetchVCCache() { + val cachedVirtualCurrencies: VirtualCurrencies? = Purchases.sharedInstance.cachedVirtualCurrencies + if (cachedVirtualCurrencies == null) { + formattedVirtualCurrencies.value = "Cached VCs are null" + Log.i("PurchaseTester", "Cached VCs are null") + } else { + val formatted = formatVirtualCurrencies(virtualCurrencies = cachedVirtualCurrencies) + formattedVirtualCurrencies.value = formatted + Log.i("PurchaseTester", formatted) + } + } + private fun formatEntitlements(entitlementInfos: Collection): String { return entitlementInfos.joinToString(separator = "\n") { it.toBriefString() } } + + private fun formatVirtualCurrencies(virtualCurrencies: VirtualCurrencies): String { + val stringBuilder = StringBuilder() + stringBuilder.append("Virtual Currencies (${virtualCurrencies.all.size}):\n") + + if (virtualCurrencies.all.isEmpty()) { + stringBuilder.append("\tNo virtual currencies available\n") + } else { + virtualCurrencies.all.forEach { keyValuePair -> + stringBuilder.append("\t${keyValuePair.value.code}:\n") + stringBuilder.append("\t\tName: ${keyValuePair.value.name}\n") + stringBuilder.append("\t\tBalance: ${keyValuePair.value.balance}\n") + stringBuilder.append("\t\tDescription: ${keyValuePair.value.serverDescription}\n") + } + } + + return stringBuilder.toString() + } } interface OverviewInteractionHandler { diff --git a/examples/purchase-tester/src/main/res/layout/fragment_overview.xml b/examples/purchase-tester/src/main/res/layout/fragment_overview.xml index 673ded6103..a03a34ec02 100644 --- a/examples/purchase-tester/src/main/res/layout/fragment_overview.xml +++ b/examples/purchase-tester/src/main/res/layout/fragment_overview.xml @@ -119,6 +119,17 @@ bind:header="@{`All Entitlements: `}" bind:detail="@{viewModel.allEntitlements}"/> + + + + + + + + + + + + + + + + + + + ().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8) + } +} + dependencies { implementation(project(path = ":purchases")) implementation(libs.androidx.core) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 36ec2e2a10..0e574ec9e2 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -274,7 +274,8 @@ platform :android do google_purchase_token: ENV['LOAD_SHEDDER_GOOGLE_PURCHASE_TOKEN'], product_id_to_purchase: ENV['LOAD_SHEDDER_PRODUCT_ID_TO_PURCHASE'], base_plan_id_to_purchase: ENV['LOAD_SHEDDER_BASE_PLAN_ID_TO_PURCHASE'], - active_entitlements_to_verify: ENV['LOAD_SHEDDER_ACTIVE_ENTITLEMENTS_TO_VERIFY'] || '' + active_entitlements_to_verify: ENV['LOAD_SHEDDER_ACTIVE_ENTITLEMENTS_TO_VERIFY'] || '', + is_load_shedder_integration_tests: true ) run_firebase_integration_tests(app_name) @@ -324,7 +325,8 @@ platform :android do def build_purchases_integration_tests(app_name:, api_key:, google_purchase_token:, product_id_to_purchase:, base_plan_id_to_purchase:, active_entitlements_to_verify: '', - proxy_url: nil, build_type: 'release', flavor: 'defaults') + proxy_url: nil, build_type: 'release', flavor: 'defaults', + is_load_shedder_integration_tests: false) constants_path = './purchases/src/androidTest/kotlin/com/revenuecat/purchases/Constants.kt' replace_text_in_files( previous_text: "REVENUECAT_API_KEY", @@ -352,6 +354,19 @@ platform :android do allow_empty: true, paths_of_files_to_update: [constants_path] ) + if is_load_shedder_integration_tests + replace_text_in_files( + previous_text: "IS_RUNNING_LOAD_SHEDDER_INTEGRATION_TESTS", + new_text: "true", + paths_of_files_to_update: [constants_path] + ) + else + replace_text_in_files( + previous_text: "IS_RUNNING_LOAD_SHEDDER_INTEGRATION_TESTS", + new_text: "false", + paths_of_files_to_update: [constants_path] + ) + end unless proxy_url.nil? replace_text_in_files( previous_text: "NO_PROXY_URL", diff --git a/feature/amazon/build.gradle b/feature/amazon/build.gradle deleted file mode 100644 index db6765f7df..0000000000 --- a/feature/amazon/build.gradle +++ /dev/null @@ -1,32 +0,0 @@ -plugins { - alias libs.plugins.android.library - alias libs.plugins.kotlin.android -} - -if (!project.getProperties()["ANDROID_VARIANT_TO_PUBLISH"].contains("customEntitlementComputation")) { - apply plugin: "com.vanniktech.maven.publish" -} - -apply from: "$rootProject.projectDir/library.gradle" - -android { - namespace 'com.revenuecat.purchases.amazon' - - flavorDimensions = ["apis"] - productFlavors { - defaults { - dimension "apis" - getIsDefault().set(true) - } - } - - defaultConfig { - missingDimensionStrategy 'apis', 'defaults' - } -} - -dependencies { - implementation project(":purchases") - - implementation libs.amazon.appstore.sdk -} diff --git a/feature/amazon/build.gradle.kts b/feature/amazon/build.gradle.kts new file mode 100644 index 0000000000..42614397ad --- /dev/null +++ b/feature/amazon/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +if (!(project.properties["ANDROID_VARIANT_TO_PUBLISH"] as String).contains("customEntitlementComputation")) { + apply(plugin = "com.vanniktech.maven.publish") +} + +apply(from = "${rootProject.projectDir}/library.gradle") + +android { + namespace = "com.revenuecat.purchases.amazon" + + flavorDimensions += "apis" + + productFlavors { + create("defaults") { + dimension = "apis" + isDefault = true + } + } + + defaultConfig { + missingDimensionStrategy("apis", "defaults") + } +} + +dependencies { + implementation(project(":purchases")) + + implementation(libs.amazon.appstore.sdk) +} diff --git a/gradle.properties b/gradle.properties index 4b44e29bfe..ecd3c3a589 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ #Fri Mar 31 10:31:20 PDT 2023 GROUP=com.revenuecat.purchases -VERSION_NAME=8.22.0-SNAPSHOT +VERSION_NAME=8.24.0 POM_DESCRIPTION=Mobile subscriptions in hours, not months. POM_URL=https://github.com/RevenueCat/purchases-android diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ef525a4243..1039017866 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,9 +28,10 @@ emergeGradlePlugin = "4.3.1" emergeSnapshots = "1.4.1" espresso = "3.4.0" fragment = "1.6.1" +googleBlockstore = "16.4.0" hamcrest = "1.3" recyclerview = "1.2.1" -roboelectric = "4.11.1" +roboelectric = "4.15.1" kotlin = "1.8.22" # Can't update until we use more recent kotlin. 1.6.0 uses Kotlin 1.9.0 kotlinxSerializationJSON = "1.5.1" @@ -54,7 +55,7 @@ window = "1.1.0" junit = "1.2.1" uiautomator = "2.3.0" benchmarkMacroJunit4 = "1.3.4" -baselineprofile = "1.4.0-beta02" +baselineprofile = "1.4.0" profileinstaller = "1.4.1" [libraries] @@ -126,6 +127,8 @@ dokka-testApi = { module = "org.jetbrains.dokka:dokka-test-api", version.ref = " emerge-snapshots = { module = "com.emergetools.snapshots:snapshots", version.ref = "emergeSnapshots" } emerge-snapshots-annotations = { module = "com.emergetools.snapshots:snapshots-annotations", version.ref = "emergeSnapshots" } + +google-blockstore = { module = "com.google.android.gms:play-services-auth-blockstore", version.ref = "googleBlockstore" } hamcrest-core = { module = "org.hamcrest:hamcrest-core", version.ref = "hamcrest" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } diff --git a/library.gradle b/library.gradle index d44c1a0391..7231c5abc8 100644 --- a/library.gradle +++ b/library.gradle @@ -5,7 +5,7 @@ android { minSdkVersion obtainMinSdkVersion() targetSdkVersion compileVersion versionCode 1 - versionName "8.22.0-SNAPSHOT" + versionName "8.24.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" } diff --git a/purchases/api-defauts.txt b/purchases/api-defauts.txt index cc2a546dff..8ca4f79201 100644 --- a/purchases/api-defauts.txt +++ b/purchases/api-defauts.txt @@ -27,13 +27,14 @@ package com.revenuecat.purchases { method @kotlin.jvm.JvmSynthetic public static suspend Object? awaitPurchaseResult(com.revenuecat.purchases.Purchases, com.revenuecat.purchases.PurchaseParams purchaseParams, kotlin.coroutines.Continuation>); method @kotlin.jvm.JvmSynthetic @kotlin.jvm.Throws(exceptionClasses=PurchasesTransactionException::class) public static suspend Object? awaitRestore(com.revenuecat.purchases.Purchases, kotlin.coroutines.Continuation); method @kotlin.jvm.JvmSynthetic public static suspend Object? awaitRestoreResult(com.revenuecat.purchases.Purchases, kotlin.coroutines.Continuation>); + method public static suspend Object? awaitStorefrontCountryCode(com.revenuecat.purchases.Purchases, kotlin.coroutines.Continuation); } public final class CoroutinesExtensionsKt { method @kotlin.jvm.JvmSynthetic @kotlin.jvm.Throws(exceptionClasses=PurchasesException::class) public static suspend Object? awaitCustomerInfo(com.revenuecat.purchases.Purchases, optional com.revenuecat.purchases.CacheFetchPolicy fetchPolicy, kotlin.coroutines.Continuation); + method @kotlin.jvm.JvmSynthetic @kotlin.jvm.Throws(exceptionClasses=PurchasesException::class) public static suspend Object? awaitGetVirtualCurrencies(com.revenuecat.purchases.Purchases, kotlin.coroutines.Continuation); method @kotlin.jvm.JvmSynthetic @kotlin.jvm.Throws(exceptionClasses=PurchasesTransactionException::class) public static suspend Object? awaitLogIn(com.revenuecat.purchases.Purchases, String appUserID, kotlin.coroutines.Continuation); method @kotlin.jvm.JvmSynthetic @kotlin.jvm.Throws(exceptionClasses=PurchasesTransactionException::class) public static suspend Object? awaitLogOut(com.revenuecat.purchases.Purchases, kotlin.coroutines.Continuation); - method public static suspend Object? awaitStorefrontCountryCode(com.revenuecat.purchases.Purchases, kotlin.coroutines.Continuation); method @kotlin.jvm.JvmSynthetic @kotlin.jvm.Throws(exceptionClasses=PurchasesException::class) public static suspend Object? awaitSyncAttributesAndOfferingsIfNeeded(com.revenuecat.purchases.Purchases, kotlin.coroutines.Continuation); method @kotlin.jvm.JvmSynthetic @kotlin.jvm.Throws(exceptionClasses=PurchasesException::class) public static suspend Object? awaitSyncPurchases(com.revenuecat.purchases.Purchases, kotlin.coroutines.Continuation); method @kotlin.jvm.JvmSynthetic @kotlin.jvm.Throws(exceptionClasses=PurchasesException::class) public static suspend Object? getAmazonLWAConsentStatus(com.revenuecat.purchases.Purchases, kotlin.coroutines.Continuation); @@ -184,13 +185,14 @@ package com.revenuecat.purchases { method @kotlin.jvm.JvmSynthetic public static com.revenuecat.purchases.WebPurchaseRedemption? asWebPurchaseRedemption(android.content.Intent); } - @kotlin.RequiresOptIn(level=kotlin.RequiresOptIn.Level.ERROR, message="This is an internal RevenueCat API that may change frequently and without warning. " + "No compatibility guarantees are provided. It is strongly discouraged to use this API.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.CLASS, kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.PROPERTY, kotlin.annotation.AnnotationTarget.TYPEALIAS}) public @interface InternalRevenueCatAPI { + @kotlin.RequiresOptIn(level=kotlin.RequiresOptIn.Level.ERROR, message="This is an internal RevenueCat API that may change frequently and without warning. " + "No compatibility guarantees are provided. It is strongly discouraged to use this API.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.CLASS, kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.PROPERTY, kotlin.annotation.AnnotationTarget.TYPEALIAS, kotlin.annotation.AnnotationTarget.CONSTRUCTOR}) public @interface InternalRevenueCatAPI { } public final class ListenerConversionsCommonKt { method public static void getOfferingsWith(com.revenuecat.purchases.Purchases, optional kotlin.jvm.functions.Function1 onError, kotlin.jvm.functions.Function1 onSuccess); method public static void getProductsWith(com.revenuecat.purchases.Purchases, java.util.List productIds, com.revenuecat.purchases.ProductType? type, optional kotlin.jvm.functions.Function1 onError, kotlin.jvm.functions.Function1,kotlin.Unit> onGetStoreProducts); method public static void getProductsWith(com.revenuecat.purchases.Purchases, java.util.List productIds, optional kotlin.jvm.functions.Function1 onError, kotlin.jvm.functions.Function1,kotlin.Unit> onGetStoreProducts); + method public static void getStorefrontCountryCodeWith(com.revenuecat.purchases.Purchases, optional kotlin.jvm.functions.Function1 onError, kotlin.jvm.functions.Function1 onSuccess); method public static void purchaseWith(com.revenuecat.purchases.Purchases, com.revenuecat.purchases.PurchaseParams purchaseParams, optional kotlin.jvm.functions.Function2 onError, kotlin.jvm.functions.Function2 onSuccess); method public static void restorePurchasesWith(com.revenuecat.purchases.Purchases, optional kotlin.jvm.functions.Function1 onError, kotlin.jvm.functions.Function1 onSuccess); } @@ -200,8 +202,8 @@ package com.revenuecat.purchases { method public static void getCustomerInfoWith(com.revenuecat.purchases.Purchases, com.revenuecat.purchases.CacheFetchPolicy fetchPolicy, optional kotlin.jvm.functions.Function1 onError, kotlin.jvm.functions.Function1 onSuccess); method public static void getCustomerInfoWith(com.revenuecat.purchases.Purchases, optional kotlin.jvm.functions.Function1 onError, kotlin.jvm.functions.Function1 onSuccess); method @Deprecated public static void getNonSubscriptionSkusWith(com.revenuecat.purchases.Purchases, java.util.List skus, kotlin.jvm.functions.Function1 onError, kotlin.jvm.functions.Function1,kotlin.Unit> onReceiveSkus); - method public static void getStorefrontCountryCodeWith(com.revenuecat.purchases.Purchases, optional kotlin.jvm.functions.Function1 onError, kotlin.jvm.functions.Function1 onSuccess); method @Deprecated public static void getSubscriptionSkusWith(com.revenuecat.purchases.Purchases, java.util.List skus, optional kotlin.jvm.functions.Function1 onError, kotlin.jvm.functions.Function1,kotlin.Unit> onReceiveSkus); + method public static void getVirtualCurrenciesWith(com.revenuecat.purchases.Purchases, optional kotlin.jvm.functions.Function1 onError, kotlin.jvm.functions.Function1 onSuccess); method public static void logInWith(com.revenuecat.purchases.Purchases, String appUserID, optional kotlin.jvm.functions.Function1 onError, kotlin.jvm.functions.Function2 onSuccess); method public static void logOutWith(com.revenuecat.purchases.Purchases, optional kotlin.jvm.functions.Function1 onError, kotlin.jvm.functions.Function1 onSuccess); method @Deprecated public static void purchasePackageWith(com.revenuecat.purchases.Purchases, android.app.Activity activity, com.revenuecat.purchases.Package packageToPurchase, optional kotlin.jvm.functions.Function2 onError, kotlin.jvm.functions.Function2 onSuccess); @@ -416,6 +418,7 @@ package com.revenuecat.purchases { method @Deprecated @kotlin.jvm.Synchronized public boolean getAllowSharingPlayStoreAccount(); method public void getAmazonLWAConsentStatus(com.revenuecat.purchases.interfaces.GetAmazonLWAConsentStatusCallback callback); method @kotlin.jvm.Synchronized public String getAppUserID(); + method public com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies? getCachedVirtualCurrencies(); method public com.revenuecat.purchases.PurchasesConfiguration getCurrentConfiguration(); method public com.revenuecat.purchases.customercenter.CustomerCenterListener? getCustomerCenterListener(); method public void getCustomerInfo(com.revenuecat.purchases.CacheFetchPolicy fetchPolicy, com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback callback); @@ -428,6 +431,7 @@ package com.revenuecat.purchases { method @Deprecated public void getNonSubscriptionSkus(java.util.List productIds, com.revenuecat.purchases.interfaces.GetStoreProductsCallback callback); method public void getOfferings(com.revenuecat.purchases.interfaces.ReceiveOfferingsCallback listener); method public static com.revenuecat.purchases.common.PlatformInfo getPlatformInfo(); + method @kotlin.jvm.Synchronized public String? getPreferredUILocaleOverride(); method public void getProducts(java.util.List productIds, com.revenuecat.purchases.interfaces.GetStoreProductsCallback callback); method public void getProducts(java.util.List productIds, optional com.revenuecat.purchases.ProductType? type, com.revenuecat.purchases.interfaces.GetStoreProductsCallback callback); method public static java.net.URL? getProxyURL(); @@ -438,7 +442,9 @@ package com.revenuecat.purchases { method public void getStorefrontCountryCode(com.revenuecat.purchases.interfaces.GetStorefrontCallback callback); method @Deprecated public void getSubscriptionSkus(java.util.List productIds, com.revenuecat.purchases.interfaces.GetStoreProductsCallback callback); method @kotlin.jvm.Synchronized public com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener? getUpdatedCustomerInfoListener(); + method public void getVirtualCurrencies(com.revenuecat.purchases.interfaces.GetVirtualCurrenciesCallback callback); method public void invalidateCustomerInfoCache(); + method public void invalidateVirtualCurrenciesCache(); method public boolean isAnonymous(); method public static boolean isConfigured(); method public void logIn(String newAppUserID); @@ -447,6 +453,7 @@ package com.revenuecat.purchases { method public void logOut(optional com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback? callback); method @Deprecated public void onAppBackgrounded(); method @Deprecated public void onAppForegrounded(); + method public boolean overridePreferredUILocale(String? localeString); method public static com.revenuecat.purchases.WebPurchaseRedemption? parseAsWebPurchaseRedemption(android.content.Intent intent); method public static com.revenuecat.purchases.WebPurchaseRedemption? parseAsWebPurchaseRedemption(String string); method public void purchase(com.revenuecat.purchases.PurchaseParams purchaseParams, com.revenuecat.purchases.interfaces.PurchaseCallback callback); @@ -483,6 +490,7 @@ package com.revenuecat.purchases { method public void setOnesignalUserID(String? onesignalUserID); method public void setPhoneNumber(String? phoneNumber); method public static void setPlatformInfo(com.revenuecat.purchases.common.PlatformInfo); + method public void setPostHogUserId(String? postHogUserId); method public static void setProxyURL(java.net.URL?); method @kotlin.jvm.Synchronized public void setPurchasesAreCompletedBy(com.revenuecat.purchases.PurchasesAreCompletedBy); method public void setPushToken(String? fcmToken); @@ -495,9 +503,9 @@ package com.revenuecat.purchases { method @Deprecated public void syncObserverModeAmazonPurchase(String productID, String receiptID, String amazonUserID, String? isoCurrencyCode, Double? price); method public void syncPurchases(); method public void syncPurchases(optional com.revenuecat.purchases.interfaces.SyncPurchasesCallback? listener); - method public void setPostHogUserId(String? postHogUserId); property @Deprecated @kotlin.jvm.Synchronized public final boolean allowSharingPlayStoreAccount; property @kotlin.jvm.Synchronized public final String appUserID; + property public final com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies? cachedVirtualCurrencies; property public final com.revenuecat.purchases.PurchasesConfiguration currentConfiguration; property public final com.revenuecat.purchases.customercenter.CustomerCenterListener? customerCenterListener; property @Deprecated public static final boolean debugLogsEnabled; @@ -508,6 +516,7 @@ package com.revenuecat.purchases { property @kotlin.jvm.Synchronized public static final com.revenuecat.purchases.LogHandler logHandler; property public static final com.revenuecat.purchases.LogLevel logLevel; property public static final com.revenuecat.purchases.common.PlatformInfo platformInfo; + property @kotlin.jvm.Synchronized public final String? preferredUILocaleOverride; property public static final java.net.URL? proxyURL; property @kotlin.jvm.Synchronized public final com.revenuecat.purchases.PurchasesAreCompletedBy purchasesAreCompletedBy; property public static final com.revenuecat.purchases.Purchases sharedInstance; @@ -555,11 +564,13 @@ package com.revenuecat.purchases { ctor public PurchasesConfiguration(com.revenuecat.purchases.PurchasesConfiguration.Builder builder); method public final String getApiKey(); method public final String? getAppUserID(); + method public final boolean getAutomaticDeviceIdentifierCollectionEnabled(); method public final android.content.Context getContext(); method public final com.revenuecat.purchases.DangerousSettings getDangerousSettings(); method public final boolean getDiagnosticsEnabled(); method @Deprecated public final boolean getObserverMode(); method public final boolean getPendingTransactionsForPrepaidPlansEnabled(); + method public final String? getPreferredUILocaleOverride(); method public final com.revenuecat.purchases.PurchasesAreCompletedBy getPurchasesAreCompletedBy(); method public final java.util.concurrent.ExecutorService? getService(); method public final boolean getShowInAppMessagesAutomatically(); @@ -567,11 +578,13 @@ package com.revenuecat.purchases { method public final com.revenuecat.purchases.EntitlementVerificationMode getVerificationMode(); property public final String apiKey; property public final String? appUserID; + property public final boolean automaticDeviceIdentifierCollectionEnabled; property public final android.content.Context context; property public final com.revenuecat.purchases.DangerousSettings dangerousSettings; property public final boolean diagnosticsEnabled; property @Deprecated public final boolean observerMode; property public final boolean pendingTransactionsForPrepaidPlansEnabled; + property public final String? preferredUILocaleOverride; property public final com.revenuecat.purchases.PurchasesAreCompletedBy purchasesAreCompletedBy; property public final java.util.concurrent.ExecutorService? service; property public final boolean showInAppMessagesAutomatically; @@ -582,6 +595,7 @@ package com.revenuecat.purchases { public static class PurchasesConfiguration.Builder { ctor public PurchasesConfiguration.Builder(android.content.Context context, String apiKey); method public final com.revenuecat.purchases.PurchasesConfiguration.Builder appUserID(String? appUserID); + method public final com.revenuecat.purchases.PurchasesConfiguration.Builder automaticDeviceIdentifierCollectionEnabled(boolean automaticDeviceIdentifierCollectionEnabled); method public com.revenuecat.purchases.PurchasesConfiguration build(); method public final com.revenuecat.purchases.PurchasesConfiguration.Builder dangerousSettings(com.revenuecat.purchases.DangerousSettings dangerousSettings); method public final com.revenuecat.purchases.PurchasesConfiguration.Builder diagnosticsEnabled(boolean diagnosticsEnabled); @@ -589,6 +603,7 @@ package com.revenuecat.purchases { method @Deprecated @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI @kotlin.jvm.JvmSynthetic public com.revenuecat.purchases.PurchasesConfiguration.Builder informationalVerificationModeAndDiagnosticsEnabled(boolean enabled); method @Deprecated public final com.revenuecat.purchases.PurchasesConfiguration.Builder observerMode(boolean observerMode); method public final com.revenuecat.purchases.PurchasesConfiguration.Builder pendingTransactionsForPrepaidPlansEnabled(boolean pendingTransactionsForPrepaidPlansEnabled); + method public final com.revenuecat.purchases.PurchasesConfiguration.Builder preferredUILocaleOverride(String? localeString); method public final com.revenuecat.purchases.PurchasesConfiguration.Builder purchasesAreCompletedBy(com.revenuecat.purchases.PurchasesAreCompletedBy purchasesAreCompletedBy); method public final com.revenuecat.purchases.PurchasesConfiguration.Builder service(java.util.concurrent.ExecutorService service); method public final com.revenuecat.purchases.PurchasesConfiguration.Builder showInAppMessagesAutomatically(boolean showInAppMessagesAutomatically); @@ -816,6 +831,21 @@ package com.revenuecat.purchases.amazon { } +package com.revenuecat.purchases.backup { + + public final class RevenueCatBackupAgent extends android.app.backup.BackupAgentHelper { + ctor public RevenueCatBackupAgent(); + method public void onBackup(android.os.ParcelFileDescriptor? oldState, android.app.backup.BackupDataOutput? data, android.os.ParcelFileDescriptor? newState); + method public void onRestore(android.app.backup.BackupDataInput? data, long appVersionCode, android.os.ParcelFileDescriptor? newState); + field public static final com.revenuecat.purchases.backup.RevenueCatBackupAgent.Companion Companion; + field public static final String REVENUECAT_PREFS_FILE_NAME = "com_revenuecat_purchases_preferences"; + } + + public static final class RevenueCatBackupAgent.Companion { + } + +} + package com.revenuecat.purchases.common { public final class PlatformInfo { @@ -843,7 +873,16 @@ package com.revenuecat.purchases.common.events { package com.revenuecat.purchases.customercenter { + @dev.drewhamilton.poko.Poko public final class CustomActionData { + ctor public CustomActionData(String actionIdentifier, String? purchaseIdentifier); + method public String getActionIdentifier(); + method public String? getPurchaseIdentifier(); + property public final String actionIdentifier; + property public final String? purchaseIdentifier; + } + public interface CustomerCenterListener { + method public default void onCustomActionSelected(String actionIdentifier, String? purchaseIdentifier); method public default void onFeedbackSurveyCompleted(String feedbackSurveyOptionId); method public default void onManagementOptionSelected(com.revenuecat.purchases.customercenter.CustomerCenterManagementOption action); method public default void onRestoreCompleted(com.revenuecat.purchases.CustomerInfo customerInfo); @@ -859,6 +898,14 @@ package com.revenuecat.purchases.customercenter { field public static final com.revenuecat.purchases.customercenter.CustomerCenterManagementOption.Cancel INSTANCE; } + @dev.drewhamilton.poko.Poko public static final class CustomerCenterManagementOption.CustomAction implements com.revenuecat.purchases.customercenter.CustomerCenterManagementOption { + ctor public CustomerCenterManagementOption.CustomAction(String actionIdentifier, String? purchaseIdentifier); + method public String getActionIdentifier(); + method public String? getPurchaseIdentifier(); + property public final String actionIdentifier; + property public final String? purchaseIdentifier; + } + public static final class CustomerCenterManagementOption.CustomUrl implements com.revenuecat.purchases.customercenter.CustomerCenterManagementOption { ctor public CustomerCenterManagementOption.CustomUrl(android.net.Uri uri); method public android.net.Uri component1(); @@ -909,6 +956,11 @@ package com.revenuecat.purchases.interfaces { method public void onReceived(String storefrontCountryCode); } + public interface GetVirtualCurrenciesCallback { + method public void onError(com.revenuecat.purchases.PurchasesError error); + method public void onReceived(com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies virtualCurrencies); + } + public interface LogInCallback { method public void onError(com.revenuecat.purchases.PurchasesError error); method public void onReceived(com.revenuecat.purchases.CustomerInfo customerInfo, boolean created); @@ -1435,14 +1487,20 @@ package com.revenuecat.purchases.models { public final class TestStoreProduct implements com.revenuecat.purchases.models.StoreProduct { ctor @Deprecated public TestStoreProduct(String id, String title, String description, com.revenuecat.purchases.models.Price price, com.revenuecat.purchases.models.Period? period, optional com.revenuecat.purchases.models.Period? freeTrialPeriod, optional com.revenuecat.purchases.models.Price? introPrice); - ctor public TestStoreProduct(String id, String name, String title, String description, com.revenuecat.purchases.models.Price price, com.revenuecat.purchases.models.Period? period, optional com.revenuecat.purchases.models.Period? freeTrialPeriod, optional com.revenuecat.purchases.models.Price? introPrice); + ctor public TestStoreProduct(String id, String name, String title, String description, com.revenuecat.purchases.models.Price price); + ctor public TestStoreProduct(String id, String name, String title, String description, com.revenuecat.purchases.models.Price price, optional com.revenuecat.purchases.models.Period? period); + ctor @Deprecated public TestStoreProduct(String id, String name, String title, String description, com.revenuecat.purchases.models.Price price, optional com.revenuecat.purchases.models.Period? period, optional com.revenuecat.purchases.models.Period? freeTrialPeriod, optional com.revenuecat.purchases.models.Price? introPrice); + ctor public TestStoreProduct(String id, String name, String title, String description, com.revenuecat.purchases.models.Price price, optional com.revenuecat.purchases.models.Period? period, optional com.revenuecat.purchases.models.PricingPhase? freeTrialPricingPhase); + ctor public TestStoreProduct(String id, String name, String title, String description, com.revenuecat.purchases.models.Price price, optional com.revenuecat.purchases.models.Period? period, optional com.revenuecat.purchases.models.PricingPhase? freeTrialPricingPhase, optional com.revenuecat.purchases.models.PricingPhase? introPricePricingPhase); + ctor public TestStoreProduct(String id, String name, String title, String description, com.revenuecat.purchases.models.Price price, optional com.revenuecat.purchases.models.Period? period, optional com.revenuecat.purchases.models.PricingPhase? freeTrialPricingPhase, optional com.revenuecat.purchases.models.PricingPhase? introPricePricingPhase, optional com.revenuecat.purchases.PresentedOfferingContext? presentedOfferingContext); method public String component1(); method public String component2(); method public String component3(); method public String component4(); method public com.revenuecat.purchases.models.Price component5(); method public com.revenuecat.purchases.models.Period? component6(); - method public com.revenuecat.purchases.models.TestStoreProduct copy(String id, String name, String title, String description, com.revenuecat.purchases.models.Price price, com.revenuecat.purchases.models.Period? period, com.revenuecat.purchases.models.Period? freeTrialPeriod, com.revenuecat.purchases.models.Price? introPrice); + method public com.revenuecat.purchases.PresentedOfferingContext? component9(); + method public com.revenuecat.purchases.models.TestStoreProduct copy(String id, String name, String title, String description, com.revenuecat.purchases.models.Price price, com.revenuecat.purchases.models.Period? period, com.revenuecat.purchases.models.PricingPhase? freeTrialPricingPhase, com.revenuecat.purchases.models.PricingPhase? introPricePricingPhase, com.revenuecat.purchases.PresentedOfferingContext? presentedOfferingContext); method @Deprecated public com.revenuecat.purchases.models.StoreProduct copyWithOfferingId(String offeringId); method public com.revenuecat.purchases.models.StoreProduct copyWithPresentedOfferingContext(com.revenuecat.purchases.PresentedOfferingContext? presentedOfferingContext); method public com.revenuecat.purchases.models.SubscriptionOption? getDefaultOption(); @@ -1790,3 +1848,24 @@ package com.revenuecat.purchases.utils { } +package com.revenuecat.purchases.virtualcurrencies { + + @dev.drewhamilton.poko.Poko @kotlinx.parcelize.Parcelize @kotlinx.serialization.Serializable public final class VirtualCurrencies implements android.os.Parcelable { + method public operator com.revenuecat.purchases.virtualcurrencies.VirtualCurrency? get(String code); + method public java.util.Map getAll(); + property public final java.util.Map all; + } + + @dev.drewhamilton.poko.Poko @kotlinx.parcelize.Parcelize @kotlinx.serialization.Serializable public final class VirtualCurrency implements android.os.Parcelable { + method public int getBalance(); + method public String getCode(); + method public String getName(); + method public String? getServerDescription(); + property public final int balance; + property public final String code; + property public final String name; + property public final String? serverDescription; + } + +} + diff --git a/purchases/api-entitlement.txt b/purchases/api-entitlement.txt index a3b09d5a88..1c43b72d31 100644 --- a/purchases/api-entitlement.txt +++ b/purchases/api-entitlement.txt @@ -27,6 +27,7 @@ package com.revenuecat.purchases { method @kotlin.jvm.JvmSynthetic public static suspend Object? awaitPurchaseResult(com.revenuecat.purchases.Purchases, com.revenuecat.purchases.PurchaseParams purchaseParams, kotlin.coroutines.Continuation>); method @kotlin.jvm.JvmSynthetic @kotlin.jvm.Throws(exceptionClasses=PurchasesTransactionException::class) public static suspend Object? awaitRestore(com.revenuecat.purchases.Purchases, kotlin.coroutines.Continuation); method @kotlin.jvm.JvmSynthetic public static suspend Object? awaitRestoreResult(com.revenuecat.purchases.Purchases, kotlin.coroutines.Continuation>); + method public static suspend Object? awaitStorefrontCountryCode(com.revenuecat.purchases.Purchases, kotlin.coroutines.Continuation); } @kotlinx.parcelize.Parcelize @kotlinx.parcelize.TypeParceler public final class CustomerInfo implements android.os.Parcelable com.revenuecat.purchases.models.RawDataContainer { @@ -170,13 +171,14 @@ package com.revenuecat.purchases { @kotlin.RequiresOptIn(level=kotlin.RequiresOptIn.Level.ERROR) @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.CLASS, kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.PROPERTY}) public @interface ExperimentalPreviewRevenueCatPurchasesAPI { } - @kotlin.RequiresOptIn(level=kotlin.RequiresOptIn.Level.ERROR, message="This is an internal RevenueCat API that may change frequently and without warning. " + "No compatibility guarantees are provided. It is strongly discouraged to use this API.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.CLASS, kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.PROPERTY, kotlin.annotation.AnnotationTarget.TYPEALIAS}) public @interface InternalRevenueCatAPI { + @kotlin.RequiresOptIn(level=kotlin.RequiresOptIn.Level.ERROR, message="This is an internal RevenueCat API that may change frequently and without warning. " + "No compatibility guarantees are provided. It is strongly discouraged to use this API.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.CLASS, kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.PROPERTY, kotlin.annotation.AnnotationTarget.TYPEALIAS, kotlin.annotation.AnnotationTarget.CONSTRUCTOR}) public @interface InternalRevenueCatAPI { } public final class ListenerConversionsCommonKt { method public static void getOfferingsWith(com.revenuecat.purchases.Purchases, optional kotlin.jvm.functions.Function1 onError, kotlin.jvm.functions.Function1 onSuccess); method public static void getProductsWith(com.revenuecat.purchases.Purchases, java.util.List productIds, com.revenuecat.purchases.ProductType? type, optional kotlin.jvm.functions.Function1 onError, kotlin.jvm.functions.Function1,kotlin.Unit> onGetStoreProducts); method public static void getProductsWith(com.revenuecat.purchases.Purchases, java.util.List productIds, optional kotlin.jvm.functions.Function1 onError, kotlin.jvm.functions.Function1,kotlin.Unit> onGetStoreProducts); + method public static void getStorefrontCountryCodeWith(com.revenuecat.purchases.Purchases, optional kotlin.jvm.functions.Function1 onError, kotlin.jvm.functions.Function1 onSuccess); method public static void purchaseWith(com.revenuecat.purchases.Purchases, com.revenuecat.purchases.PurchaseParams purchaseParams, optional kotlin.jvm.functions.Function2 onError, kotlin.jvm.functions.Function2 onSuccess); method public static void restorePurchasesWith(com.revenuecat.purchases.Purchases, optional kotlin.jvm.functions.Function1 onError, kotlin.jvm.functions.Function1 onSuccess); } @@ -395,6 +397,7 @@ package com.revenuecat.purchases { method public static java.net.URL? getProxyURL(); method public static com.revenuecat.purchases.Purchases getSharedInstance(); method @kotlin.jvm.Synchronized public String? getStorefrontCountryCode(); + method public void getStorefrontCountryCode(com.revenuecat.purchases.interfaces.GetStorefrontCallback callback); method @kotlin.jvm.Synchronized public com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener? getUpdatedCustomerInfoListener(); method public static boolean isConfigured(); method public void purchase(com.revenuecat.purchases.PurchaseParams purchaseParams, com.revenuecat.purchases.interfaces.PurchaseCallback callback); @@ -461,11 +464,13 @@ package com.revenuecat.purchases { ctor public PurchasesConfiguration(com.revenuecat.purchases.PurchasesConfiguration.Builder builder); method public final String getApiKey(); method public final String? getAppUserID(); + method public final boolean getAutomaticDeviceIdentifierCollectionEnabled(); method public final android.content.Context getContext(); method public final com.revenuecat.purchases.DangerousSettings getDangerousSettings(); method public final boolean getDiagnosticsEnabled(); method @Deprecated public final boolean getObserverMode(); method public final boolean getPendingTransactionsForPrepaidPlansEnabled(); + method public final String? getPreferredUILocaleOverride(); method public final com.revenuecat.purchases.PurchasesAreCompletedBy getPurchasesAreCompletedBy(); method public final java.util.concurrent.ExecutorService? getService(); method public final boolean getShowInAppMessagesAutomatically(); @@ -473,11 +478,13 @@ package com.revenuecat.purchases { method public final com.revenuecat.purchases.EntitlementVerificationMode getVerificationMode(); property public final String apiKey; property public final String? appUserID; + property public final boolean automaticDeviceIdentifierCollectionEnabled; property public final android.content.Context context; property public final com.revenuecat.purchases.DangerousSettings dangerousSettings; property public final boolean diagnosticsEnabled; property @Deprecated public final boolean observerMode; property public final boolean pendingTransactionsForPrepaidPlansEnabled; + property public final String? preferredUILocaleOverride; property public final com.revenuecat.purchases.PurchasesAreCompletedBy purchasesAreCompletedBy; property public final java.util.concurrent.ExecutorService? service; property public final boolean showInAppMessagesAutomatically; @@ -488,6 +495,7 @@ package com.revenuecat.purchases { public static class PurchasesConfiguration.Builder { ctor public PurchasesConfiguration.Builder(android.content.Context context, String apiKey); method public final com.revenuecat.purchases.PurchasesConfiguration.Builder appUserID(String? appUserID); + method public final com.revenuecat.purchases.PurchasesConfiguration.Builder automaticDeviceIdentifierCollectionEnabled(boolean automaticDeviceIdentifierCollectionEnabled); method public com.revenuecat.purchases.PurchasesConfiguration build(); method public final com.revenuecat.purchases.PurchasesConfiguration.Builder dangerousSettings(com.revenuecat.purchases.DangerousSettings dangerousSettings); method public final com.revenuecat.purchases.PurchasesConfiguration.Builder diagnosticsEnabled(boolean diagnosticsEnabled); @@ -495,6 +503,7 @@ package com.revenuecat.purchases { method @Deprecated @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI @kotlin.jvm.JvmSynthetic public com.revenuecat.purchases.PurchasesConfiguration.Builder informationalVerificationModeAndDiagnosticsEnabled(boolean enabled); method @Deprecated public final com.revenuecat.purchases.PurchasesConfiguration.Builder observerMode(boolean observerMode); method public final com.revenuecat.purchases.PurchasesConfiguration.Builder pendingTransactionsForPrepaidPlansEnabled(boolean pendingTransactionsForPrepaidPlansEnabled); + method public final com.revenuecat.purchases.PurchasesConfiguration.Builder preferredUILocaleOverride(String? localeString); method public final com.revenuecat.purchases.PurchasesConfiguration.Builder purchasesAreCompletedBy(com.revenuecat.purchases.PurchasesAreCompletedBy purchasesAreCompletedBy); method public final com.revenuecat.purchases.PurchasesConfiguration.Builder service(java.util.concurrent.ExecutorService service); method public final com.revenuecat.purchases.PurchasesConfiguration.Builder showInAppMessagesAutomatically(boolean showInAppMessagesAutomatically); @@ -732,6 +741,21 @@ package com.revenuecat.purchases.amazon { } +package com.revenuecat.purchases.backup { + + public final class RevenueCatBackupAgent extends android.app.backup.BackupAgentHelper { + ctor public RevenueCatBackupAgent(); + method public void onBackup(android.os.ParcelFileDescriptor? oldState, android.app.backup.BackupDataOutput? data, android.os.ParcelFileDescriptor? newState); + method public void onRestore(android.app.backup.BackupDataInput? data, long appVersionCode, android.os.ParcelFileDescriptor? newState); + field public static final com.revenuecat.purchases.backup.RevenueCatBackupAgent.Companion Companion; + field public static final String REVENUECAT_PREFS_FILE_NAME = "com_revenuecat_purchases_preferences"; + } + + public static final class RevenueCatBackupAgent.Companion { + } + +} + package com.revenuecat.purchases.common { public final class PlatformInfo { @@ -759,7 +783,16 @@ package com.revenuecat.purchases.common.events { package com.revenuecat.purchases.customercenter { + @dev.drewhamilton.poko.Poko public final class CustomActionData { + ctor public CustomActionData(String actionIdentifier, String? purchaseIdentifier); + method public String getActionIdentifier(); + method public String? getPurchaseIdentifier(); + property public final String actionIdentifier; + property public final String? purchaseIdentifier; + } + public interface CustomerCenterListener { + method public default void onCustomActionSelected(String actionIdentifier, String? purchaseIdentifier); method public default void onFeedbackSurveyCompleted(String feedbackSurveyOptionId); method public default void onManagementOptionSelected(com.revenuecat.purchases.customercenter.CustomerCenterManagementOption action); method public default void onRestoreCompleted(com.revenuecat.purchases.CustomerInfo customerInfo); @@ -775,6 +808,14 @@ package com.revenuecat.purchases.customercenter { field public static final com.revenuecat.purchases.customercenter.CustomerCenterManagementOption.Cancel INSTANCE; } + @dev.drewhamilton.poko.Poko public static final class CustomerCenterManagementOption.CustomAction implements com.revenuecat.purchases.customercenter.CustomerCenterManagementOption { + ctor public CustomerCenterManagementOption.CustomAction(String actionIdentifier, String? purchaseIdentifier); + method public String getActionIdentifier(); + method public String? getPurchaseIdentifier(); + property public final String actionIdentifier; + property public final String? purchaseIdentifier; + } + public static final class CustomerCenterManagementOption.CustomUrl implements com.revenuecat.purchases.customercenter.CustomerCenterManagementOption { ctor public CustomerCenterManagementOption.CustomUrl(android.net.Uri uri); method public android.net.Uri component1(); @@ -810,6 +851,11 @@ package com.revenuecat.purchases.interfaces { method public void onReceived(String storefrontCountryCode); } + public interface GetVirtualCurrenciesCallback { + method public void onError(com.revenuecat.purchases.PurchasesError error); + method public void onReceived(com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies virtualCurrencies); + } + public interface LogInCallback { method public void onError(com.revenuecat.purchases.PurchasesError error); method public void onReceived(com.revenuecat.purchases.CustomerInfo customerInfo, boolean created); @@ -1336,14 +1382,20 @@ package com.revenuecat.purchases.models { public final class TestStoreProduct implements com.revenuecat.purchases.models.StoreProduct { ctor @Deprecated public TestStoreProduct(String id, String title, String description, com.revenuecat.purchases.models.Price price, com.revenuecat.purchases.models.Period? period, optional com.revenuecat.purchases.models.Period? freeTrialPeriod, optional com.revenuecat.purchases.models.Price? introPrice); - ctor public TestStoreProduct(String id, String name, String title, String description, com.revenuecat.purchases.models.Price price, com.revenuecat.purchases.models.Period? period, optional com.revenuecat.purchases.models.Period? freeTrialPeriod, optional com.revenuecat.purchases.models.Price? introPrice); + ctor public TestStoreProduct(String id, String name, String title, String description, com.revenuecat.purchases.models.Price price); + ctor public TestStoreProduct(String id, String name, String title, String description, com.revenuecat.purchases.models.Price price, optional com.revenuecat.purchases.models.Period? period); + ctor @Deprecated public TestStoreProduct(String id, String name, String title, String description, com.revenuecat.purchases.models.Price price, optional com.revenuecat.purchases.models.Period? period, optional com.revenuecat.purchases.models.Period? freeTrialPeriod, optional com.revenuecat.purchases.models.Price? introPrice); + ctor public TestStoreProduct(String id, String name, String title, String description, com.revenuecat.purchases.models.Price price, optional com.revenuecat.purchases.models.Period? period, optional com.revenuecat.purchases.models.PricingPhase? freeTrialPricingPhase); + ctor public TestStoreProduct(String id, String name, String title, String description, com.revenuecat.purchases.models.Price price, optional com.revenuecat.purchases.models.Period? period, optional com.revenuecat.purchases.models.PricingPhase? freeTrialPricingPhase, optional com.revenuecat.purchases.models.PricingPhase? introPricePricingPhase); + ctor public TestStoreProduct(String id, String name, String title, String description, com.revenuecat.purchases.models.Price price, optional com.revenuecat.purchases.models.Period? period, optional com.revenuecat.purchases.models.PricingPhase? freeTrialPricingPhase, optional com.revenuecat.purchases.models.PricingPhase? introPricePricingPhase, optional com.revenuecat.purchases.PresentedOfferingContext? presentedOfferingContext); method public String component1(); method public String component2(); method public String component3(); method public String component4(); method public com.revenuecat.purchases.models.Price component5(); method public com.revenuecat.purchases.models.Period? component6(); - method public com.revenuecat.purchases.models.TestStoreProduct copy(String id, String name, String title, String description, com.revenuecat.purchases.models.Price price, com.revenuecat.purchases.models.Period? period, com.revenuecat.purchases.models.Period? freeTrialPeriod, com.revenuecat.purchases.models.Price? introPrice); + method public com.revenuecat.purchases.PresentedOfferingContext? component9(); + method public com.revenuecat.purchases.models.TestStoreProduct copy(String id, String name, String title, String description, com.revenuecat.purchases.models.Price price, com.revenuecat.purchases.models.Period? period, com.revenuecat.purchases.models.PricingPhase? freeTrialPricingPhase, com.revenuecat.purchases.models.PricingPhase? introPricePricingPhase, com.revenuecat.purchases.PresentedOfferingContext? presentedOfferingContext); method @Deprecated public com.revenuecat.purchases.models.StoreProduct copyWithOfferingId(String offeringId); method public com.revenuecat.purchases.models.StoreProduct copyWithPresentedOfferingContext(com.revenuecat.purchases.PresentedOfferingContext? presentedOfferingContext); method public com.revenuecat.purchases.models.SubscriptionOption? getDefaultOption(); @@ -1691,3 +1743,24 @@ package com.revenuecat.purchases.utils { } +package com.revenuecat.purchases.virtualcurrencies { + + @dev.drewhamilton.poko.Poko @kotlinx.parcelize.Parcelize @kotlinx.serialization.Serializable public final class VirtualCurrencies implements android.os.Parcelable { + method public operator com.revenuecat.purchases.virtualcurrencies.VirtualCurrency? get(String code); + method public java.util.Map getAll(); + property public final java.util.Map all; + } + + @dev.drewhamilton.poko.Poko @kotlinx.parcelize.Parcelize @kotlinx.serialization.Serializable public final class VirtualCurrency implements android.os.Parcelable { + method public int getBalance(); + method public String getCode(); + method public String getName(); + method public String? getServerDescription(); + property public final int balance; + property public final String code; + property public final String name; + property public final String? serverDescription; + } + +} + diff --git a/purchases/build.gradle.kts b/purchases/build.gradle.kts index 58dd80ef38..6a6f96c671 100644 --- a/purchases/build.gradle.kts +++ b/purchases/build.gradle.kts @@ -1,4 +1,6 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask +import java.io.FileInputStream +import java.util.Properties plugins { alias(libs.plugins.android.library) @@ -12,6 +14,10 @@ plugins { alias(libs.plugins.baselineprofile) } +val localProperties = Properties() +val localPropertiesFile = rootProject.file("local.properties") +if (localPropertiesFile.exists()) localProperties.load(FileInputStream(localPropertiesFile)) + apply(from = "${rootProject.projectDir}/library.gradle") android { @@ -37,6 +43,12 @@ android { testApplicationId = obtainTestApplicationId() testBuildType = obtainTestBuildType() + buildConfigField( + type = "boolean", + name = "ENABLE_SIMULATED_STORE", + value = (localProperties["ENABLE_SIMULATED_STORE"] as? String ?: "false").toString(), + ) + packagingOptions.resources.excludes.addAll( listOf("META-INF/LICENSE.md", "META-INF/LICENSE-notice.md"), ) @@ -105,6 +117,11 @@ tasks.withType>().configureEach { } } +tasks.withType { + // Disabling verification in tests until Amazon publishes a version of their SDK compiled with a modern JDK. + jvmArgs("-noverify") +} + fun obtainTestApplicationId(): String = if (project.hasProperty("testApplicationId")) { project.properties["testApplicationId"] as String @@ -128,6 +145,7 @@ dependencies { implementation(libs.androidx.lifecycle.common) implementation(libs.androidx.lifecycle.process) implementation(libs.kotlinx.serialization.json) + implementation(libs.google.blockstore) implementation(libs.tink) implementation(libs.playServices.ads.identifier) implementation(libs.coroutines.core) @@ -233,6 +251,14 @@ tasks.dokkaHtmlPartial.configure { } } +// Remove afterEvaluate +// after https://github.com/Kotlin/kotlinx-kover/issues/362 is fixed +afterEvaluate { + dependencies { + add("kover", project(":feature:amazon")) + } +} + baselineProfile { mergeIntoMain = true baselineProfileOutputDir = "." @@ -241,11 +267,3 @@ baselineProfile { exclude("com.revenuecat.purchases.ui.revenuecatui.**") } } - -// Remove afterEvaluate -// after https://github.com/Kotlin/kotlinx-kover/issues/362 is fixed -afterEvaluate { - dependencies { - add("kover", project(":feature:amazon")) - } -} diff --git a/purchases/src/androidTest/kotlin/com/revenuecat/purchases/BasePurchasesIntegrationTest.kt b/purchases/src/androidTest/kotlin/com/revenuecat/purchases/BasePurchasesIntegrationTest.kt index 1c754697f5..0cdfdda42d 100644 --- a/purchases/src/androidTest/kotlin/com/revenuecat/purchases/BasePurchasesIntegrationTest.kt +++ b/purchases/src/androidTest/kotlin/com/revenuecat/purchases/BasePurchasesIntegrationTest.kt @@ -1,9 +1,9 @@ package com.revenuecat.purchases import android.content.Context -import android.preference.PreferenceManager import androidx.lifecycle.lifecycleScope import androidx.test.ext.junit.rules.activityScenarioRule +import com.revenuecat.purchases.backup.RevenueCatBackupAgent import com.revenuecat.purchases.common.BillingAbstract import com.revenuecat.purchases.models.StoreTransaction import io.mockk.every @@ -170,8 +170,15 @@ open class BasePurchasesIntegrationTest { } } + protected fun isRunningLoadShedderIntegrationTests(): Boolean { + return Constants.isRunningLoadShedderIntegrationTests.toBoolean() + } + private fun clearAllSharedPreferences(context: Context) { - PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit() + context.getSharedPreferences( + RevenueCatBackupAgent.REVENUECAT_PREFS_FILE_NAME, + Context.MODE_PRIVATE, + ).edit().clear().commit() context.getSharedPreferences( eTagsSharedPreferencesNameTemplate.format(context.packageName), Context.MODE_PRIVATE, @@ -183,7 +190,10 @@ open class BasePurchasesIntegrationTest { } private fun writeSharedPreferences(context: Context, values: Map) { - val editor = PreferenceManager.getDefaultSharedPreferences(context).edit() + val editor = context.getSharedPreferences( + RevenueCatBackupAgent.REVENUECAT_PREFS_FILE_NAME, + Context.MODE_PRIVATE, + ).edit() values.forEach { (key, value) -> editor.putString(key, value) } diff --git a/purchases/src/androidTest/kotlin/com/revenuecat/purchases/Constants.kt b/purchases/src/androidTest/kotlin/com/revenuecat/purchases/Constants.kt index 2fd663a816..3bfa35aaff 100644 --- a/purchases/src/androidTest/kotlin/com/revenuecat/purchases/Constants.kt +++ b/purchases/src/androidTest/kotlin/com/revenuecat/purchases/Constants.kt @@ -9,4 +9,6 @@ object Constants { // comma separated list of active entitlements to verify const val activeEntitlementIdsToVerify = "ACTIVE_ENTITLEMENT_IDS_TO_VERIFY" + + const val isRunningLoadShedderIntegrationTests = "IS_RUNNING_LOAD_SHEDDER_INTEGRATION_TESTS" } diff --git a/purchases/src/androidTestDefaults/kotlin/com/revenuecat/purchases/PurchasesIntegrationTest.kt b/purchases/src/androidTestDefaults/kotlin/com/revenuecat/purchases/PurchasesIntegrationTest.kt index d92ce56726..c65755f63c 100644 --- a/purchases/src/androidTestDefaults/kotlin/com/revenuecat/purchases/PurchasesIntegrationTest.kt +++ b/purchases/src/androidTestDefaults/kotlin/com/revenuecat/purchases/PurchasesIntegrationTest.kt @@ -4,6 +4,7 @@ import android.content.Context import android.os.StrictMode import androidx.test.ext.junit.runners.AndroidJUnit4 import com.revenuecat.purchases.common.AppConfig +import com.revenuecat.purchases.common.DefaultLocaleProvider import com.revenuecat.purchases.common.HTTPClient import com.revenuecat.purchases.common.PlatformInfo import com.revenuecat.purchases.common.networking.ETagManager @@ -16,6 +17,8 @@ import com.revenuecat.purchases.helpers.mockQueryProductDetails import com.revenuecat.purchases.interfaces.StorefrontProvider import com.revenuecat.purchases.models.GooglePurchasingData import com.revenuecat.purchases.models.GoogleStoreProduct +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrency import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -31,9 +34,11 @@ import org.junit.Test import org.junit.runner.RunWith import java.net.URL import java.net.UnknownHostException +import java.util.UUID import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +@Suppress("TooManyFunctions") @RunWith(AndroidJUnit4::class) class PurchasesIntegrationTest : BasePurchasesIntegrationTest() { @@ -205,6 +210,196 @@ class PurchasesIntegrationTest : BasePurchasesIntegrationTest() { } } + @Test + fun testGetVirtualCurrenciesWithBalancesOfZero() { + // Virtual Currencies aren't supported by the load shedder yet, so we don't want to run + // VC tests in the load shedder integration tests + if (isRunningLoadShedderIntegrationTests()) { + return + } + + val appUserIDWith0BalanceCurrencies = "integrationTestUserWithAllBalancesEqualTo0" + val lock = CountDownLatch(1) + + Purchases.sharedInstance.logInWith( + appUserID = appUserIDWith0BalanceCurrencies, + onError = { error -> fail("should have been able to login. Error: $error") }, + onSuccess = { _, created -> + assertThat(created).isFalse() // This user should already exist + + Purchases.sharedInstance.invalidateVirtualCurrenciesCache() + + Purchases.sharedInstance.getVirtualCurrenciesWith( + onError = { error -> fail("should be success. Error: $error") }, + onSuccess = { virtualCurrencies -> + validateAllZeroBalances(virtualCurrencies = virtualCurrencies) + lock.countDown() + }, + ) + }, + ) + + lock.await(testTimeout.inWholeSeconds, TimeUnit.SECONDS) + assertThat(lock.count).isZero + } + + @Test + fun testGetVirtualCurrenciesWithBalancesWithSomeNonZeroValues() { + // Virtual Currencies aren't supported by the load shedder yet, so we don't want to run + // VC tests in the load shedder integration tests + if (isRunningLoadShedderIntegrationTests()) { + return + } + + val appUserIDWith0BalanceCurrencies = "integrationTestUserWithAllBalancesNonZero" + val lock = CountDownLatch(1) + + Purchases.sharedInstance.logInWith( + appUserID = appUserIDWith0BalanceCurrencies, + onError = { error -> fail("should have been able to login. Error: $error") }, + onSuccess = { _, created -> + assertThat(created).isFalse() // This user should already exist + + Purchases.sharedInstance.invalidateVirtualCurrenciesCache() + + Purchases.sharedInstance.getVirtualCurrenciesWith( + onError = { error -> fail("should be success. Error: $error") }, + onSuccess = { virtualCurrencies -> + validateNonZeroBalances(virtualCurrencies = virtualCurrencies) + lock.countDown() + }, + ) + }, + ) + + lock.await(testTimeout.inWholeSeconds, TimeUnit.SECONDS) + assertThat(lock.count).isZero + } + + @Test + fun testGettingVirtualCurrenciesForNewUserReturnsVCsWith0Balance() { + // Virtual Currencies aren't supported by the load shedder yet, so we don't want to run + // VC tests in the load shedder integration tests + if (isRunningLoadShedderIntegrationTests()) { + return + } + + val newAppUserID = "integrationTestUser_${UUID.randomUUID()}" + val lock = CountDownLatch(1) + + Purchases.sharedInstance.logInWith( + appUserID = newAppUserID, + onError = { error -> fail("should have been able to login. Error: $error") }, + onSuccess = { _, created -> + assertThat(created).isTrue() // This user should be new + + Purchases.sharedInstance.invalidateVirtualCurrenciesCache() + + Purchases.sharedInstance.getVirtualCurrenciesWith( + onError = { error -> fail("should be success. Error: $error") }, + onSuccess = { virtualCurrencies -> + validateAllZeroBalances(virtualCurrencies = virtualCurrencies) + lock.countDown() + }, + ) + }, + ) + + lock.await(testTimeout.inWholeSeconds, TimeUnit.SECONDS) + assertThat(lock.count).isZero + } + + @Test + fun testCachedVirtualCurrencies() { + // Virtual Currencies aren't supported by the load shedder yet, so we don't want to run + // VC tests in the load shedder integration tests + if (isRunningLoadShedderIntegrationTests()) { + return + } + + val appUserID = "integrationTestUserWithAllBalancesNonZero" + val lock = CountDownLatch(1) + + Purchases.sharedInstance.logInWith( + appUserID = appUserID, + onError = { error -> fail("should have been able to login. Error: $error") }, + onSuccess = { _, created -> + assertThat(created).isFalse() // This user should be already exist + + Purchases.sharedInstance.invalidateVirtualCurrenciesCache() + + Purchases.sharedInstance.getVirtualCurrenciesWith( + onError = { error -> fail("should be success. Error: $error") }, + onSuccess = { virtualCurrencies -> + validateNonZeroBalances(virtualCurrencies = virtualCurrencies) + + var cachedVirtualCurrencies = Purchases.sharedInstance.cachedVirtualCurrencies + validateNonZeroBalances(virtualCurrencies = cachedVirtualCurrencies) + + Purchases.sharedInstance.invalidateVirtualCurrenciesCache() + cachedVirtualCurrencies = Purchases.sharedInstance.cachedVirtualCurrencies + assertThat(cachedVirtualCurrencies).isNull() + + lock.countDown() + }, + ) + }, + ) + + lock.await(testTimeout.inWholeSeconds, TimeUnit.SECONDS) + assertThat(lock.count).isZero + } + + private fun validateAllZeroBalances(virtualCurrencies: VirtualCurrencies?) { + validateVirtualCurrenciesObject( + virtualCurrencies = virtualCurrencies, + testVCBalance = 0, + testVC2Balance = 0, + ) + } + + private fun validateNonZeroBalances(virtualCurrencies: VirtualCurrencies?) { + validateVirtualCurrenciesObject( + virtualCurrencies = virtualCurrencies, + testVCBalance = 100, + testVC2Balance = 777, + ) + } + + @Suppress("MagicNumber") + private fun validateVirtualCurrenciesObject( + virtualCurrencies: VirtualCurrencies?, + testVCBalance: Int, + testVC2Balance: Int, + testVC3Balance: Int = 0, + ) { + assert(virtualCurrencies!!.all.count() == 3) + + val expectedTestVirtualCurrency = VirtualCurrency( + code = "TEST", + name = "Test Currency", + balance = testVCBalance, + serverDescription = "This is a test currency", + ) + assertThat(virtualCurrencies["TEST"]).isEqualTo(expectedTestVirtualCurrency) + + val expectedTestVirtualCurrency2 = VirtualCurrency( + code = "TEST2", + name = "Test Currency 2", + balance = testVC2Balance, + serverDescription = "This is test currency 2", + ) + assertThat(virtualCurrencies["TEST2"]).isEqualTo(expectedTestVirtualCurrency2) + + val expectedTestVirtualCurrency3 = VirtualCurrency( + code = "TEST3", + name = "Test Currency 3", + balance = testVC3Balance, + serverDescription = null, + ) + assertThat(virtualCurrencies["TEST3"]).isEqualTo(expectedTestVirtualCurrency3) + } + // endregion // region reachability @@ -282,6 +477,7 @@ class PurchasesIntegrationTest : BasePurchasesIntegrationTest() { return "test-storefront" } }, + localeProvider = DefaultLocaleProvider(), ) } diff --git a/purchases/src/customEntitlementComputation/kotlin/com/revenuecat/purchases/Purchases.kt b/purchases/src/customEntitlementComputation/kotlin/com/revenuecat/purchases/Purchases.kt index ce8bb0237b..54c17391aa 100644 --- a/purchases/src/customEntitlementComputation/kotlin/com/revenuecat/purchases/Purchases.kt +++ b/purchases/src/customEntitlementComputation/kotlin/com/revenuecat/purchases/Purchases.kt @@ -10,6 +10,7 @@ import com.revenuecat.purchases.common.infoLog import com.revenuecat.purchases.common.log import com.revenuecat.purchases.interfaces.Callback import com.revenuecat.purchases.interfaces.GetStoreProductsCallback +import com.revenuecat.purchases.interfaces.GetStorefrontCallback import com.revenuecat.purchases.interfaces.PurchaseCallback import com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback import com.revenuecat.purchases.interfaces.ReceiveOfferingsCallback @@ -179,6 +180,14 @@ class Purchases internal constructor( fun switchUser(newAppUserID: String) { purchasesOrchestrator.switchUser(newAppUserID) } + + /** + * This method will try to obtain the Store (Google/Amazon) country code in ISO-3166-1 alpha2. + * If there is any error, it will return null and log said error. + */ + fun getStorefrontCountryCode(callback: GetStorefrontCallback) { + purchasesOrchestrator.getStorefrontCountryCode(callback) + } //endregion /** diff --git a/purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt b/purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt index eb683294f9..db0e787bec 100644 --- a/purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt +++ b/purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt @@ -19,6 +19,7 @@ import com.revenuecat.purchases.interfaces.GetAmazonLWAConsentStatusCallback import com.revenuecat.purchases.interfaces.GetCustomerCenterConfigCallback import com.revenuecat.purchases.interfaces.GetStoreProductsCallback import com.revenuecat.purchases.interfaces.GetStorefrontCallback +import com.revenuecat.purchases.interfaces.GetVirtualCurrenciesCallback import com.revenuecat.purchases.interfaces.LogInCallback import com.revenuecat.purchases.interfaces.PurchaseCallback import com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback @@ -34,6 +35,7 @@ import com.revenuecat.purchases.paywalls.DownloadedFontFamily import com.revenuecat.purchases.strings.BillingStrings import com.revenuecat.purchases.strings.ConfigureStrings import com.revenuecat.purchases.utils.DefaultIsDebugBuildProvider +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies import java.net.URL /** @@ -350,13 +352,7 @@ class Purchases internal constructor( storeProduct: StoreProduct, callback: PurchaseCallback, ) { - purchasesOrchestrator.startPurchase( - activity, - storeProduct.purchasingData, - null, - null, - callback, - ) + purchase(PurchaseParams.Builder(activity, storeProduct).build(), callback) } /** @@ -380,13 +376,7 @@ class Purchases internal constructor( packageToPurchase: Package, listener: PurchaseCallback, ) { - purchasesOrchestrator.startPurchase( - activity, - packageToPurchase.product.purchasingData, - packageToPurchase.presentedOfferingContext, - null, - listener, - ) + purchase(PurchaseParams.Builder(activity, packageToPurchase).build(), listener) } /** @@ -462,6 +452,41 @@ class Purchases internal constructor( purchasesOrchestrator.getCustomerInfo(fetchPolicy, true, callback) } + /** + * Fetches the virtual currencies for the current subscriber. + * + * @param callback A listener called when the virtual currencies are available. + */ + fun getVirtualCurrencies( + callback: GetVirtualCurrenciesCallback, + ) { + purchasesOrchestrator.getVirtualCurrencies(callback = callback) + } + + /** + * Invalidates the cache for virtual currencies. + * + * This is useful for cases where a virtual currency's balance might have been updated + * outside of the app, like if you decreased a user's balance from the user spending a virtual currency, + * or if you increased the balance from your backend using the server APIs. + * + * For more info, see our [virtual currency docs](https://www.revenuecat.com/docs/offerings/virtual-currency) + */ + fun invalidateVirtualCurrenciesCache() { + purchasesOrchestrator.invalidateVirtualCurrenciesCache() + } + + /** + * The currently cached [VirtualCurrencies] if one is available. + * This is synchronous, and therefore useful for contexts where an app needs a [VirtualCurrencies] + * right away without waiting for a callback. This value will remain null until virtual currencies + * have been fetched at least once with [Purchases.getVirtualCurrencies] or an equivalent function. + * + * This allows initializing state to ensure that UI can be loaded from the very first frame. + */ + val cachedVirtualCurrencies: VirtualCurrencies? + get() = purchasesOrchestrator.cachedVirtualCurrencies + /** * Call this when you are finished using the [UpdatedCustomerInfoListener]. You should call this * to avoid memory leaks. @@ -809,6 +834,28 @@ class Purchases internal constructor( purchasesOrchestrator.allowSharingPlayStoreAccount = value } + /** + * The preferred UI locale override for RevenueCat UI components. + * This affects both API requests and UI rendering. + * + * @return The preferred UI locale override, or null if using system default + */ + val preferredUILocaleOverride: String? + @Synchronized get() = purchasesOrchestrator.preferredUILocaleOverride + + /** + * Override the preferred UI locale for RevenueCat UI components at runtime. + * This affects both API requests and UI rendering. + * + * If the locale changes, this will automatically clear the offerings cache and trigger + * a background refetch to get paywall templates with the correct localizations. + * + * @param localeString The locale string (e.g., "es-ES", "en-US") or null to use system default + */ + fun overridePreferredUILocale(localeString: String?): Boolean { + return purchasesOrchestrator.overridePreferredUILocale(localeString) + } + /** * Gets the StoreProduct for the given list of subscription products. * @param [productIds] List of productIds diff --git a/purchases/src/defaults/kotlin/com/revenuecat/purchases/coroutinesExtensions.kt b/purchases/src/defaults/kotlin/com/revenuecat/purchases/coroutinesExtensions.kt index bc444d11c6..f82680b08b 100644 --- a/purchases/src/defaults/kotlin/com/revenuecat/purchases/coroutinesExtensions.kt +++ b/purchases/src/defaults/kotlin/com/revenuecat/purchases/coroutinesExtensions.kt @@ -4,27 +4,11 @@ import com.revenuecat.purchases.CacheFetchPolicy.CACHED_OR_FETCHED import com.revenuecat.purchases.customercenter.CustomerCenterConfigData import com.revenuecat.purchases.data.LogInResult import com.revenuecat.purchases.interfaces.GetCustomerCenterConfigCallback +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine -/** - * This method will try to obtain the Store (Google/Amazon) country code in ISO-3166-1 alpha2. - * If there is any error, it will return null and log said error. - * Coroutine friendly version of [Purchases.getStorefrontCountryCode]. - * - * @throws [PurchasesException] with a [PurchasesError] if there's an error retrieving the country code. - * @return The Store country code in ISO-3166-1 alpha2. - */ -suspend fun Purchases.awaitStorefrontCountryCode(): String { - return suspendCoroutine { continuation -> - getStorefrontCountryCodeWith( - onSuccess = continuation::resume, - onError = { continuation.resumeWithException(PurchasesException(it)) }, - ) - } -} - /** * Get latest available customer info. * Coroutine friendly version of [Purchases.getCustomerInfo]. @@ -190,3 +174,24 @@ suspend fun Purchases.awaitCustomerCenterConfigData(): CustomerCenterConfigData }) } } + +/** + * Fetches the virtual currencies for the current subscriber. + * + * Coroutine friendly version of [Purchases.getVirtualCurrencies]. + * + * @throws [PurchasesException] with a [PurchasesError] if an error occurred while fetching + * the virtual currencies. + * + * @return The [VirtualCurrencies] with the subscriber's virtual currencies. + */ +@JvmSynthetic +@Throws(PurchasesException::class) +suspend fun Purchases.awaitGetVirtualCurrencies(): VirtualCurrencies { + return suspendCoroutine { continuation -> + getVirtualCurrenciesWith( + onSuccess = { continuation.resume(it) }, + onError = { continuation.resumeWithException(PurchasesException(it)) }, + ) + } +} diff --git a/purchases/src/defaults/kotlin/com/revenuecat/purchases/listenerConversions.kt b/purchases/src/defaults/kotlin/com/revenuecat/purchases/listenerConversions.kt index 78877debef..5c7ef11090 100644 --- a/purchases/src/defaults/kotlin/com/revenuecat/purchases/listenerConversions.kt +++ b/purchases/src/defaults/kotlin/com/revenuecat/purchases/listenerConversions.kt @@ -4,7 +4,7 @@ import android.app.Activity import com.revenuecat.purchases.customercenter.CustomerCenterConfigData import com.revenuecat.purchases.interfaces.GetAmazonLWAConsentStatusCallback import com.revenuecat.purchases.interfaces.GetCustomerCenterConfigCallback -import com.revenuecat.purchases.interfaces.GetStorefrontCallback +import com.revenuecat.purchases.interfaces.GetVirtualCurrenciesCallback import com.revenuecat.purchases.interfaces.LogInCallback import com.revenuecat.purchases.interfaces.ProductChangeCallback import com.revenuecat.purchases.interfaces.SyncAttributesAndOfferingsCallback @@ -12,6 +12,7 @@ import com.revenuecat.purchases.interfaces.SyncPurchasesCallback import com.revenuecat.purchases.models.StoreProduct import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.models.SubscriptionOption +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies internal fun logInSuccessListener( onSuccess: (customerInfo: CustomerInfo, created: Boolean) -> Unit?, @@ -78,6 +79,19 @@ internal fun getAmazonLWAConsentStatusListener( } } +internal fun getVirtualCurrenciesCallback( + onSuccess: (virtualCurrencies: VirtualCurrencies) -> Unit, + onError: (error: PurchasesError) -> Unit, +) = object : GetVirtualCurrenciesCallback { + override fun onReceived(virtualCurrencies: VirtualCurrencies) { + onSuccess(virtualCurrencies) + } + + override fun onError(error: PurchasesError) { + onError(error) + } +} + @OptIn(InternalRevenueCatAPI::class) internal fun getCustomerCenterConfigDataListener( onSuccess: (CustomerCenterConfigData) -> Unit, @@ -92,28 +106,6 @@ internal fun getCustomerCenterConfigDataListener( } } -/** - * This method will try to obtain the Store (Google/Amazon) country code in ISO-3166-1 alpha2. - * If there is any error, it will return null and log said error. - * @param [onSuccess] Will be called after the call has completed. - * @param [onError] Will be called after the call has completed with an error. - */ -@Suppress("unused") -fun Purchases.getStorefrontCountryCodeWith( - onError: (error: PurchasesError) -> Unit = ON_ERROR_STUB, - onSuccess: (storefrontCountryCode: String) -> Unit, -) { - getStorefrontCountryCode(object : GetStorefrontCallback { - override fun onReceived(storefrontCountryCode: String) { - onSuccess(storefrontCountryCode) - } - - override fun onError(error: PurchasesError) { - onError(error) - } - }) -} - /** * Purchase product. If purchasing a subscription, it will choose the default [SubscriptionOption]. * @param [activity] Current activity @@ -279,6 +271,23 @@ fun Purchases.getAmazonLWAConsentStatusWith( getAmazonLWAConsentStatus(getAmazonLWAConsentStatusListener(onSuccess, onError)) } +/** + * Fetches the virtual currencies for the current subscriber. + * + * @param [onSuccess] Will be called after the call has completed successfully + * with a [VirtualCurrencies] object. + * @param [onError] Will be called after the call has completed with an error. + */ +@Suppress("unused") +fun Purchases.getVirtualCurrenciesWith( + onError: (error: PurchasesError) -> Unit = ON_ERROR_STUB, + onSuccess: (virtualCurrencies: VirtualCurrencies) -> Unit, +) { + getVirtualCurrencies( + callback = getVirtualCurrenciesCallback(onSuccess, onError), + ) +} + // region Deprecated /** diff --git a/purchases/src/main/baseline-prof.txt b/purchases/src/main/baseline-prof.txt index 8b5006189f..4c78ad7af8 100644 --- a/purchases/src/main/baseline-prof.txt +++ b/purchases/src/main/baseline-prof.txt @@ -1,8 +1,9 @@ Lcom/revenuecat/purchases/APIKeyValidator; HSPLcom/revenuecat/purchases/APIKeyValidator;->()V HSPLcom/revenuecat/purchases/APIKeyValidator;->getApiKeyPlatform(Ljava/lang/String;)Lcom/revenuecat/purchases/APIKeyValidator$APIKeyPlatform; +HSPLcom/revenuecat/purchases/APIKeyValidator;->logValidationResult(Lcom/revenuecat/purchases/APIKeyValidator$ValidationResult;)V HSPLcom/revenuecat/purchases/APIKeyValidator;->validate(Ljava/lang/String;Lcom/revenuecat/purchases/Store;)Lcom/revenuecat/purchases/APIKeyValidator$ValidationResult; -HSPLcom/revenuecat/purchases/APIKeyValidator;->validateAndLog(Ljava/lang/String;Lcom/revenuecat/purchases/Store;)V +HSPLcom/revenuecat/purchases/APIKeyValidator;->validateAndLog(Ljava/lang/String;Lcom/revenuecat/purchases/Store;)Lcom/revenuecat/purchases/APIKeyValidator$ValidationResult; Lcom/revenuecat/purchases/APIKeyValidator$APIKeyPlatform; HSPLcom/revenuecat/purchases/APIKeyValidator$APIKeyPlatform;->$values()[Lcom/revenuecat/purchases/APIKeyValidator$APIKeyPlatform; HSPLcom/revenuecat/purchases/APIKeyValidator$APIKeyPlatform;->()V @@ -28,7 +29,7 @@ HSPLcom/revenuecat/purchases/AttributionFetcherFactory$WhenMappings;->() Lcom/revenuecat/purchases/BillingFactory; HSPLcom/revenuecat/purchases/BillingFactory;->()V HSPLcom/revenuecat/purchases/BillingFactory;->()V -HSPLcom/revenuecat/purchases/BillingFactory;->createBilling(Lcom/revenuecat/purchases/Store;Landroid/app/Application;Lcom/revenuecat/purchases/common/BackendHelper;Lcom/revenuecat/purchases/common/caching/DeviceCache;ZLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;Lcom/revenuecat/purchases/PurchasesStateProvider;Z)Lcom/revenuecat/purchases/common/BillingAbstract; +HSPLcom/revenuecat/purchases/BillingFactory;->createBilling(Lcom/revenuecat/purchases/Store;Landroid/app/Application;Lcom/revenuecat/purchases/common/BackendHelper;Lcom/revenuecat/purchases/common/caching/DeviceCache;ZLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;Lcom/revenuecat/purchases/PurchasesStateProvider;ZLcom/revenuecat/purchases/common/Backend;Lcom/revenuecat/purchases/APIKeyValidator$ValidationResult;)Lcom/revenuecat/purchases/common/BillingAbstract; Lcom/revenuecat/purchases/BillingFactory$WhenMappings; HSPLcom/revenuecat/purchases/BillingFactory$WhenMappings;->()V Lcom/revenuecat/purchases/CacheFetchPolicy; @@ -39,108 +40,47 @@ HSPLcom/revenuecat/purchases/CacheFetchPolicy;->values()[Lcom/revenuecat/purchas Lcom/revenuecat/purchases/CacheFetchPolicy$Companion; HSPLcom/revenuecat/purchases/CacheFetchPolicy$Companion;->()V HSPLcom/revenuecat/purchases/CacheFetchPolicy$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V -HSPLcom/revenuecat/purchases/CacheFetchPolicy$Companion;->default()Lcom/revenuecat/purchases/CacheFetchPolicy; -Lcom/revenuecat/purchases/CoroutinesExtensionsKt; -HSPLcom/revenuecat/purchases/CoroutinesExtensionsKt;->awaitCustomerInfo(Lcom/revenuecat/purchases/Purchases;Lcom/revenuecat/purchases/CacheFetchPolicy;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -Lcom/revenuecat/purchases/CoroutinesExtensionsKt$awaitCustomerInfo$2$1; -HSPLcom/revenuecat/purchases/CoroutinesExtensionsKt$awaitCustomerInfo$2$1;->(Ljava/lang/Object;)V -HSPLcom/revenuecat/purchases/CoroutinesExtensionsKt$awaitCustomerInfo$2$1;->invoke(Lcom/revenuecat/purchases/CustomerInfo;)V -HSPLcom/revenuecat/purchases/CoroutinesExtensionsKt$awaitCustomerInfo$2$1;->invoke(Ljava/lang/Object;)Ljava/lang/Object; -Lcom/revenuecat/purchases/CoroutinesExtensionsKt$awaitCustomerInfo$2$2; -HSPLcom/revenuecat/purchases/CoroutinesExtensionsKt$awaitCustomerInfo$2$2;->(Lkotlin/coroutines/Continuation;)V -Lcom/revenuecat/purchases/CustomerInfo; -HSPLcom/revenuecat/purchases/CustomerInfo;->()V -HSPLcom/revenuecat/purchases/CustomerInfo;->(Lcom/revenuecat/purchases/EntitlementInfos;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;ILjava/util/Date;Ljava/lang/String;Landroid/net/Uri;Ljava/util/Date;Lorg/json/JSONObject;)V -HSPLcom/revenuecat/purchases/CustomerInfo;->access$activeIdentifiers(Lcom/revenuecat/purchases/CustomerInfo;Ljava/util/Map;)Ljava/util/Set; -HSPLcom/revenuecat/purchases/CustomerInfo;->access$getSubscriberJSONObject$p(Lcom/revenuecat/purchases/CustomerInfo;)Lorg/json/JSONObject; -HSPLcom/revenuecat/purchases/CustomerInfo;->activeIdentifiers(Ljava/util/Map;)Ljava/util/Set; -HSPLcom/revenuecat/purchases/CustomerInfo;->getActiveSubscriptions()Ljava/util/Set; -HSPLcom/revenuecat/purchases/CustomerInfo;->getAllExpirationDatesByProduct()Ljava/util/Map; -HSPLcom/revenuecat/purchases/CustomerInfo;->getEntitlements()Lcom/revenuecat/purchases/EntitlementInfos; -HSPLcom/revenuecat/purchases/CustomerInfo;->getNonSubscriptionTransactions()Ljava/util/List; -HSPLcom/revenuecat/purchases/CustomerInfo;->getRawData()Lorg/json/JSONObject; -HSPLcom/revenuecat/purchases/CustomerInfo;->getRequestDate()Ljava/util/Date; -Lcom/revenuecat/purchases/CustomerInfo$Creator; -HSPLcom/revenuecat/purchases/CustomerInfo$Creator;->()V -Lcom/revenuecat/purchases/CustomerInfo$activeSubscriptions$2; -HSPLcom/revenuecat/purchases/CustomerInfo$activeSubscriptions$2;->(Lcom/revenuecat/purchases/CustomerInfo;)V -HSPLcom/revenuecat/purchases/CustomerInfo$activeSubscriptions$2;->invoke()Ljava/lang/Object; -HSPLcom/revenuecat/purchases/CustomerInfo$activeSubscriptions$2;->invoke()Ljava/util/Set; -Lcom/revenuecat/purchases/CustomerInfo$allPurchasedProductIds$2; -HSPLcom/revenuecat/purchases/CustomerInfo$allPurchasedProductIds$2;->(Lcom/revenuecat/purchases/CustomerInfo;)V -Lcom/revenuecat/purchases/CustomerInfo$allPurchasedSkus$2; -HSPLcom/revenuecat/purchases/CustomerInfo$allPurchasedSkus$2;->(Lcom/revenuecat/purchases/CustomerInfo;)V -Lcom/revenuecat/purchases/CustomerInfo$latestExpirationDate$2; -HSPLcom/revenuecat/purchases/CustomerInfo$latestExpirationDate$2;->(Lcom/revenuecat/purchases/CustomerInfo;)V -Lcom/revenuecat/purchases/CustomerInfo$nonSubscriptionTransactions$2; -HSPLcom/revenuecat/purchases/CustomerInfo$nonSubscriptionTransactions$2;->(Lcom/revenuecat/purchases/CustomerInfo;)V -HSPLcom/revenuecat/purchases/CustomerInfo$nonSubscriptionTransactions$2;->invoke()Ljava/lang/Object; -HSPLcom/revenuecat/purchases/CustomerInfo$nonSubscriptionTransactions$2;->invoke()Ljava/util/List; -Lcom/revenuecat/purchases/CustomerInfo$nonSubscriptionTransactions$2$invoke$$inlined$sortedBy$1; -HSPLcom/revenuecat/purchases/CustomerInfo$nonSubscriptionTransactions$2$invoke$$inlined$sortedBy$1;->()V -Lcom/revenuecat/purchases/CustomerInfo$subscriptionsByProductIdentifier$2; -HSPLcom/revenuecat/purchases/CustomerInfo$subscriptionsByProductIdentifier$2;->(Lcom/revenuecat/purchases/CustomerInfo;)V -Lcom/revenuecat/purchases/CustomerInfoDataResult; -HSPLcom/revenuecat/purchases/CustomerInfoDataResult;->(Lcom/revenuecat/purchases/utils/Result;Ljava/lang/Boolean;)V -HSPLcom/revenuecat/purchases/CustomerInfoDataResult;->(Lcom/revenuecat/purchases/utils/Result;Ljava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V -HSPLcom/revenuecat/purchases/CustomerInfoDataResult;->getHadUnsyncedPurchasesBefore()Ljava/lang/Boolean; -HSPLcom/revenuecat/purchases/CustomerInfoDataResult;->getResult()Lcom/revenuecat/purchases/utils/Result; Lcom/revenuecat/purchases/CustomerInfoHelper; -HSPLcom/revenuecat/purchases/CustomerInfoHelper;->$r8$lambda$olD7WVes0t2RZUXkzU7MMUPVXYU(Lkotlin/jvm/functions/Function0;)V +HSPLcom/revenuecat/purchases/CustomerInfoHelper;->$r8$lambda$JZP8dykiLGkSuEq6VF-LPfVbQXQ(Lkotlin/jvm/functions/Function0;)V HSPLcom/revenuecat/purchases/CustomerInfoHelper;->(Lcom/revenuecat/purchases/common/caching/DeviceCache;Lcom/revenuecat/purchases/common/Backend;Lcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager;Lcom/revenuecat/purchases/CustomerInfoUpdateHandler;Lcom/revenuecat/purchases/PostPendingTransactionsHelper;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;Lcom/revenuecat/purchases/common/DateProvider;Landroid/os/Handler;)V HSPLcom/revenuecat/purchases/CustomerInfoHelper;->(Lcom/revenuecat/purchases/common/caching/DeviceCache;Lcom/revenuecat/purchases/common/Backend;Lcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager;Lcom/revenuecat/purchases/CustomerInfoUpdateHandler;Lcom/revenuecat/purchases/PostPendingTransactionsHelper;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;Lcom/revenuecat/purchases/common/DateProvider;Landroid/os/Handler;ILkotlin/jvm/internal/DefaultConstructorMarker;)V HSPLcom/revenuecat/purchases/CustomerInfoHelper;->access$dispatch(Lcom/revenuecat/purchases/CustomerInfoHelper;Lkotlin/jvm/functions/Function0;)V HSPLcom/revenuecat/purchases/CustomerInfoHelper;->access$getCustomerInfoFetchOnly(Lcom/revenuecat/purchases/CustomerInfoHelper;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)V -HSPLcom/revenuecat/purchases/CustomerInfoHelper;->access$getCustomerInfoUpdateHandler$p(Lcom/revenuecat/purchases/CustomerInfoHelper;)Lcom/revenuecat/purchases/CustomerInfoUpdateHandler; +HSPLcom/revenuecat/purchases/CustomerInfoHelper;->access$getDeviceCache$p(Lcom/revenuecat/purchases/CustomerInfoHelper;)Lcom/revenuecat/purchases/common/caching/DeviceCache; HSPLcom/revenuecat/purchases/CustomerInfoHelper;->access$getOfflineEntitlementsManager$p(Lcom/revenuecat/purchases/CustomerInfoHelper;)Lcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager; -HSPLcom/revenuecat/purchases/CustomerInfoHelper;->access$trackGetCustomerInfoResultIfNeeded(Lcom/revenuecat/purchases/CustomerInfoHelper;ZLjava/util/Date;Lcom/revenuecat/purchases/CustomerInfoDataResult;Lcom/revenuecat/purchases/CacheFetchPolicy;)V -HSPLcom/revenuecat/purchases/CustomerInfoHelper;->dispatch$lambda$0(Lkotlin/jvm/functions/Function0;)V +HSPLcom/revenuecat/purchases/CustomerInfoHelper;->dispatch$lambda$5(Lkotlin/jvm/functions/Function0;)V HSPLcom/revenuecat/purchases/CustomerInfoHelper;->dispatch(Lkotlin/jvm/functions/Function0;)V -HSPLcom/revenuecat/purchases/CustomerInfoHelper;->getCachedCustomerInfo(Ljava/lang/String;)Lcom/revenuecat/purchases/CustomerInfo; -HSPLcom/revenuecat/purchases/CustomerInfoHelper;->getCustomerInfoCachedOrFetched(Ljava/lang/String;ZZLkotlin/jvm/functions/Function1;)V HSPLcom/revenuecat/purchases/CustomerInfoHelper;->getCustomerInfoFetchOnly(Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)V HSPLcom/revenuecat/purchases/CustomerInfoHelper;->postPendingPurchasesAndFetchCustomerInfo(Ljava/lang/String;ZZLkotlin/jvm/functions/Function1;)V HSPLcom/revenuecat/purchases/CustomerInfoHelper;->retrieveCustomerInfo$default(Lcom/revenuecat/purchases/CustomerInfoHelper;Ljava/lang/String;Lcom/revenuecat/purchases/CacheFetchPolicy;ZZZLcom/revenuecat/purchases/interfaces/ReceiveCustomerInfoCallback;ILjava/lang/Object;)V HSPLcom/revenuecat/purchases/CustomerInfoHelper;->retrieveCustomerInfo(Ljava/lang/String;Lcom/revenuecat/purchases/CacheFetchPolicy;ZZZLcom/revenuecat/purchases/interfaces/ReceiveCustomerInfoCallback;)V -HSPLcom/revenuecat/purchases/CustomerInfoHelper;->trackGetCustomerInfoResultIfNeeded(ZLjava/util/Date;Lcom/revenuecat/purchases/CustomerInfoDataResult;Lcom/revenuecat/purchases/CacheFetchPolicy;)V HSPLcom/revenuecat/purchases/CustomerInfoHelper;->trackGetCustomerInfoStartedIfNeeded(Z)V -HSPLcom/revenuecat/purchases/CustomerInfoHelper;->updateCachedCustomerInfoIfStale(Ljava/lang/String;ZZ)V Lcom/revenuecat/purchases/CustomerInfoHelper$$ExternalSyntheticLambda0; HSPLcom/revenuecat/purchases/CustomerInfoHelper$$ExternalSyntheticLambda0;->(Lkotlin/jvm/functions/Function0;)V HSPLcom/revenuecat/purchases/CustomerInfoHelper$$ExternalSyntheticLambda0;->run()V Lcom/revenuecat/purchases/CustomerInfoHelper$WhenMappings; HSPLcom/revenuecat/purchases/CustomerInfoHelper$WhenMappings;->()V -Lcom/revenuecat/purchases/CustomerInfoHelper$getCustomerInfoCachedOrFetched$1; -HSPLcom/revenuecat/purchases/CustomerInfoHelper$getCustomerInfoCachedOrFetched$1;->(Lkotlin/jvm/functions/Function1;Lcom/revenuecat/purchases/CustomerInfo;)V -HSPLcom/revenuecat/purchases/CustomerInfoHelper$getCustomerInfoCachedOrFetched$1;->invoke()Ljava/lang/Object; -HSPLcom/revenuecat/purchases/CustomerInfoHelper$getCustomerInfoCachedOrFetched$1;->invoke()V Lcom/revenuecat/purchases/CustomerInfoHelper$getCustomerInfoFetchOnly$1; HSPLcom/revenuecat/purchases/CustomerInfoHelper$getCustomerInfoFetchOnly$1;->(Lcom/revenuecat/purchases/CustomerInfoHelper;Lkotlin/jvm/functions/Function1;)V -HSPLcom/revenuecat/purchases/CustomerInfoHelper$getCustomerInfoFetchOnly$1;->invoke(Lcom/revenuecat/purchases/CustomerInfo;)V -HSPLcom/revenuecat/purchases/CustomerInfoHelper$getCustomerInfoFetchOnly$1;->invoke(Ljava/lang/Object;)Ljava/lang/Object; -Lcom/revenuecat/purchases/CustomerInfoHelper$getCustomerInfoFetchOnly$1$1; -HSPLcom/revenuecat/purchases/CustomerInfoHelper$getCustomerInfoFetchOnly$1$1;->(Lkotlin/jvm/functions/Function1;Lcom/revenuecat/purchases/CustomerInfo;)V -HSPLcom/revenuecat/purchases/CustomerInfoHelper$getCustomerInfoFetchOnly$1$1;->invoke()Ljava/lang/Object; -HSPLcom/revenuecat/purchases/CustomerInfoHelper$getCustomerInfoFetchOnly$1$1;->invoke()V Lcom/revenuecat/purchases/CustomerInfoHelper$getCustomerInfoFetchOnly$2; HSPLcom/revenuecat/purchases/CustomerInfoHelper$getCustomerInfoFetchOnly$2;->(Lcom/revenuecat/purchases/CustomerInfoHelper;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V +HSPLcom/revenuecat/purchases/CustomerInfoHelper$getCustomerInfoFetchOnly$2;->invoke(Lcom/revenuecat/purchases/PurchasesError;Z)V +HSPLcom/revenuecat/purchases/CustomerInfoHelper$getCustomerInfoFetchOnly$2;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +Lcom/revenuecat/purchases/CustomerInfoHelper$getCustomerInfoFetchOnly$2$4; +HSPLcom/revenuecat/purchases/CustomerInfoHelper$getCustomerInfoFetchOnly$2$4;->(Lkotlin/jvm/functions/Function1;Lcom/revenuecat/purchases/PurchasesError;)V +HSPLcom/revenuecat/purchases/CustomerInfoHelper$getCustomerInfoFetchOnly$2$4;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/CustomerInfoHelper$getCustomerInfoFetchOnly$2$4;->invoke()V Lcom/revenuecat/purchases/CustomerInfoHelper$postPendingPurchasesAndFetchCustomerInfo$1; HSPLcom/revenuecat/purchases/CustomerInfoHelper$postPendingPurchasesAndFetchCustomerInfo$1;->(Lcom/revenuecat/purchases/CustomerInfoHelper;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)V HSPLcom/revenuecat/purchases/CustomerInfoHelper$postPendingPurchasesAndFetchCustomerInfo$1;->invoke(Lcom/revenuecat/purchases/SyncPendingPurchaseResult;)V HSPLcom/revenuecat/purchases/CustomerInfoHelper$postPendingPurchasesAndFetchCustomerInfo$1;->invoke(Ljava/lang/Object;)Ljava/lang/Object; -Lcom/revenuecat/purchases/CustomerInfoHelper$postPendingPurchasesAndFetchCustomerInfo$1$2; -HSPLcom/revenuecat/purchases/CustomerInfoHelper$postPendingPurchasesAndFetchCustomerInfo$1$2;->(Lkotlin/jvm/functions/Function1;)V -HSPLcom/revenuecat/purchases/CustomerInfoHelper$postPendingPurchasesAndFetchCustomerInfo$1$2;->invoke(Lcom/revenuecat/purchases/utils/Result;)V -HSPLcom/revenuecat/purchases/CustomerInfoHelper$postPendingPurchasesAndFetchCustomerInfo$1$2;->invoke(Ljava/lang/Object;)Ljava/lang/Object; -Lcom/revenuecat/purchases/CustomerInfoHelper$retrieveCustomerInfo$callbackWithDiagnostics$1; -HSPLcom/revenuecat/purchases/CustomerInfoHelper$retrieveCustomerInfo$callbackWithDiagnostics$1;->(Lcom/revenuecat/purchases/CustomerInfoHelper;ZLjava/util/Date;Lcom/revenuecat/purchases/CacheFetchPolicy;Lcom/revenuecat/purchases/interfaces/ReceiveCustomerInfoCallback;)V -HSPLcom/revenuecat/purchases/CustomerInfoHelper$retrieveCustomerInfo$callbackWithDiagnostics$1;->invoke(Lcom/revenuecat/purchases/CustomerInfoDataResult;)V -HSPLcom/revenuecat/purchases/CustomerInfoHelper$retrieveCustomerInfo$callbackWithDiagnostics$1;->invoke(Ljava/lang/Object;)Ljava/lang/Object; +Lcom/revenuecat/purchases/CustomerInfoHelper$postPendingPurchasesAndFetchCustomerInfo$1$3; +HSPLcom/revenuecat/purchases/CustomerInfoHelper$postPendingPurchasesAndFetchCustomerInfo$1$3;->(Lkotlin/jvm/functions/Function1;)V +HSPLcom/revenuecat/purchases/CustomerInfoHelper$postPendingPurchasesAndFetchCustomerInfo$1$3;->invoke(Lcom/revenuecat/purchases/utils/Result;)V +HSPLcom/revenuecat/purchases/CustomerInfoHelper$postPendingPurchasesAndFetchCustomerInfo$1$3;->invoke(Ljava/lang/Object;)Ljava/lang/Object; Lcom/revenuecat/purchases/CustomerInfoUpdateHandler; HSPLcom/revenuecat/purchases/CustomerInfoUpdateHandler;->(Lcom/revenuecat/purchases/common/caching/DeviceCache;Lcom/revenuecat/purchases/identity/IdentityManager;Lcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager;Lcom/revenuecat/purchases/common/AppConfig;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;Landroid/os/Handler;)V HSPLcom/revenuecat/purchases/CustomerInfoUpdateHandler;->(Lcom/revenuecat/purchases/common/caching/DeviceCache;Lcom/revenuecat/purchases/identity/IdentityManager;Lcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager;Lcom/revenuecat/purchases/common/AppConfig;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;Landroid/os/Handler;ILkotlin/jvm/internal/DefaultConstructorMarker;)V -HSPLcom/revenuecat/purchases/CustomerInfoUpdateHandler;->cacheAndNotifyListeners(Lcom/revenuecat/purchases/CustomerInfo;)V -HSPLcom/revenuecat/purchases/CustomerInfoUpdateHandler;->notifyListeners(Lcom/revenuecat/purchases/CustomerInfo;)V Lcom/revenuecat/purchases/DangerousSettings; HSPLcom/revenuecat/purchases/DangerousSettings;->()V HSPLcom/revenuecat/purchases/DangerousSettings;->(Z)V @@ -150,12 +90,6 @@ HSPLcom/revenuecat/purchases/DangerousSettings;->getAutoSyncPurchases()Z HSPLcom/revenuecat/purchases/DangerousSettings;->getCustomEntitlementComputation$purchases_defaultsRelease()Z Lcom/revenuecat/purchases/DangerousSettings$Creator; HSPLcom/revenuecat/purchases/DangerousSettings$Creator;->()V -Lcom/revenuecat/purchases/EntitlementInfos; -HSPLcom/revenuecat/purchases/EntitlementInfos;->()V -HSPLcom/revenuecat/purchases/EntitlementInfos;->(Ljava/util/Map;Lcom/revenuecat/purchases/VerificationResult;)V -HSPLcom/revenuecat/purchases/EntitlementInfos;->getVerification()Lcom/revenuecat/purchases/VerificationResult; -Lcom/revenuecat/purchases/EntitlementInfos$Creator; -HSPLcom/revenuecat/purchases/EntitlementInfos$Creator;->()V Lcom/revenuecat/purchases/EntitlementVerificationMode; HSPLcom/revenuecat/purchases/EntitlementVerificationMode;->$values()[Lcom/revenuecat/purchases/EntitlementVerificationMode; HSPLcom/revenuecat/purchases/EntitlementVerificationMode;->()V @@ -172,20 +106,6 @@ Lcom/revenuecat/purchases/FontAlias$Companion; HSPLcom/revenuecat/purchases/FontAlias$Companion;->()V HSPLcom/revenuecat/purchases/FontAlias$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V Lcom/revenuecat/purchases/LifecycleDelegate; -Lcom/revenuecat/purchases/ListenerConversionsCommonKt; -HSPLcom/revenuecat/purchases/ListenerConversionsCommonKt;->()V -HSPLcom/revenuecat/purchases/ListenerConversionsCommonKt;->receiveCustomerInfoCallback(Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lcom/revenuecat/purchases/interfaces/ReceiveCustomerInfoCallback; -Lcom/revenuecat/purchases/ListenerConversionsCommonKt$ON_ERROR_STUB$1; -HSPLcom/revenuecat/purchases/ListenerConversionsCommonKt$ON_ERROR_STUB$1;->()V -HSPLcom/revenuecat/purchases/ListenerConversionsCommonKt$ON_ERROR_STUB$1;->()V -Lcom/revenuecat/purchases/ListenerConversionsCommonKt$ON_PURCHASE_ERROR_STUB$1; -HSPLcom/revenuecat/purchases/ListenerConversionsCommonKt$ON_PURCHASE_ERROR_STUB$1;->()V -HSPLcom/revenuecat/purchases/ListenerConversionsCommonKt$ON_PURCHASE_ERROR_STUB$1;->()V -Lcom/revenuecat/purchases/ListenerConversionsCommonKt$receiveCustomerInfoCallback$1; -HSPLcom/revenuecat/purchases/ListenerConversionsCommonKt$receiveCustomerInfoCallback$1;->(Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V -HSPLcom/revenuecat/purchases/ListenerConversionsCommonKt$receiveCustomerInfoCallback$1;->onReceived(Lcom/revenuecat/purchases/CustomerInfo;)V -Lcom/revenuecat/purchases/ListenerConversionsKt; -HSPLcom/revenuecat/purchases/ListenerConversionsKt;->getCustomerInfoWith(Lcom/revenuecat/purchases/Purchases;Lcom/revenuecat/purchases/CacheFetchPolicy;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V Lcom/revenuecat/purchases/LogHandler; Lcom/revenuecat/purchases/LogLevel; HSPLcom/revenuecat/purchases/LogLevel;->$values()[Lcom/revenuecat/purchases/LogLevel; @@ -218,36 +138,42 @@ HSPLcom/revenuecat/purchases/Offering$weekly$2;->(Lcom/revenuecat/purchase Lcom/revenuecat/purchases/OfferingParserFactory; HSPLcom/revenuecat/purchases/OfferingParserFactory;->()V HSPLcom/revenuecat/purchases/OfferingParserFactory;->()V -HSPLcom/revenuecat/purchases/OfferingParserFactory;->createOfferingParser(Lcom/revenuecat/purchases/Store;)Lcom/revenuecat/purchases/common/OfferingParser; +HSPLcom/revenuecat/purchases/OfferingParserFactory;->createOfferingParser(Lcom/revenuecat/purchases/Store;Lcom/revenuecat/purchases/APIKeyValidator$ValidationResult;)Lcom/revenuecat/purchases/common/OfferingParser; Lcom/revenuecat/purchases/OfferingParserFactory$WhenMappings; HSPLcom/revenuecat/purchases/OfferingParserFactory$WhenMappings;->()V Lcom/revenuecat/purchases/Package; HSPLcom/revenuecat/purchases/Package;->(Ljava/lang/String;Lcom/revenuecat/purchases/PackageType;Lcom/revenuecat/purchases/models/StoreProduct;Lcom/revenuecat/purchases/PresentedOfferingContext;)V HSPLcom/revenuecat/purchases/Package;->(Ljava/lang/String;Lcom/revenuecat/purchases/PackageType;Lcom/revenuecat/purchases/models/StoreProduct;Ljava/lang/String;)V HSPLcom/revenuecat/purchases/Package;->getIdentifier()Ljava/lang/String; -HSPLcom/revenuecat/purchases/Package;->getPackageType()Lcom/revenuecat/purchases/PackageType; HSPLcom/revenuecat/purchases/Package;->getProduct()Lcom/revenuecat/purchases/models/StoreProduct; Lcom/revenuecat/purchases/PackageType; HSPLcom/revenuecat/purchases/PackageType;->$values()[Lcom/revenuecat/purchases/PackageType; HSPLcom/revenuecat/purchases/PackageType;->()V HSPLcom/revenuecat/purchases/PackageType;->(Ljava/lang/String;ILjava/lang/String;)V HSPLcom/revenuecat/purchases/PackageType;->getIdentifier()Ljava/lang/String; -HSPLcom/revenuecat/purchases/PackageType;->values()[Lcom/revenuecat/purchases/PackageType; Lcom/revenuecat/purchases/PostPendingTransactionsHelper; -HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper;->$r8$lambda$bLOxRCWX1FtdNSKDQf9cZtH3YrM(Lcom/revenuecat/purchases/PostPendingTransactionsHelper;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)V +HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper;->$r8$lambda$y8bQnD8LwbZ-1xIrL88o7RovEYg(Lcom/revenuecat/purchases/PostPendingTransactionsHelper;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)V HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper;->(Lcom/revenuecat/purchases/common/AppConfig;Lcom/revenuecat/purchases/common/caching/DeviceCache;Lcom/revenuecat/purchases/common/BillingAbstract;Lcom/revenuecat/purchases/common/Dispatcher;Lcom/revenuecat/purchases/identity/IdentityManager;Lcom/revenuecat/purchases/PostTransactionWithProductDetailsHelper;)V HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper;->syncPendingPurchaseQueue$default(Lcom/revenuecat/purchases/PostPendingTransactionsHelper;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)V -HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper;->syncPendingPurchaseQueue$lambda$0(Lcom/revenuecat/purchases/PostPendingTransactionsHelper;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)V +HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper;->syncPendingPurchaseQueue$lambda$2(Lcom/revenuecat/purchases/PostPendingTransactionsHelper;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)V HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper;->syncPendingPurchaseQueue(ZLkotlin/jvm/functions/Function1;)V Lcom/revenuecat/purchases/PostPendingTransactionsHelper$$ExternalSyntheticLambda0; HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper$$ExternalSyntheticLambda0;->(Lcom/revenuecat/purchases/PostPendingTransactionsHelper;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)V HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper$$ExternalSyntheticLambda0;->run()V -Lcom/revenuecat/purchases/PostPendingTransactionsHelper$syncPendingPurchaseQueue$1$1; -HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper$syncPendingPurchaseQueue$1$1;->(Lcom/revenuecat/purchases/PostPendingTransactionsHelper;ZLjava/lang/String;Lkotlin/jvm/functions/Function1;)V -Lcom/revenuecat/purchases/PostPendingTransactionsHelper$syncPendingPurchaseQueue$1$2; -HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper$syncPendingPurchaseQueue$1$2;->(Lkotlin/jvm/functions/Function1;)V -HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper$syncPendingPurchaseQueue$1$2;->invoke(Lcom/revenuecat/purchases/PurchasesError;)V -HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper$syncPendingPurchaseQueue$1$2;->invoke(Ljava/lang/Object;)Ljava/lang/Object; +Lcom/revenuecat/purchases/PostPendingTransactionsHelper$syncPendingPurchaseQueue$$inlined$log$2; +HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper$syncPendingPurchaseQueue$$inlined$log$2;->(Lcom/revenuecat/purchases/common/LogIntent;)V +HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper$syncPendingPurchaseQueue$$inlined$log$2;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper$syncPendingPurchaseQueue$$inlined$log$2;->invoke()Ljava/lang/String; +Lcom/revenuecat/purchases/PostPendingTransactionsHelper$syncPendingPurchaseQueue$3$1; +HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper$syncPendingPurchaseQueue$3$1;->(Lcom/revenuecat/purchases/PostPendingTransactionsHelper;ZLjava/lang/String;Lkotlin/jvm/functions/Function1;)V +Lcom/revenuecat/purchases/PostPendingTransactionsHelper$syncPendingPurchaseQueue$3$2; +HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper$syncPendingPurchaseQueue$3$2;->(Lkotlin/jvm/functions/Function1;)V +HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper$syncPendingPurchaseQueue$3$2;->invoke(Lcom/revenuecat/purchases/PurchasesError;)V +HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper$syncPendingPurchaseQueue$3$2;->invoke(Ljava/lang/Object;)Ljava/lang/Object; +Lcom/revenuecat/purchases/PostPendingTransactionsHelper$syncPendingPurchaseQueue$3$2$invoke$$inlined$log$1; +HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper$syncPendingPurchaseQueue$3$2$invoke$$inlined$log$1;->(Lcom/revenuecat/purchases/common/LogIntent;Lcom/revenuecat/purchases/PurchasesError;)V +HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper$syncPendingPurchaseQueue$3$2$invoke$$inlined$log$1;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/PostPendingTransactionsHelper$syncPendingPurchaseQueue$3$2$invoke$$inlined$log$1;->invoke()Ljava/lang/String; Lcom/revenuecat/purchases/PostReceiptHelper; HSPLcom/revenuecat/purchases/PostReceiptHelper;->(Lcom/revenuecat/purchases/common/AppConfig;Lcom/revenuecat/purchases/common/Backend;Lcom/revenuecat/purchases/common/BillingAbstract;Lcom/revenuecat/purchases/CustomerInfoUpdateHandler;Lcom/revenuecat/purchases/common/caching/DeviceCache;Lcom/revenuecat/purchases/subscriberattributes/SubscriberAttributesManager;Lcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager;Lcom/revenuecat/purchases/paywalls/PaywallPresentedCache;)V Lcom/revenuecat/purchases/PostTransactionWithProductDetailsHelper; @@ -269,7 +195,6 @@ HSPLcom/revenuecat/purchases/Purchases;->(Lcom/revenuecat/purchases/Purcha HSPLcom/revenuecat/purchases/Purchases;->access$getBackingFieldSharedInstance$cp()Lcom/revenuecat/purchases/Purchases; HSPLcom/revenuecat/purchases/Purchases;->access$getFrameworkVersion$cp()Ljava/lang/String; HSPLcom/revenuecat/purchases/Purchases;->access$setBackingFieldSharedInstance$cp(Lcom/revenuecat/purchases/Purchases;)V -HSPLcom/revenuecat/purchases/Purchases;->getCustomerInfo(Lcom/revenuecat/purchases/CacheFetchPolicy;Lcom/revenuecat/purchases/interfaces/ReceiveCustomerInfoCallback;)V HSPLcom/revenuecat/purchases/Purchases;->getPurchasesAreCompletedBy()Lcom/revenuecat/purchases/PurchasesAreCompletedBy; HSPLcom/revenuecat/purchases/Purchases;->getStorefrontCountryCode()Ljava/lang/String; HSPLcom/revenuecat/purchases/Purchases;->setCustomerCenterListener(Lcom/revenuecat/purchases/customercenter/CustomerCenterListener;)V @@ -346,8 +271,8 @@ HSPLcom/revenuecat/purchases/PurchasesErrorCode;->getDescription()Ljava/lang/Str HSPLcom/revenuecat/purchases/PurchasesErrorCode;->values()[Lcom/revenuecat/purchases/PurchasesErrorCode; Lcom/revenuecat/purchases/PurchasesException; Lcom/revenuecat/purchases/PurchasesFactory; -HSPLcom/revenuecat/purchases/PurchasesFactory;->(Lcom/revenuecat/purchases/utils/IsDebugBuildProvider;Lcom/revenuecat/purchases/APIKeyValidator;)V -HSPLcom/revenuecat/purchases/PurchasesFactory;->(Lcom/revenuecat/purchases/utils/IsDebugBuildProvider;Lcom/revenuecat/purchases/APIKeyValidator;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +HSPLcom/revenuecat/purchases/PurchasesFactory;->(Lcom/revenuecat/purchases/utils/IsDebugBuildProvider;Lcom/revenuecat/purchases/APIKeyValidator;Lkotlin/jvm/functions/Function0;)V +HSPLcom/revenuecat/purchases/PurchasesFactory;->(Lcom/revenuecat/purchases/utils/IsDebugBuildProvider;Lcom/revenuecat/purchases/APIKeyValidator;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V HSPLcom/revenuecat/purchases/PurchasesFactory;->createDefaultExecutor()Ljava/util/concurrent/ExecutorService; HSPLcom/revenuecat/purchases/PurchasesFactory;->createEventsExecutor()Ljava/util/concurrent/ExecutorService; HSPLcom/revenuecat/purchases/PurchasesFactory;->createEventsManager(Landroid/content/Context;Lcom/revenuecat/purchases/identity/IdentityManager;Lcom/revenuecat/purchases/common/Dispatcher;Lcom/revenuecat/purchases/common/Backend;)Lcom/revenuecat/purchases/common/events/EventsManager; @@ -355,7 +280,10 @@ HSPLcom/revenuecat/purchases/PurchasesFactory;->createPurchases$default(Lcom/rev HSPLcom/revenuecat/purchases/PurchasesFactory;->createPurchases(Lcom/revenuecat/purchases/PurchasesConfiguration;Lcom/revenuecat/purchases/common/PlatformInfo;Ljava/net/URL;Lcom/revenuecat/purchases/common/BillingAbstract;ZZZ)Lcom/revenuecat/purchases/Purchases; HSPLcom/revenuecat/purchases/PurchasesFactory;->getApplication(Landroid/content/Context;)Landroid/app/Application; HSPLcom/revenuecat/purchases/PurchasesFactory;->hasPermission(Landroid/content/Context;Ljava/lang/String;)Z -HSPLcom/revenuecat/purchases/PurchasesFactory;->validateConfiguration(Lcom/revenuecat/purchases/PurchasesConfiguration;)V +HSPLcom/revenuecat/purchases/PurchasesFactory;->validateConfiguration(Lcom/revenuecat/purchases/PurchasesConfiguration;)Lcom/revenuecat/purchases/APIKeyValidator$ValidationResult; +Lcom/revenuecat/purchases/PurchasesFactory$1; +HSPLcom/revenuecat/purchases/PurchasesFactory$1;->()V +HSPLcom/revenuecat/purchases/PurchasesFactory$1;->()V Lcom/revenuecat/purchases/PurchasesFactory$LowPriorityThreadFactory; HSPLcom/revenuecat/purchases/PurchasesFactory$LowPriorityThreadFactory;->$r8$lambda$Yz-FA7ZJgX-ZmuWeJdpRwiwNNr8(Ljava/lang/Runnable;)V HSPLcom/revenuecat/purchases/PurchasesFactory$LowPriorityThreadFactory;->(Ljava/lang/String;)V @@ -368,11 +296,31 @@ Lcom/revenuecat/purchases/PurchasesFactory$createEventsManager$1; HSPLcom/revenuecat/purchases/PurchasesFactory$createEventsManager$1;->(Lcom/revenuecat/purchases/common/Backend;)V HSPLcom/revenuecat/purchases/PurchasesFactory$createEventsManager$1;->invoke(Lcom/revenuecat/purchases/common/events/EventsRequest;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;)V HSPLcom/revenuecat/purchases/PurchasesFactory$createEventsManager$1;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +Lcom/revenuecat/purchases/PurchasesFactory$createPurchases$lambda$8$$inlined$log$1; +HSPLcom/revenuecat/purchases/PurchasesFactory$createPurchases$lambda$8$$inlined$log$1;->(Lcom/revenuecat/purchases/common/LogIntent;)V +HSPLcom/revenuecat/purchases/PurchasesFactory$createPurchases$lambda$8$$inlined$log$1;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/PurchasesFactory$createPurchases$lambda$8$$inlined$log$1;->invoke()Ljava/lang/String; +Lcom/revenuecat/purchases/PurchasesFactory$createPurchases$lambda$8$$inlined$log$2; +HSPLcom/revenuecat/purchases/PurchasesFactory$createPurchases$lambda$8$$inlined$log$2;->(Lcom/revenuecat/purchases/common/LogIntent;)V +HSPLcom/revenuecat/purchases/PurchasesFactory$createPurchases$lambda$8$$inlined$log$2;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/PurchasesFactory$createPurchases$lambda$8$$inlined$log$2;->invoke()Ljava/lang/String; +Lcom/revenuecat/purchases/PurchasesFactory$createPurchases$lambda$8$$inlined$log$3; +HSPLcom/revenuecat/purchases/PurchasesFactory$createPurchases$lambda$8$$inlined$log$3;->(Lcom/revenuecat/purchases/common/LogIntent;Lcom/revenuecat/purchases/common/AppConfig;)V +HSPLcom/revenuecat/purchases/PurchasesFactory$createPurchases$lambda$8$$inlined$log$3;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/PurchasesFactory$createPurchases$lambda$8$$inlined$log$3;->invoke()Ljava/lang/String; +Lcom/revenuecat/purchases/PurchasesFactory$createPurchases$lambda$8$$inlined$log$4; +HSPLcom/revenuecat/purchases/PurchasesFactory$createPurchases$lambda$8$$inlined$log$4;->(Lcom/revenuecat/purchases/common/LogIntent;Lcom/revenuecat/purchases/PurchasesConfiguration;)V +HSPLcom/revenuecat/purchases/PurchasesFactory$createPurchases$lambda$8$$inlined$log$4;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/PurchasesFactory$createPurchases$lambda$8$$inlined$log$4;->invoke()Ljava/lang/String; +Lcom/revenuecat/purchases/PurchasesFactory$createPurchases$lambda$8$$inlined$log$5; +HSPLcom/revenuecat/purchases/PurchasesFactory$createPurchases$lambda$8$$inlined$log$5;->(Lcom/revenuecat/purchases/common/LogIntent;Lcom/revenuecat/purchases/PurchasesConfiguration;)V +HSPLcom/revenuecat/purchases/PurchasesFactory$createPurchases$lambda$8$$inlined$log$5;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/PurchasesFactory$createPurchases$lambda$8$$inlined$log$5;->invoke()Ljava/lang/String; Lcom/revenuecat/purchases/PurchasesOrchestrator; -HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->$r8$lambda$SMPSTRxxF3tX0pgBfGwsryVsvQU(Lkotlin/jvm/functions/Function0;)V +HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->$r8$lambda$sp8A_5Zfcu3pKPPlyBS9LSUa6m8(Lkotlin/jvm/functions/Function0;)V HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->()V -HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->(Landroid/app/Application;Ljava/lang/String;Lcom/revenuecat/purchases/common/Backend;Lcom/revenuecat/purchases/common/BillingAbstract;Lcom/revenuecat/purchases/common/caching/DeviceCache;Lcom/revenuecat/purchases/identity/IdentityManager;Lcom/revenuecat/purchases/subscriberattributes/SubscriberAttributesManager;Lcom/revenuecat/purchases/common/AppConfig;Lcom/revenuecat/purchases/CustomerInfoHelper;Lcom/revenuecat/purchases/CustomerInfoUpdateHandler;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;Lcom/revenuecat/purchases/common/DateProvider;Lcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager;Lcom/revenuecat/purchases/PostReceiptHelper;Lcom/revenuecat/purchases/PostTransactionWithProductDetailsHelper;Lcom/revenuecat/purchases/PostPendingTransactionsHelper;Lcom/revenuecat/purchases/SyncPurchasesHelper;Lcom/revenuecat/purchases/common/offerings/OfferingsManager;Lcom/revenuecat/purchases/common/events/EventsManager;Lcom/revenuecat/purchases/paywalls/PaywallPresentedCache;Lcom/revenuecat/purchases/PurchasesStateCache;Landroid/os/Handler;Lcom/revenuecat/purchases/common/Dispatcher;Lcom/revenuecat/purchases/PurchasesConfiguration;Lcom/revenuecat/purchases/paywalls/FontLoader;Lcom/revenuecat/purchases/deeplinks/WebPurchaseRedemptionHelper;Lkotlin/jvm/functions/Function0;)V -HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->(Landroid/app/Application;Ljava/lang/String;Lcom/revenuecat/purchases/common/Backend;Lcom/revenuecat/purchases/common/BillingAbstract;Lcom/revenuecat/purchases/common/caching/DeviceCache;Lcom/revenuecat/purchases/identity/IdentityManager;Lcom/revenuecat/purchases/subscriberattributes/SubscriberAttributesManager;Lcom/revenuecat/purchases/common/AppConfig;Lcom/revenuecat/purchases/CustomerInfoHelper;Lcom/revenuecat/purchases/CustomerInfoUpdateHandler;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;Lcom/revenuecat/purchases/common/DateProvider;Lcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager;Lcom/revenuecat/purchases/PostReceiptHelper;Lcom/revenuecat/purchases/PostTransactionWithProductDetailsHelper;Lcom/revenuecat/purchases/PostPendingTransactionsHelper;Lcom/revenuecat/purchases/SyncPurchasesHelper;Lcom/revenuecat/purchases/common/offerings/OfferingsManager;Lcom/revenuecat/purchases/common/events/EventsManager;Lcom/revenuecat/purchases/paywalls/PaywallPresentedCache;Lcom/revenuecat/purchases/PurchasesStateCache;Landroid/os/Handler;Lcom/revenuecat/purchases/common/Dispatcher;Lcom/revenuecat/purchases/PurchasesConfiguration;Lcom/revenuecat/purchases/paywalls/FontLoader;Lcom/revenuecat/purchases/deeplinks/WebPurchaseRedemptionHelper;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->(Landroid/app/Application;Ljava/lang/String;Lcom/revenuecat/purchases/common/Backend;Lcom/revenuecat/purchases/common/BillingAbstract;Lcom/revenuecat/purchases/common/caching/DeviceCache;Lcom/revenuecat/purchases/identity/IdentityManager;Lcom/revenuecat/purchases/subscriberattributes/SubscriberAttributesManager;Lcom/revenuecat/purchases/common/AppConfig;Lcom/revenuecat/purchases/CustomerInfoHelper;Lcom/revenuecat/purchases/CustomerInfoUpdateHandler;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;Lcom/revenuecat/purchases/common/DateProvider;Lcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager;Lcom/revenuecat/purchases/PostReceiptHelper;Lcom/revenuecat/purchases/PostTransactionWithProductDetailsHelper;Lcom/revenuecat/purchases/PostPendingTransactionsHelper;Lcom/revenuecat/purchases/SyncPurchasesHelper;Lcom/revenuecat/purchases/common/offerings/OfferingsManager;Lcom/revenuecat/purchases/common/events/EventsManager;Lcom/revenuecat/purchases/paywalls/PaywallPresentedCache;Lcom/revenuecat/purchases/PurchasesStateCache;Landroid/os/Handler;Lcom/revenuecat/purchases/common/Dispatcher;Lcom/revenuecat/purchases/PurchasesConfiguration;Lcom/revenuecat/purchases/paywalls/FontLoader;Lcom/revenuecat/purchases/deeplinks/WebPurchaseRedemptionHelper;Lcom/revenuecat/purchases/virtualcurrencies/VirtualCurrencyManager;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)V +HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->(Landroid/app/Application;Ljava/lang/String;Lcom/revenuecat/purchases/common/Backend;Lcom/revenuecat/purchases/common/BillingAbstract;Lcom/revenuecat/purchases/common/caching/DeviceCache;Lcom/revenuecat/purchases/identity/IdentityManager;Lcom/revenuecat/purchases/subscriberattributes/SubscriberAttributesManager;Lcom/revenuecat/purchases/common/AppConfig;Lcom/revenuecat/purchases/CustomerInfoHelper;Lcom/revenuecat/purchases/CustomerInfoUpdateHandler;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;Lcom/revenuecat/purchases/common/DateProvider;Lcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager;Lcom/revenuecat/purchases/PostReceiptHelper;Lcom/revenuecat/purchases/PostTransactionWithProductDetailsHelper;Lcom/revenuecat/purchases/PostPendingTransactionsHelper;Lcom/revenuecat/purchases/SyncPurchasesHelper;Lcom/revenuecat/purchases/common/offerings/OfferingsManager;Lcom/revenuecat/purchases/common/events/EventsManager;Lcom/revenuecat/purchases/paywalls/PaywallPresentedCache;Lcom/revenuecat/purchases/PurchasesStateCache;Landroid/os/Handler;Lcom/revenuecat/purchases/common/Dispatcher;Lcom/revenuecat/purchases/PurchasesConfiguration;Lcom/revenuecat/purchases/paywalls/FontLoader;Lcom/revenuecat/purchases/deeplinks/WebPurchaseRedemptionHelper;Lcom/revenuecat/purchases/virtualcurrencies/VirtualCurrencyManager;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->access$flushPaywallEvents(Lcom/revenuecat/purchases/PurchasesOrchestrator;)V HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->access$getApplication$p(Lcom/revenuecat/purchases/PurchasesOrchestrator;)Landroid/app/Application; HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->access$getCachedImageLoader$cp()Lcoil/ImageLoader; @@ -388,12 +336,11 @@ HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->access$setCachedImageLoader HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->access$shouldRefreshCustomerInfo(Lcom/revenuecat/purchases/PurchasesOrchestrator;Z)Z HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->access$synchronizeSubscriberAttributesIfNeeded(Lcom/revenuecat/purchases/PurchasesOrchestrator;)V HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->dispatch(Lkotlin/jvm/functions/Function0;)V -HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->enqueue$lambda$14(Lkotlin/jvm/functions/Function0;)V +HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->enqueue$lambda$51(Lkotlin/jvm/functions/Function0;)V HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->enqueue(Lkotlin/jvm/functions/Function0;)V HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->flushPaywallEvents()V HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->getAllowSharingPlayStoreAccount()Z HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->getAppUserID()Ljava/lang/String; -HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->getCustomerInfo(Lcom/revenuecat/purchases/CacheFetchPolicy;ZLcom/revenuecat/purchases/interfaces/ReceiveCustomerInfoCallback;)V HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->getFinishTransactions()Z HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->getLifecycleHandler()Lcom/revenuecat/purchases/AppLifecycleHandler; HSPLcom/revenuecat/purchases/PurchasesOrchestrator;->getOfflineEntitlementsManager()Lcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager; @@ -418,11 +365,14 @@ HSPLcom/revenuecat/purchases/PurchasesOrchestrator$1;->()V HSPLcom/revenuecat/purchases/PurchasesOrchestrator$1;->invoke()Landroidx/lifecycle/LifecycleOwner; HSPLcom/revenuecat/purchases/PurchasesOrchestrator$1;->invoke()Ljava/lang/Object; Lcom/revenuecat/purchases/PurchasesOrchestrator$2; -HSPLcom/revenuecat/purchases/PurchasesOrchestrator$2;->(Lcom/revenuecat/purchases/PurchasesOrchestrator;)V +HSPLcom/revenuecat/purchases/PurchasesOrchestrator$2;->()V +HSPLcom/revenuecat/purchases/PurchasesOrchestrator$2;->()V Lcom/revenuecat/purchases/PurchasesOrchestrator$3; HSPLcom/revenuecat/purchases/PurchasesOrchestrator$3;->(Lcom/revenuecat/purchases/PurchasesOrchestrator;)V -HSPLcom/revenuecat/purchases/PurchasesOrchestrator$3;->invoke()Ljava/lang/Object; -HSPLcom/revenuecat/purchases/PurchasesOrchestrator$3;->invoke()V +Lcom/revenuecat/purchases/PurchasesOrchestrator$4; +HSPLcom/revenuecat/purchases/PurchasesOrchestrator$4;->(Lcom/revenuecat/purchases/PurchasesOrchestrator;)V +HSPLcom/revenuecat/purchases/PurchasesOrchestrator$4;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/PurchasesOrchestrator$4;->invoke()V Lcom/revenuecat/purchases/PurchasesOrchestrator$Companion; HSPLcom/revenuecat/purchases/PurchasesOrchestrator$Companion;->()V HSPLcom/revenuecat/purchases/PurchasesOrchestrator$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -440,10 +390,18 @@ Lcom/revenuecat/purchases/PurchasesOrchestrator$lifecycleHandler$2; HSPLcom/revenuecat/purchases/PurchasesOrchestrator$lifecycleHandler$2;->(Lcom/revenuecat/purchases/PurchasesOrchestrator;)V HSPLcom/revenuecat/purchases/PurchasesOrchestrator$lifecycleHandler$2;->invoke()Lcom/revenuecat/purchases/AppLifecycleHandler; HSPLcom/revenuecat/purchases/PurchasesOrchestrator$lifecycleHandler$2;->invoke()Ljava/lang/Object; -Lcom/revenuecat/purchases/PurchasesOrchestrator$onAppForegrounded$2; -HSPLcom/revenuecat/purchases/PurchasesOrchestrator$onAppForegrounded$2;->(Lcom/revenuecat/purchases/PurchasesOrchestrator;Lkotlin/jvm/internal/Ref$BooleanRef;)V -HSPLcom/revenuecat/purchases/PurchasesOrchestrator$onAppForegrounded$2;->invoke()Ljava/lang/Object; -HSPLcom/revenuecat/purchases/PurchasesOrchestrator$onAppForegrounded$2;->invoke()V +Lcom/revenuecat/purchases/PurchasesOrchestrator$onAppForegrounded$$inlined$log$1; +HSPLcom/revenuecat/purchases/PurchasesOrchestrator$onAppForegrounded$$inlined$log$1;->(Lcom/revenuecat/purchases/common/LogIntent;)V +HSPLcom/revenuecat/purchases/PurchasesOrchestrator$onAppForegrounded$$inlined$log$1;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/PurchasesOrchestrator$onAppForegrounded$$inlined$log$1;->invoke()Ljava/lang/String; +Lcom/revenuecat/purchases/PurchasesOrchestrator$onAppForegrounded$3; +HSPLcom/revenuecat/purchases/PurchasesOrchestrator$onAppForegrounded$3;->(Lcom/revenuecat/purchases/PurchasesOrchestrator;Lkotlin/jvm/internal/Ref$BooleanRef;)V +HSPLcom/revenuecat/purchases/PurchasesOrchestrator$onAppForegrounded$3;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/PurchasesOrchestrator$onAppForegrounded$3;->invoke()V +Lcom/revenuecat/purchases/PurchasesOrchestrator$onAppForegrounded$3$invoke$$inlined$log$1; +HSPLcom/revenuecat/purchases/PurchasesOrchestrator$onAppForegrounded$3$invoke$$inlined$log$1;->(Lcom/revenuecat/purchases/common/LogIntent;)V +HSPLcom/revenuecat/purchases/PurchasesOrchestrator$onAppForegrounded$3$invoke$$inlined$log$1;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/PurchasesOrchestrator$onAppForegrounded$3$invoke$$inlined$log$1;->invoke()Ljava/lang/String; Lcom/revenuecat/purchases/PurchasesOrchestrator$showInAppMessagesIfNeeded$1; HSPLcom/revenuecat/purchases/PurchasesOrchestrator$showInAppMessagesIfNeeded$1;->(Lcom/revenuecat/purchases/PurchasesOrchestrator;)V Lcom/revenuecat/purchases/PurchasesState; @@ -483,11 +441,9 @@ Lcom/revenuecat/purchases/VerificationResult; HSPLcom/revenuecat/purchases/VerificationResult;->$values()[Lcom/revenuecat/purchases/VerificationResult; HSPLcom/revenuecat/purchases/VerificationResult;->()V HSPLcom/revenuecat/purchases/VerificationResult;->(Ljava/lang/String;I)V -HSPLcom/revenuecat/purchases/VerificationResult;->valueOf(Ljava/lang/String;)Lcom/revenuecat/purchases/VerificationResult; -HSPLcom/revenuecat/purchases/VerificationResult;->values()[Lcom/revenuecat/purchases/VerificationResult; Lcom/revenuecat/purchases/common/AppConfig; HSPLcom/revenuecat/purchases/common/AppConfig;->()V -HSPLcom/revenuecat/purchases/common/AppConfig;->(Landroid/content/Context;Lcom/revenuecat/purchases/PurchasesAreCompletedBy;ZLcom/revenuecat/purchases/common/PlatformInfo;Ljava/net/URL;Lcom/revenuecat/purchases/Store;ZLcom/revenuecat/purchases/DangerousSettings;ZZZ)V +HSPLcom/revenuecat/purchases/common/AppConfig;->(Landroid/content/Context;Lcom/revenuecat/purchases/PurchasesAreCompletedBy;ZLcom/revenuecat/purchases/common/PlatformInfo;Ljava/net/URL;Lcom/revenuecat/purchases/Store;ZLcom/revenuecat/purchases/APIKeyValidator$ValidationResult;Lcom/revenuecat/purchases/DangerousSettings;ZZZ)V PLcom/revenuecat/purchases/common/AppConfig;->access$getDiagnosticsURL$cp()Ljava/net/URL; PLcom/revenuecat/purchases/common/AppConfig;->access$getPaywallEventsURL$cp()Ljava/net/URL; HSPLcom/revenuecat/purchases/common/AppConfig;->getBaseURL()Ljava/net/URL; @@ -497,7 +453,6 @@ HSPLcom/revenuecat/purchases/common/AppConfig;->getEnableOfflineEntitlements()Z HSPLcom/revenuecat/purchases/common/AppConfig;->getFallbackBaseURLs()Ljava/util/List; HSPLcom/revenuecat/purchases/common/AppConfig;->getFinishTransactions()Z HSPLcom/revenuecat/purchases/common/AppConfig;->getForceServerErrors()Z -HSPLcom/revenuecat/purchases/common/AppConfig;->getForceSigningErrors()Z HSPLcom/revenuecat/purchases/common/AppConfig;->getLanguageTag()Ljava/lang/String; HSPLcom/revenuecat/purchases/common/AppConfig;->getPackageName()Ljava/lang/String; HSPLcom/revenuecat/purchases/common/AppConfig;->getPlatformInfo()Lcom/revenuecat/purchases/common/PlatformInfo; @@ -528,6 +483,7 @@ PLcom/revenuecat/purchases/common/Backend;->getDiagnosticsCallbacks()Ljava/util/ HSPLcom/revenuecat/purchases/common/Backend;->getOfferings(Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V HSPLcom/revenuecat/purchases/common/Backend;->getOfferingsCallbacks()Ljava/util/Map; PLcom/revenuecat/purchases/common/Backend;->getPaywallEventsCallbacks()Ljava/util/Map; +PLcom/revenuecat/purchases/common/Backend;->getProductEntitlementCallbacks()Ljava/util/Map; HSPLcom/revenuecat/purchases/common/Backend;->getProductEntitlementMapping(Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V HSPLcom/revenuecat/purchases/common/Backend;->getVerificationMode()Lcom/revenuecat/purchases/common/verification/SignatureVerificationMode; HSPLcom/revenuecat/purchases/common/Backend;->postDiagnostics(Ljava/util/List;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V @@ -550,6 +506,8 @@ HSPLcom/revenuecat/purchases/common/Backend$getOfferings$call$1;->call()Lcom/rev HSPLcom/revenuecat/purchases/common/Backend$getOfferings$call$1;->onCompletion(Lcom/revenuecat/purchases/common/networking/HTTPResult;)V Lcom/revenuecat/purchases/common/Backend$getProductEntitlementMapping$call$1; HSPLcom/revenuecat/purchases/common/Backend$getProductEntitlementMapping$call$1;->(Lcom/revenuecat/purchases/common/Backend;Lcom/revenuecat/purchases/common/networking/Endpoint$GetProductEntitlementMapping;Ljava/lang/String;)V +PLcom/revenuecat/purchases/common/Backend$getProductEntitlementMapping$call$1;->call()Lcom/revenuecat/purchases/common/networking/HTTPResult; +PLcom/revenuecat/purchases/common/Backend$getProductEntitlementMapping$call$1;->onCompletion(Lcom/revenuecat/purchases/common/networking/HTTPResult;)V Lcom/revenuecat/purchases/common/Backend$postDiagnostics$call$1; HSPLcom/revenuecat/purchases/common/Backend$postDiagnostics$call$1;->(Lcom/revenuecat/purchases/common/Backend;Ljava/util/Map;Ljava/util/List;)V PLcom/revenuecat/purchases/common/Backend$postDiagnostics$call$1;->call()Lcom/revenuecat/purchases/common/networking/HTTPResult; @@ -558,6 +516,16 @@ Lcom/revenuecat/purchases/common/Backend$postEvents$call$1; HSPLcom/revenuecat/purchases/common/Backend$postEvents$call$1;->(Lcom/revenuecat/purchases/common/Backend;Ljava/util/Map;Lcom/revenuecat/purchases/common/events/EventsRequest;)V PLcom/revenuecat/purchases/common/Backend$postEvents$call$1;->call()Lcom/revenuecat/purchases/common/networking/HTTPResult; PLcom/revenuecat/purchases/common/Backend$postEvents$call$1;->onCompletion(Lcom/revenuecat/purchases/common/networking/HTTPResult;)V +Lcom/revenuecat/purchases/common/BackendErrorCode; +HSPLcom/revenuecat/purchases/common/BackendErrorCode;->$values()[Lcom/revenuecat/purchases/common/BackendErrorCode; +HSPLcom/revenuecat/purchases/common/BackendErrorCode;->()V +HSPLcom/revenuecat/purchases/common/BackendErrorCode;->(Ljava/lang/String;II)V +HSPLcom/revenuecat/purchases/common/BackendErrorCode;->getValue()I +HSPLcom/revenuecat/purchases/common/BackendErrorCode;->values()[Lcom/revenuecat/purchases/common/BackendErrorCode; +Lcom/revenuecat/purchases/common/BackendErrorCode$Companion; +HSPLcom/revenuecat/purchases/common/BackendErrorCode$Companion;->()V +HSPLcom/revenuecat/purchases/common/BackendErrorCode$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V +HSPLcom/revenuecat/purchases/common/BackendErrorCode$Companion;->valueOf(I)Lcom/revenuecat/purchases/common/BackendErrorCode; Lcom/revenuecat/purchases/common/BackendHelper; HSPLcom/revenuecat/purchases/common/BackendHelper;->(Ljava/lang/String;Lcom/revenuecat/purchases/common/Dispatcher;Lcom/revenuecat/purchases/common/AppConfig;Lcom/revenuecat/purchases/common/HTTPClient;)V HSPLcom/revenuecat/purchases/common/BackendHelper;->enqueue(Lcom/revenuecat/purchases/common/Dispatcher$AsyncCall;Lcom/revenuecat/purchases/common/Dispatcher;Lcom/revenuecat/purchases/common/Delay;)V @@ -586,14 +554,6 @@ HSPLcom/revenuecat/purchases/common/Config;->()V HSPLcom/revenuecat/purchases/common/Config;->()V HSPLcom/revenuecat/purchases/common/Config;->getLogLevel()Lcom/revenuecat/purchases/LogLevel; HSPLcom/revenuecat/purchases/common/Config;->setLogLevel(Lcom/revenuecat/purchases/LogLevel;)V -Lcom/revenuecat/purchases/common/CustomerInfoFactory; -HSPLcom/revenuecat/purchases/common/CustomerInfoFactory;->()V -HSPLcom/revenuecat/purchases/common/CustomerInfoFactory;->()V -HSPLcom/revenuecat/purchases/common/CustomerInfoFactory;->buildCustomerInfo(Lcom/revenuecat/purchases/common/networking/HTTPResult;)Lcom/revenuecat/purchases/CustomerInfo; -HSPLcom/revenuecat/purchases/common/CustomerInfoFactory;->buildCustomerInfo(Lorg/json/JSONObject;Ljava/util/Date;Lcom/revenuecat/purchases/VerificationResult;)Lcom/revenuecat/purchases/CustomerInfo; -HSPLcom/revenuecat/purchases/common/CustomerInfoFactory;->parseDates(Lorg/json/JSONObject;Ljava/lang/String;)Ljava/util/HashMap; -HSPLcom/revenuecat/purchases/common/CustomerInfoFactory;->parseExpirations(Lorg/json/JSONObject;)Ljava/util/Map; -HSPLcom/revenuecat/purchases/common/CustomerInfoFactory;->parsePurchaseDates(Lorg/json/JSONObject;)Ljava/util/Map; Lcom/revenuecat/purchases/common/DateProvider; Lcom/revenuecat/purchases/common/DefaultDateProvider; HSPLcom/revenuecat/purchases/common/DefaultDateProvider;->()V @@ -606,6 +566,7 @@ HSPLcom/revenuecat/purchases/common/DefaultLogHandler;->()V HSPLcom/revenuecat/purchases/common/DefaultLogHandler;->d(Ljava/lang/String;Ljava/lang/String;)V HSPLcom/revenuecat/purchases/common/DefaultLogHandler;->e(Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)V HSPLcom/revenuecat/purchases/common/DefaultLogHandler;->v(Ljava/lang/String;Ljava/lang/String;)V +HSPLcom/revenuecat/purchases/common/DefaultLogHandler;->w(Ljava/lang/String;Ljava/lang/String;)V Lcom/revenuecat/purchases/common/Delay; HSPLcom/revenuecat/purchases/common/Delay;->$values()[Lcom/revenuecat/purchases/common/Delay; HSPLcom/revenuecat/purchases/common/Delay;->()V @@ -613,17 +574,17 @@ HSPLcom/revenuecat/purchases/common/Delay;->(Ljava/lang/String;IJJ)V HSPLcom/revenuecat/purchases/common/Delay;->getMaxDelay-UwyO8pc()J HSPLcom/revenuecat/purchases/common/Delay;->getMinDelay-UwyO8pc()J Lcom/revenuecat/purchases/common/Dispatcher; -HSPLcom/revenuecat/purchases/common/Dispatcher;->$r8$lambda$vMSMbCeXQEecE09wmS9qpyCMXNk(Ljava/lang/Runnable;Lcom/revenuecat/purchases/common/Dispatcher;)V +HSPLcom/revenuecat/purchases/common/Dispatcher;->$r8$lambda$OmerDqHI1SqV8YQNMITx4ClJWj8(Ljava/lang/Runnable;Lcom/revenuecat/purchases/common/Dispatcher;)V HSPLcom/revenuecat/purchases/common/Dispatcher;->()V HSPLcom/revenuecat/purchases/common/Dispatcher;->(Ljava/util/concurrent/ExecutorService;Landroid/os/Handler;Z)V HSPLcom/revenuecat/purchases/common/Dispatcher;->(Ljava/util/concurrent/ExecutorService;Landroid/os/Handler;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V HSPLcom/revenuecat/purchases/common/Dispatcher;->enqueue$default(Lcom/revenuecat/purchases/common/Dispatcher;Ljava/lang/Runnable;Lcom/revenuecat/purchases/common/Delay;ILjava/lang/Object;)V -HSPLcom/revenuecat/purchases/common/Dispatcher;->enqueue$lambda$2$lambda$1(Ljava/lang/Runnable;Lcom/revenuecat/purchases/common/Dispatcher;)V +HSPLcom/revenuecat/purchases/common/Dispatcher;->enqueue$lambda$3$lambda$2(Ljava/lang/Runnable;Lcom/revenuecat/purchases/common/Dispatcher;)V HSPLcom/revenuecat/purchases/common/Dispatcher;->enqueue(Ljava/lang/Runnable;Lcom/revenuecat/purchases/common/Delay;)V HSPLcom/revenuecat/purchases/common/Dispatcher;->isClosed()Z -Lcom/revenuecat/purchases/common/Dispatcher$$ExternalSyntheticLambda1; -HSPLcom/revenuecat/purchases/common/Dispatcher$$ExternalSyntheticLambda1;->(Ljava/lang/Runnable;Lcom/revenuecat/purchases/common/Dispatcher;)V -HSPLcom/revenuecat/purchases/common/Dispatcher$$ExternalSyntheticLambda1;->run()V +Lcom/revenuecat/purchases/common/Dispatcher$$ExternalSyntheticLambda0; +HSPLcom/revenuecat/purchases/common/Dispatcher$$ExternalSyntheticLambda0;->(Ljava/lang/Runnable;Lcom/revenuecat/purchases/common/Dispatcher;)V +HSPLcom/revenuecat/purchases/common/Dispatcher$$ExternalSyntheticLambda0;->run()V Lcom/revenuecat/purchases/common/Dispatcher$AsyncCall; HSPLcom/revenuecat/purchases/common/Dispatcher$AsyncCall;->()V HSPLcom/revenuecat/purchases/common/Dispatcher$AsyncCall;->run()V @@ -637,8 +598,12 @@ HSPLcom/revenuecat/purchases/common/DispatcherConstants;->getJitterDelay-UwyO8pc HSPLcom/revenuecat/purchases/common/DispatcherConstants;->getJitterLongDelay-UwyO8pc()J Lcom/revenuecat/purchases/common/DurationExtensionsKt; HSPLcom/revenuecat/purchases/common/DurationExtensionsKt;->between(Lkotlin/time/Duration$Companion;Ljava/util/Date;Ljava/util/Date;)J -Lcom/revenuecat/purchases/common/EntitlementInfoFactoriesKt; -HSPLcom/revenuecat/purchases/common/EntitlementInfoFactoriesKt;->buildEntitlementInfos(Lorg/json/JSONObject;Lorg/json/JSONObject;Lorg/json/JSONObject;Ljava/util/Date;Lcom/revenuecat/purchases/VerificationResult;)Lcom/revenuecat/purchases/EntitlementInfos; +Lcom/revenuecat/purchases/common/ErrorsKt; +HSPLcom/revenuecat/purchases/common/ErrorsKt;->toPurchasesError(Lcom/revenuecat/purchases/common/BackendErrorCode;Ljava/lang/String;)Lcom/revenuecat/purchases/PurchasesError; +HSPLcom/revenuecat/purchases/common/ErrorsKt;->toPurchasesError(Lcom/revenuecat/purchases/common/networking/HTTPResult;)Lcom/revenuecat/purchases/PurchasesError; +HSPLcom/revenuecat/purchases/common/ErrorsKt;->toPurchasesErrorCode(Lcom/revenuecat/purchases/common/BackendErrorCode;)Lcom/revenuecat/purchases/PurchasesErrorCode; +Lcom/revenuecat/purchases/common/ErrorsKt$WhenMappings; +HSPLcom/revenuecat/purchases/common/ErrorsKt$WhenMappings;->()V Lcom/revenuecat/purchases/common/FileHelper; HSPLcom/revenuecat/purchases/common/FileHelper;->(Landroid/content/Context;)V HSPLcom/revenuecat/purchases/common/FileHelper;->appendToFile(Ljava/lang/String;Ljava/lang/String;)V @@ -678,15 +643,16 @@ HSPLcom/revenuecat/purchases/common/HTTPClient;->performRequest$default(Lcom/rev HSPLcom/revenuecat/purchases/common/HTTPClient;->performRequest(Ljava/net/URL;Lcom/revenuecat/purchases/common/networking/Endpoint;Ljava/util/Map;Ljava/util/List;Ljava/util/Map;ZLjava/util/List;I)Lcom/revenuecat/purchases/common/networking/HTTPResult; HSPLcom/revenuecat/purchases/common/HTTPClient;->readFully(Ljava/io/InputStream;)Ljava/lang/String; HSPLcom/revenuecat/purchases/common/HTTPClient;->trackHttpRequestPerformedIfNeeded(Ljava/net/URL;Lcom/revenuecat/purchases/common/networking/Endpoint;Ljava/util/Date;ZLcom/revenuecat/purchases/common/networking/HTTPResult;Z)V -HSPLcom/revenuecat/purchases/common/HTTPClient;->verifyResponse(Ljava/lang/String;Ljava/net/URLConnection;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/revenuecat/purchases/VerificationResult; PLcom/revenuecat/purchases/common/HTTPClient;->writeFully(Ljava/io/BufferedWriter;Ljava/lang/String;)V Lcom/revenuecat/purchases/common/HTTPClient$Companion; HSPLcom/revenuecat/purchases/common/HTTPClient$Companion;->()V HSPLcom/revenuecat/purchases/common/HTTPClient$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V Lcom/revenuecat/purchases/common/HTTPClient$WhenMappings; HSPLcom/revenuecat/purchases/common/HTTPClient$WhenMappings;->()V -Lcom/revenuecat/purchases/common/IntExtensionsKt; -HSPLcom/revenuecat/purchases/common/IntExtensionsKt;->fromLittleEndianBytes(Lkotlin/jvm/internal/IntCompanionObject;[B)I +Lcom/revenuecat/purchases/common/HTTPClient$getInputStream$$inlined$log$1; +HSPLcom/revenuecat/purchases/common/HTTPClient$getInputStream$$inlined$log$1;->(Lcom/revenuecat/purchases/common/LogIntent;Ljava/lang/Exception;)V +HSPLcom/revenuecat/purchases/common/HTTPClient$getInputStream$$inlined$log$1;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/common/HTTPClient$getInputStream$$inlined$log$1;->invoke()Ljava/lang/String; Lcom/revenuecat/purchases/common/JsonProvider; HSPLcom/revenuecat/purchases/common/JsonProvider;->()V HSPLcom/revenuecat/purchases/common/JsonProvider;->access$getDefaultJson$cp()Lkotlinx/serialization/json/Json; @@ -707,27 +673,17 @@ HSPLcom/revenuecat/purchases/common/LogIntent;->(Ljava/lang/String;ILjava/ HSPLcom/revenuecat/purchases/common/LogIntent;->getEmojiList()Ljava/util/List; HSPLcom/revenuecat/purchases/common/LogIntent;->values()[Lcom/revenuecat/purchases/common/LogIntent; Lcom/revenuecat/purchases/common/LogUtilsKt; -HSPLcom/revenuecat/purchases/common/LogUtilsKt;->debugLog(Ljava/lang/String;)V HSPLcom/revenuecat/purchases/common/LogUtilsKt;->debugLogsEnabled(Lcom/revenuecat/purchases/LogLevel$Companion;Z)Lcom/revenuecat/purchases/LogLevel; -HSPLcom/revenuecat/purchases/common/LogUtilsKt;->errorLog$default(Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)V HSPLcom/revenuecat/purchases/common/LogUtilsKt;->errorLog(Lcom/revenuecat/purchases/PurchasesError;)V -HSPLcom/revenuecat/purchases/common/LogUtilsKt;->errorLog(Ljava/lang/String;Ljava/lang/Throwable;)V -HSPLcom/revenuecat/purchases/common/LogUtilsKt;->logIfEnabled(Lcom/revenuecat/purchases/LogLevel;Lkotlin/jvm/functions/Function2;Ljava/lang/String;)V -HSPLcom/revenuecat/purchases/common/LogUtilsKt;->verboseLog(Ljava/lang/String;)V Lcom/revenuecat/purchases/common/LogUtilsKt$WhenMappings; HSPLcom/revenuecat/purchases/common/LogUtilsKt$WhenMappings;->()V -Lcom/revenuecat/purchases/common/LogUtilsKt$debugLog$1; -HSPLcom/revenuecat/purchases/common/LogUtilsKt$debugLog$1;->(Ljava/lang/Object;)V -HSPLcom/revenuecat/purchases/common/LogUtilsKt$debugLog$1;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; -HSPLcom/revenuecat/purchases/common/LogUtilsKt$debugLog$1;->invoke(Ljava/lang/String;Ljava/lang/String;)V -Lcom/revenuecat/purchases/common/LogUtilsKt$verboseLog$1; -HSPLcom/revenuecat/purchases/common/LogUtilsKt$verboseLog$1;->(Ljava/lang/Object;)V -HSPLcom/revenuecat/purchases/common/LogUtilsKt$verboseLog$1;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; -HSPLcom/revenuecat/purchases/common/LogUtilsKt$verboseLog$1;->invoke(Ljava/lang/String;Ljava/lang/String;)V +Lcom/revenuecat/purchases/common/LogUtilsKt$errorLog$$inlined$log$2; +HSPLcom/revenuecat/purchases/common/LogUtilsKt$errorLog$$inlined$log$2;->(Lcom/revenuecat/purchases/common/LogIntent;Lcom/revenuecat/purchases/PurchasesError;)V +HSPLcom/revenuecat/purchases/common/LogUtilsKt$errorLog$$inlined$log$2;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/common/LogUtilsKt$errorLog$$inlined$log$2;->invoke()Ljava/lang/String; Lcom/revenuecat/purchases/common/LogWrapperKt; HSPLcom/revenuecat/purchases/common/LogWrapperKt;->()V HSPLcom/revenuecat/purchases/common/LogWrapperKt;->getCurrentLogHandler()Lcom/revenuecat/purchases/LogHandler; -HSPLcom/revenuecat/purchases/common/LogWrapperKt;->log(Lcom/revenuecat/purchases/common/LogIntent;Ljava/lang/String;)V Lcom/revenuecat/purchases/common/LogWrapperKt$WhenMappings; HSPLcom/revenuecat/purchases/common/LogWrapperKt$WhenMappings;->()V Lcom/revenuecat/purchases/common/OfferingParser; @@ -754,8 +710,9 @@ HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->(Landroid/conten HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->access$getApiKey$p(Lcom/revenuecat/purchases/common/caching/DeviceCache;)Ljava/lang/String; HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->access$getApiKeyPrefix(Lcom/revenuecat/purchases/common/caching/DeviceCache;)Ljava/lang/String; HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->cacheAppUserID(Ljava/lang/String;Landroid/content/SharedPreferences$Editor;)Landroid/content/SharedPreferences$Editor; -HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->cacheCustomerInfo(Ljava/lang/String;Lcom/revenuecat/purchases/CustomerInfo;)V HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->cleanupOldAttributionData()V +HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->clearCustomerInfoCacheTimestamp(Landroid/content/SharedPreferences$Editor;Ljava/lang/String;)Landroid/content/SharedPreferences$Editor; +HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->clearCustomerInfoCacheTimestamp(Ljava/lang/String;)V HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->customerInfoCacheKey(Ljava/lang/String;)Ljava/lang/String; HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->customerInfoLastUpdatedCacheKey(Ljava/lang/String;)Ljava/lang/String; HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->findKeysThatStartWith(Ljava/lang/String;)Ljava/util/Set; @@ -763,7 +720,6 @@ HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->getApiKeyPrefix()Ljava HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->getAppUserIDCacheKey()Ljava/lang/String; HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->getCachedAppUserID()Ljava/lang/String; HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->getCachedCustomerInfo(Ljava/lang/String;)Lcom/revenuecat/purchases/CustomerInfo; -HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->getCustomerInfoCachesLastUpdated(Ljava/lang/String;)Ljava/util/Date; HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->getCustomerInfoCachesLastUpdatedCacheBaseKey()Ljava/lang/String; HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->getJSONObjectOrNull(Ljava/lang/String;)Lorg/json/JSONObject; HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->getLegacyAppUserIDCacheKey()Ljava/lang/String; @@ -772,7 +728,6 @@ HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->getProductEntitlementM HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->getProductEntitlementMappingLastUpdatedCacheKey()Ljava/lang/String; HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->getStorefront()Ljava/lang/String; HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->getStorefrontCacheKey()Ljava/lang/String; -HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->isCustomerInfoCacheStale(Ljava/lang/String;Z)Z HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->isProductEntitlementMappingCacheStale()Z HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->newKey(Ljava/lang/String;)Ljava/lang/String; HSPLcom/revenuecat/purchases/common/caching/DeviceCache;->setCustomerInfoCacheTimestamp(Ljava/lang/String;Ljava/util/Date;)V @@ -812,6 +767,10 @@ HSPLcom/revenuecat/purchases/common/caching/DeviceCache$storefrontCacheKey$2;->i HSPLcom/revenuecat/purchases/common/caching/DeviceCache$storefrontCacheKey$2;->invoke()Ljava/lang/String; Lcom/revenuecat/purchases/common/caching/DeviceCache$tokensCacheKey$2; HSPLcom/revenuecat/purchases/common/caching/DeviceCache$tokensCacheKey$2;->(Lcom/revenuecat/purchases/common/caching/DeviceCache;)V +Lcom/revenuecat/purchases/common/caching/DeviceCache$virtualCurrenciesCacheBaseKey$2; +HSPLcom/revenuecat/purchases/common/caching/DeviceCache$virtualCurrenciesCacheBaseKey$2;->(Lcom/revenuecat/purchases/common/caching/DeviceCache;)V +Lcom/revenuecat/purchases/common/caching/DeviceCache$virtualCurrenciesLastUpdatedCacheBaseKey$2; +HSPLcom/revenuecat/purchases/common/caching/DeviceCache$virtualCurrenciesLastUpdatedCacheBaseKey$2;->(Lcom/revenuecat/purchases/common/caching/DeviceCache;)V Lcom/revenuecat/purchases/common/caching/DeviceCacheKt; HSPLcom/revenuecat/purchases/common/caching/DeviceCacheKt;->()V HSPLcom/revenuecat/purchases/common/caching/DeviceCacheKt;->access$getPRODUCT_ENTITLEMENT_MAPPING_CACHE_REFRESH_PERIOD$p()J @@ -848,6 +807,7 @@ HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsHelper;->()V HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsHelper;->(Landroid/content/Context;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsFileHelper;Lkotlin/Lazy;)V HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsHelper;->(Landroid/content/Context;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsFileHelper;Lkotlin/Lazy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V PLcom/revenuecat/purchases/common/diagnostics/DiagnosticsHelper;->clearConsecutiveNumberOfErrors()V +PLcom/revenuecat/purchases/common/diagnostics/DiagnosticsHelper;->resetDiagnosticsStatus()V Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsHelper$1; HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsHelper$1;->(Landroid/content/Context;)V PLcom/revenuecat/purchases/common/diagnostics/DiagnosticsHelper$1;->invoke()Landroid/content/SharedPreferences; @@ -863,6 +823,7 @@ HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;-> HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;->access$getBackend$p(Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;)Lcom/revenuecat/purchases/common/Backend; HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;->access$getDiagnosticsFileHelper$p(Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;)Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsFileHelper; PLcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;->access$getDiagnosticsHelper$p(Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;)Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsHelper; +PLcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;->access$getDiagnosticsTracker$p(Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;)Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker; HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;->access$getEventsToSync(Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;)Ljava/util/List; HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;->enqueue$lambda$0(Lkotlin/jvm/functions/Function0;)V HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;->enqueue(Lkotlin/jvm/functions/Function0;)V @@ -889,28 +850,26 @@ Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer$syncDiagnos HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer$syncDiagnosticsFileIfNeeded$1;->(Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;)V HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer$syncDiagnosticsFileIfNeeded$1;->invoke()Ljava/lang/Object; HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer$syncDiagnosticsFileIfNeeded$1;->invoke()V -Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer$syncDiagnosticsFileIfNeeded$1$1; -HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer$syncDiagnosticsFileIfNeeded$1$1;->(Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;I)V -PLcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer$syncDiagnosticsFileIfNeeded$1$1;->invoke(Ljava/lang/Object;)Ljava/lang/Object; -PLcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer$syncDiagnosticsFileIfNeeded$1$1;->invoke(Lorg/json/JSONObject;)V -Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer$syncDiagnosticsFileIfNeeded$1$2; -HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer$syncDiagnosticsFileIfNeeded$1$2;->(Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;)V +Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer$syncDiagnosticsFileIfNeeded$1$3; +HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer$syncDiagnosticsFileIfNeeded$1$3;->(Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;I)V +Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer$syncDiagnosticsFileIfNeeded$1$4; +HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer$syncDiagnosticsFileIfNeeded$1$4;->(Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer;)V +PLcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer$syncDiagnosticsFileIfNeeded$1$4;->invoke(Lcom/revenuecat/purchases/PurchasesError;Z)V +PLcom/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer$syncDiagnosticsFileIfNeeded$1$4;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker; -HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;->$r8$lambda$o_131qcMSpAaLusluiTB0Yicx1g(Lkotlin/jvm/functions/Function0;)V +HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;->$r8$lambda$LOr3TlHXeFomb_TfUyJhLmcFKu0(Lkotlin/jvm/functions/Function0;)V HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;->()V HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;->(Lcom/revenuecat/purchases/common/AppConfig;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsFileHelper;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsHelper;Lcom/revenuecat/purchases/common/Dispatcher;Ljava/util/UUID;)V HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;->(Lcom/revenuecat/purchases/common/AppConfig;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsFileHelper;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsHelper;Lcom/revenuecat/purchases/common/Dispatcher;Ljava/util/UUID;ILkotlin/jvm/internal/DefaultConstructorMarker;)V HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;->access$getDiagnosticsFileHelper$p(Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;)Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsFileHelper; HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;->checkAndClearDiagnosticsFileIfTooBig(Lkotlin/jvm/functions/Function0;)V -HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;->enqueue$lambda$0(Lkotlin/jvm/functions/Function0;)V +HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;->enqueue$lambda$2(Lkotlin/jvm/functions/Function0;)V HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;->enqueue(Lkotlin/jvm/functions/Function0;)V HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;->setListener(Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsEventTrackerListener;)V -HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;->trackCustomerInfoVerificationResultIfNeeded(Lcom/revenuecat/purchases/CustomerInfo;)V +PLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;->trackClearingDiagnosticsAfterFailedSync()V HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;->trackEvent(Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsEntry;)V HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;->trackEvent(Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsEntryName;Ljava/util/Map;)V HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;->trackEventInCurrentThread$purchases_defaultsRelease(Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsEntry;)V -HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;->trackGetCustomerInfoResult-17CK4j0(Lcom/revenuecat/purchases/CacheFetchPolicy;Lcom/revenuecat/purchases/VerificationResult;Ljava/lang/Boolean;Ljava/lang/String;Ljava/lang/Integer;J)V -HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;->trackGetCustomerInfoStarted()V HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;->trackGoogleBillingSetupFinished(ILjava/lang/String;I)V HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;->trackGoogleBillingStartConnection()V HSPLcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;->trackHttpRequestPerformed-OCcUtpk(Ljava/lang/String;Lcom/revenuecat/purchases/common/networking/Endpoint;JZILjava/lang/Integer;Lcom/revenuecat/purchases/common/networking/HTTPResult$Origin;Lcom/revenuecat/purchases/VerificationResult;Z)V @@ -960,7 +919,7 @@ HSPLcom/revenuecat/purchases/common/events/BackendEvent$Paywalls;->()V HSPLcom/revenuecat/purchases/common/events/BackendEvent$Paywalls;->(ILjava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IJLjava/lang/String;ZLjava/lang/String;Lkotlinx/serialization/internal/SerializationConstructorMarker;)V HSPLcom/revenuecat/purchases/common/events/BackendEvent$Paywalls;->(Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IJLjava/lang/String;ZLjava/lang/String;)V HSPLcom/revenuecat/purchases/common/events/BackendEvent$Paywalls;->hashCode()I -HSPLcom/revenuecat/purchases/common/events/BackendEvent$Paywalls;->write$Self(Lcom/revenuecat/purchases/common/events/BackendEvent$Paywalls;Lkotlinx/serialization/encoding/CompositeEncoder;Lkotlinx/serialization/descriptors/SerialDescriptor;)V +HSPLcom/revenuecat/purchases/common/events/BackendEvent$Paywalls;->write$Self$purchases_defaultsRelease(Lcom/revenuecat/purchases/common/events/BackendEvent$Paywalls;Lkotlinx/serialization/encoding/CompositeEncoder;Lkotlinx/serialization/descriptors/SerialDescriptor;)V Lcom/revenuecat/purchases/common/events/BackendEvent$Paywalls$$serializer; HSPLcom/revenuecat/purchases/common/events/BackendEvent$Paywalls$$serializer;->()V HSPLcom/revenuecat/purchases/common/events/BackendEvent$Paywalls$$serializer;->()V @@ -1005,7 +964,7 @@ HSPLcom/revenuecat/purchases/common/events/BackendStoredEvent$Paywalls;->(ILcom/revenuecat/purchases/common/events/BackendEvent$Paywalls;Lkotlinx/serialization/internal/SerializationConstructorMarker;)V HSPLcom/revenuecat/purchases/common/events/BackendStoredEvent$Paywalls;->(Lcom/revenuecat/purchases/common/events/BackendEvent$Paywalls;)V HSPLcom/revenuecat/purchases/common/events/BackendStoredEvent$Paywalls;->getEvent()Lcom/revenuecat/purchases/common/events/BackendEvent$Paywalls; -HSPLcom/revenuecat/purchases/common/events/BackendStoredEvent$Paywalls;->write$Self(Lcom/revenuecat/purchases/common/events/BackendStoredEvent$Paywalls;Lkotlinx/serialization/encoding/CompositeEncoder;Lkotlinx/serialization/descriptors/SerialDescriptor;)V +HSPLcom/revenuecat/purchases/common/events/BackendStoredEvent$Paywalls;->write$Self$purchases_defaultsRelease(Lcom/revenuecat/purchases/common/events/BackendStoredEvent$Paywalls;Lkotlinx/serialization/encoding/CompositeEncoder;Lkotlinx/serialization/descriptors/SerialDescriptor;)V Lcom/revenuecat/purchases/common/events/BackendStoredEvent$Paywalls$$serializer; HSPLcom/revenuecat/purchases/common/events/BackendStoredEvent$Paywalls$$serializer;->()V HSPLcom/revenuecat/purchases/common/events/BackendStoredEvent$Paywalls$$serializer;->()V @@ -1080,13 +1039,13 @@ Lcom/revenuecat/purchases/common/events/EventsManager$flushEvents$1; HSPLcom/revenuecat/purchases/common/events/EventsManager$flushEvents$1;->(Lcom/revenuecat/purchases/common/events/EventsManager;)V HSPLcom/revenuecat/purchases/common/events/EventsManager$flushEvents$1;->invoke()Ljava/lang/Object; HSPLcom/revenuecat/purchases/common/events/EventsManager$flushEvents$1;->invoke()V -Lcom/revenuecat/purchases/common/events/EventsManager$flushEvents$1$2; -PLcom/revenuecat/purchases/common/events/EventsManager$flushEvents$1$2;->invoke()Ljava/lang/Object; -PLcom/revenuecat/purchases/common/events/EventsManager$flushEvents$1$2;->invoke()V -PLcom/revenuecat/purchases/common/events/EventsManager$flushEvents$1$2$1;->(Lcom/revenuecat/purchases/common/events/EventsManager;Ljava/util/List;)V -PLcom/revenuecat/purchases/common/events/EventsManager$flushEvents$1$2$1;->invoke()Ljava/lang/Object; -PLcom/revenuecat/purchases/common/events/EventsManager$flushEvents$1$2$1;->invoke()V -Lcom/revenuecat/purchases/common/events/EventsManager$flushEvents$1$3; +Lcom/revenuecat/purchases/common/events/EventsManager$flushEvents$1$5; +Lcom/revenuecat/purchases/common/events/EventsManager$flushEvents$1$6; +PLcom/revenuecat/purchases/common/events/EventsManager$flushEvents$1$6;->invoke(Lcom/revenuecat/purchases/PurchasesError;Z)V +PLcom/revenuecat/purchases/common/events/EventsManager$flushEvents$1$6;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +PLcom/revenuecat/purchases/common/events/EventsManager$flushEvents$1$6$2;->(ZLcom/revenuecat/purchases/common/events/EventsManager;Ljava/util/List;)V +PLcom/revenuecat/purchases/common/events/EventsManager$flushEvents$1$6$2;->invoke()Ljava/lang/Object; +PLcom/revenuecat/purchases/common/events/EventsManager$flushEvents$1$6$2;->invoke()V Lcom/revenuecat/purchases/common/events/EventsManager$flushLegacyEvents$1; HSPLcom/revenuecat/purchases/common/events/EventsManager$flushLegacyEvents$1;->(Lcom/revenuecat/purchases/common/events/EventsManager;)V HSPLcom/revenuecat/purchases/common/events/EventsManager$flushLegacyEvents$1;->invoke()Ljava/lang/Object; @@ -1106,7 +1065,7 @@ HSPLcom/revenuecat/purchases/common/events/EventsManager$track$1;->invoke()V Lcom/revenuecat/purchases/common/events/EventsRequest; HSPLcom/revenuecat/purchases/common/events/EventsRequest;->()V HSPLcom/revenuecat/purchases/common/events/EventsRequest;->getCacheKey()Ljava/util/List; -HSPLcom/revenuecat/purchases/common/events/EventsRequest;->write$Self(Lcom/revenuecat/purchases/common/events/EventsRequest;Lkotlinx/serialization/encoding/CompositeEncoder;Lkotlinx/serialization/descriptors/SerialDescriptor;)V +HSPLcom/revenuecat/purchases/common/events/EventsRequest;->write$Self$purchases_defaultsRelease(Lcom/revenuecat/purchases/common/events/EventsRequest;Lkotlinx/serialization/encoding/CompositeEncoder;Lkotlinx/serialization/descriptors/SerialDescriptor;)V Lcom/revenuecat/purchases/common/events/EventsRequest$$serializer; HSPLcom/revenuecat/purchases/common/events/EventsRequest$$serializer;->()V HSPLcom/revenuecat/purchases/common/events/EventsRequest$$serializer;->()V @@ -1118,10 +1077,6 @@ HSPLcom/revenuecat/purchases/common/events/EventsRequest$Companion;->()V HSPLcom/revenuecat/purchases/common/events/EventsRequest$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V HSPLcom/revenuecat/purchases/common/events/EventsRequest$Companion;->serializer()Lkotlinx/serialization/KSerializer; Lcom/revenuecat/purchases/common/events/FeatureEvent; -Lcom/revenuecat/purchases/common/networking/ETagData; -HSPLcom/revenuecat/purchases/common/networking/ETagData;->(Ljava/lang/String;Ljava/util/Date;)V -HSPLcom/revenuecat/purchases/common/networking/ETagData;->getETag()Ljava/lang/String; -HSPLcom/revenuecat/purchases/common/networking/ETagData;->getLastRefreshTime()Ljava/util/Date; Lcom/revenuecat/purchases/common/networking/ETagManager; HSPLcom/revenuecat/purchases/common/networking/ETagManager;->()V HSPLcom/revenuecat/purchases/common/networking/ETagManager;->(Landroid/content/Context;Lkotlin/Lazy;Lcom/revenuecat/purchases/common/DateProvider;)V @@ -1129,11 +1084,6 @@ HSPLcom/revenuecat/purchases/common/networking/ETagManager;->(Landroid/con HSPLcom/revenuecat/purchases/common/networking/ETagManager;->getETagHeaders$purchases_defaultsRelease(Ljava/lang/String;ZZ)Ljava/util/Map; HSPLcom/revenuecat/purchases/common/networking/ETagManager;->getHTTPResultFromCacheOrBackend$purchases_defaultsRelease(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/util/Date;Lcom/revenuecat/purchases/VerificationResult;)Lcom/revenuecat/purchases/common/networking/HTTPResult; HSPLcom/revenuecat/purchases/common/networking/ETagManager;->getStoredResultSavedInSharedPreferences(Ljava/lang/String;)Lcom/revenuecat/purchases/common/networking/HTTPResultWithETag; -HSPLcom/revenuecat/purchases/common/networking/ETagManager;->shouldStoreBackendResult(Lcom/revenuecat/purchases/common/networking/HTTPResult;)Z -HSPLcom/revenuecat/purchases/common/networking/ETagManager;->shouldUseCachedVersion$purchases_defaultsRelease(I)Z -HSPLcom/revenuecat/purchases/common/networking/ETagManager;->shouldUseETag(Lcom/revenuecat/purchases/common/networking/HTTPResultWithETag;Z)Z -HSPLcom/revenuecat/purchases/common/networking/ETagManager;->storeBackendResultIfNoError$purchases_defaultsRelease(Ljava/lang/String;Lcom/revenuecat/purchases/common/networking/HTTPResult;Ljava/lang/String;)V -HSPLcom/revenuecat/purchases/common/networking/ETagManager;->storeResult(Ljava/lang/String;Lcom/revenuecat/purchases/common/networking/HTTPResult;Ljava/lang/String;)V Lcom/revenuecat/purchases/common/networking/ETagManager$1; HSPLcom/revenuecat/purchases/common/networking/ETagManager$1;->(Landroid/content/Context;)V HSPLcom/revenuecat/purchases/common/networking/ETagManager$1;->invoke()Landroid/content/SharedPreferences; @@ -1142,8 +1092,6 @@ Lcom/revenuecat/purchases/common/networking/ETagManager$Companion; HSPLcom/revenuecat/purchases/common/networking/ETagManager$Companion;->()V HSPLcom/revenuecat/purchases/common/networking/ETagManager$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V HSPLcom/revenuecat/purchases/common/networking/ETagManager$Companion;->initializeSharedPreferences(Landroid/content/Context;)Landroid/content/SharedPreferences; -Lcom/revenuecat/purchases/common/networking/ETagManager$WhenMappings; -HSPLcom/revenuecat/purchases/common/networking/ETagManager$WhenMappings;->()V Lcom/revenuecat/purchases/common/networking/Endpoint; HSPLcom/revenuecat/purchases/common/networking/Endpoint;->(Ljava/lang/String;Ljava/lang/String;)V HSPLcom/revenuecat/purchases/common/networking/Endpoint;->(Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -1163,6 +1111,7 @@ Lcom/revenuecat/purchases/common/networking/Endpoint$GetProductEntitlementMappin HSPLcom/revenuecat/purchases/common/networking/Endpoint$GetProductEntitlementMapping;->()V HSPLcom/revenuecat/purchases/common/networking/Endpoint$GetProductEntitlementMapping;->()V HSPLcom/revenuecat/purchases/common/networking/Endpoint$GetProductEntitlementMapping;->getPath()Ljava/lang/String; +Lcom/revenuecat/purchases/common/networking/Endpoint$GetVirtualCurrencies; Lcom/revenuecat/purchases/common/networking/Endpoint$LogIn; HSPLcom/revenuecat/purchases/common/networking/Endpoint$LogIn;->()V HSPLcom/revenuecat/purchases/common/networking/Endpoint$LogIn;->()V @@ -1190,34 +1139,18 @@ HSPLcom/revenuecat/purchases/common/networking/HTTPRequest$Companion;->(Lk Lcom/revenuecat/purchases/common/networking/HTTPResult; HSPLcom/revenuecat/purchases/common/networking/HTTPResult;->()V HSPLcom/revenuecat/purchases/common/networking/HTTPResult;->(ILjava/lang/String;Lcom/revenuecat/purchases/common/networking/HTTPResult$Origin;Ljava/util/Date;Lcom/revenuecat/purchases/VerificationResult;)V -HSPLcom/revenuecat/purchases/common/networking/HTTPResult;->copy$default(Lcom/revenuecat/purchases/common/networking/HTTPResult;ILjava/lang/String;Lcom/revenuecat/purchases/common/networking/HTTPResult$Origin;Ljava/util/Date;Lcom/revenuecat/purchases/VerificationResult;ILjava/lang/Object;)Lcom/revenuecat/purchases/common/networking/HTTPResult; -HSPLcom/revenuecat/purchases/common/networking/HTTPResult;->copy(ILjava/lang/String;Lcom/revenuecat/purchases/common/networking/HTTPResult$Origin;Ljava/util/Date;Lcom/revenuecat/purchases/VerificationResult;)Lcom/revenuecat/purchases/common/networking/HTTPResult; HSPLcom/revenuecat/purchases/common/networking/HTTPResult;->getBackendErrorCode()Ljava/lang/Integer; -HSPLcom/revenuecat/purchases/common/networking/HTTPResult;->getBody()Lorg/json/JSONObject; +HSPLcom/revenuecat/purchases/common/networking/HTTPResult;->getBackendErrorMessage()Ljava/lang/String; HSPLcom/revenuecat/purchases/common/networking/HTTPResult;->getOrigin()Lcom/revenuecat/purchases/common/networking/HTTPResult$Origin; -HSPLcom/revenuecat/purchases/common/networking/HTTPResult;->getRequestDate()Ljava/util/Date; HSPLcom/revenuecat/purchases/common/networking/HTTPResult;->getResponseCode()I HSPLcom/revenuecat/purchases/common/networking/HTTPResult;->getVerificationResult()Lcom/revenuecat/purchases/VerificationResult; -HSPLcom/revenuecat/purchases/common/networking/HTTPResult;->serialize()Ljava/lang/String; Lcom/revenuecat/purchases/common/networking/HTTPResult$Companion; HSPLcom/revenuecat/purchases/common/networking/HTTPResult$Companion;->()V HSPLcom/revenuecat/purchases/common/networking/HTTPResult$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V -HSPLcom/revenuecat/purchases/common/networking/HTTPResult$Companion;->deserialize(Ljava/lang/String;)Lcom/revenuecat/purchases/common/networking/HTTPResult; Lcom/revenuecat/purchases/common/networking/HTTPResult$Origin; HSPLcom/revenuecat/purchases/common/networking/HTTPResult$Origin;->$values()[Lcom/revenuecat/purchases/common/networking/HTTPResult$Origin; HSPLcom/revenuecat/purchases/common/networking/HTTPResult$Origin;->()V HSPLcom/revenuecat/purchases/common/networking/HTTPResult$Origin;->(Ljava/lang/String;I)V -HSPLcom/revenuecat/purchases/common/networking/HTTPResult$Origin;->valueOf(Ljava/lang/String;)Lcom/revenuecat/purchases/common/networking/HTTPResult$Origin; -HSPLcom/revenuecat/purchases/common/networking/HTTPResult$Origin;->values()[Lcom/revenuecat/purchases/common/networking/HTTPResult$Origin; -Lcom/revenuecat/purchases/common/networking/HTTPResultWithETag; -HSPLcom/revenuecat/purchases/common/networking/HTTPResultWithETag;->()V -HSPLcom/revenuecat/purchases/common/networking/HTTPResultWithETag;->(Lcom/revenuecat/purchases/common/networking/ETagData;Lcom/revenuecat/purchases/common/networking/HTTPResult;)V -HSPLcom/revenuecat/purchases/common/networking/HTTPResultWithETag;->getHttpResult()Lcom/revenuecat/purchases/common/networking/HTTPResult; -HSPLcom/revenuecat/purchases/common/networking/HTTPResultWithETag;->serialize()Ljava/lang/String; -Lcom/revenuecat/purchases/common/networking/HTTPResultWithETag$Companion; -HSPLcom/revenuecat/purchases/common/networking/HTTPResultWithETag$Companion;->()V -HSPLcom/revenuecat/purchases/common/networking/HTTPResultWithETag$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V -HSPLcom/revenuecat/purchases/common/networking/HTTPResultWithETag$Companion;->deserialize(Ljava/lang/String;)Lcom/revenuecat/purchases/common/networking/HTTPResultWithETag; Lcom/revenuecat/purchases/common/networking/MapConverter; HSPLcom/revenuecat/purchases/common/networking/MapConverter;->()V PLcom/revenuecat/purchases/common/networking/MapConverter;->convertToJSON$purchases_defaultsRelease(Ljava/util/Map;)Lorg/json/JSONObject; @@ -1227,69 +1160,65 @@ HSPLcom/revenuecat/purchases/common/networking/RCHTTPStatusCodes;->()V HSPLcom/revenuecat/purchases/common/networking/RCHTTPStatusCodes;->()V HSPLcom/revenuecat/purchases/common/networking/RCHTTPStatusCodes;->isServerError(I)Z HSPLcom/revenuecat/purchases/common/networking/RCHTTPStatusCodes;->isSuccessful(I)Z +PLcom/revenuecat/purchases/common/networking/RCHTTPStatusCodes;->isSynced(I)Z Lcom/revenuecat/purchases/common/offerings/OfferingsCache; -HSPLcom/revenuecat/purchases/common/offerings/OfferingsCache;->(Lcom/revenuecat/purchases/common/caching/DeviceCache;Lcom/revenuecat/purchases/common/DateProvider;Lcom/revenuecat/purchases/common/caching/InMemoryCachedObject;)V -HSPLcom/revenuecat/purchases/common/offerings/OfferingsCache;->(Lcom/revenuecat/purchases/common/caching/DeviceCache;Lcom/revenuecat/purchases/common/DateProvider;Lcom/revenuecat/purchases/common/caching/InMemoryCachedObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V -HSPLcom/revenuecat/purchases/common/offerings/OfferingsCache;->clearOfferingsCacheTimestamp()V +HSPLcom/revenuecat/purchases/common/offerings/OfferingsCache;->(Lcom/revenuecat/purchases/common/caching/DeviceCache;Lcom/revenuecat/purchases/common/DateProvider;Lcom/revenuecat/purchases/common/caching/InMemoryCachedObject;Lcom/revenuecat/purchases/common/LocaleProvider;)V +HSPLcom/revenuecat/purchases/common/offerings/OfferingsCache;->(Lcom/revenuecat/purchases/common/caching/DeviceCache;Lcom/revenuecat/purchases/common/DateProvider;Lcom/revenuecat/purchases/common/caching/InMemoryCachedObject;Lcom/revenuecat/purchases/common/LocaleProvider;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +HSPLcom/revenuecat/purchases/common/offerings/OfferingsCache;->forceCacheStale()V HSPLcom/revenuecat/purchases/common/offerings/OfferingsCache;->isOfferingsCacheStale(Z)Z Lcom/revenuecat/purchases/common/offerings/OfferingsFactory; HSPLcom/revenuecat/purchases/common/offerings/OfferingsFactory;->(Lcom/revenuecat/purchases/common/BillingAbstract;Lcom/revenuecat/purchases/common/OfferingParser;Lcom/revenuecat/purchases/common/Dispatcher;)V -HSPLcom/revenuecat/purchases/common/offerings/OfferingsFactory;->createOfferings(Lorg/json/JSONObject;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V -HSPLcom/revenuecat/purchases/common/offerings/OfferingsFactory;->extractProductIdentifiers(Lorg/json/JSONObject;)Ljava/util/Set; -HSPLcom/revenuecat/purchases/common/offerings/OfferingsFactory;->getStoreProductsById(Ljava/util/Set;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V -Lcom/revenuecat/purchases/common/offerings/OfferingsFactory$createOfferings$1; -HSPLcom/revenuecat/purchases/common/offerings/OfferingsFactory$createOfferings$1;->(Ljava/util/Set;Lcom/revenuecat/purchases/common/offerings/OfferingsFactory;Lorg/json/JSONObject;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V -Lcom/revenuecat/purchases/common/offerings/OfferingsFactory$createOfferings$2; -HSPLcom/revenuecat/purchases/common/offerings/OfferingsFactory$createOfferings$2;->(Lkotlin/jvm/functions/Function1;)V -HSPLcom/revenuecat/purchases/common/offerings/OfferingsFactory$createOfferings$2;->invoke(Lcom/revenuecat/purchases/PurchasesError;)V -HSPLcom/revenuecat/purchases/common/offerings/OfferingsFactory$createOfferings$2;->invoke(Ljava/lang/Object;)Ljava/lang/Object; -Lcom/revenuecat/purchases/common/offerings/OfferingsFactory$getStoreProductsById$1; -HSPLcom/revenuecat/purchases/common/offerings/OfferingsFactory$getStoreProductsById$1;->(Lcom/revenuecat/purchases/common/offerings/OfferingsFactory;Ljava/util/Set;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V -Lcom/revenuecat/purchases/common/offerings/OfferingsFactory$getStoreProductsById$2; -HSPLcom/revenuecat/purchases/common/offerings/OfferingsFactory$getStoreProductsById$2;->(Lkotlin/jvm/functions/Function1;)V -HSPLcom/revenuecat/purchases/common/offerings/OfferingsFactory$getStoreProductsById$2;->invoke(Lcom/revenuecat/purchases/PurchasesError;)V -HSPLcom/revenuecat/purchases/common/offerings/OfferingsFactory$getStoreProductsById$2;->invoke(Ljava/lang/Object;)Ljava/lang/Object; Lcom/revenuecat/purchases/common/offerings/OfferingsManager; +HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager;->$r8$lambda$Pldn3UTduixDp-oHHYCTcjyyHb4(Lkotlin/jvm/functions/Function0;)V HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager;->(Lcom/revenuecat/purchases/common/offerings/OfferingsCache;Lcom/revenuecat/purchases/common/Backend;Lcom/revenuecat/purchases/common/offerings/OfferingsFactory;Lcom/revenuecat/purchases/utils/OfferingImagePreDownloader;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;Lcom/revenuecat/purchases/paywalls/OfferingFontPreDownloader;Lcom/revenuecat/purchases/common/DateProvider;Landroid/os/Handler;)V HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager;->(Lcom/revenuecat/purchases/common/offerings/OfferingsCache;Lcom/revenuecat/purchases/common/Backend;Lcom/revenuecat/purchases/common/offerings/OfferingsFactory;Lcom/revenuecat/purchases/utils/OfferingImagePreDownloader;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;Lcom/revenuecat/purchases/paywalls/OfferingFontPreDownloader;Lcom/revenuecat/purchases/common/DateProvider;Landroid/os/Handler;ILkotlin/jvm/internal/DefaultConstructorMarker;)V -HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager;->access$createAndCacheOfferings(Lcom/revenuecat/purchases/common/offerings/OfferingsManager;Lorg/json/JSONObject;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager;->access$handleErrorFetchingOfferings(Lcom/revenuecat/purchases/common/offerings/OfferingsManager;Lcom/revenuecat/purchases/PurchasesError;Lkotlin/jvm/functions/Function1;)V -HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager;->createAndCacheOfferings(Lorg/json/JSONObject;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V +HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager;->dispatch$lambda$7(Lkotlin/jvm/functions/Function0;)V HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager;->dispatch(Lkotlin/jvm/functions/Function0;)V HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager;->fetchAndCacheOfferings$default(Lcom/revenuecat/purchases/common/offerings/OfferingsManager;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager;->fetchAndCacheOfferings(Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager;->handleErrorFetchingOfferings(Lcom/revenuecat/purchases/PurchasesError;Lkotlin/jvm/functions/Function1;)V HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager;->onAppForeground(Ljava/lang/String;)V -Lcom/revenuecat/purchases/common/offerings/OfferingsManager$createAndCacheOfferings$1; -HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$createAndCacheOfferings$1;->(Lcom/revenuecat/purchases/common/offerings/OfferingsManager;Lkotlin/jvm/functions/Function1;)V -HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$createAndCacheOfferings$1;->invoke(Lcom/revenuecat/purchases/PurchasesError;)V -HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$createAndCacheOfferings$1;->invoke(Ljava/lang/Object;)Ljava/lang/Object; -Lcom/revenuecat/purchases/common/offerings/OfferingsManager$createAndCacheOfferings$2; -HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$createAndCacheOfferings$2;->(Lcom/revenuecat/purchases/common/offerings/OfferingsManager;Lorg/json/JSONObject;Lkotlin/jvm/functions/Function1;)V -Lcom/revenuecat/purchases/common/offerings/OfferingsManager$fetchAndCacheOfferings$1; -HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$fetchAndCacheOfferings$1;->(Lcom/revenuecat/purchases/common/offerings/OfferingsManager;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V -HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$fetchAndCacheOfferings$1;->invoke(Ljava/lang/Object;)Ljava/lang/Object; -HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$fetchAndCacheOfferings$1;->invoke(Lorg/json/JSONObject;)V +Lcom/revenuecat/purchases/common/offerings/OfferingsManager$$ExternalSyntheticLambda0; +HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$$ExternalSyntheticLambda0;->(Lkotlin/jvm/functions/Function0;)V +HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$$ExternalSyntheticLambda0;->run()V +Lcom/revenuecat/purchases/common/offerings/OfferingsManager$fetchAndCacheOfferings$$inlined$log$1; +HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$fetchAndCacheOfferings$$inlined$log$1;->(Lcom/revenuecat/purchases/common/LogIntent;)V +HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$fetchAndCacheOfferings$$inlined$log$1;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$fetchAndCacheOfferings$$inlined$log$1;->invoke()Ljava/lang/String; Lcom/revenuecat/purchases/common/offerings/OfferingsManager$fetchAndCacheOfferings$2; HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$fetchAndCacheOfferings$2;->(Lcom/revenuecat/purchases/common/offerings/OfferingsManager;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V -Lcom/revenuecat/purchases/common/offerings/OfferingsManager$handleErrorFetchingOfferings$1; -HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$handleErrorFetchingOfferings$1;->(Lkotlin/jvm/functions/Function1;Lcom/revenuecat/purchases/PurchasesError;)V -HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$handleErrorFetchingOfferings$1;->invoke()Ljava/lang/Object; -HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$handleErrorFetchingOfferings$1;->invoke()V +Lcom/revenuecat/purchases/common/offerings/OfferingsManager$fetchAndCacheOfferings$3; +HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$fetchAndCacheOfferings$3;->(Lcom/revenuecat/purchases/common/offerings/OfferingsManager;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V +HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$fetchAndCacheOfferings$3;->invoke(Lcom/revenuecat/purchases/PurchasesError;Z)V +HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$fetchAndCacheOfferings$3;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +Lcom/revenuecat/purchases/common/offerings/OfferingsManager$handleErrorFetchingOfferings$$inlined$log$1; +HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$handleErrorFetchingOfferings$$inlined$log$1;->(Lcom/revenuecat/purchases/common/LogIntent;Lcom/revenuecat/purchases/PurchasesError;)V +HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$handleErrorFetchingOfferings$$inlined$log$1;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$handleErrorFetchingOfferings$$inlined$log$1;->invoke()Ljava/lang/String; +Lcom/revenuecat/purchases/common/offerings/OfferingsManager$handleErrorFetchingOfferings$2; +HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$handleErrorFetchingOfferings$2;->(Lkotlin/jvm/functions/Function1;Lcom/revenuecat/purchases/PurchasesError;)V +HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$handleErrorFetchingOfferings$2;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$handleErrorFetchingOfferings$2;->invoke()V +Lcom/revenuecat/purchases/common/offerings/OfferingsManager$onAppForeground$$inlined$log$1; +HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$onAppForeground$$inlined$log$1;->(Lcom/revenuecat/purchases/common/LogIntent;)V +HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$onAppForeground$$inlined$log$1;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/common/offerings/OfferingsManager$onAppForeground$$inlined$log$1;->invoke()Ljava/lang/String; Lcom/revenuecat/purchases/common/offlineentitlements/OfflineCustomerInfoCalculator; HSPLcom/revenuecat/purchases/common/offlineentitlements/OfflineCustomerInfoCalculator;->(Lcom/revenuecat/purchases/common/offlineentitlements/PurchasedProductsFetcher;Lcom/revenuecat/purchases/common/AppConfig;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;Lcom/revenuecat/purchases/common/DateProvider;)V HSPLcom/revenuecat/purchases/common/offlineentitlements/OfflineCustomerInfoCalculator;->(Lcom/revenuecat/purchases/common/offlineentitlements/PurchasedProductsFetcher;Lcom/revenuecat/purchases/common/AppConfig;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;Lcom/revenuecat/purchases/common/DateProvider;ILkotlin/jvm/internal/DefaultConstructorMarker;)V Lcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager; HSPLcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager;->(Lcom/revenuecat/purchases/common/Backend;Lcom/revenuecat/purchases/common/offlineentitlements/OfflineCustomerInfoCalculator;Lcom/revenuecat/purchases/common/caching/DeviceCache;Lcom/revenuecat/purchases/common/AppConfig;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;)V -HSPLcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager;->getOfflineCustomerInfo()Lcom/revenuecat/purchases/CustomerInfo; HSPLcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager;->isOfflineEntitlementsEnabled()Z -HSPLcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager;->resetOfflineCustomerInfoCache()V +HSPLcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager;->shouldCalculateOfflineCustomerInfoInGetCustomerInfoRequest(ZLjava/lang/String;)Z HSPLcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager;->updateProductEntitlementMappingCacheIfStale$default(Lcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V HSPLcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager;->updateProductEntitlementMappingCacheIfStale(Lkotlin/jvm/functions/Function1;)V -Lcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager$updateProductEntitlementMappingCacheIfStale$1; -HSPLcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager$updateProductEntitlementMappingCacheIfStale$1;->(Lcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager;Lkotlin/jvm/functions/Function1;)V Lcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager$updateProductEntitlementMappingCacheIfStale$2; -HSPLcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager$updateProductEntitlementMappingCacheIfStale$2;->(Lkotlin/jvm/functions/Function1;)V +HSPLcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager$updateProductEntitlementMappingCacheIfStale$2;->(Lcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager;Lkotlin/jvm/functions/Function1;)V +Lcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager$updateProductEntitlementMappingCacheIfStale$3; +HSPLcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager$updateProductEntitlementMappingCacheIfStale$3;->(Lkotlin/jvm/functions/Function1;)V +PLcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager$updateProductEntitlementMappingCacheIfStale$3;->invoke(Lcom/revenuecat/purchases/PurchasesError;)V +PLcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager$updateProductEntitlementMappingCacheIfStale$3;->invoke(Ljava/lang/Object;)Ljava/lang/Object; Lcom/revenuecat/purchases/common/offlineentitlements/PurchasedProductsFetcher; HSPLcom/revenuecat/purchases/common/offlineentitlements/PurchasedProductsFetcher;->(Lcom/revenuecat/purchases/common/caching/DeviceCache;Lcom/revenuecat/purchases/common/BillingAbstract;Lcom/revenuecat/purchases/common/DateProvider;)V HSPLcom/revenuecat/purchases/common/offlineentitlements/PurchasedProductsFetcher;->(Lcom/revenuecat/purchases/common/caching/DeviceCache;Lcom/revenuecat/purchases/common/BillingAbstract;Lcom/revenuecat/purchases/common/DateProvider;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -1299,42 +1228,12 @@ HSPLcom/revenuecat/purchases/common/verification/DefaultSignatureVerifier;->(Ljava/lang/String;)V HSPLcom/revenuecat/purchases/common/verification/DefaultSignatureVerifier;->(Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V HSPLcom/revenuecat/purchases/common/verification/DefaultSignatureVerifier;->([B)V -HSPLcom/revenuecat/purchases/common/verification/DefaultSignatureVerifier;->verify([B[B)Z Lcom/revenuecat/purchases/common/verification/DefaultSignatureVerifier$Companion; HSPLcom/revenuecat/purchases/common/verification/DefaultSignatureVerifier$Companion;->()V HSPLcom/revenuecat/purchases/common/verification/DefaultSignatureVerifier$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V Lcom/revenuecat/purchases/common/verification/IntermediateSignatureHelper; HSPLcom/revenuecat/purchases/common/verification/IntermediateSignatureHelper;->(Lcom/revenuecat/purchases/common/verification/SignatureVerifier;)V -HSPLcom/revenuecat/purchases/common/verification/IntermediateSignatureHelper;->createIntermediateKeyVerifierIfVerified(Lcom/revenuecat/purchases/common/verification/Signature;)Lcom/revenuecat/purchases/utils/Result; -HSPLcom/revenuecat/purchases/common/verification/IntermediateSignatureHelper;->getIntermediateKeyExpirationDate([B)Ljava/util/Date; Lcom/revenuecat/purchases/common/verification/InvalidSignatureSizeException; -Lcom/revenuecat/purchases/common/verification/Signature; -HSPLcom/revenuecat/purchases/common/verification/Signature;->()V -HSPLcom/revenuecat/purchases/common/verification/Signature;->([B[B[B[B[B)V -HSPLcom/revenuecat/purchases/common/verification/Signature;->getIntermediateKey()[B -HSPLcom/revenuecat/purchases/common/verification/Signature;->getIntermediateKeyExpiration()[B -HSPLcom/revenuecat/purchases/common/verification/Signature;->getIntermediateKeySignature()[B -HSPLcom/revenuecat/purchases/common/verification/Signature;->getPayload()[B -HSPLcom/revenuecat/purchases/common/verification/Signature;->getSalt()[B -Lcom/revenuecat/purchases/common/verification/Signature$Companion; -HSPLcom/revenuecat/purchases/common/verification/Signature$Companion;->()V -HSPLcom/revenuecat/purchases/common/verification/Signature$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V -HSPLcom/revenuecat/purchases/common/verification/Signature$Companion;->fromString$purchases_defaultsRelease(Ljava/lang/String;)Lcom/revenuecat/purchases/common/verification/Signature; -Lcom/revenuecat/purchases/common/verification/Signature$Component; -HSPLcom/revenuecat/purchases/common/verification/Signature$Component;->$values()[Lcom/revenuecat/purchases/common/verification/Signature$Component; -HSPLcom/revenuecat/purchases/common/verification/Signature$Component;->()V -HSPLcom/revenuecat/purchases/common/verification/Signature$Component;->(Ljava/lang/String;II)V -HSPLcom/revenuecat/purchases/common/verification/Signature$Component;->getEndByte()I -HSPLcom/revenuecat/purchases/common/verification/Signature$Component;->getSize()I -HSPLcom/revenuecat/purchases/common/verification/Signature$Component;->getStartByte()I -HSPLcom/revenuecat/purchases/common/verification/Signature$Component;->values()[Lcom/revenuecat/purchases/common/verification/Signature$Component; -Lcom/revenuecat/purchases/common/verification/Signature$Component$Companion; -HSPLcom/revenuecat/purchases/common/verification/Signature$Component$Companion;->()V -HSPLcom/revenuecat/purchases/common/verification/Signature$Component$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V -HSPLcom/revenuecat/purchases/common/verification/Signature$Component$Companion;->getTotalSize()I -Lcom/revenuecat/purchases/common/verification/SignatureKt; -HSPLcom/revenuecat/purchases/common/verification/SignatureKt;->access$copyOf([BLcom/revenuecat/purchases/common/verification/Signature$Component;)[B -HSPLcom/revenuecat/purchases/common/verification/SignatureKt;->copyOf([BLcom/revenuecat/purchases/common/verification/Signature$Component;)[B Lcom/revenuecat/purchases/common/verification/SignatureVerificationException; Lcom/revenuecat/purchases/common/verification/SignatureVerificationMode; HSPLcom/revenuecat/purchases/common/verification/SignatureVerificationMode;->()V @@ -1354,7 +1253,6 @@ HSPLcom/revenuecat/purchases/common/verification/SignatureVerificationMode$Disab Lcom/revenuecat/purchases/common/verification/SignatureVerificationMode$Informational; HSPLcom/revenuecat/purchases/common/verification/SignatureVerificationMode$Informational;->(Lcom/revenuecat/purchases/common/verification/IntermediateSignatureHelper;)V HSPLcom/revenuecat/purchases/common/verification/SignatureVerificationMode$Informational;->equals(Ljava/lang/Object;)Z -HSPLcom/revenuecat/purchases/common/verification/SignatureVerificationMode$Informational;->getIntermediateSignatureHelper()Lcom/revenuecat/purchases/common/verification/IntermediateSignatureHelper; Lcom/revenuecat/purchases/common/verification/SignatureVerifier; Lcom/revenuecat/purchases/common/verification/SigningManager; HSPLcom/revenuecat/purchases/common/verification/SigningManager;->()V @@ -1362,13 +1260,9 @@ HSPLcom/revenuecat/purchases/common/verification/SigningManager;->(Lcom/re HSPLcom/revenuecat/purchases/common/verification/SigningManager;->createRandomNonce()Ljava/lang/String; HSPLcom/revenuecat/purchases/common/verification/SigningManager;->getSignatureVerificationMode()Lcom/revenuecat/purchases/common/verification/SignatureVerificationMode; HSPLcom/revenuecat/purchases/common/verification/SigningManager;->shouldVerifyEndpoint(Lcom/revenuecat/purchases/common/networking/Endpoint;)Z -HSPLcom/revenuecat/purchases/common/verification/SigningManager;->verifyResponse(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/revenuecat/purchases/VerificationResult; Lcom/revenuecat/purchases/common/verification/SigningManager$Companion; HSPLcom/revenuecat/purchases/common/verification/SigningManager$Companion;->()V HSPLcom/revenuecat/purchases/common/verification/SigningManager$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V -Lcom/revenuecat/purchases/common/verification/SigningManager$Parameters; -HSPLcom/revenuecat/purchases/common/verification/SigningManager$Parameters;->([BLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V -HSPLcom/revenuecat/purchases/common/verification/SigningManager$Parameters;->toSignatureToVerify()[B Lcom/revenuecat/purchases/customercenter/CustomerCenterConfigData$HelpPath$PathType; HSPLcom/revenuecat/purchases/customercenter/CustomerCenterConfigData$HelpPath$PathType;->$values()[Lcom/revenuecat/purchases/customercenter/CustomerCenterConfigData$HelpPath$PathType; HSPLcom/revenuecat/purchases/customercenter/CustomerCenterConfigData$HelpPath$PathType;->()V @@ -1425,8 +1319,8 @@ Lcom/revenuecat/purchases/google/BillingResultExtensionsKt; HSPLcom/revenuecat/purchases/google/BillingResultExtensionsKt;->toHumanReadableDescription(Lcom/android/billingclient/api/BillingResult;)Ljava/lang/String; Lcom/revenuecat/purchases/google/BillingWrapper; HSPLcom/revenuecat/purchases/google/BillingWrapper;->$r8$lambda$914swE6pyeEY6CZPzYye25MjAIs(Lcom/revenuecat/purchases/google/BillingWrapper;)V -HSPLcom/revenuecat/purchases/google/BillingWrapper;->$r8$lambda$YOjjjHMQ7BZzeiFAdssjFfobJG0(Lcom/android/billingclient/api/BillingResult;Lcom/revenuecat/purchases/google/BillingWrapper;)V -HSPLcom/revenuecat/purchases/google/BillingWrapper;->$r8$lambda$_3sD0n0aaREKDCmTc1A3xgtg6rQ(Lkotlin/jvm/functions/Function1;Lcom/revenuecat/purchases/PurchasesError;)V +HSPLcom/revenuecat/purchases/google/BillingWrapper;->$r8$lambda$MMJbC0bP5Pc6bqaWuibLTAl_nHE(Lcom/android/billingclient/api/BillingResult;Lcom/revenuecat/purchases/google/BillingWrapper;)V +HSPLcom/revenuecat/purchases/google/BillingWrapper;->$r8$lambda$w31Me_tSQy1YxlSy1B0RuEE3yrI(Lkotlin/jvm/functions/Function1;Lcom/revenuecat/purchases/PurchasesError;)V HSPLcom/revenuecat/purchases/google/BillingWrapper;->()V HSPLcom/revenuecat/purchases/google/BillingWrapper;->(Lcom/revenuecat/purchases/google/BillingWrapper$ClientFactory;Landroid/os/Handler;Lcom/revenuecat/purchases/common/caching/DeviceCache;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;Lcom/revenuecat/purchases/PurchasesStateProvider;Lcom/revenuecat/purchases/common/DateProvider;)V HSPLcom/revenuecat/purchases/google/BillingWrapper;->(Lcom/revenuecat/purchases/google/BillingWrapper$ClientFactory;Landroid/os/Handler;Lcom/revenuecat/purchases/common/caching/DeviceCache;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;Lcom/revenuecat/purchases/PurchasesStateProvider;Lcom/revenuecat/purchases/common/DateProvider;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -1435,11 +1329,10 @@ HSPLcom/revenuecat/purchases/google/BillingWrapper;->executePendingRequests()V HSPLcom/revenuecat/purchases/google/BillingWrapper;->executeRequestOnUIThread$default(Lcom/revenuecat/purchases/google/BillingWrapper;Ljava/lang/Long;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V HSPLcom/revenuecat/purchases/google/BillingWrapper;->executeRequestOnUIThread(Ljava/lang/Long;Lkotlin/jvm/functions/Function1;)V HSPLcom/revenuecat/purchases/google/BillingWrapper;->getAppInBackground()Z -HSPLcom/revenuecat/purchases/google/BillingWrapper;->onBillingSetupFinished$lambda$18(Lcom/android/billingclient/api/BillingResult;Lcom/revenuecat/purchases/google/BillingWrapper;)V +HSPLcom/revenuecat/purchases/google/BillingWrapper;->onBillingSetupFinished$lambda$34(Lcom/android/billingclient/api/BillingResult;Lcom/revenuecat/purchases/google/BillingWrapper;)V HSPLcom/revenuecat/purchases/google/BillingWrapper;->onBillingSetupFinished(Lcom/android/billingclient/api/BillingResult;)V -HSPLcom/revenuecat/purchases/google/BillingWrapper;->queryProductDetailsAsync(Lcom/revenuecat/purchases/ProductType;Ljava/util/Set;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V HSPLcom/revenuecat/purchases/google/BillingWrapper;->queryPurchases(Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V -HSPLcom/revenuecat/purchases/google/BillingWrapper;->sendErrorsToAllPendingRequests$lambda$36$lambda$35(Lkotlin/jvm/functions/Function1;Lcom/revenuecat/purchases/PurchasesError;)V +HSPLcom/revenuecat/purchases/google/BillingWrapper;->sendErrorsToAllPendingRequests$lambda$59$lambda$58(Lkotlin/jvm/functions/Function1;Lcom/revenuecat/purchases/PurchasesError;)V HSPLcom/revenuecat/purchases/google/BillingWrapper;->sendErrorsToAllPendingRequests(Lcom/revenuecat/purchases/PurchasesError;)V HSPLcom/revenuecat/purchases/google/BillingWrapper;->showInAppMessagesIfNeeded(Landroid/app/Activity;Ljava/util/List;Lkotlin/jvm/functions/Function0;)V HSPLcom/revenuecat/purchases/google/BillingWrapper;->startConnection()V @@ -1460,25 +1353,28 @@ HSPLcom/revenuecat/purchases/google/BillingWrapper$ClientFactory;->buildClient(L Lcom/revenuecat/purchases/google/BillingWrapper$Companion; HSPLcom/revenuecat/purchases/google/BillingWrapper$Companion;->()V HSPLcom/revenuecat/purchases/google/BillingWrapper$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V -Lcom/revenuecat/purchases/google/BillingWrapper$queryProductDetailsAsync$useCase$1; -HSPLcom/revenuecat/purchases/google/BillingWrapper$queryProductDetailsAsync$useCase$1;->(Ljava/lang/Object;)V -Lcom/revenuecat/purchases/google/BillingWrapper$queryProductDetailsAsync$useCase$2; -HSPLcom/revenuecat/purchases/google/BillingWrapper$queryProductDetailsAsync$useCase$2;->(Ljava/lang/Object;)V -HSPLcom/revenuecat/purchases/google/BillingWrapper$queryProductDetailsAsync$useCase$2;->invoke(Ljava/lang/Long;Lkotlin/jvm/functions/Function1;)V -HSPLcom/revenuecat/purchases/google/BillingWrapper$queryProductDetailsAsync$useCase$2;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; -Lcom/revenuecat/purchases/google/BillingWrapper$queryPurchases$1; -HSPLcom/revenuecat/purchases/google/BillingWrapper$queryPurchases$1;->(Ljava/lang/Object;)V +Lcom/revenuecat/purchases/google/BillingWrapper$queryPurchases$$inlined$log$1; +HSPLcom/revenuecat/purchases/google/BillingWrapper$queryPurchases$$inlined$log$1;->(Lcom/revenuecat/purchases/common/LogIntent;)V +HSPLcom/revenuecat/purchases/google/BillingWrapper$queryPurchases$$inlined$log$1;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/google/BillingWrapper$queryPurchases$$inlined$log$1;->invoke()Ljava/lang/String; Lcom/revenuecat/purchases/google/BillingWrapper$queryPurchases$2; HSPLcom/revenuecat/purchases/google/BillingWrapper$queryPurchases$2;->(Ljava/lang/Object;)V -HSPLcom/revenuecat/purchases/google/BillingWrapper$queryPurchases$2;->invoke(Ljava/lang/Long;Lkotlin/jvm/functions/Function1;)V -HSPLcom/revenuecat/purchases/google/BillingWrapper$queryPurchases$2;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; -Lcom/revenuecat/purchases/google/BillingWrapper$showInAppMessagesIfNeeded$1; -HSPLcom/revenuecat/purchases/google/BillingWrapper$showInAppMessagesIfNeeded$1;->(Lcom/revenuecat/purchases/google/BillingWrapper;Ljava/lang/ref/WeakReference;Lcom/android/billingclient/api/InAppMessageParams;Lkotlin/jvm/functions/Function0;)V -HSPLcom/revenuecat/purchases/google/BillingWrapper$showInAppMessagesIfNeeded$1;->invoke(Lcom/revenuecat/purchases/PurchasesError;)V -HSPLcom/revenuecat/purchases/google/BillingWrapper$showInAppMessagesIfNeeded$1;->invoke(Ljava/lang/Object;)Ljava/lang/Object; +Lcom/revenuecat/purchases/google/BillingWrapper$queryPurchases$3; +HSPLcom/revenuecat/purchases/google/BillingWrapper$queryPurchases$3;->(Ljava/lang/Object;)V +HSPLcom/revenuecat/purchases/google/BillingWrapper$queryPurchases$3;->invoke(Ljava/lang/Long;Lkotlin/jvm/functions/Function1;)V +HSPLcom/revenuecat/purchases/google/BillingWrapper$queryPurchases$3;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +Lcom/revenuecat/purchases/google/BillingWrapper$showInAppMessagesIfNeeded$2; +HSPLcom/revenuecat/purchases/google/BillingWrapper$showInAppMessagesIfNeeded$2;->(Lcom/revenuecat/purchases/google/BillingWrapper;Ljava/lang/ref/WeakReference;Lcom/android/billingclient/api/InAppMessageParams;Lkotlin/jvm/functions/Function0;)V +HSPLcom/revenuecat/purchases/google/BillingWrapper$showInAppMessagesIfNeeded$2;->invoke(Lcom/revenuecat/purchases/PurchasesError;)V +HSPLcom/revenuecat/purchases/google/BillingWrapper$showInAppMessagesIfNeeded$2;->invoke(Ljava/lang/Object;)Ljava/lang/Object; +Lcom/revenuecat/purchases/google/BillingWrapper$startConnection$lambda$8$lambda$7$$inlined$log$1; +HSPLcom/revenuecat/purchases/google/BillingWrapper$startConnection$lambda$8$lambda$7$$inlined$log$1;->(Lcom/revenuecat/purchases/common/LogIntent;Lcom/android/billingclient/api/BillingClient;)V +HSPLcom/revenuecat/purchases/google/BillingWrapper$startConnection$lambda$8$lambda$7$$inlined$log$1;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/google/BillingWrapper$startConnection$lambda$8$lambda$7$$inlined$log$1;->invoke()Ljava/lang/String; Lcom/revenuecat/purchases/google/ErrorsKt; HSPLcom/revenuecat/purchases/google/ErrorsKt;->billingResponseToPurchasesError(ILjava/lang/String;)Lcom/revenuecat/purchases/PurchasesError; HSPLcom/revenuecat/purchases/google/ErrorsKt;->getBillingResponseCodeName(I)Ljava/lang/String; +HSPLcom/revenuecat/purchases/google/ErrorsKt;->getOnPurchasesUpdatedSubResponseCodeName(I)Ljava/lang/String; Lcom/revenuecat/purchases/google/attribution/GoogleDeviceIdentifiersFetcher; HSPLcom/revenuecat/purchases/google/attribution/GoogleDeviceIdentifiersFetcher;->(Lcom/revenuecat/purchases/common/Dispatcher;)V Lcom/revenuecat/purchases/google/usecase/BillingClientUseCase; @@ -1493,26 +1389,18 @@ HSPLcom/revenuecat/purchases/google/usecase/BillingClientUseCase$run$1;->invoke( Lcom/revenuecat/purchases/google/usecase/BillingClientUseCaseKt; HSPLcom/revenuecat/purchases/google/usecase/BillingClientUseCaseKt;->()V HSPLcom/revenuecat/purchases/google/usecase/BillingClientUseCaseKt;->access$getRETRY_TIMER_START$p()J -Lcom/revenuecat/purchases/google/usecase/QueryProductDetailsUseCase; -HSPLcom/revenuecat/purchases/google/usecase/QueryProductDetailsUseCase;->()V -HSPLcom/revenuecat/purchases/google/usecase/QueryProductDetailsUseCase;->(Lcom/revenuecat/purchases/google/usecase/QueryProductDetailsUseCaseParams;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V -Lcom/revenuecat/purchases/google/usecase/QueryProductDetailsUseCase$Companion; -HSPLcom/revenuecat/purchases/google/usecase/QueryProductDetailsUseCase$Companion;->()V -HSPLcom/revenuecat/purchases/google/usecase/QueryProductDetailsUseCase$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V -Lcom/revenuecat/purchases/google/usecase/QueryProductDetailsUseCaseParams; -HSPLcom/revenuecat/purchases/google/usecase/QueryProductDetailsUseCaseParams;->(Lcom/revenuecat/purchases/common/DateProvider;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;Ljava/util/Set;Lcom/revenuecat/purchases/ProductType;Z)V Lcom/revenuecat/purchases/google/usecase/QueryPurchasesUseCase; HSPLcom/revenuecat/purchases/google/usecase/QueryPurchasesUseCase;->(Lcom/revenuecat/purchases/google/usecase/QueryPurchasesUseCaseParams;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V Lcom/revenuecat/purchases/google/usecase/QueryPurchasesUseCaseParams; HSPLcom/revenuecat/purchases/google/usecase/QueryPurchasesUseCaseParams;->(Lcom/revenuecat/purchases/common/DateProvider;Lcom/revenuecat/purchases/common/diagnostics/DiagnosticsTracker;Z)V Lcom/revenuecat/purchases/google/usecase/UseCaseParams; Lcom/revenuecat/purchases/identity/IdentityManager; -HSPLcom/revenuecat/purchases/identity/IdentityManager;->$r8$lambda$sPweLupIX7i3JUzb9pGiyGDPwQA(Lkotlin/jvm/functions/Function0;)V +HSPLcom/revenuecat/purchases/identity/IdentityManager;->$r8$lambda$xsoDBYp9eZL68of2YZKuTiMggRc(Lkotlin/jvm/functions/Function0;)V HSPLcom/revenuecat/purchases/identity/IdentityManager;->(Lcom/revenuecat/purchases/common/caching/DeviceCache;Lcom/revenuecat/purchases/subscriberattributes/caching/SubscriberAttributesCache;Lcom/revenuecat/purchases/subscriberattributes/SubscriberAttributesManager;Lcom/revenuecat/purchases/common/offerings/OfferingsCache;Lcom/revenuecat/purchases/common/Backend;Lcom/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager;Lcom/revenuecat/purchases/common/Dispatcher;)V HSPLcom/revenuecat/purchases/identity/IdentityManager;->access$getDeviceCache$p(Lcom/revenuecat/purchases/identity/IdentityManager;)Lcom/revenuecat/purchases/common/caching/DeviceCache; HSPLcom/revenuecat/purchases/identity/IdentityManager;->configure(Ljava/lang/String;)V HSPLcom/revenuecat/purchases/identity/IdentityManager;->currentUserIsAnonymous()Z -HSPLcom/revenuecat/purchases/identity/IdentityManager;->enqueue$lambda$3(Lkotlin/jvm/functions/Function0;)V +HSPLcom/revenuecat/purchases/identity/IdentityManager;->enqueue$lambda$10(Lkotlin/jvm/functions/Function0;)V HSPLcom/revenuecat/purchases/identity/IdentityManager;->enqueue(Lkotlin/jvm/functions/Function0;)V HSPLcom/revenuecat/purchases/identity/IdentityManager;->getCurrentAppUserID()Ljava/lang/String; HSPLcom/revenuecat/purchases/identity/IdentityManager;->invalidateETagCacheIfNeeded(Ljava/lang/String;)V @@ -1521,15 +1409,17 @@ HSPLcom/revenuecat/purchases/identity/IdentityManager;->shouldInvalidateETagCach Lcom/revenuecat/purchases/identity/IdentityManager$$ExternalSyntheticLambda0; HSPLcom/revenuecat/purchases/identity/IdentityManager$$ExternalSyntheticLambda0;->(Lkotlin/jvm/functions/Function0;)V HSPLcom/revenuecat/purchases/identity/IdentityManager$$ExternalSyntheticLambda0;->run()V -Lcom/revenuecat/purchases/identity/IdentityManager$configure$1; -HSPLcom/revenuecat/purchases/identity/IdentityManager$configure$1;->(Lcom/revenuecat/purchases/identity/IdentityManager;)V -HSPLcom/revenuecat/purchases/identity/IdentityManager$configure$1;->invoke()Ljava/lang/Object; -HSPLcom/revenuecat/purchases/identity/IdentityManager$configure$1;->invoke()V +Lcom/revenuecat/purchases/identity/IdentityManager$configure$$inlined$log$2; +HSPLcom/revenuecat/purchases/identity/IdentityManager$configure$$inlined$log$2;->(Lcom/revenuecat/purchases/common/LogIntent;Ljava/lang/String;)V +HSPLcom/revenuecat/purchases/identity/IdentityManager$configure$$inlined$log$2;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/identity/IdentityManager$configure$$inlined$log$2;->invoke()Ljava/lang/String; +Lcom/revenuecat/purchases/identity/IdentityManager$configure$3; +HSPLcom/revenuecat/purchases/identity/IdentityManager$configure$3;->(Lcom/revenuecat/purchases/identity/IdentityManager;)V +HSPLcom/revenuecat/purchases/identity/IdentityManager$configure$3;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/identity/IdentityManager$configure$3;->invoke()V Lcom/revenuecat/purchases/interfaces/ProductChangeCallback; Lcom/revenuecat/purchases/interfaces/PurchaseErrorCallback; -Lcom/revenuecat/purchases/interfaces/ReceiveCustomerInfoCallback; Lcom/revenuecat/purchases/interfaces/StorefrontProvider; -Lcom/revenuecat/purchases/interfaces/UpdatedCustomerInfoListener; Lcom/revenuecat/purchases/models/InAppMessageType; HSPLcom/revenuecat/purchases/models/InAppMessageType;->$values()[Lcom/revenuecat/purchases/models/InAppMessageType; HSPLcom/revenuecat/purchases/models/InAppMessageType;->()V @@ -1570,7 +1460,7 @@ HSPLcom/revenuecat/purchases/models/PricingPhase;->getBillingPeriod()Lcom/revenu HSPLcom/revenuecat/purchases/models/PricingPhase;->getPrice()Lcom/revenuecat/purchases/models/Price; Lcom/revenuecat/purchases/models/PricingPhase$Creator; HSPLcom/revenuecat/purchases/models/PricingPhase$Creator;->()V -Lcom/revenuecat/purchases/models/RawDataContainer; +Lcom/revenuecat/purchases/models/PurchasingData; Lcom/revenuecat/purchases/models/RecurrenceMode; HSPLcom/revenuecat/purchases/models/RecurrenceMode;->$values()[Lcom/revenuecat/purchases/models/RecurrenceMode; HSPLcom/revenuecat/purchases/models/RecurrenceMode;->()V @@ -1596,17 +1486,21 @@ Lcom/revenuecat/purchases/models/SubscriptionOptionsKt; HSPLcom/revenuecat/purchases/models/SubscriptionOptionsKt;->()V HSPLcom/revenuecat/purchases/models/SubscriptionOptionsKt;->access$getDAYS_IN_UNIT$p()Ljava/util/Map; Lcom/revenuecat/purchases/models/TestStoreProduct; +HSPLcom/revenuecat/purchases/models/TestStoreProduct;->(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/revenuecat/purchases/models/Price;Lcom/revenuecat/purchases/models/Period;)V HSPLcom/revenuecat/purchases/models/TestStoreProduct;->(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/revenuecat/purchases/models/Price;Lcom/revenuecat/purchases/models/Period;Lcom/revenuecat/purchases/models/Period;Lcom/revenuecat/purchases/models/Price;)V HSPLcom/revenuecat/purchases/models/TestStoreProduct;->(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/revenuecat/purchases/models/Price;Lcom/revenuecat/purchases/models/Period;Lcom/revenuecat/purchases/models/Period;Lcom/revenuecat/purchases/models/Price;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +HSPLcom/revenuecat/purchases/models/TestStoreProduct;->(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/revenuecat/purchases/models/Price;Lcom/revenuecat/purchases/models/Period;Lcom/revenuecat/purchases/models/PricingPhase;Lcom/revenuecat/purchases/models/PricingPhase;)V HSPLcom/revenuecat/purchases/models/TestStoreProduct;->buildSubscriptionOptions()Lcom/revenuecat/purchases/models/SubscriptionOptions; HSPLcom/revenuecat/purchases/models/TestStoreProduct;->getDefaultOption()Lcom/revenuecat/purchases/models/SubscriptionOption; HSPLcom/revenuecat/purchases/models/TestStoreProduct;->getId()Ljava/lang/String; HSPLcom/revenuecat/purchases/models/TestStoreProduct;->getPeriod()Lcom/revenuecat/purchases/models/Period; HSPLcom/revenuecat/purchases/models/TestStoreProduct;->getPrice()Lcom/revenuecat/purchases/models/Price; +HSPLcom/revenuecat/purchases/models/TestStoreProduct;->getPurchasingData()Lcom/revenuecat/purchases/models/PurchasingData; HSPLcom/revenuecat/purchases/models/TestStoreProduct;->getSubscriptionOptions()Lcom/revenuecat/purchases/models/SubscriptionOptions; +HSPLcom/revenuecat/purchases/models/TestStoreProduct;->getType()Lcom/revenuecat/purchases/ProductType; Lcom/revenuecat/purchases/models/TestSubscriptionOption; -HSPLcom/revenuecat/purchases/models/TestSubscriptionOption;->(Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/util/List;Lcom/revenuecat/purchases/PresentedOfferingContext;Lcom/revenuecat/purchases/models/InstallmentsInfo;)V -HSPLcom/revenuecat/purchases/models/TestSubscriptionOption;->(Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/util/List;Lcom/revenuecat/purchases/PresentedOfferingContext;Lcom/revenuecat/purchases/models/InstallmentsInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +HSPLcom/revenuecat/purchases/models/TestSubscriptionOption;->(Ljava/util/List;Ljava/lang/String;Ljava/util/List;Lcom/revenuecat/purchases/PresentedOfferingContext;Lcom/revenuecat/purchases/models/InstallmentsInfo;Lcom/revenuecat/purchases/models/PurchasingData;)V +HSPLcom/revenuecat/purchases/models/TestSubscriptionOption;->(Ljava/util/List;Ljava/lang/String;Ljava/util/List;Lcom/revenuecat/purchases/PresentedOfferingContext;Lcom/revenuecat/purchases/models/InstallmentsInfo;Lcom/revenuecat/purchases/models/PurchasingData;ILkotlin/jvm/internal/DefaultConstructorMarker;)V HSPLcom/revenuecat/purchases/models/TestSubscriptionOption;->getPricingPhases()Ljava/util/List; HSPLcom/revenuecat/purchases/models/TestSubscriptionOption;->getTags()Ljava/util/List; Lcom/revenuecat/purchases/paywalls/ColorUtilsKt; @@ -1656,7 +1550,6 @@ HSPLcom/revenuecat/purchases/paywalls/PaywallData$Configuration;->getTermsOfServ Lcom/revenuecat/purchases/paywalls/PaywallData$Configuration$ColorInformation; HSPLcom/revenuecat/purchases/paywalls/PaywallData$Configuration$ColorInformation;->()V HSPLcom/revenuecat/purchases/paywalls/PaywallData$Configuration$ColorInformation;->(Lcom/revenuecat/purchases/paywalls/PaywallData$Configuration$Colors;Lcom/revenuecat/purchases/paywalls/PaywallData$Configuration$Colors;)V -HSPLcom/revenuecat/purchases/paywalls/PaywallData$Configuration$ColorInformation;->(Lcom/revenuecat/purchases/paywalls/PaywallData$Configuration$Colors;Lcom/revenuecat/purchases/paywalls/PaywallData$Configuration$Colors;ILkotlin/jvm/internal/DefaultConstructorMarker;)V HSPLcom/revenuecat/purchases/paywalls/PaywallData$Configuration$ColorInformation;->getDark()Lcom/revenuecat/purchases/paywalls/PaywallData$Configuration$Colors; HSPLcom/revenuecat/purchases/paywalls/PaywallData$Configuration$ColorInformation;->getLight()Lcom/revenuecat/purchases/paywalls/PaywallData$Configuration$Colors; Lcom/revenuecat/purchases/paywalls/PaywallData$Configuration$ColorInformation$$serializer; @@ -1694,7 +1587,6 @@ HSPLcom/revenuecat/purchases/paywalls/PaywallData$Configuration$Companion;->()V HSPLcom/revenuecat/purchases/paywalls/PaywallData$Configuration$Images;->(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V -HSPLcom/revenuecat/purchases/paywalls/PaywallData$Configuration$Images;->(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V HSPLcom/revenuecat/purchases/paywalls/PaywallData$Configuration$Images;->getBackground()Ljava/lang/String; HSPLcom/revenuecat/purchases/paywalls/PaywallData$Configuration$Images;->getHeader()Ljava/lang/String; HSPLcom/revenuecat/purchases/paywalls/PaywallData$Configuration$Images;->getIcon()Ljava/lang/String; @@ -1760,9 +1652,6 @@ HSPLcom/revenuecat/purchases/paywalls/components/ButtonComponent$UrlMethod;->val Lcom/revenuecat/purchases/paywalls/components/ButtonComponent$UrlMethod$Companion; HSPLcom/revenuecat/purchases/paywalls/components/ButtonComponent$UrlMethod$Companion;->()V HSPLcom/revenuecat/purchases/paywalls/components/ButtonComponent$UrlMethod$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V -Lcom/revenuecat/purchases/paywalls/components/ButtonComponent$UrlMethod$Companion$1; -HSPLcom/revenuecat/purchases/paywalls/components/ButtonComponent$UrlMethod$Companion$1;->()V -HSPLcom/revenuecat/purchases/paywalls/components/ButtonComponent$UrlMethod$Companion$1;->()V Lcom/revenuecat/purchases/paywalls/components/common/LocaleId; HSPLcom/revenuecat/purchases/paywalls/components/common/LocaleId;->()V HSPLcom/revenuecat/purchases/paywalls/components/common/LocaleId;->constructor-impl(Ljava/lang/String;)Ljava/lang/String; @@ -1812,6 +1701,8 @@ HSPLcom/revenuecat/purchases/paywalls/events/PaywallStoredEvent;->()V Lcom/revenuecat/purchases/paywalls/events/PaywallStoredEvent$Companion; HSPLcom/revenuecat/purchases/paywalls/events/PaywallStoredEvent$Companion;->()V HSPLcom/revenuecat/purchases/paywalls/events/PaywallStoredEvent$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V +Lcom/revenuecat/purchases/simulatedstore/SimulatedStorePurchasingData; +HSPLcom/revenuecat/purchases/simulatedstore/SimulatedStorePurchasingData;->(Ljava/lang/String;Lcom/revenuecat/purchases/ProductType;Lcom/revenuecat/purchases/models/StoreProduct;)V Lcom/revenuecat/purchases/subscriberattributes/SubscriberAttributesFactoriesKt; HSPLcom/revenuecat/purchases/subscriberattributes/SubscriberAttributesFactoriesKt;->buildSubscriberAttributesMapPerUser(Lorg/json/JSONObject;)Ljava/util/Map; Lcom/revenuecat/purchases/subscriberattributes/SubscriberAttributesFactoriesKt$buildSubscriberAttributesMapPerUser$1; @@ -1830,6 +1721,10 @@ Lcom/revenuecat/purchases/subscriberattributes/SubscriberAttributesManager$synch HSPLcom/revenuecat/purchases/subscriberattributes/SubscriberAttributesManager$synchronizeSubscriberAttributesForAllUsers$1;->(Lcom/revenuecat/purchases/subscriberattributes/SubscriberAttributesManager;Lkotlin/jvm/functions/Function0;Ljava/lang/String;)V HSPLcom/revenuecat/purchases/subscriberattributes/SubscriberAttributesManager$synchronizeSubscriberAttributesForAllUsers$1;->invoke()Ljava/lang/Object; HSPLcom/revenuecat/purchases/subscriberattributes/SubscriberAttributesManager$synchronizeSubscriberAttributesForAllUsers$1;->invoke()V +Lcom/revenuecat/purchases/subscriberattributes/SubscriberAttributesManager$synchronizeSubscriberAttributesForAllUsers$1$invoke$$inlined$log$1; +HSPLcom/revenuecat/purchases/subscriberattributes/SubscriberAttributesManager$synchronizeSubscriberAttributesForAllUsers$1$invoke$$inlined$log$1;->(Lcom/revenuecat/purchases/common/LogIntent;)V +HSPLcom/revenuecat/purchases/subscriberattributes/SubscriberAttributesManager$synchronizeSubscriberAttributesForAllUsers$1$invoke$$inlined$log$1;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/subscriberattributes/SubscriberAttributesManager$synchronizeSubscriberAttributesForAllUsers$1$invoke$$inlined$log$1;->invoke()Ljava/lang/String; Lcom/revenuecat/purchases/subscriberattributes/SubscriberAttributesPoster; HSPLcom/revenuecat/purchases/subscriberattributes/SubscriberAttributesPoster;->(Lcom/revenuecat/purchases/common/BackendHelper;)V Lcom/revenuecat/purchases/subscriberattributes/caching/CachingHelpersKt; @@ -1842,6 +1737,10 @@ HSPLcom/revenuecat/purchases/subscriberattributes/caching/SubscriberAttributesCa HSPLcom/revenuecat/purchases/subscriberattributes/caching/SubscriberAttributesCache;->getDeviceCache$purchases_defaultsRelease()Lcom/revenuecat/purchases/common/caching/DeviceCache; HSPLcom/revenuecat/purchases/subscriberattributes/caching/SubscriberAttributesCache;->getSubscriberAttributesCacheKey$purchases_defaultsRelease()Ljava/lang/String; HSPLcom/revenuecat/purchases/subscriberattributes/caching/SubscriberAttributesCache;->getUnsyncedSubscriberAttributes()Ljava/util/Map; +Lcom/revenuecat/purchases/subscriberattributes/caching/SubscriberAttributesCache$deleteSyncedSubscriberAttributesForOtherUsers$$inlined$log$1; +HSPLcom/revenuecat/purchases/subscriberattributes/caching/SubscriberAttributesCache$deleteSyncedSubscriberAttributesForOtherUsers$$inlined$log$1;->(Lcom/revenuecat/purchases/common/LogIntent;Ljava/lang/String;)V +HSPLcom/revenuecat/purchases/subscriberattributes/caching/SubscriberAttributesCache$deleteSyncedSubscriberAttributesForOtherUsers$$inlined$log$1;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/subscriberattributes/caching/SubscriberAttributesCache$deleteSyncedSubscriberAttributesForOtherUsers$$inlined$log$1;->invoke()Ljava/lang/String; Lcom/revenuecat/purchases/subscriberattributes/caching/SubscriberAttributesCache$subscriberAttributesCacheKey$2; HSPLcom/revenuecat/purchases/subscriberattributes/caching/SubscriberAttributesCache$subscriberAttributesCacheKey$2;->(Lcom/revenuecat/purchases/subscriberattributes/caching/SubscriberAttributesCache;)V HSPLcom/revenuecat/purchases/subscriberattributes/caching/SubscriberAttributesCache$subscriberAttributesCacheKey$2;->invoke()Ljava/lang/Object; @@ -1869,6 +1768,7 @@ HSPLcom/revenuecat/purchases/utils/EventsFileHelper;->(Lcom/revenuecat/pur HSPLcom/revenuecat/purchases/utils/EventsFileHelper;->access$mapToEvent(Lcom/revenuecat/purchases/utils/EventsFileHelper;Ljava/lang/String;)Lcom/revenuecat/purchases/utils/Event; HSPLcom/revenuecat/purchases/utils/EventsFileHelper;->appendEvent(Lcom/revenuecat/purchases/utils/Event;)V PLcom/revenuecat/purchases/utils/EventsFileHelper;->clear(I)V +PLcom/revenuecat/purchases/utils/EventsFileHelper;->deleteFile()V HSPLcom/revenuecat/purchases/utils/EventsFileHelper;->mapToEvent(Ljava/lang/String;)Lcom/revenuecat/purchases/utils/Event; HSPLcom/revenuecat/purchases/utils/EventsFileHelper;->readFile(Lkotlin/jvm/functions/Function1;)V HSPLcom/revenuecat/purchases/utils/EventsFileHelper;->readFileAsJson(Lkotlin/jvm/functions/Function1;)V @@ -1894,14 +1794,8 @@ HSPLcom/revenuecat/purchases/utils/FileExtensionsKt;->getSizeInKB(Ljava/io/File; Lcom/revenuecat/purchases/utils/IsDebugBuildProvider; Lcom/revenuecat/purchases/utils/Iso8601Utils; HSPLcom/revenuecat/purchases/utils/Iso8601Utils;->()V -HSPLcom/revenuecat/purchases/utils/Iso8601Utils;->checkOffset(Ljava/lang/String;IC)Z HSPLcom/revenuecat/purchases/utils/Iso8601Utils;->format(Ljava/util/Date;)Ljava/lang/String; HSPLcom/revenuecat/purchases/utils/Iso8601Utils;->padInt(Ljava/lang/StringBuilder;II)V -HSPLcom/revenuecat/purchases/utils/Iso8601Utils;->parse(Ljava/lang/String;)Ljava/util/Date; -HSPLcom/revenuecat/purchases/utils/Iso8601Utils;->parseInt(Ljava/lang/String;II)I -Lcom/revenuecat/purchases/utils/JSONObjectExtensionsKt; -HSPLcom/revenuecat/purchases/utils/JSONObjectExtensionsKt;->getNullableString(Lorg/json/JSONObject;Ljava/lang/String;)Ljava/lang/String; -HSPLcom/revenuecat/purchases/utils/JSONObjectExtensionsKt;->optNullableString(Lorg/json/JSONObject;Ljava/lang/String;)Ljava/lang/String; Lcom/revenuecat/purchases/utils/JsonElementExtensionsKt; HSPLcom/revenuecat/purchases/utils/JsonElementExtensionsKt;->asMap(Lkotlinx/serialization/json/JsonElement;)Ljava/util/Map; HSPLcom/revenuecat/purchases/utils/JsonElementExtensionsKt;->getExtractedContent(Lkotlinx/serialization/json/JsonElement;)Ljava/lang/Object; @@ -1918,6 +1812,10 @@ HSPLcom/revenuecat/purchases/utils/OfferingImagePreDownloader;->(ZLcom/rev Lcom/revenuecat/purchases/utils/PriceExtensionsKt; HSPLcom/revenuecat/purchases/utils/PriceExtensionsKt;->pricePerMonth(Lcom/revenuecat/purchases/models/Price;Lcom/revenuecat/purchases/models/Period;Ljava/util/Locale;)Lcom/revenuecat/purchases/models/Price; HSPLcom/revenuecat/purchases/utils/PriceExtensionsKt;->pricePerPeriod(Lcom/revenuecat/purchases/models/Price;DLjava/util/Locale;)Lcom/revenuecat/purchases/models/Price; +Lcom/revenuecat/purchases/utils/PriceFactory; +HSPLcom/revenuecat/purchases/utils/PriceFactory;->()V +HSPLcom/revenuecat/purchases/utils/PriceFactory;->()V +HSPLcom/revenuecat/purchases/utils/PriceFactory;->createPrice$purchases_defaultsRelease(JLjava/lang/String;Ljava/util/Locale;)Lcom/revenuecat/purchases/models/Price; Lcom/revenuecat/purchases/utils/RateLimiter; HSPLcom/revenuecat/purchases/utils/RateLimiter;->(IJ)V HSPLcom/revenuecat/purchases/utils/RateLimiter;->(IJLkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -1925,8 +1823,8 @@ Lcom/revenuecat/purchases/utils/Result; HSPLcom/revenuecat/purchases/utils/Result;->()V HSPLcom/revenuecat/purchases/utils/Result;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V Lcom/revenuecat/purchases/utils/Result$Error; -Lcom/revenuecat/purchases/utils/Result$Success; -HSPLcom/revenuecat/purchases/utils/Result$Success;->(Ljava/lang/Object;)V -HSPLcom/revenuecat/purchases/utils/Result$Success;->getValue()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/utils/Result$Error;->(Ljava/lang/Object;)V Lcom/revenuecat/purchases/utils/SerializationException; -Lcom/revenuecat/purchases/utils/UrlConnectionFactory; \ No newline at end of file +Lcom/revenuecat/purchases/utils/UrlConnectionFactory; +Lcom/revenuecat/purchases/virtualcurrencies/VirtualCurrencyManager; +HSPLcom/revenuecat/purchases/virtualcurrencies/VirtualCurrencyManager;->(Lcom/revenuecat/purchases/identity/IdentityManager;Lcom/revenuecat/purchases/common/caching/DeviceCache;Lcom/revenuecat/purchases/common/Backend;Lcom/revenuecat/purchases/common/AppConfig;)V \ No newline at end of file diff --git a/purchases/src/main/java/com/revenuecat/purchases/customercenter/CustomerCenterManagementOption.kt b/purchases/src/main/java/com/revenuecat/purchases/customercenter/CustomerCenterManagementOption.kt index fc244fa7e6..5fa2d6b420 100644 --- a/purchases/src/main/java/com/revenuecat/purchases/customercenter/CustomerCenterManagementOption.kt +++ b/purchases/src/main/java/com/revenuecat/purchases/customercenter/CustomerCenterManagementOption.kt @@ -1,6 +1,7 @@ package com.revenuecat.purchases.customercenter import android.net.Uri +import dev.drewhamilton.poko.Poko /** * Interface representing different customer center management options. @@ -21,4 +22,15 @@ interface CustomerCenterManagementOption { * Action to handle a missing purchase */ object MissingPurchase : CustomerCenterManagementOption + + /** + * Action representing a custom action configured in the Customer Center dashboard. + * @property actionIdentifier The unique identifier for the custom action + * @property purchaseIdentifier The optional product identifier of the active purchase + */ + @Poko + class CustomAction( + val actionIdentifier: String, + val purchaseIdentifier: String?, + ) : CustomerCenterManagementOption } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/APIKeyValidator.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/APIKeyValidator.kt index fe66ee4175..2a763b2481 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/APIKeyValidator.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/APIKeyValidator.kt @@ -1,12 +1,13 @@ package com.revenuecat.purchases -import androidx.annotation.VisibleForTesting import com.revenuecat.purchases.common.debugLog import com.revenuecat.purchases.common.errorLog +import com.revenuecat.purchases.common.warnLog import com.revenuecat.purchases.strings.ConfigureStrings private const val GOOGLE_API_KEY_PREFIX = "goog_" private const val AMAZON_API_KEY_PREFIX = "amzn_" +private const val TEST_API_KEY_PREFIX = "test_" internal class APIKeyValidator { @@ -14,32 +15,29 @@ internal class APIKeyValidator { GOOGLE, AMAZON, LEGACY, + TEST, OTHER_PLATFORM, } - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) enum class ValidationResult { VALID, GOOGLE_KEY_AMAZON_STORE, AMAZON_KEY_GOOGLE_STORE, LEGACY, + SIMULATED_STORE, OTHER_PLATFORM, } - fun validateAndLog(apiKey: String, configuredStore: Store) { - when (validate(apiKey, configuredStore)) { - ValidationResult.AMAZON_KEY_GOOGLE_STORE -> errorLog { ConfigureStrings.AMAZON_API_KEY_GOOGLE_STORE } - ValidationResult.GOOGLE_KEY_AMAZON_STORE -> errorLog { ConfigureStrings.GOOGLE_API_KEY_AMAZON_STORE } - ValidationResult.LEGACY -> debugLog { ConfigureStrings.LEGACY_API_KEY } - ValidationResult.OTHER_PLATFORM -> errorLog { ConfigureStrings.INVALID_API_KEY } - ValidationResult.VALID -> {} - } + fun validateAndLog(apiKey: String, configuredStore: Store): ValidationResult { + val validationResult = validate(apiKey, configuredStore) + logValidationResult(validationResult) + return validationResult } - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - fun validate(apiKey: String, configuredStore: Store): ValidationResult { + private fun validate(apiKey: String, configuredStore: Store): ValidationResult { val apiKeyPlatform = getApiKeyPlatform(apiKey) return when { + apiKeyPlatform == APIKeyPlatform.TEST -> ValidationResult.SIMULATED_STORE apiKeyPlatform == APIKeyPlatform.GOOGLE && configuredStore == Store.PLAY_STORE -> ValidationResult.VALID apiKeyPlatform == APIKeyPlatform.AMAZON && configuredStore == Store.AMAZON -> ValidationResult.VALID apiKeyPlatform == APIKeyPlatform.GOOGLE && configuredStore == Store.AMAZON -> { @@ -54,10 +52,22 @@ internal class APIKeyValidator { } } + private fun logValidationResult(validationResult: ValidationResult) { + when (validationResult) { + ValidationResult.AMAZON_KEY_GOOGLE_STORE -> errorLog { ConfigureStrings.AMAZON_API_KEY_GOOGLE_STORE } + ValidationResult.GOOGLE_KEY_AMAZON_STORE -> errorLog { ConfigureStrings.GOOGLE_API_KEY_AMAZON_STORE } + ValidationResult.LEGACY -> debugLog { ConfigureStrings.LEGACY_API_KEY } + ValidationResult.OTHER_PLATFORM -> errorLog { ConfigureStrings.INVALID_API_KEY } + ValidationResult.SIMULATED_STORE -> warnLog { ConfigureStrings.SIMULATED_STORE_API_KEY } + ValidationResult.VALID -> {} + } + } + private fun getApiKeyPlatform(apiKey: String): APIKeyPlatform { return when { apiKey.startsWith(GOOGLE_API_KEY_PREFIX) -> APIKeyPlatform.GOOGLE apiKey.startsWith(AMAZON_API_KEY_PREFIX) -> APIKeyPlatform.AMAZON + apiKey.startsWith(TEST_API_KEY_PREFIX) -> APIKeyPlatform.TEST !apiKey.contains('_') -> APIKeyPlatform.LEGACY else -> APIKeyPlatform.OTHER_PLATFORM } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/BillingFactory.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/BillingFactory.kt index 15366c2e9f..f57f7ac18d 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/BillingFactory.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/BillingFactory.kt @@ -3,11 +3,14 @@ package com.revenuecat.purchases import android.app.Application import android.os.Handler import com.revenuecat.purchases.amazon.AmazonBilling +import com.revenuecat.purchases.common.Backend import com.revenuecat.purchases.common.BackendHelper +import com.revenuecat.purchases.common.BillingAbstract import com.revenuecat.purchases.common.caching.DeviceCache import com.revenuecat.purchases.common.diagnostics.DiagnosticsTracker import com.revenuecat.purchases.common.errorLog import com.revenuecat.purchases.google.BillingWrapper +import com.revenuecat.purchases.simulatedstore.SimulatedStoreBillingWrapper internal object BillingFactory { @@ -21,33 +24,45 @@ internal object BillingFactory { diagnosticsTrackerIfEnabled: DiagnosticsTracker?, stateProvider: PurchasesStateProvider, pendingTransactionsForPrepaidPlansEnabled: Boolean, - ) = when (store) { - Store.PLAY_STORE -> BillingWrapper( - BillingWrapper.ClientFactory(application, pendingTransactionsForPrepaidPlansEnabled), - Handler(application.mainLooper), - cache, - diagnosticsTrackerIfEnabled, - stateProvider, - ) - Store.AMAZON -> { - try { - AmazonBilling( - application.applicationContext, - cache, - finishTransactions, - Handler(application.mainLooper), - backendHelper, - stateProvider, - diagnosticsTrackerIfEnabled, - ) - } catch (e: NoClassDefFoundError) { - errorLog(e) { "Make sure purchases-amazon is added as dependency" } - throw e - } + backend: Backend, + apiKeyValidationResult: APIKeyValidator.ValidationResult, + ): BillingAbstract { + if (apiKeyValidationResult == APIKeyValidator.ValidationResult.SIMULATED_STORE) { + return SimulatedStoreBillingWrapper( + deviceCache = cache, + mainHandler = Handler(application.mainLooper), + purchasesStateProvider = stateProvider, + backend = backend, + ) } - else -> { - errorLog { "Incompatible store ($store) used" } - throw IllegalArgumentException("Couldn't configure SDK. Incompatible store ($store) used") + return when (store) { + Store.PLAY_STORE -> BillingWrapper( + BillingWrapper.ClientFactory(application, pendingTransactionsForPrepaidPlansEnabled), + Handler(application.mainLooper), + cache, + diagnosticsTrackerIfEnabled, + stateProvider, + ) + Store.AMAZON -> { + try { + AmazonBilling( + application.applicationContext, + cache, + finishTransactions, + Handler(application.mainLooper), + backendHelper, + stateProvider, + diagnosticsTrackerIfEnabled, + ) + } catch (e: NoClassDefFoundError) { + errorLog(e) { "Make sure purchases-amazon is added as dependency" } + throw e + } + } + else -> { + errorLog { "Incompatible store ($store) used" } + throw IllegalArgumentException("Couldn't configure SDK. Incompatible store ($store) used") + } } } } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/CoroutinesExtensionsCommon.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/CoroutinesExtensionsCommon.kt index 288fd3c72f..db638385e6 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/CoroutinesExtensionsCommon.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/CoroutinesExtensionsCommon.kt @@ -236,3 +236,20 @@ suspend fun Purchases.awaitRestoreResult(): Result { ) } } + +/** + * This method will try to obtain the Store (Google/Amazon) country code in ISO-3166-1 alpha2. + * If there is any error, it will return null and log said error. + * Coroutine friendly version of [Purchases.getStorefrontCountryCode]. + * + * @throws [PurchasesException] with a [PurchasesError] if there's an error retrieving the country code. + * @return The Store country code in ISO-3166-1 alpha2. + */ +suspend fun Purchases.awaitStorefrontCountryCode(): String { + return suspendCoroutine { continuation -> + getStorefrontCountryCodeWith( + onSuccess = continuation::resume, + onError = { continuation.resumeWithException(PurchasesException(it)) }, + ) + } +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/EntitlementInfo.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/EntitlementInfo.kt index f107f960c5..779c7f58ef 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/EntitlementInfo.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/EntitlementInfo.kt @@ -236,7 +236,6 @@ enum class Store { */ @SerialName("paddle") PADDLE, - ; internal companion object { diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/InternalRevenueCatAPI.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/InternalRevenueCatAPI.kt index 2e642982a9..09eefd4dbe 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/InternalRevenueCatAPI.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/InternalRevenueCatAPI.kt @@ -11,5 +11,6 @@ package com.revenuecat.purchases AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY, AnnotationTarget.TYPEALIAS, + AnnotationTarget.CONSTRUCTOR, ) annotation class InternalRevenueCatAPI diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/ListenerConversionsCommon.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/ListenerConversionsCommon.kt index 40d1a90d24..83baada918 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/ListenerConversionsCommon.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/ListenerConversionsCommon.kt @@ -1,6 +1,7 @@ package com.revenuecat.purchases import com.revenuecat.purchases.interfaces.GetStoreProductsCallback +import com.revenuecat.purchases.interfaces.GetStorefrontCallback import com.revenuecat.purchases.interfaces.PurchaseCallback import com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback import com.revenuecat.purchases.interfaces.ReceiveOfferingsCallback @@ -141,3 +142,25 @@ fun Purchases.restorePurchasesWith( ) { restorePurchases(receiveCustomerInfoCallback(onSuccess, onError)) } + +/** + * This method will try to obtain the Store (Google/Amazon) country code in ISO-3166-1 alpha2. + * If there is any error, it will return null and log said error. + * @param [onSuccess] Will be called after the call has completed. + * @param [onError] Will be called after the call has completed with an error. + */ +@Suppress("unused") +fun Purchases.getStorefrontCountryCodeWith( + onError: (error: PurchasesError) -> Unit = ON_ERROR_STUB, + onSuccess: (storefrontCountryCode: String) -> Unit, +) { + getStorefrontCountryCode(object : GetStorefrontCallback { + override fun onReceived(storefrontCountryCode: String) { + onSuccess(storefrontCountryCode) + } + + override fun onError(error: PurchasesError) { + onError(error) + } + }) +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/Offering.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/Offering.kt index ad3dbf35e6..44cd3273b0 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/Offering.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/Offering.kt @@ -106,4 +106,16 @@ constructor( fun getMetadataString(key: String, default: String): String { return this.metadata[key] as? String ?: default } + + @InternalRevenueCatAPI + fun copy(presentedOfferingContext: PresentedOfferingContext): Offering { + return Offering( + identifier = this.identifier, + serverDescription = this.serverDescription, + metadata = this.metadata, + availablePackages = this.availablePackages.map { it.copy(presentedOfferingContext) }, + paywall = this.paywall, + paywallComponents = this.paywallComponents, + ) + } } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/OfferingParserFactory.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/OfferingParserFactory.kt index fbaf67e4d2..3b207cb607 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/OfferingParserFactory.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/OfferingParserFactory.kt @@ -3,25 +3,32 @@ package com.revenuecat.purchases import com.revenuecat.purchases.common.GoogleOfferingParser import com.revenuecat.purchases.common.OfferingParser import com.revenuecat.purchases.common.errorLog +import com.revenuecat.purchases.simulatedstore.SimulatedStoreOfferingParser internal object OfferingParserFactory { fun createOfferingParser( store: Store, - ) = when (store) { - Store.PLAY_STORE -> GoogleOfferingParser() - Store.AMAZON -> { - try { - Class.forName("com.revenuecat.purchases.amazon.AmazonOfferingParser") - .getConstructor().newInstance() as OfferingParser - } catch (e: ClassNotFoundException) { - errorLog(e) { "Make sure purchases-amazon is added as dependency" } - throw e - } + apiKeyValidationResult: APIKeyValidator.ValidationResult, + ): OfferingParser { + if (apiKeyValidationResult == APIKeyValidator.ValidationResult.SIMULATED_STORE) { + return SimulatedStoreOfferingParser() } - else -> { - errorLog { "Incompatible store ($store) used" } - throw IllegalArgumentException("Couldn't configure SDK. Incompatible store ($store) used") + return when (store) { + Store.PLAY_STORE -> GoogleOfferingParser() + Store.AMAZON -> { + try { + Class.forName("com.revenuecat.purchases.amazon.AmazonOfferingParser") + .getConstructor().newInstance() as OfferingParser + } catch (e: ClassNotFoundException) { + errorLog(e) { "Make sure purchases-amazon is added as dependency" } + throw e + } + } + else -> { + errorLog { "Incompatible store ($store) used" } + throw IllegalArgumentException("Couldn't configure SDK. Incompatible store ($store) used") + } } } } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/Package.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/Package.kt index 6cf9daadac..6318b4285e 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/Package.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/Package.kt @@ -41,6 +41,15 @@ data class Package( ) val offering: String get() = presentedOfferingContext.offeringIdentifier ?: "" + + internal fun copy(presentedOfferingContext: PresentedOfferingContext): Package { + return Package( + identifier = this.identifier, + packageType = this.packageType, + product = this.product.copyWithPresentedOfferingContext(presentedOfferingContext), + presentedOfferingContext = presentedOfferingContext, + ) + } } /** diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt index 03be77fe6d..a2ee22ff36 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt @@ -5,7 +5,6 @@ import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.PurchasingData import com.revenuecat.purchases.models.StoreProduct import com.revenuecat.purchases.models.SubscriptionOption -import com.revenuecat.purchases.models.TestStoreProduct data class PurchaseParams(val builder: Builder) { @@ -58,17 +57,6 @@ data class PurchaseParams(val builder: Builder) { constructor(activity: Activity, storeProduct: StoreProduct) : this(activity, storeProduct.purchasingData, storeProduct.presentedOfferingContext, storeProduct) - private fun ensureNoTestProduct(storeProduct: StoreProduct) { - if (storeProduct is TestStoreProduct) { - throw PurchasesException( - PurchasesError( - PurchasesErrorCode.ProductNotAvailableForPurchaseError, - "Cannot purchase $storeProduct", - ), - ) - } - } - constructor(activity: Activity, subscriptionOption: SubscriptionOption) : this( activity, @@ -133,10 +121,6 @@ data class PurchaseParams(val builder: Builder) { } open fun build(): PurchaseParams { - product?.let { - ensureNoTestProduct(it) - } - return PurchaseParams(this) } } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesConfiguration.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesConfiguration.kt index a73c554177..705e15ea74 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesConfiguration.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesConfiguration.kt @@ -35,6 +35,8 @@ open class PurchasesConfiguration(builder: Builder) { val dangerousSettings: DangerousSettings val verificationMode: EntitlementVerificationMode val pendingTransactionsForPrepaidPlansEnabled: Boolean + val automaticDeviceIdentifierCollectionEnabled: Boolean + val preferredUILocaleOverride: String? init { this.context = @@ -53,6 +55,9 @@ open class PurchasesConfiguration(builder: Builder) { this.dangerousSettings = builder.dangerousSettings this.showInAppMessagesAutomatically = builder.showInAppMessagesAutomatically this.pendingTransactionsForPrepaidPlansEnabled = builder.pendingTransactionsForPrepaidPlansEnabled + this.automaticDeviceIdentifierCollectionEnabled = + builder.automaticDeviceIdentifierCollectionEnabled + this.preferredUILocaleOverride = builder.preferredUILocaleOverride } internal fun copy( @@ -68,6 +73,10 @@ open class PurchasesConfiguration(builder: Builder) { .dangerousSettings(dangerousSettings) .showInAppMessagesAutomatically(showInAppMessagesAutomatically) .pendingTransactionsForPrepaidPlansEnabled(pendingTransactionsForPrepaidPlansEnabled) + .automaticDeviceIdentifierCollectionEnabled( + automaticDeviceIdentifierCollectionEnabled, + ) + .preferredUILocaleOverride(preferredUILocaleOverride) if (service != null) { builder = builder.service(service) } @@ -107,6 +116,12 @@ open class PurchasesConfiguration(builder: Builder) { @set:JvmSynthetic @get:JvmSynthetic internal var pendingTransactionsForPrepaidPlansEnabled: Boolean = false + @set:JvmSynthetic @get:JvmSynthetic + internal var automaticDeviceIdentifierCollectionEnabled: Boolean = true + + @set:JvmSynthetic @get:JvmSynthetic + internal var preferredUILocaleOverride: String? = null + /** * A unique id for identifying the user */ @@ -256,6 +271,35 @@ open class PurchasesConfiguration(builder: Builder) { this.pendingTransactionsForPrepaidPlansEnabled = pendingTransactionsForPrepaidPlansEnabled } + /** + * Enable this setting to allow the collection of identifiers when setting the identifier for an + * attribution network. For example, when calling [Purchases.setAdjustID] or [Purchases.setAppsflyerID], + * the SDK would collect the Android advertising ID, IP and device versions, if available, and send them + * to RevenueCat. This is required by some attribution networks to attribute installs and re-installs. + * + * Enabling this setting does NOT mean we will always collect the identifiers. We will only do so when + * setting an attribution network ID AND the user has not limited ad tracking on their device. + * + * Default is enabled. + */ + fun automaticDeviceIdentifierCollectionEnabled(automaticDeviceIdentifierCollectionEnabled: Boolean) = apply { + this.automaticDeviceIdentifierCollectionEnabled = automaticDeviceIdentifierCollectionEnabled + } + + /** + * Sets the preferred UI locale for RevenueCat UI components like Paywalls and Customer Center. + * This allows you to override the system locale and display the UI in a specific language. + * + * @param localeString The locale string in the format "language_COUNTRY" (e.g., "en_US", "es_ES", "de_DE"). + * Pass null to use the system default locale. + * + * **Note:** This only affects UI components from the RevenueCatUI module and requires + * importing RevenueCatUI in your project. + */ + fun preferredUILocaleOverride(localeString: String?) = apply { + this.preferredUILocaleOverride = localeString + } + /** * Creates a [PurchasesConfiguration] instance with the specified properties. */ @@ -279,6 +323,8 @@ open class PurchasesConfiguration(builder: Builder) { if (dangerousSettings != other.dangerousSettings) return false if (verificationMode != other.verificationMode) return false if (pendingTransactionsForPrepaidPlansEnabled != other.pendingTransactionsForPrepaidPlansEnabled) return false + if (automaticDeviceIdentifierCollectionEnabled != other.automaticDeviceIdentifierCollectionEnabled) return false + if (preferredUILocaleOverride != other.preferredUILocaleOverride) return false return true } @@ -293,6 +339,8 @@ open class PurchasesConfiguration(builder: Builder) { result = 31 * result + dangerousSettings.hashCode() result = 31 * result + verificationMode.hashCode() result = 31 * result + pendingTransactionsForPrepaidPlansEnabled.hashCode() + result = 31 * result + automaticDeviceIdentifierCollectionEnabled.hashCode() + result = 31 * result + (preferredUILocaleOverride?.hashCode() ?: 0) return result } } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt index 75f8ec5703..a5e2960750 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt @@ -4,18 +4,20 @@ import android.Manifest import android.app.Application import android.content.Context import android.content.pm.PackageManager -import android.preference.PreferenceManager import androidx.annotation.VisibleForTesting import androidx.core.os.UserManagerCompat +import com.revenuecat.purchases.api.BuildConfig import com.revenuecat.purchases.common.AppConfig import com.revenuecat.purchases.common.Backend import com.revenuecat.purchases.common.BackendHelper import com.revenuecat.purchases.common.BillingAbstract +import com.revenuecat.purchases.common.DefaultLocaleProvider import com.revenuecat.purchases.common.Dispatcher import com.revenuecat.purchases.common.FileHelper import com.revenuecat.purchases.common.HTTPClient import com.revenuecat.purchases.common.LogIntent import com.revenuecat.purchases.common.PlatformInfo +import com.revenuecat.purchases.common.SharedPreferencesManager import com.revenuecat.purchases.common.caching.DeviceCache import com.revenuecat.purchases.common.debugLog import com.revenuecat.purchases.common.diagnostics.DiagnosticsFileHelper @@ -49,6 +51,7 @@ import com.revenuecat.purchases.utils.CoilImageDownloader import com.revenuecat.purchases.utils.IsDebugBuildProvider import com.revenuecat.purchases.utils.OfferingImagePreDownloader import com.revenuecat.purchases.utils.isAndroidNOrNewer +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencyManager import java.net.URL import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -57,6 +60,7 @@ import java.util.concurrent.ThreadFactory internal class PurchasesFactory( private val isDebugBuild: IsDebugBuildProvider, private val apiKeyValidator: APIKeyValidator = APIKeyValidator(), + private val isSimulatedStoreEnabled: () -> Boolean = { BuildConfig.ENABLE_SIMULATED_STORE }, ) { @Suppress("LongMethod", "LongParameterList", "CyclomaticComplexMethod") @@ -69,9 +73,17 @@ internal class PurchasesFactory( forceSigningError: Boolean = false, runningIntegrationTests: Boolean = false, ): Purchases { - validateConfiguration(configuration) + val apiKeyValidationResult = validateConfiguration(configuration) with(configuration) { + val finalStore = if ( + apiKeyValidationResult == APIKeyValidator.ValidationResult.SIMULATED_STORE && isSimulatedStoreEnabled() + ) { + Store.UNKNOWN_STORE // We should add a new store when we fully support the simulated store. + } else { + store + } + val application = context.getApplication() val appConfig = AppConfig( context, @@ -79,8 +91,9 @@ internal class PurchasesFactory( showInAppMessagesAutomatically, platformInfo, proxyURL, - store, + finalStore, isDebugBuild(), + apiKeyValidationResult, dangerousSettings, runningIntegrationTests, forceServerErrors, @@ -102,7 +115,7 @@ internal class PurchasesFactory( } val prefs = try { - PreferenceManager.getDefaultSharedPreferences(contextForStorage) + SharedPreferencesManager(contextForStorage).getSharedPreferences() } catch (e: IllegalStateException) { @Suppress("MaxLineLength") if (!UserManagerCompat.isUserUnlocked(context)) { @@ -161,7 +174,15 @@ internal class PurchasesFactory( val cache = DeviceCache(prefs, apiKey) - val httpClient = HTTPClient(appConfig, eTagManager, diagnosticsTracker, signingManager, cache) + val localeProvider = DefaultLocaleProvider() + val httpClient = HTTPClient( + appConfig, + eTagManager, + diagnosticsTracker, + signingManager, + cache, + localeProvider = localeProvider, + ) val backendHelper = BackendHelper(apiKey, backendDispatcher, appConfig, httpClient) val backend = Backend( appConfig, @@ -175,7 +196,7 @@ internal class PurchasesFactory( // Override used for integration tests. val billing: BillingAbstract = overrideBillingAbstract ?: BillingFactory.createBilling( - store, + finalStore, application, backendHelper, cache, @@ -183,6 +204,8 @@ internal class PurchasesFactory( diagnosticsTracker, purchasesStateProvider, pendingTransactionsForPrepaidPlansEnabled, + backend, + apiKeyValidationResult, ) val subscriberAttributesPoster = SubscriberAttributesPoster(backendHelper) @@ -195,6 +218,7 @@ internal class PurchasesFactory( subscriberAttributesCache, subscriberAttributesPoster, attributionFetcher, + automaticDeviceIdentifierCollectionEnabled, ) val offlineCustomerInfoCalculator = OfflineCustomerInfoCalculator( @@ -211,7 +235,10 @@ internal class PurchasesFactory( diagnosticsTracker, ) - val offeringsCache = OfferingsCache(cache) + val offeringsCache = OfferingsCache( + deviceCache = cache, + localeProvider = localeProvider, + ) val identityManager = IdentityManager( cache, @@ -266,7 +293,7 @@ internal class PurchasesFactory( postPendingTransactionsHelper, diagnosticsTracker, ) - val offeringParser = OfferingParserFactory.createOfferingParser(store) + val offeringParser = OfferingParserFactory.createOfferingParser(finalStore, apiKeyValidationResult) var diagnosticsSynchronizer: DiagnosticsSynchronizer? = null @Suppress("ComplexCondition") @@ -319,6 +346,13 @@ internal class PurchasesFactory( ConfigureStrings.VERIFICATION_MODE_SELECTED.format(configuration.verificationMode.name) } + val virtualCurrencyManager = VirtualCurrencyManager( + identityManager = identityManager, + deviceCache = cache, + backend = backend, + appConfig = appConfig, + ) + val purchasesOrchestrator = PurchasesOrchestrator( application, appUserID, @@ -344,6 +378,8 @@ internal class PurchasesFactory( dispatcher = dispatcher, initialConfiguration = configuration, fontLoader = fontLoader, + localeProvider = localeProvider, + virtualCurrencyManager = virtualCurrencyManager, ) return Purchases(purchasesOrchestrator) @@ -380,7 +416,7 @@ internal class PurchasesFactory( } @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - fun validateConfiguration(configuration: PurchasesConfiguration) { + fun validateConfiguration(configuration: PurchasesConfiguration): APIKeyValidator.ValidationResult { with(configuration) { require(context.hasPermission(Manifest.permission.INTERNET)) { "Purchases requires INTERNET permission." @@ -388,9 +424,23 @@ internal class PurchasesFactory( require(apiKey.isNotBlank()) { "API key must be set. Get this from the RevenueCat web app" } + val apiKeyValidationResult = apiKeyValidator.validateAndLog(apiKey, store) + + if (!isDebugBuild() && + apiKeyValidationResult == APIKeyValidator.ValidationResult.SIMULATED_STORE && isSimulatedStoreEnabled() + ) { + throw PurchasesException( + PurchasesError( + code = PurchasesErrorCode.ConfigurationError, + underlyingErrorMessage = "Please configure the Play Store/Amazon store app on the " + + "RevenueCat dashboard and use its corresponding API key before releasing.", + ), + ) + } + require(context.applicationContext is Application) { "Needs an application context." } - apiKeyValidator.validateAndLog(apiKey, store) + return apiKeyValidationResult } } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt index d6258f3ad9..3556a7588c 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt @@ -2,6 +2,7 @@ package com.revenuecat.purchases import android.app.Activity import android.app.Application +import android.app.backup.BackupManager import android.content.Context import android.os.Handler import android.os.Looper @@ -14,6 +15,8 @@ import coil.disk.DiskCache import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingResult +import com.revenuecat.purchases.api.BuildConfig +import com.revenuecat.purchases.blockstore.BlockstoreHelper import com.revenuecat.purchases.common.AppConfig import com.revenuecat.purchases.common.Backend import com.revenuecat.purchases.common.BillingAbstract @@ -21,6 +24,7 @@ import com.revenuecat.purchases.common.Config import com.revenuecat.purchases.common.Constants import com.revenuecat.purchases.common.DateProvider import com.revenuecat.purchases.common.DefaultDateProvider +import com.revenuecat.purchases.common.DefaultLocaleProvider import com.revenuecat.purchases.common.Delay import com.revenuecat.purchases.common.Dispatcher import com.revenuecat.purchases.common.LogIntent @@ -42,6 +46,7 @@ import com.revenuecat.purchases.common.offerings.OfferingsManager import com.revenuecat.purchases.common.offlineentitlements.OfflineEntitlementsManager import com.revenuecat.purchases.common.sha1 import com.revenuecat.purchases.common.subscriberattributes.SubscriberAttributeKey +import com.revenuecat.purchases.common.verboseLog import com.revenuecat.purchases.common.warnLog import com.revenuecat.purchases.customercenter.CustomerCenterListener import com.revenuecat.purchases.deeplinks.WebPurchaseRedemptionHelper @@ -52,6 +57,7 @@ import com.revenuecat.purchases.interfaces.GetAmazonLWAConsentStatusCallback import com.revenuecat.purchases.interfaces.GetCustomerCenterConfigCallback import com.revenuecat.purchases.interfaces.GetStoreProductsCallback import com.revenuecat.purchases.interfaces.GetStorefrontCallback +import com.revenuecat.purchases.interfaces.GetVirtualCurrenciesCallback import com.revenuecat.purchases.interfaces.LogInCallback import com.revenuecat.purchases.interfaces.ProductChangeCallback import com.revenuecat.purchases.interfaces.PurchaseCallback @@ -84,6 +90,8 @@ import com.revenuecat.purchases.subscriberattributes.SubscriberAttributesManager import com.revenuecat.purchases.utils.CustomActivityLifecycleHandler import com.revenuecat.purchases.utils.RateLimiter import com.revenuecat.purchases.utils.isAndroidNOrNewer +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencyManager import java.net.URL import java.util.Collections import java.util.Date @@ -121,6 +129,7 @@ internal class PurchasesOrchestrator( private val dispatcher: Dispatcher, private val initialConfiguration: PurchasesConfiguration, private val fontLoader: FontLoader, + private val localeProvider: DefaultLocaleProvider, private val webPurchaseRedemptionHelper: WebPurchaseRedemptionHelper = WebPurchaseRedemptionHelper( backend, @@ -128,7 +137,11 @@ internal class PurchasesOrchestrator( offlineEntitlementsManager, customerInfoUpdateHandler, ), + private val virtualCurrencyManager: VirtualCurrencyManager, val processLifecycleOwnerProvider: () -> LifecycleOwner = { ProcessLifecycleOwner.get() }, + private val isSimulatedStoreEnabled: () -> Boolean = { BuildConfig.ENABLE_SIMULATED_STORE }, + private val blockstoreHelper: BlockstoreHelper = BlockstoreHelper(application, identityManager), + private val backupManager: BackupManager = BackupManager(application), ) : LifecycleDelegate, CustomActivityLifecycleHandler { internal var state: PurchasesState @@ -186,10 +199,22 @@ internal class PurchasesOrchestrator( @SuppressWarnings("MagicNumber") private val lastSyncAttributesAndOfferingsRateLimiter = RateLimiter(5, 60.seconds) + @SuppressWarnings("MagicNumber") + private val preferredLocaleOverrideRateLimiter = RateLimiter(2, 60.seconds) + var storefrontCountryCode: String? = null private set + @Volatile + private var _preferredUILocaleOverride: String? = initialConfiguration.preferredUILocaleOverride + + val preferredUILocaleOverride: String? + get() = _preferredUILocaleOverride + init { + // Initialize locale provider with the initial preferred locale override + localeProvider.setPreferredLocaleOverride(_preferredUILocaleOverride) + identityManager.configure(backingFieldAppUserID) billing.stateListener = object : BillingAbstract.StateListener { @@ -252,6 +277,15 @@ internal class PurchasesOrchestrator( fetchPolicy = CacheFetchPolicy.FETCH_CURRENT, appInBackground = false, allowSharingPlayStoreAccount = allowSharingPlayStoreAccount, + callback = object : ReceiveCustomerInfoCallback { + override fun onReceived(customerInfo: CustomerInfo) { + blockstoreHelper.storeUserIdIfNeeded(customerInfo) + } + + override fun onError(error: PurchasesError) { + // no-op + } + }, ) } offeringsManager.onAppForeground(identityManager.currentAppUserID) @@ -330,6 +364,21 @@ internal class PurchasesOrchestrator( fun syncPurchases( listener: SyncPurchasesCallback? = null, ) { + if (isSimulatedStoreEnabled() && + appConfig.apiKeyValidationResult == APIKeyValidator.ValidationResult.SIMULATED_STORE + ) { + log(LogIntent.DEBUG) { RestoreStrings.SYNC_PURCHASES_SIMULATED_STORE } + getCustomerInfo(object : ReceiveCustomerInfoCallback { + override fun onReceived(customerInfo: CustomerInfo) { + listener?.onSuccess(customerInfo) + } + + override fun onError(error: PurchasesError) { + listener?.onError(error) + } + }) + return + } syncPurchasesHelper.syncPurchases( isRestore = this.allowSharingPlayStoreAccount, appInBackground = this.state.appInBackground, @@ -403,6 +452,40 @@ internal class PurchasesOrchestrator( ) } + /** + * Override the preferred UI locale for RevenueCat UI components like Paywalls and Customer Center. + * This allows you to display the UI in a specific language, different from the system locale. + * + * @param localeString The locale string in the format "language_COUNTRY" (e.g., "en_US", "es_ES", "de_DE"). + * Pass null to revert to using the system default locale. + * + * **Note:** This only affects UI components from the RevenueCatUI module and requires + * importing RevenueCatUI in your project. The locale override will take effect the next time + * a paywall or customer center is displayed. + */ + fun overridePreferredUILocale(localeString: String?): Boolean { + val previousLocale = _preferredUILocaleOverride + + if (previousLocale == localeString) { + debugLog { "Locale unchanged, no fresh fetch needed" } + return false + } + + synchronized(this) { + _preferredUILocaleOverride = localeString + localeProvider.setPreferredLocaleOverride(localeString) + } + + debugLog { "Locale changed, attempting to fetch fresh offerings" } + return fetchOfferingsWithRateLimit { offerings, error -> + if (offerings != null) { + debugLog { "Fresh offerings fetch completed successfully" } + } else { + debugLog { "Fresh offerings fetch failed: ${error?.message}" } + } + } + } + fun getOfferings( listener: ReceiveOfferingsCallback, fetchCurrent: Boolean = false, @@ -465,6 +548,7 @@ internal class PurchasesOrchestrator( } } + @Suppress("LongMethod") fun restorePurchases( callback: ReceiveCustomerInfoCallback, ) { @@ -472,6 +556,13 @@ internal class PurchasesOrchestrator( if (!allowSharingPlayStoreAccount) { log(LogIntent.WARNING) { RestoreStrings.SHARING_ACC_RESTORE_FALSE } } + if (isSimulatedStoreEnabled() && + appConfig.apiKeyValidationResult == APIKeyValidator.ValidationResult.SIMULATED_STORE + ) { + log(LogIntent.DEBUG) { RestoreStrings.RESTORE_PURCHASES_SIMULATED_STORE } + getCustomerInfo(callback) + return + } val startTime = dateProvider.now diagnosticsTrackerIfEnabled?.trackRestorePurchasesStarted() @@ -502,43 +593,46 @@ internal class PurchasesOrchestrator( } } - billing.queryAllPurchases( - appUserID, - onReceivePurchaseHistory = { allPurchases -> - if (allPurchases.isEmpty()) { - getCustomerInfo(callbackWithTracking) - } else { - allPurchases.sortedBy { it.purchaseTime }.let { sortedByTime -> - sortedByTime.forEach { purchase -> - postReceiptHelper.postTransactionAndConsumeIfNeeded( - purchase = purchase, - storeProduct = null, - isRestore = true, - appUserID = appUserID, - initiationSource = PostReceiptInitiationSource.RESTORE, - onSuccess = { _, info -> - log(LogIntent.DEBUG) { RestoreStrings.PURCHASE_RESTORED.format(purchase) } - if (sortedByTime.last() == purchase) { - dispatch { callbackWithTracking.onReceived(info) } - } - }, - onError = { _, error -> - log(LogIntent.RC_ERROR) { - RestoreStrings.RESTORING_PURCHASE_ERROR.format(purchase, error) - } - if (sortedByTime.last() == purchase) { - dispatch { callbackWithTracking.onError(error) } - } - }, - ) + blockstoreHelper.aliasCurrentAndStoredUserIdsIfNeeded { + billing.queryAllPurchases( + appUserID, + onReceivePurchaseHistory = { allPurchases -> + if (allPurchases.isEmpty()) { + log(LogIntent.DEBUG) { RestoreStrings.RESTORE_PURCHASES_NO_PURCHASES_FOUND } + getCustomerInfo(callbackWithTracking) + } else { + allPurchases.sortedBy { it.purchaseTime }.let { sortedByTime -> + sortedByTime.forEach { purchase -> + postReceiptHelper.postTransactionAndConsumeIfNeeded( + purchase = purchase, + storeProduct = null, + isRestore = true, + appUserID = appUserID, + initiationSource = PostReceiptInitiationSource.RESTORE, + onSuccess = { _, info -> + log(LogIntent.DEBUG) { RestoreStrings.PURCHASE_RESTORED.format(purchase) } + if (sortedByTime.last() == purchase) { + dispatch { callbackWithTracking.onReceived(info) } + } + }, + onError = { _, error -> + log(LogIntent.RC_ERROR) { + RestoreStrings.RESTORING_PURCHASE_ERROR.format(purchase, error) + } + if (sortedByTime.last() == purchase) { + dispatch { callbackWithTracking.onError(error) } + } + }, + ) + } } } - } - }, - onReceivePurchaseHistoryError = { error -> - dispatch { callbackWithTracking.onError(error) } - }, - ) + }, + onReceivePurchaseHistoryError = { error -> + dispatch { callbackWithTracking.onError(error) } + }, + ) + } } fun logIn( @@ -546,19 +640,22 @@ internal class PurchasesOrchestrator( callback: LogInCallback? = null, ) { identityManager.currentAppUserID.takeUnless { it == newAppUserID }?.let { - identityManager.logIn( - newAppUserID, - onSuccess = { customerInfo, created -> - dispatch { - callback?.onReceived(customerInfo, created) - customerInfoUpdateHandler.notifyListeners(customerInfo) - } - offeringsManager.fetchAndCacheOfferings(newAppUserID, state.appInBackground) - }, - onError = { error -> - dispatch { callback?.onError(error) } - }, - ) + blockstoreHelper.clearUserIdBackupIfNeeded { + identityManager.logIn( + newAppUserID, + onSuccess = { customerInfo, created -> + dispatch { + callback?.onReceived(customerInfo, created) + customerInfoUpdateHandler.notifyListeners(customerInfo) + } + offeringsManager.fetchAndCacheOfferings(newAppUserID, state.appInBackground) + backupManager.dataChanged() + }, + onError = { error -> + dispatch { callback?.onError(error) } + }, + ) + } } ?: customerInfoHelper.retrieveCustomerInfo( identityManager.currentAppUserID, @@ -585,6 +682,7 @@ internal class PurchasesOrchestrator( state = state.copy(purchaseCallbacksByProductId = Collections.emptyMap()) } updateAllCaches(identityManager.currentAppUserID, callback) + backupManager.dataChanged() } } } @@ -854,6 +952,39 @@ internal class PurchasesOrchestrator( } // endregion + + /** + * Fetches fresh offerings with rate limiting to prevent excessive network requests. + * + * @param callback Callback to handle the result + * @return true if fresh fetch was triggered, false if rate limited + */ + private fun fetchOfferingsWithRateLimit(callback: (Offerings?, PurchasesError?) -> Unit): Boolean { + return if (preferredLocaleOverrideRateLimiter.shouldProceed()) { + verboseLog { "Fetching fresh offerings" } + getOfferings( + object : ReceiveOfferingsCallback { + override fun onReceived(offerings: Offerings) { + callback(offerings, null) + } + + override fun onError(error: PurchasesError) { + callback(null, error) + } + }, + fetchCurrent = true, + ) + true + } else { + debugLog { + "Fresh offerings fetch rate limit reached: ${preferredLocaleOverrideRateLimiter.maxCallsInPeriod} " + + "per ${preferredLocaleOverrideRateLimiter.periodSeconds.inWholeSeconds} seconds. " + + "Fetch not triggered." + } + false + } + } + // region Campaign parameters fun setMediaSource(mediaSource: String?) { @@ -911,6 +1042,21 @@ internal class PurchasesOrchestrator( } //endregion + + // region Virtual Currencies + fun getVirtualCurrencies( + callback: GetVirtualCurrenciesCallback, + ) { + virtualCurrencyManager.virtualCurrencies(callback = callback) + } + + fun invalidateVirtualCurrenciesCache() { + virtualCurrencyManager.invalidateVirtualCurrenciesCache() + } + + val cachedVirtualCurrencies: VirtualCurrencies? + get() = virtualCurrencyManager.cachedVirtualCurrencies() + //endregion // region Custom entitlements computation @@ -1083,9 +1229,15 @@ internal class PurchasesOrchestrator( private fun getPurchaseCompletedCallbacks(): Pair { val onSuccess: SuccessfulPurchaseCallback = { storeTransaction, info -> - getPurchaseCallback(storeTransaction.productIds[0])?.let { purchaseCallback -> - dispatch { - purchaseCallback.onCompleted(storeTransaction, info) + // This lets the backup manager know a change in data happened that would be good to backup. + // In this case, we want to make sure that if there is a purchase, we schedule a backup. + backupManager.dataChanged() + blockstoreHelper.aliasCurrentAndStoredUserIdsIfNeeded { + blockstoreHelper.storeUserIdIfNeeded(info) + getPurchaseCallback(storeTransaction.productIds[0])?.let { purchaseCallback -> + dispatch { + purchaseCallback.onCompleted(storeTransaction, info) + } } } } @@ -1121,7 +1273,7 @@ internal class PurchasesOrchestrator( } } - fun startPurchase( + private fun startPurchase( activity: Activity, purchasingData: PurchasingData, presentedOfferingContext: PresentedOfferingContext?, @@ -1173,7 +1325,7 @@ internal class PurchasesOrchestrator( ) } - fun startProductChange( + private fun startProductChange( activity: Activity, purchasingData: PurchasingData, presentedOfferingContext: PresentedOfferingContext?, @@ -1244,62 +1396,6 @@ internal class PurchasesOrchestrator( } } - fun startDeprecatedProductChange( - activity: Activity, - purchasingData: PurchasingData, - presentedOfferingContext: PresentedOfferingContext?, - oldProductId: String, - googleReplacementMode: GoogleReplacementMode?, - listener: ProductChangeCallback, - ) { - if (purchasingData.productType != ProductType.SUBS) { - getAndClearProductChangeCallback() - listener.dispatch( - PurchasesError( - PurchasesErrorCode.PurchaseNotAllowedError, - PurchaseStrings.UPGRADING_INVALID_TYPE, - ).also { errorLog(it) }, - ) - return - } - - log(LogIntent.PURCHASE) { - PurchaseStrings.PRODUCT_CHANGE_STARTED.format( - " $purchasingData ${ - presentedOfferingContext?.offeringIdentifier?.let { - PurchaseStrings.OFFERING + "$it" - } - } oldProductId: $oldProductId googleReplacementMode $googleReplacementMode", - - ) - } - var userPurchasing: String? = null // Avoids race condition for userid being modified before purchase is made - synchronized(this@PurchasesOrchestrator) { - if (!appConfig.finishTransactions) { - log(LogIntent.WARNING) { PurchaseStrings.PURCHASE_FINISH_TRANSACTION_FALSE } - } - if (state.deprecatedProductChangeCallback == null) { - state = state.copy(deprecatedProductChangeCallback = listener) - userPurchasing = identityManager.currentAppUserID - } - } - userPurchasing?.let { appUserID -> - replaceOldPurchaseWithNewProduct( - purchasingData, - oldProductId, - googleReplacementMode, - activity, - appUserID, - presentedOfferingContext, - null, - listener, - ) - } ?: run { - getAndClearProductChangeCallback() - listener.dispatch(PurchasesError(PurchasesErrorCode.OperationAlreadyInProgressError).also { errorLog(it) }) - } - } - private fun replaceOldPurchaseWithNewProduct( purchasingData: PurchasingData, oldProductId: String, diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/backup/RevenueCatBackupAgent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/backup/RevenueCatBackupAgent.kt new file mode 100644 index 0000000000..e0892aa4b9 --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/backup/RevenueCatBackupAgent.kt @@ -0,0 +1,51 @@ +package com.revenuecat.purchases.backup + +import android.app.backup.BackupAgentHelper +import android.app.backup.BackupDataInput +import android.app.backup.BackupDataOutput +import android.app.backup.SharedPreferencesBackupHelper +import android.os.ParcelFileDescriptor +import com.revenuecat.purchases.common.debugLog + +private const val REVENUECAT_PREFS_BACKUP_KEY = "revenuecat_prefs_backup" + +/** + * A BackupAgent that proactively backs up RevenueCat's SharedPreferences. You may use this by adding the following + * to your AndroidManifest.xml within the `` tag: + * ``` + * android:backupAgent="com.revenuecat.purchases.backup.RevenueCatBackupAgent" + * ``` + * This will backup the SharedPreferences file used by the RevenueCat SDK, allowing it to keep the same user as + * was previously used in the same or different device with the same Google account, removing the need for users to + * restore purchases. + * + * Some important notes: + * - This backup may not work on all devices, as it's ultimately controlled by the Android system and the user settings. + * See https://developer.android.com/identity/data/keyvaluebackup for more details on how key-value backup works + * and how to test it. + * - Setting the backup agent in your AndroidManifest would disable auto backup for your app, if it was enabled. + * If you want to use auto backup to also backup your app's data or have your own Backup Agent to backup some of your + * files, please make sure you add the SharedPreferences file `com_revenuecat_purchases_preferences` to your auto backup + * configuration. See https://developer.android.com/identity/data/autobackup. + */ +class RevenueCatBackupAgent : BackupAgentHelper() { + companion object { + const val REVENUECAT_PREFS_FILE_NAME = "com_revenuecat_purchases_preferences" + } + + override fun onCreate() { + SharedPreferencesBackupHelper(this, REVENUECAT_PREFS_FILE_NAME).also { + addHelper(REVENUECAT_PREFS_BACKUP_KEY, it) + } + } + + override fun onBackup(oldState: ParcelFileDescriptor?, data: BackupDataOutput?, newState: ParcelFileDescriptor?) { + debugLog { "RevenueCatBackupAgent: Initiating backup" } + super.onBackup(oldState, data, newState) + } + + override fun onRestore(data: BackupDataInput?, appVersionCode: Long, newState: ParcelFileDescriptor?) { + debugLog { "RevenueCatBackupAgent: Initiating restoration" } + super.onRestore(data, appVersionCode, newState) + } +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/blockstore/BlockstoreHelper.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/blockstore/BlockstoreHelper.kt new file mode 100644 index 0000000000..f7ecb1323b --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/blockstore/BlockstoreHelper.kt @@ -0,0 +1,160 @@ +package com.revenuecat.purchases.blockstore + +import android.content.Context +import com.google.android.gms.auth.blockstore.Blockstore +import com.google.android.gms.auth.blockstore.BlockstoreClient +import com.google.android.gms.auth.blockstore.DeleteBytesRequest +import com.google.android.gms.auth.blockstore.RetrieveBytesRequest +import com.google.android.gms.auth.blockstore.RetrieveBytesResponse.BlockstoreData +import com.google.android.gms.auth.blockstore.StoreBytesData +import com.revenuecat.purchases.CustomerInfo +import com.revenuecat.purchases.PurchasesException +import com.revenuecat.purchases.common.debugLog +import com.revenuecat.purchases.common.errorLog +import com.revenuecat.purchases.identity.IdentityManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +internal class BlockstoreHelper +@OptIn(ExperimentalCoroutinesApi::class) +constructor( + applicationContext: Context, + private val identityManager: IdentityManager, + private val blockstoreClient: BlockstoreClient = Blockstore.getClient(applicationContext), + private val ioScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO.limitedParallelism(1)), + private val mainScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main), +) { + + private companion object { + const val BLOCKSTORE_USER_ID_KEY = "com.revenuecat.purchases.app_user_id" + const val BLOCKSTORE_MAX_ENTRIES = 16 + } + + fun storeUserIdIfNeeded(customerInfo: CustomerInfo) { + val currentUserId = identityManager.currentAppUserID + if ( + !IdentityManager.isUserIDAnonymous(currentUserId) || + customerInfo.allPurchasedProductIds.isEmpty() + ) { + return + } + ioScope.launch { + val blockstoreData = try { + getBlockstoreData() + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + errorLog(e) { "Failed to retrieve Block store data. Will not store userId. Error: ${e.message}" } + return@launch + } + try { + storeUserIdIfNeeded(blockstoreData, currentUserId) + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + errorLog(e) { "Failed to store user Id in Block store: ${e.message}" } + } + } + } + + fun aliasCurrentAndStoredUserIdsIfNeeded(callback: () -> Unit) { + fun callCompletion() { + mainScope.launch { + callback() + } + } + val currentUserId = identityManager.currentAppUserID + if (!IdentityManager.isUserIDAnonymous(currentUserId)) { + callCompletion() + return + } + ioScope.launch { + val blockstoreData = try { + getBlockstoreData() + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + errorLog(e) { "Failed to retrieve Block store data. Will not recover userId. Error: ${e.message}" } + callCompletion() + return@launch + } + val blockstoreUserId = blockstoreData[BLOCKSTORE_USER_ID_KEY]?.bytes?.let { String(it) } + if (blockstoreUserId == null || blockstoreUserId == currentUserId) { + callCompletion() + return@launch + } + try { + debugLog { "Aliasing Blockstore user ID: $blockstoreUserId with current UserID" } + identityManager.aliasCurrentUserIdTo( + oldAppUserID = blockstoreUserId, + ) + } catch (e: PurchasesException) { + errorLog(e) { + "Failed to alias Block store user ID: ${e.message}. " + + "Underlying error: ${e.underlyingErrorMessage}. " + + "Any purchases on previous anonymous user will not be recovered." + } + callCompletion() + return@launch + } + callCompletion() + } + } + + fun clearUserIdBackupIfNeeded(callback: () -> Unit) { + val request = DeleteBytesRequest.Builder() + .setKeys(listOf(BLOCKSTORE_USER_ID_KEY)) + .build() + ioScope.launch { + blockstoreClient.deleteBytes(request) + .addOnSuccessListener { + debugLog { "Block store cached UserID cleared if any" } + callback() + } + .addOnFailureListener { + errorLog(it) { "Tried to clear Block store cached UserID but failed: ${it.message}" } + callback() + } + } + } + + private suspend fun getBlockstoreData(): Map { + val retrieveRequest = RetrieveBytesRequest.Builder() + .setRetrieveAll(true) + .build() + return suspendCoroutine { cont -> + blockstoreClient.retrieveBytes(retrieveRequest) + .addOnSuccessListener { cont.resume(it.blockstoreDataMap) } + .addOnFailureListener { cont.resumeWithException(it) } + } + } + + @Suppress("ReturnCount") + private suspend fun storeUserIdIfNeeded( + blockstoreDataMap: Map, + userId: String, + ) { + if (blockstoreDataMap[BLOCKSTORE_USER_ID_KEY] != null) { + debugLog { "Block store: Not storing user id since there is one already present." } + return + } + if (blockstoreDataMap.size >= BLOCKSTORE_MAX_ENTRIES) { + debugLog { "Block store: Not storing user id since block store is already full." } + return + } + debugLog { "Block store: Storing UserID: $userId in Block store." } + val storeRequest = StoreBytesData.Builder() + .setBytes(userId.toByteArray()) + .setKey(BLOCKSTORE_USER_ID_KEY) + .setShouldBackupToCloud(true) + .build() + suspendCoroutine { cont -> + blockstoreClient.storeBytes(storeRequest) + .addOnSuccessListener { + debugLog { "Block store: User ID: $userId stored in Block store." } + cont.resume(Unit) + } + .addOnFailureListener { cont.resumeWithException(it) } + } + } +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/AppConfig.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/AppConfig.kt index 7059d2fbc5..e6fb33b87d 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/AppConfig.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/AppConfig.kt @@ -1,6 +1,7 @@ package com.revenuecat.purchases.common import android.content.Context +import com.revenuecat.purchases.APIKeyValidator import com.revenuecat.purchases.DangerousSettings import com.revenuecat.purchases.PurchasesAreCompletedBy import com.revenuecat.purchases.Store @@ -18,6 +19,7 @@ internal class AppConfig( proxyURL: URL?, val store: Store, val isDebugBuild: Boolean, + val apiKeyValidationResult: APIKeyValidator.ValidationResult, val dangerousSettings: DangerousSettings = DangerousSettings(autoSyncPurchases = true), // Should only be used for tests private val runningTests: Boolean = false, @@ -81,6 +83,7 @@ internal class AppConfig( if (baseURL != other.baseURL) return false if (showInAppMessagesAutomatically != other.showInAppMessagesAutomatically) return false if (isAppBackgrounded != other.isAppBackgrounded) return false + if (apiKeyValidationResult != other.apiKeyValidationResult) return false return true } @@ -99,6 +102,7 @@ internal class AppConfig( result = 31 * result + baseURL.hashCode() result = 31 * result + showInAppMessagesAutomatically.hashCode() result = 31 * result + isAppBackgrounded.hashCode() + result = 31 * result + apiKeyValidationResult.hashCode() return result } @@ -113,6 +117,7 @@ internal class AppConfig( "packageName='$packageName', " + "finishTransactions=$finishTransactions, " + "showInAppMessagesAutomatically=$showInAppMessagesAutomatically, " + + "apiKeyValidationResult=$apiKeyValidationResult, " + "baseURL=$baseURL)" } } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt index a2037faf79..c5e0ce47a7 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt @@ -16,6 +16,7 @@ import com.revenuecat.purchases.common.networking.Endpoint import com.revenuecat.purchases.common.networking.HTTPResult import com.revenuecat.purchases.common.networking.PostReceiptResponse import com.revenuecat.purchases.common.networking.RCHTTPStatusCodes +import com.revenuecat.purchases.common.networking.WebBillingProductsResponse import com.revenuecat.purchases.common.networking.buildPostReceiptResponse import com.revenuecat.purchases.common.offlineentitlements.ProductEntitlementMapping import com.revenuecat.purchases.common.verification.SignatureVerificationMode @@ -28,6 +29,8 @@ import com.revenuecat.purchases.paywalls.events.PaywallPostReceiptData import com.revenuecat.purchases.strings.NetworkStrings import com.revenuecat.purchases.utils.asMap import com.revenuecat.purchases.utils.filterNotNullValues +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrenciesFactory import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import kotlinx.serialization.json.encodeToJsonElement @@ -63,6 +66,9 @@ internal typealias PostReceiptDataErrorCallback = ( /** @suppress */ internal typealias IdentifyCallback = Pair<(CustomerInfo, Boolean) -> Unit, (PurchasesError) -> Unit> +/** @suppress */ +internal typealias AliasCallback = Pair<() -> Unit, (PurchasesError) -> Unit> + /** @suppress */ internal typealias DiagnosticsCallback = Pair<(JSONObject) -> Unit, (PurchasesError, Boolean) -> Unit> @@ -77,6 +83,10 @@ internal typealias CustomerCenterCallback = Pair<(CustomerCenterConfigData) -> U internal typealias RedeemWebPurchaseCallback = (RedeemWebPurchaseListener.Result) -> Unit +internal typealias VirtualCurrenciesCallback = Pair<(VirtualCurrencies) -> Unit, (PurchasesError) -> Unit> + +internal typealias WebBillingProductsCallback = Pair<(WebBillingProductsResponse) -> Unit, (PurchasesError) -> Unit> + internal enum class PostReceiptErrorHandlingBehavior { SHOULD_BE_MARKED_SYNCED, SHOULD_USE_OFFLINE_ENTITLEMENTS_AND_NOT_CONSUME, @@ -118,6 +128,9 @@ internal class Backend( @get:Synchronized @set:Synchronized @Volatile var identifyCallbacks = mutableMapOf>() + @get:Synchronized @set:Synchronized + @Volatile var aliasCallbacks = mutableMapOf>() + @get:Synchronized @set:Synchronized @Volatile var diagnosticsCallbacks = mutableMapOf>() @@ -133,6 +146,13 @@ internal class Backend( @get:Synchronized @set:Synchronized @Volatile var redeemWebPurchaseCallbacks = mutableMapOf>() + @get:Synchronized @set:Synchronized + @Volatile var virtualCurrenciesCallbacks = + mutableMapOf>() + + @get:Synchronized @set:Synchronized + @Volatile var webBillingProductsCallbacks = mutableMapOf>() + fun close() { this.dispatcher.close() } @@ -442,6 +462,57 @@ internal class Backend( } } + fun aliasUsers( + oldAppUserID: String, + newAppUserID: String, + onSuccessHandler: () -> Unit, + onErrorHandler: (PurchasesError) -> Unit, + ) { + val cacheKey = listOfNotNull( + oldAppUserID, + newAppUserID, + ) + val call = object : Dispatcher.AsyncCall() { + override fun call(): HTTPResult { + val body = mapOf( + APP_USER_ID to oldAppUserID, + NEW_APP_USER_ID to newAppUserID, + ) + return httpClient.performRequest( + appConfig.baseURL, + Endpoint.AliasUsers(oldAppUserID), + body, + postFieldsToSign = null, + backendHelper.authenticationHeaders, + fallbackBaseURLs = appConfig.fallbackBaseURLs, + ) + } + + override fun onError(error: PurchasesError) { + synchronized(this@Backend) { + aliasCallbacks.remove(cacheKey) + }?.forEach { (_, onErrorHandler) -> + onErrorHandler(error) + } + } + + override fun onCompletion(result: HTTPResult) { + if (result.isSuccessful()) { + synchronized(this@Backend) { + aliasCallbacks.remove(cacheKey) + }?.forEach { (onSuccessHandler, _) -> + onSuccessHandler() + } + } else { + onError(result.toPurchasesError().also { errorLog(it) }) + } + } + } + synchronized(this@Backend) { + aliasCallbacks.addCallback(call, dispatcher, cacheKey, onSuccessHandler to onErrorHandler) + } + } + fun postDiagnostics( diagnosticsList: List, onSuccessHandler: (JSONObject) -> Unit, @@ -742,6 +813,132 @@ internal class Backend( } } + fun getVirtualCurrencies( + appUserID: String, + appInBackground: Boolean, + onSuccess: (VirtualCurrencies) -> Unit, + onError: (PurchasesError) -> Unit, + ) { + val endpoint = Endpoint.GetVirtualCurrencies(userId = appUserID) + val path = endpoint.getPath() + val cacheKey = BackgroundAwareCallbackCacheKey(listOf(path), appInBackground) + + val call = object : Dispatcher.AsyncCall() { + override fun call(): HTTPResult { + return httpClient.performRequest( + appConfig.baseURL, + endpoint, + body = null, + postFieldsToSign = null, + backendHelper.authenticationHeaders, + fallbackBaseURLs = appConfig.fallbackBaseURLs, + ) + } + + override fun onError(error: PurchasesError) { + synchronized(this@Backend) { + virtualCurrenciesCallbacks.remove(cacheKey) + }?.forEach { (_, onErrorHandler) -> + onErrorHandler(error) + } + } + + override fun onCompletion(result: HTTPResult) { + synchronized(this@Backend) { + virtualCurrenciesCallbacks.remove(cacheKey) + }?.forEach { (onSuccessHandler, onErrorHandler) -> + if (result.isSuccessful()) { + try { + val virtualCurrencies = VirtualCurrenciesFactory.buildVirtualCurrencies( + httpResult = result, + ) + onSuccessHandler(virtualCurrencies) + } catch (e: JSONException) { + onErrorHandler(e.toPurchasesError().also { errorLog(it) }) + } catch (e: SerializationException) { + onErrorHandler(e.toPurchasesError().also { errorLog(it) }) + } catch (e: IllegalArgumentException) { + onErrorHandler(e.toPurchasesError().also { errorLog(it) }) + } + } else { + onErrorHandler(result.toPurchasesError().also { errorLog(it) }) + } + } + } + } + + synchronized(this@Backend) { + val delay = if (appInBackground) Delay.DEFAULT else Delay.NONE + virtualCurrenciesCallbacks.addBackgroundAwareCallback( + call, + dispatcher, + cacheKey, + onSuccess to onError, + delay, + ) + } + } + + fun getWebBillingProducts( + appUserID: String, + productIds: Set, + onSuccess: (WebBillingProductsResponse) -> Unit, + onError: (PurchasesError) -> Unit, + ) { + val endpoint = Endpoint.WebBillingGetProducts(appUserID, productIds) + val path = endpoint.getPath() + val call = object : Dispatcher.AsyncCall() { + override fun call(): HTTPResult { + return httpClient.performRequest( + appConfig.baseURL, + endpoint, + body = null, + postFieldsToSign = null, + backendHelper.authenticationHeaders, + fallbackBaseURLs = appConfig.fallbackBaseURLs, + ) + } + + override fun onError(error: PurchasesError) { + synchronized(this@Backend) { + webBillingProductsCallbacks.remove(path) + }?.forEach { (_, onErrorHandler) -> + onErrorHandler(error) + } + } + + override fun onCompletion(result: HTTPResult) { + synchronized(this@Backend) { + webBillingProductsCallbacks.remove(path) + }?.forEach { (onSuccessHandler, onErrorHandler) -> + if (result.isSuccessful()) { + try { + val productsResponse = json.decodeFromString( + result.payload, + ) + onSuccessHandler(productsResponse) + } catch (e: SerializationException) { + onErrorHandler(e.toPurchasesError().also { errorLog(it) }) + } catch (e: IllegalArgumentException) { + onErrorHandler(e.toPurchasesError().also { errorLog(it) }) + } + } else { + onErrorHandler(result.toPurchasesError().also { errorLog(it) }) + } + } + } + } + synchronized(this@Backend) { + webBillingProductsCallbacks.addCallback( + call, + dispatcher, + path, + onSuccess to onError, + Delay.NONE, + ) + } + } + fun clearCaches() { httpClient.clearCaches() } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/Config.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/Config.kt index bf5c0e26f7..c10ea5d419 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/Config.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/Config.kt @@ -6,5 +6,5 @@ import com.revenuecat.purchases.api.BuildConfig internal object Config { var logLevel = LogLevel.debugLogsEnabled(BuildConfig.DEBUG) - const val frameworkVersion = "8.22.0-SNAPSHOT" + const val frameworkVersion = "8.24.0" } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/HTTPClient.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/HTTPClient.kt index 351bd08825..626f994e91 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/HTTPClient.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/HTTPClient.kt @@ -46,7 +46,7 @@ internal class HTTPClient( private val storefrontProvider: StorefrontProvider, private val dateProvider: DateProvider = DefaultDateProvider(), private val mapConverter: MapConverter = MapConverter(), - private val localeProvider: LocaleProvider = DefaultLocaleProvider(), + private val localeProvider: LocaleProvider, ) { @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal companion object { @@ -183,14 +183,13 @@ internal class HTTPClient( ): HTTPResult? { val jsonBody = body?.let { mapConverter.convertToJSON(it) } val path = endpoint.getPath() - val urlPathWithVersion = "/v1$path" val connection: HttpURLConnection val shouldSignResponse = signingManager.shouldVerifyEndpoint(endpoint) val shouldAddNonce = shouldSignResponse && endpoint.needsNonceToPerformSigning val nonce: String? val postFieldsToSignHeader: String? try { - val fullURL = URL(baseURL, urlPathWithVersion) + val fullURL = URL(baseURL, path) nonce = if (shouldAddNonce) signingManager.createRandomNonce() else null postFieldsToSignHeader = postFieldsToSign?.takeIf { shouldSignResponse }?.let { @@ -198,7 +197,7 @@ internal class HTTPClient( } val headers = getHeaders( requestHeaders, - urlPathWithVersion, + path, refreshETag, nonce, shouldSignResponse, @@ -233,7 +232,7 @@ internal class HTTPClient( val verificationResult = if (shouldSignResponse && RCHTTPStatusCodes.isSuccessful(responseCode) ) { - verifyResponse(urlPathWithVersion, connection, payload, nonce, postFieldsToSignHeader) + verifyResponse(path, connection, payload, nonce, postFieldsToSignHeader) } else { VerificationResult.NOT_REQUESTED } @@ -248,7 +247,7 @@ internal class HTTPClient( responseCode, payload, getETagHeader(connection), - urlPathWithVersion, + path, refreshETag, getRequestDateHeader(connection), verificationResult, diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/LocaleProvider.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/LocaleProvider.kt index fe2b11f479..ee249974b0 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/LocaleProvider.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/LocaleProvider.kt @@ -7,6 +7,24 @@ internal interface LocaleProvider { } internal class DefaultLocaleProvider : LocaleProvider { + + private var preferredLocaleOverride: String? = null + + fun setPreferredLocaleOverride(localeString: String?) { + preferredLocaleOverride = localeString + } + override val currentLocalesLanguageTags: String - get() = LocaleListCompat.getDefault().toLanguageTags() + get() { + val result = preferredLocaleOverride?.let { + val defaultLocales = LocaleListCompat.getDefault().toLanguageTags() + if (defaultLocales.isEmpty()) { + it + } else { + "$it,$defaultLocales" + } + } ?: LocaleListCompat.getDefault().toLanguageTags() + + return result + } } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/SharedPreferencesManager.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/SharedPreferencesManager.kt new file mode 100644 index 0000000000..92c0e132a6 --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/SharedPreferencesManager.kt @@ -0,0 +1,147 @@ +package com.revenuecat.purchases.common + +import android.content.Context +import android.content.SharedPreferences +import android.preference.PreferenceManager +import androidx.annotation.VisibleForTesting +import androidx.core.content.edit +import com.revenuecat.purchases.backup.RevenueCatBackupAgent + +/** + * Provides an instance of SharedPreferences to be used by the RevenueCat SDK. + * It handles migration from legacy shared preferences to a dedicated RevenueCat-specific preferences file. + * The migration is performed only once, and it ensures that no data is lost during the process + */ +internal class SharedPreferencesManager( + context: Context, + private val revenueCatSharedPreferences: SharedPreferences = context.getSharedPreferences( + RevenueCatBackupAgent.REVENUECAT_PREFS_FILE_NAME, + Context.MODE_PRIVATE, + ), + private val legacySharedPreferences: Lazy = lazy { + PreferenceManager.getDefaultSharedPreferences(context) + }, +) { + + companion object { + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + const val SHARED_PREFERENCES_PREFIX = "com.revenuecat.purchases." + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + const val EXPECTED_VERSION_KEY = "com.revenuecat.purchases.shared_preferences_version" + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + const val EXPECTED_VERSION = 1 + } + + /** + * Gets the appropriate shared preferences, performing migration if needed + */ + fun getSharedPreferences(): SharedPreferences { + synchronized(this) { ensureMigrated() } + return revenueCatSharedPreferences + } + + private fun ensureMigrated() { + val alreadyHasVersion = hasRevenueCatVersion() + if (!alreadyHasVersion) { + if (legacySharedPreferences.value.all.keys.any { + key -> + key.startsWith(SHARED_PREFERENCES_PREFIX) + } + ) { + performMigration() + } + updateSharedPreferencesVersion() + } + } + + /** + * Performs the migration from legacy shared preferences to RevenueCat-specific ones + */ + private fun performMigration() { + log( + LogIntent.DEBUG, + ) { "Starting shared preferences migration from legacy to RevenueCat-specific preferences" } + + val revenueCatKeys = getRevenueCatKeysToMigrate() + + val legacyPrefs by legacySharedPreferences + val revenueCatPrefs = revenueCatSharedPreferences + revenueCatPrefs.edit { + for (key in revenueCatKeys) { + migratePreferenceValue(legacyPrefs, this, key) + } + } + + log( + LogIntent.DEBUG, + ) { "Finished shared preferences migration from legacy to RevenueCat-specific preferences" } + } + + private fun getRevenueCatKeysToMigrate(): List { + val legacyPrefs by legacySharedPreferences + val revenueCatKeys = legacyPrefs.all.keys.filter { key -> + key.startsWith(SHARED_PREFERENCES_PREFIX) + } + + log(LogIntent.DEBUG) { "Found ${revenueCatKeys.size} RevenueCat keys to migrate: $revenueCatKeys" } + return revenueCatKeys + } + + private fun migratePreferenceValue( + legacyPrefs: SharedPreferences, + editor: SharedPreferences.Editor, + key: String, + ): Boolean { + return try { + val value = legacyPrefs.all[key] + when (value) { + is String -> { + editor.putString(key, value) + true + } + is Boolean -> { + editor.putBoolean(key, value) + true + } + is Int -> { + editor.putInt(key, value) + true + } + is Long -> { + editor.putLong(key, value) + true + } + is Float -> { + editor.putFloat(key, value) + true + } + is Set<*> -> { + @Suppress("UNCHECKED_CAST") + editor.putStringSet(key, value as? Set ?: emptySet()) + true + } + else -> { + log( + LogIntent.WARNING, + ) { "Unknown preference type for key $key: ${value?.javaClass?.simpleName}" } + false + } + } + } catch (e: java.lang.ClassCastException) { + errorLog(e) { "Failed to migrate preference with key due to type casting: $key" } + false + } + } + + private fun hasRevenueCatVersion(): Boolean { + return revenueCatSharedPreferences.contains(EXPECTED_VERSION_KEY) + } + + private fun updateSharedPreferencesVersion() { + revenueCatSharedPreferences.edit { + putInt(EXPECTED_VERSION_KEY, EXPECTED_VERSION) + } + } +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/caching/DeviceCache.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/caching/DeviceCache.kt index f0d827995d..05247a6741 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/caching/DeviceCache.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/caching/DeviceCache.kt @@ -23,6 +23,11 @@ import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.strings.BillingStrings import com.revenuecat.purchases.strings.OfflineEntitlementsStrings import com.revenuecat.purchases.strings.ReceiptStrings +import com.revenuecat.purchases.strings.VirtualCurrencyStrings +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrenciesFactory +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json import org.json.JSONException import org.json.JSONObject import java.util.Date @@ -62,6 +67,14 @@ internal open class DeviceCache( "$apiKeyPrefix.purchaserInfoLastUpdated" } + private val virtualCurrenciesCacheBaseKey: String by lazy { + "$apiKeyPrefix.virtualCurrencies" + } + + private val virtualCurrenciesLastUpdatedCacheBaseKey: String by lazy { + "$apiKeyPrefix.virtualCurrenciesLastUpdated" + } + private val offeringsResponseCacheKey: String by lazy { "$apiKeyPrefix.offeringsResponse" } fun startEditing(): SharedPreferences.Editor { @@ -95,6 +108,8 @@ internal open class DeviceCache( .clearCustomerInfo() .clearAppUserID() .clearCustomerInfoCacheTimestamp(appUserID) + .clearVirtualCurrenciesCacheTimestamp(appUserID) + .clearVirtualCurrenciesCache(appUserID) .apply() } @@ -227,6 +242,113 @@ internal open class DeviceCache( // endregion + // region virtual currencies + fun virtualCurrenciesCacheKey(appUserID: String) = "$virtualCurrenciesCacheBaseKey.$appUserID" + + fun virtualCurrenciesLastUpdatedCacheKey(appUserID: String) = "$virtualCurrenciesLastUpdatedCacheBaseKey.$appUserID" + + @Suppress("SwallowedException", "ForbiddenComment") + @Synchronized + fun getCachedVirtualCurrencies(appUserID: String): VirtualCurrencies? { + return preferences.getString(virtualCurrenciesCacheKey(appUserID), null) + ?.let { json -> + try { + return VirtualCurrenciesFactory.buildVirtualCurrencies(jsonString = json) + } catch (error: JSONException) { + log(LogIntent.WARNING) { + VirtualCurrencyStrings.ERROR_DECODING_CACHED_VIRTUAL_CURRENCIES.format(error) + } + null + } catch (error: SerializationException) { + log(LogIntent.WARNING) { + VirtualCurrencyStrings.ERROR_DECODING_CACHED_VIRTUAL_CURRENCIES.format(error) + } + null + } catch (error: IllegalArgumentException) { + log(LogIntent.WARNING) { + VirtualCurrencyStrings.ERROR_DECODING_CACHED_VIRTUAL_CURRENCIES.format(error) + } + null + } + } + } + + @Synchronized + fun cacheVirtualCurrencies(appUserID: String, virtualCurrencies: VirtualCurrencies) { + val virtualCurrenciesJSONString = Json.Default.encodeToString(VirtualCurrencies.serializer(), virtualCurrencies) + + preferences.edit() + .putString( + virtualCurrenciesCacheKey(appUserID), + virtualCurrenciesJSONString, + ).apply() + + setVirtualCurrenciesCacheTimestampToNow(appUserID) + } + + @Synchronized + fun isVirtualCurrenciesCacheStale(appUserID: String, appInBackground: Boolean) = + getVirtualCurrenciesCacheLastUpdated(appUserID) + .isCacheStale(appInBackground, dateProvider) + + @Synchronized + fun clearVirtualCurrenciesCache(appUserID: String) { + val editor = preferences.edit() + clearVirtualCurrenciesCache(appUserID, editor) + editor.apply() + } + + @Synchronized + fun clearVirtualCurrenciesCache( + appUserID: String, + editor: SharedPreferences.Editor, + ) { + editor.clearVirtualCurrenciesCacheTimestamp(appUserID = appUserID) + editor.clearVirtualCurrenciesCache(appUserID = appUserID) + } + + @Synchronized + fun setVirtualCurrenciesCacheTimestampToNow(appUserID: String) { + setVirtualCurrenciesCacheTimestamp(appUserID, dateProvider.now) + } + + @Synchronized + fun setVirtualCurrenciesCacheTimestamp(appUserID: String, date: Date) { + preferences.edit().putLong(virtualCurrenciesLastUpdatedCacheKey(appUserID), date.time).apply() + } + + @Synchronized + private fun getVirtualCurrenciesCacheLastUpdated(appUserID: String): Date { + return Date(preferences.getLong(virtualCurrenciesLastUpdatedCacheKey(appUserID), 0)) + } + + private fun SharedPreferences.Editor.clearVirtualCurrenciesCacheTimestamp( + appUserID: String, + ): SharedPreferences.Editor { + remove(virtualCurrenciesLastUpdatedCacheKey(appUserID)) + + getCachedAppUserID()?.let { + remove(virtualCurrenciesLastUpdatedCacheKey(it)) + } + getLegacyCachedAppUserID()?.let { + remove(virtualCurrenciesLastUpdatedCacheKey(it)) + } + return this + } + + private fun SharedPreferences.Editor.clearVirtualCurrenciesCache(appUserID: String): SharedPreferences.Editor { + remove(virtualCurrenciesCacheKey(appUserID)) + + getCachedAppUserID()?.let { + remove(virtualCurrenciesCacheKey(it)) + } + getLegacyCachedAppUserID()?.let { + remove(virtualCurrenciesCacheKey(it)) + } + return this + } + // endregion + // region attribution data @Synchronized diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer.kt index 88574f9c30..6a1a67933b 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/diagnostics/DiagnosticsSynchronizer.kt @@ -1,7 +1,5 @@ package com.revenuecat.purchases.common.diagnostics -import android.content.Context -import android.content.SharedPreferences import androidx.annotation.VisibleForTesting import com.revenuecat.purchases.common.Backend import com.revenuecat.purchases.common.Dispatcher @@ -30,12 +28,6 @@ internal class DiagnosticsSynchronizer( @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) const val MAX_EVENTS_TO_SYNC_PER_REQUEST: Int = 200 - - fun initializeSharedPreferences(context: Context): SharedPreferences = - context.getSharedPreferences( - "com_revenuecat_purchases_${context.packageName}_preferences_diagnostics", - Context.MODE_PRIVATE, - ) } val isSyncing = AtomicBoolean(false) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/errors.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/errors.kt index 8e6b457de1..28ce511935 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/errors.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/errors.kt @@ -32,6 +32,7 @@ internal enum class BackendErrorCode(val value: Int) { BackendInvalidSubscriberAttributes(7263), BackendInvalidSubscriberAttributesBody(7264), BackendSubscriberAttributesAreBeingUpdated(7629), + BackendPaymentNotComplete(7651), BackendRequestAlreadyInProgress(7638), BackendProductIDsMalformed(7662), BackendInvalidWebRedemptionToken(7849), @@ -108,6 +109,7 @@ private fun BackendErrorCode.toPurchasesErrorCode(): PurchasesErrorCode { BackendErrorCode.BackendRequestAlreadyInProgress, BackendErrorCode.BackendSubscriberAttributesAreBeingUpdated, -> PurchasesErrorCode.OperationAlreadyInProgressError + BackendErrorCode.BackendPaymentNotComplete -> PurchasesErrorCode.PaymentPendingError BackendErrorCode.BackendCouldNotCreateAlias -> PurchasesErrorCode.ConfigurationError BackendErrorCode.BackendProductIDsMalformed -> PurchasesErrorCode.UnsupportedError BackendErrorCode.BackendInvalidWebRedemptionToken -> PurchasesErrorCode.PurchaseInvalidError diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/ETagManager.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/ETagManager.kt index b28b6ab799..0d6f8ef033 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/ETagManager.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/ETagManager.kt @@ -72,7 +72,7 @@ internal class ETagManager( responseCode: Int, payload: String, eTagHeader: String?, - urlPathWithVersion: String, + urlPath: String, refreshETag: Boolean, requestDate: Date?, verificationResult: VerificationResult, @@ -86,7 +86,7 @@ internal class ETagManager( ) eTagHeader?.let { eTagInResponse -> if (shouldUseCachedVersion(responseCode)) { - val storedResult = getStoredResult(urlPathWithVersion)?.let { storedResult -> + val storedResult = getStoredResult(urlPath)?.let { storedResult -> storedResult.copy( // This assumes we won't store verification failures in the cache and we will clear the cache // when enabling verification. @@ -103,7 +103,7 @@ internal class ETagManager( } } - storeBackendResultIfNoError(urlPathWithVersion, resultFromBackend, eTagInResponse) + storeBackendResultIfNoError(urlPath, resultFromBackend, eTagInResponse) } return resultFromBackend } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/Endpoint.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/Endpoint.kt index fa5e0541eb..d9f6634f10 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/Endpoint.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/Endpoint.kt @@ -4,48 +4,68 @@ import android.net.Uri internal sealed class Endpoint(val pathTemplate: String, val name: String) { abstract fun getPath(): String - data class GetCustomerInfo(val userId: String) : Endpoint("/subscribers/%s", "get_customer") { + data class GetCustomerInfo(val userId: String) : Endpoint("/v1/subscribers/%s", "get_customer") { override fun getPath() = pathTemplate.format(Uri.encode(userId)) } - object PostReceipt : Endpoint("/receipts", "post_receipt") { + object PostReceipt : Endpoint("/v1/receipts", "post_receipt") { override fun getPath() = pathTemplate } - data class GetOfferings(val userId: String) : Endpoint("/subscribers/%s/offerings", "get_offerings") { + data class GetOfferings(val userId: String) : Endpoint("/v1/subscribers/%s/offerings", "get_offerings") { override fun getPath() = pathTemplate.format(Uri.encode(userId)) } - object LogIn : Endpoint("/subscribers/identify", "log_in") { + object LogIn : Endpoint("/v1/subscribers/identify", "log_in") { override fun getPath() = pathTemplate } - object PostDiagnostics : Endpoint("/diagnostics", "post_diagnostics") { + data class AliasUsers(val userId: String) : Endpoint("/v1/subscribers/%s/alias", "alias_users") { + override fun getPath() = pathTemplate.format(Uri.encode(userId)) + } + object PostDiagnostics : Endpoint("/v1/diagnostics", "post_diagnostics") { override fun getPath() = pathTemplate } - object PostPaywallEvents : Endpoint("/events", "post_paywall_events") { + object PostPaywallEvents : Endpoint("/v1/events", "post_paywall_events") { override fun getPath() = pathTemplate } - data class PostAttributes(val userId: String) : Endpoint("/subscribers/%s/attributes", "post_attributes") { + data class PostAttributes(val userId: String) : Endpoint("/v1/subscribers/%s/attributes", "post_attributes") { override fun getPath() = pathTemplate.format(Uri.encode(userId)) } data class GetAmazonReceipt( val userId: String, val receiptId: String, - ) : Endpoint("/receipts/amazon/%s/%s", "get_amazon_receipt") { + ) : Endpoint("/v1/receipts/amazon/%s/%s", "get_amazon_receipt") { override fun getPath() = pathTemplate.format(Uri.encode(userId), receiptId) } - object GetProductEntitlementMapping : Endpoint("/product_entitlement_mapping", "get_product_entitlement_mapping") { + object GetProductEntitlementMapping : Endpoint( + "/v1/product_entitlement_mapping", + "get_product_entitlement_mapping", + ) { override fun getPath() = pathTemplate } data class GetCustomerCenterConfig(val userId: String) : Endpoint( - "/customercenter/%s", + "/v1/customercenter/%s", "get_customer_center_config", ) { override fun getPath() = pathTemplate.format(Uri.encode(userId)) } object PostRedeemWebPurchase : Endpoint( - "/subscribers/redeem_purchase", + "/v1/subscribers/redeem_purchase", "post_redeem_web_purchase", ) { override fun getPath() = pathTemplate } + data class GetVirtualCurrencies(val userId: String) : Endpoint( + pathTemplate = "/v1/subscribers/%s/virtual_currencies", + name = "get_virtual_currencies", + ) { + override fun getPath() = pathTemplate.format(Uri.encode(userId)) + } + data class WebBillingGetProducts(val userId: String, val productIds: Set) : Endpoint( + pathTemplate = "/rcbilling/v1/subscribers/%s/products?id=%s", + name = "web_billing_get_products", + ) { + override fun getPath(): String { + return pathTemplate.format(Uri.encode(userId), productIds.joinToString("&id=") { Uri.encode(it) }) + } + } val supportsSignatureVerification: Boolean get() = when (this) { @@ -55,6 +75,7 @@ internal sealed class Endpoint(val pathTemplate: String, val name: String) { is GetOfferings, GetProductEntitlementMapping, PostRedeemWebPurchase, + is GetVirtualCurrencies, -> true is GetAmazonReceipt, @@ -62,6 +83,8 @@ internal sealed class Endpoint(val pathTemplate: String, val name: String) { PostDiagnostics, PostPaywallEvents, is GetCustomerCenterConfig, + is WebBillingGetProducts, + is AliasUsers, -> false } @@ -72,6 +95,7 @@ internal sealed class Endpoint(val pathTemplate: String, val name: String) { LogIn, PostReceipt, PostRedeemWebPurchase, + is GetVirtualCurrencies, -> true is GetAmazonReceipt, @@ -81,6 +105,8 @@ internal sealed class Endpoint(val pathTemplate: String, val name: String) { PostPaywallEvents, GetProductEntitlementMapping, is GetCustomerCenterConfig, + is WebBillingGetProducts, + is AliasUsers, -> false } @@ -101,6 +127,9 @@ internal sealed class Endpoint(val pathTemplate: String, val name: String) { PostPaywallEvents, is GetCustomerInfo, is GetCustomerCenterConfig, + is GetVirtualCurrencies, + is WebBillingGetProducts, + is AliasUsers, -> false } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/WebBillingProductsResponse.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/WebBillingProductsResponse.kt new file mode 100644 index 0000000000..aa9d1b9c57 --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/WebBillingProductsResponse.kt @@ -0,0 +1,40 @@ +package com.revenuecat.purchases.common.networking + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class WebBillingProductsResponse( + @SerialName("product_details") val productDetails: List, +) + +@Serializable +internal data class WebBillingProductResponse( + val identifier: String, + @SerialName("product_type") val productType: String, + val title: String, + val description: String? = null, + @SerialName("default_purchase_option_id") val defaultPurchaseOptionId: String? = null, + @SerialName("purchase_options") val purchaseOptions: Map, +) + +@Serializable +internal data class WebBillingPurchaseOption( + @SerialName("base_price") val basePrice: WebBillingPrice? = null, + val base: WebBillingPhase? = null, + val trial: WebBillingPhase? = null, + @SerialName("intro_price") val introPrice: WebBillingPhase? = null, +) + +@Serializable +internal data class WebBillingPhase( + val price: WebBillingPrice? = null, + @SerialName("period_duration") val periodDuration: String? = null, + @SerialName("cycle_count") val cycleCount: Int = 1, +) + +@Serializable +internal data class WebBillingPrice( + @SerialName("amount_micros") val amountMicros: Long, + val currency: String, +) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/offerings/OfferingsCache.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/offerings/OfferingsCache.kt index 678153568f..8f0862cf61 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/offerings/OfferingsCache.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/offerings/OfferingsCache.kt @@ -3,7 +3,6 @@ package com.revenuecat.purchases.common.offerings import com.revenuecat.purchases.Offerings import com.revenuecat.purchases.common.DateProvider import com.revenuecat.purchases.common.DefaultDateProvider -import com.revenuecat.purchases.common.DefaultLocaleProvider import com.revenuecat.purchases.common.LocaleProvider import com.revenuecat.purchases.common.caching.DeviceCache import com.revenuecat.purchases.common.caching.InMemoryCachedObject @@ -16,7 +15,7 @@ internal class OfferingsCache( private val offeringsCachedObject: InMemoryCachedObject = InMemoryCachedObject( dateProvider = dateProvider, ), - private val localeProvider: LocaleProvider = DefaultLocaleProvider(), + private val localeProvider: LocaleProvider, ) { private var cachedLanguageTags: String? = null diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/customercenter/CustomActionData.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/customercenter/CustomActionData.kt new file mode 100644 index 0000000000..31de5fc54d --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/customercenter/CustomActionData.kt @@ -0,0 +1,50 @@ +package com.revenuecat.purchases.customercenter + +import dev.drewhamilton.poko.Poko + +/** + * Data associated with a custom action selection in the Customer Center. + * + * This class encapsulates information about a custom action that has been triggered + * in the Customer Center. Custom actions are defined in the configuration and allow + * applications to handle specialized user flows beyond well-known actions. + * + * ## Usage + * + * Custom actions are handled through the CustomerCenterListener: + * + * val listener = object : CustomerCenterListener { + * override fun onCustomActionSelected(actionIdentifier: String, purchaseIdentifier: String?) { + * // Handle the custom action + * when (actionIdentifier) { + * "delete_user" -> deleteUserAccount() + * "rate_app" -> showAppStoreRating() + * else -> { + * // Handle unknown action + * } + * } + * } + * } + */ +@Poko +class CustomActionData( + /** + * The unique identifier for the custom action. + * + * This identifier is configured in the Customer Center dashboard and allows + * applications to distinguish between different types of custom actions. + */ + val actionIdentifier: String, + + /** + * The product identifier of the purchase being viewed in a detail screen, if any. + * + * This provides context about which specific purchase the custom action relates to. + * It will be `null` if the custom action was triggered from the general management screen + * rather than from a specific purchase detail screen. + * + * - When triggered from a purchase detail screen: Contains the product identifier of that purchase + * - When triggered from the management screen: Will be `null` + */ + val purchaseIdentifier: String?, +) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/customercenter/CustomerCenterConfigData.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/customercenter/CustomerCenterConfigData.kt index f5f3876eb0..f218031c12 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/customercenter/CustomerCenterConfigData.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/customercenter/CustomerCenterConfigData.kt @@ -223,6 +223,9 @@ data class CustomerCenterConfigData( @SerialName("badge_free_trial_cancelled") BADGE_FREE_TRIAL_CANCELLED, + @SerialName("badge_lifetime") + BADGE_LIFETIME, + @SerialName("app_store") APP_STORE, @@ -243,6 +246,24 @@ data class CustomerCenterConfigData( @SerialName("card_store_promotional") CARD_STORE_PROMOTIONAL, + + @SerialName("resubscribe") + RESUBSCRIBE, + + @SerialName("type_subscription") + TYPE_SUBSCRIPTION, + + @SerialName("type_one_time_purchase") + TYPE_ONE_TIME_PURCHASE, + + @SerialName("buy_subscription") + BUY_SUBSCRIPTION, + + @SerialName("last_charge_was") + LAST_CHARGE_WAS, + + @SerialName("next_billing_date_on") + NEXT_BILLING_DATE_ON, ; val defaultValue: String @@ -320,6 +341,7 @@ data class CustomerCenterConfigData( BADGE_CANCELLED -> "Cancelled" BADGE_FREE_TRIAL -> "Free Trial" BADGE_FREE_TRIAL_CANCELLED -> "Cancelled Trial" + BADGE_LIFETIME -> "Lifetime" APP_STORE -> "App Store" MAC_APP_STORE -> "Mac App Store" GOOGLE_PLAY_STORE -> "Google Play Store" @@ -327,6 +349,12 @@ data class CustomerCenterConfigData( WEB_STORE -> "Web" UNKNOWN_STORE -> "Unknown" CARD_STORE_PROMOTIONAL -> "Via Support" + RESUBSCRIBE -> "Resubscribe" + TYPE_SUBSCRIPTION -> "Subscription" + TYPE_ONE_TIME_PURCHASE -> "One time purchase" + BUY_SUBSCRIPTION -> "Buy Subscription" + LAST_CHARGE_WAS -> "Last charge: {{ price }}" + NEXT_BILLING_DATE_ON -> "Next billing date: {{ date }}" } } @@ -344,6 +372,7 @@ data class CustomerCenterConfigData( @SerialName("feedback_survey") val feedbackSurvey: PathDetail.FeedbackSurvey? = null, val url: String? = null, @SerialName("open_method") val openMethod: OpenMethod? = null, + @SerialName("action_identifier") val actionIdentifier: String? = null, ) { @Serializable sealed class PathDetail { @@ -414,6 +443,7 @@ data class CustomerCenterConfigData( CHANGE_PLANS, CANCEL, CUSTOM_URL, + CUSTOM_ACTION, UNKNOWN, } @@ -444,12 +474,29 @@ data class CustomerCenterConfigData( ) } + @Serializable + data class ScreenOffering( + val type: ScreenOfferingType, + @SerialName("offering_id") val offeringId: String? = null, + @SerialName("button_text") val buttonText: String? = null, + ) { + @Serializable + enum class ScreenOfferingType(val value: String) { + @SerialName("CURRENT") + CURRENT("CURRENT"), + + @SerialName("SPECIFIC") + SPECIFIC("SPECIFIC"), + } + } + @Serializable data class Screen( val type: ScreenType, val title: String, @Serializable(with = EmptyStringToNullSerializer::class) val subtitle: String? = null, @Serializable(with = HelpPathsSerializer::class) val paths: List, + val offering: ScreenOffering? = null, ) { @Serializable enum class ScreenType { diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/customercenter/CustomerCenterListener.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/customercenter/CustomerCenterListener.kt index f737ba1928..1af368bf54 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/customercenter/CustomerCenterListener.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/customercenter/CustomerCenterListener.kt @@ -59,4 +59,14 @@ interface CustomerCenterListener { fun onManagementOptionSelected(action: CustomerCenterManagementOption) { // Default empty implementation } + + /** + * Called when a custom action is selected in the Customer Center. + * + * @param actionIdentifier The unique identifier for the custom action + * @param purchaseIdentifier The product identifier of the purchase being viewed, if any + */ + fun onCustomActionSelected(actionIdentifier: String, purchaseIdentifier: String?) { + // Default empty implementation + } } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/customercenter/ScreenOfferingExtensions.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/customercenter/ScreenOfferingExtensions.kt new file mode 100644 index 0000000000..19dc8014d2 --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/customercenter/ScreenOfferingExtensions.kt @@ -0,0 +1,41 @@ +package com.revenuecat.purchases.customercenter + +import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.Offering +import com.revenuecat.purchases.Purchases +import com.revenuecat.purchases.PurchasesError +import com.revenuecat.purchases.getOfferingsWith + +@InternalRevenueCatAPI +fun CustomerCenterConfigData.Screen.resolveOffering( + purchases: Purchases, + onError: (error: PurchasesError) -> Unit = {}, + onSuccess: (offering: Offering?) -> Unit, +) { + val screenOffering = this.offering + + if (screenOffering == null) { + onSuccess(null) + return + } + + purchases.getOfferingsWith( + onError = onError, + onSuccess = { offerings -> + val resolvedOffering = when (screenOffering.type) { + CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.CURRENT -> { + offerings.current + } + CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.SPECIFIC -> { + val offeringId = screenOffering.offeringId + if (offeringId != null) { + offerings.all[offeringId] + } else { + null + } + } + } + onSuccess(resolvedOffering) + }, + ) +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/identity/IdentityManager.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/identity/IdentityManager.kt index cec360805a..ebd70f07c0 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/identity/IdentityManager.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/identity/IdentityManager.kt @@ -3,6 +3,7 @@ package com.revenuecat.purchases.identity import com.revenuecat.purchases.CustomerInfo import com.revenuecat.purchases.PurchasesError import com.revenuecat.purchases.PurchasesErrorCode +import com.revenuecat.purchases.PurchasesException import com.revenuecat.purchases.VerificationResult import com.revenuecat.purchases.common.Backend import com.revenuecat.purchases.common.Delay @@ -21,6 +22,9 @@ import com.revenuecat.purchases.subscriberattributes.SubscriberAttributesManager import com.revenuecat.purchases.subscriberattributes.caching.SubscriberAttributesCache import java.util.Locale import java.util.UUID +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine @Suppress("TooManyFunctions", "LongParameterList") internal class IdentityManager( @@ -32,12 +36,17 @@ internal class IdentityManager( private val offlineEntitlementsManager: OfflineEntitlementsManager, private val dispatcher: Dispatcher, ) { + companion object { + private val anonymousIdRegex = "^\\\$RCAnonymousID:([a-f0-9]{32})$".toRegex() + + fun isUserIDAnonymous(appUserID: String): Boolean { + return anonymousIdRegex.matches(appUserID) + } + } val currentAppUserID: String get() = deviceCache.getCachedAppUserID() ?: "" - private val anonymousIdRegex = "^\\\$RCAnonymousID:([a-f0-9]{32})$".toRegex() - // region Public functions @Synchronized @@ -66,6 +75,32 @@ internal class IdentityManager( } } + suspend fun aliasCurrentUserIdTo( + oldAppUserID: String, + ) { + val newAppUserID = currentAppUserID + return suspendCoroutine { continuation -> + backend.aliasUsers( + oldAppUserID = oldAppUserID, + newAppUserID = newAppUserID, + onSuccessHandler = { + synchronized(this@IdentityManager) { + log(LogIntent.USER) { + IdentityStrings.ALIAS_OLD_USER_ID_TO_CURRENT_SUCCESSFUL.format(oldAppUserID, newAppUserID) + } + offeringsCache.clearCache() + deviceCache.clearCustomerInfoCache(newAppUserID) + offlineEntitlementsManager.resetOfflineCustomerInfoCache() + } + continuation.resume(Unit) + }, + onErrorHandler = { error -> + continuation.resumeWithException(PurchasesException(error)) + }, + ) + } + } + fun logIn( newAppUserID: String, onSuccess: (CustomerInfo, Boolean) -> Unit, @@ -165,10 +200,6 @@ internal class IdentityManager( backend.verificationMode != SignatureVerificationMode.Disabled } - private fun isUserIDAnonymous(appUserID: String): Boolean { - return anonymousIdRegex.matches(appUserID) - } - private fun generateRandomID(): String { return "\$RCAnonymousID:" + UUID.randomUUID().toString().toLowerCase(Locale.ROOT).replace("-", "") .also { diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/interfaces/GetVirtualCurrenciesCallback.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/interfaces/GetVirtualCurrenciesCallback.kt new file mode 100644 index 0000000000..af5908a335 --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/interfaces/GetVirtualCurrenciesCallback.kt @@ -0,0 +1,21 @@ +package com.revenuecat.purchases.interfaces + +import com.revenuecat.purchases.PurchasesError +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies + +/** + * Interface to be implemented when making calls that return a [VirtualCurrencies] + */ +interface GetVirtualCurrenciesCallback { + /** + * Will be called after the call has completed. + * @param virtualCurrencies [VirtualCurrencies] class sent back when the call has completed + */ + fun onReceived(virtualCurrencies: VirtualCurrencies) + + /** + * Will be called after the call has completed with an error. + * @param error A [PurchasesError] containing the reason for the failure of the call + */ + fun onError(error: PurchasesError) +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/models/TestStoreProduct.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/models/TestStoreProduct.kt index b7798edda7..2565a7f6c7 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/models/TestStoreProduct.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/models/TestStoreProduct.kt @@ -2,21 +2,65 @@ package com.revenuecat.purchases.models import com.revenuecat.purchases.PresentedOfferingContext import com.revenuecat.purchases.ProductType +import com.revenuecat.purchases.simulatedstore.SimulatedStorePurchasingData /** * A test-only [StoreProduct] implementation. * This can be used to create mock data for tests or Jetpack Compose previews. */ -data class TestStoreProduct( +data class TestStoreProduct @JvmOverloads constructor( override val id: String, override val name: String, override val title: String, override val description: String, override val price: Price, - override val period: Period?, - private val freeTrialPeriod: Period? = null, - private val introPrice: Price? = null, + override val period: Period? = null, + private val freeTrialPricingPhase: PricingPhase? = null, + private val introPricePricingPhase: PricingPhase? = null, + override val presentedOfferingContext: PresentedOfferingContext? = null, ) : StoreProduct { + + @Deprecated( + "Replaced with constructor that takes pricing phases for free trial and intro price", + ReplaceWith( + "TestStoreProduct(id, name, title, description, price, period, " + + "freeTrialPricingPhase, introPricePricingPhase)", + ), + ) + constructor( + id: String, + name: String, + title: String, + description: String, + price: Price, + period: Period? = null, + freeTrialPeriod: Period? = null, + introPrice: Price? = null, + ) : this( + id, + name, + title, + description, + price, + period, + freeTrialPeriod?.let { + PricingPhase( + billingPeriod = it, + recurrenceMode = RecurrenceMode.FINITE_RECURRING, + billingCycleCount = 1, + price = Price(amountMicros = 0, currencyCode = price.currencyCode, formatted = "Free"), + ) + }, + introPrice?.let { + PricingPhase( + billingPeriod = Period(value = 1, unit = Period.Unit.MONTH, iso8601 = "P1M"), + recurrenceMode = RecurrenceMode.FINITE_RECURRING, + billingCycleCount = 1, + price = it, + ) + }, + ) + @Deprecated( "Replaced with constructor that takes a name", ReplaceWith( @@ -48,13 +92,11 @@ data class TestStoreProduct( get() = buildSubscriptionOptions() override val defaultOption: SubscriptionOption? get() = subscriptionOptions?.defaultOffer - override val purchasingData: PurchasingData - get() = object : PurchasingData { - override val productId: String - get() = id - override val productType: ProductType - get() = type - } + override val purchasingData: PurchasingData = SimulatedStorePurchasingData( + productId = id, + productType = type, + storeProduct = this, + ) @Deprecated( "Use presentedOfferingContext", @@ -62,8 +104,6 @@ data class TestStoreProduct( ) override val presentedOfferingIdentifier: String? get() = presentedOfferingContext?.offeringIdentifier - override val presentedOfferingContext: PresentedOfferingContext? - get() = null override val sku: String get() = id @@ -76,27 +116,21 @@ data class TestStoreProduct( } override fun copyWithPresentedOfferingContext(presentedOfferingContext: PresentedOfferingContext?): StoreProduct { - return this + return TestStoreProduct( + id = id, + name = name, + title = title, + description = description, + price = price, + period = period, + freeTrialPricingPhase = freeTrialPricingPhase, + introPricePricingPhase = introPricePricingPhase, + presentedOfferingContext = presentedOfferingContext, + ) } private fun buildSubscriptionOptions(): SubscriptionOptions? { if (period == null) return null - val freePhase = freeTrialPeriod?.let { freeTrialPeriod -> - PricingPhase( - billingPeriod = freeTrialPeriod, - recurrenceMode = RecurrenceMode.FINITE_RECURRING, - billingCycleCount = 1, - price = Price(amountMicros = 0, currencyCode = price.currencyCode, formatted = "Free"), - ) - } - val introPhase = introPrice?.let { introPrice -> - PricingPhase( - billingPeriod = Period(value = 1, unit = Period.Unit.MONTH, iso8601 = "P1M"), - recurrenceMode = RecurrenceMode.FINITE_RECURRING, - billingCycleCount = 1, - price = introPrice, - ) - } val basePricePhase = PricingPhase( billingPeriod = period, recurrenceMode = RecurrenceMode.INFINITE_RECURRING, @@ -105,12 +139,12 @@ data class TestStoreProduct( ) val subscriptionOptionsList = listOfNotNull( TestSubscriptionOption( - id, - listOfNotNull(freePhase, introPhase, basePricePhase), - ).takeIf { freeTrialPeriod != null || introPhase != null }, + listOfNotNull(freeTrialPricingPhase, introPricePricingPhase, basePricePhase), + purchasingData = purchasingData, + ).takeIf { freeTrialPricingPhase != null || introPricePricingPhase != null }, TestSubscriptionOption( - id, listOf(basePricePhase), + purchasingData = purchasingData, ), ) return SubscriptionOptions(subscriptionOptionsList) @@ -118,7 +152,6 @@ data class TestStoreProduct( } private class TestSubscriptionOption( - val productIdentifier: String, override val pricingPhases: List, val basePlanId: String = "testBasePlanId", override val tags: List = emptyList(), @@ -126,18 +159,11 @@ private class TestSubscriptionOption( offeringIdentifier = "offering", ), override val installmentsInfo: InstallmentsInfo? = null, + override val purchasingData: PurchasingData, ) : SubscriptionOption { override val id: String get() = if (pricingPhases.size == 1) basePlanId else "$basePlanId:testOfferId" override val presentedOfferingIdentifier: String? get() = presentedOfferingContext.offeringIdentifier - - override val purchasingData: PurchasingData - get() = object : PurchasingData { - override val productId: String - get() = productIdentifier - override val productType: ProductType - get() = ProductType.SUBS - } } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/ButtonComponent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/ButtonComponent.kt index d270ec7a60..11f2a89663 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/ButtonComponent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/ButtonComponent.kt @@ -22,6 +22,7 @@ import kotlinx.serialization.encoding.Encoder class ButtonComponent( @get:JvmSynthetic val action: Action, @get:JvmSynthetic val stack: StackComponent, + @get:JvmSynthetic val transition: PaywallTransition? = null, ) : PaywallComponent { @InternalRevenueCatAPI diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallAnimation.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallAnimation.kt new file mode 100644 index 0000000000..79718b5dc7 --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallAnimation.kt @@ -0,0 +1,47 @@ +package com.revenuecat.purchases.paywalls.components + +import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.utils.serializers.EnumDeserializerWithDefault +import dev.drewhamilton.poko.Poko +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Defines an animation to be used for paywall transitions. + * + * @property type The type of animation to use like ease in, ease out, etc. + * @property msDelay The delay in milliseconds before the animation starts. + * @property msDuration The duration in milliseconds of the animation. + */ +@Suppress("unused") +@InternalRevenueCatAPI +@Poko +@Serializable +@SerialName("animation") +class PaywallAnimation( + @get:JvmSynthetic val type: AnimationType, + @get:JvmSynthetic @SerialName("ms_delay") val msDelay: Int, + @get:JvmSynthetic @SerialName("ms_duration") val msDuration: Int, +) { + + @Serializable(with = AnimationTypeSerializer::class) + enum class AnimationType { + EASE_IN, + EASE_OUT, + EASE_IN_OUT, + LINEAR, + } +} + +@OptIn(InternalRevenueCatAPI::class) +internal object AnimationTypeSerializer : EnumDeserializerWithDefault( + defaultValue = PaywallAnimation.AnimationType.EASE_IN_OUT, + typeForValue = { value -> + when (value) { + PaywallAnimation.AnimationType.EASE_IN -> "ease_in" + PaywallAnimation.AnimationType.EASE_OUT -> "ease_out" + PaywallAnimation.AnimationType.EASE_IN_OUT -> "ease_in_out" + PaywallAnimation.AnimationType.LINEAR -> "linear" + } + }, +) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallTransition.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallTransition.kt new file mode 100644 index 0000000000..632e26ff9a --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallTransition.kt @@ -0,0 +1,76 @@ +package com.revenuecat.purchases.paywalls.components + +import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.utils.serializers.EnumDeserializerWithDefault +import dev.drewhamilton.poko.Poko +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Defines how a paywall screen is transitioned when it initially appears. + * + * @property type The type of transition to use. Defaults to [TransitionType.FADE]. + * @property displacementStrategy Determines how/when the view hierarchy is displaced by the view being animated in. + * @property animation Additional animation configuration for the transition. + */ +@InternalRevenueCatAPI +@Poko +@Serializable +class PaywallTransition( + @get:JvmSynthetic val type: TransitionType = TransitionType.FADE, + @get:JvmSynthetic + @SerialName("displacement_strategy") + val displacementStrategy: DisplacementStrategy, + @get:JvmSynthetic val animation: PaywallAnimation? = null, +) { + + /** + * Determines how the view being animated out is displaced by the view being animated in. + * + * A [GREEDY] displacement will result in the space being taken up by the incoming view + * *before* it attempts to transition into the view hierarchy. + * + * A [LAZY] displacement will not do this, instead it will result in shifting the layout + * as the new view inserts itself. + */ + @Serializable(with = DisplacementStrategyDeserializer::class) + enum class DisplacementStrategy { + @SerialName("greedy") + GREEDY, + + @SerialName("lazy") + LAZY, + } + + @Serializable(with = TransitionTypeSerializer::class) + enum class TransitionType { + FADE, + FADE_AND_SCALE, + SCALE, + SLIDE, + } +} + +@OptIn(InternalRevenueCatAPI::class) +internal object DisplacementStrategyDeserializer : EnumDeserializerWithDefault( + defaultValue = PaywallTransition.DisplacementStrategy.GREEDY, + typeForValue = { value -> + when (value) { + PaywallTransition.DisplacementStrategy.GREEDY -> "greedy" + PaywallTransition.DisplacementStrategy.LAZY -> "lazy" + } + }, +) + +@OptIn(InternalRevenueCatAPI::class) +internal object TransitionTypeSerializer : EnumDeserializerWithDefault( + defaultValue = PaywallTransition.TransitionType.FADE, + typeForValue = { value -> + when (value) { + PaywallTransition.TransitionType.FADE -> "fade" + PaywallTransition.TransitionType.FADE_AND_SCALE -> "fade_and_scale" + PaywallTransition.TransitionType.SCALE -> "scale" + PaywallTransition.TransitionType.SLIDE -> "slide" + } + }, +) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/simulatedstore/SimulatedStoreBillingWrapper.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/simulatedstore/SimulatedStoreBillingWrapper.kt new file mode 100644 index 0000000000..c04bfbea9c --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/simulatedstore/SimulatedStoreBillingWrapper.kt @@ -0,0 +1,247 @@ +package com.revenuecat.purchases.simulatedstore + +import android.app.Activity +import android.os.Handler +import com.revenuecat.purchases.PostReceiptInitiationSource +import com.revenuecat.purchases.PresentedOfferingContext +import com.revenuecat.purchases.ProductType +import com.revenuecat.purchases.PurchasesError +import com.revenuecat.purchases.PurchasesErrorCallback +import com.revenuecat.purchases.PurchasesErrorCode +import com.revenuecat.purchases.PurchasesException +import com.revenuecat.purchases.PurchasesStateProvider +import com.revenuecat.purchases.common.Backend +import com.revenuecat.purchases.common.BillingAbstract +import com.revenuecat.purchases.common.ReplaceProductInfo +import com.revenuecat.purchases.common.StoreProductsCallback +import com.revenuecat.purchases.common.caching.DeviceCache +import com.revenuecat.purchases.common.debugLog +import com.revenuecat.purchases.models.InAppMessageType +import com.revenuecat.purchases.models.PurchaseState +import com.revenuecat.purchases.models.PurchaseType +import com.revenuecat.purchases.models.PurchasingData +import com.revenuecat.purchases.models.StoreProduct +import com.revenuecat.purchases.models.StoreTransaction +import com.revenuecat.purchases.utils.AlertDialogHelper +import com.revenuecat.purchases.utils.DefaultAlertDialogHelper +import org.json.JSONObject +import java.util.Date +import java.util.UUID + +@Suppress("TooManyFunctions") +internal class SimulatedStoreBillingWrapper( + private val deviceCache: DeviceCache, + private val mainHandler: Handler, + purchasesStateProvider: PurchasesStateProvider, + private val backend: Backend, + private val dialogHelper: AlertDialogHelper = DefaultAlertDialogHelper(), +) : BillingAbstract(purchasesStateProvider) { + + @Volatile + private var connected = false + + override fun startConnectionOnMainThread(delayMilliseconds: Long) { + mainHandler.postDelayed({ + startConnection() + }, delayMilliseconds) + } + + override fun startConnection() { + debugLog { "SimulatedStoreBillingAbstract: Starting connection" } + connected = true + stateListener?.onConnected() + } + + override fun endConnection() { + debugLog { "SimulatedStoreBillingAbstract: Ending connection" } + connected = false + } + + override fun queryAllPurchases( + appUserID: String, + onReceivePurchaseHistory: (List) -> Unit, + onReceivePurchaseHistoryError: PurchasesErrorCallback, + ) { + debugLog { "SimulatedStoreBillingAbstract: queryAllPurchases - returning empty list" } + onReceivePurchaseHistory(emptyList()) + } + + override fun queryProductDetailsAsync( + productType: ProductType, + productIds: Set, + onReceive: StoreProductsCallback, + onError: PurchasesErrorCallback, + ) { + debugLog { "SimulatedStoreBillingAbstract: queryProductDetailsAsync for products: $productIds" } + + backend.getWebBillingProducts( + appUserID = deviceCache.getCachedAppUserID() ?: "", + productIds = productIds, + onSuccess = { response -> + try { + val storeProducts = response.productDetails.map { productResponse -> + SimulatedStoreProductConverter.convertToStoreProduct(productResponse) + } + onReceive(storeProducts) + } catch (e: PurchasesException) { + onError(e.error) + } + }, + onError = onError, + ) + } + + override fun consumeAndSave( + finishTransactions: Boolean, + purchase: StoreTransaction, + shouldConsume: Boolean, + initiationSource: PostReceiptInitiationSource, + ) { + debugLog { "SimulatedStoreBillingAbstract: consumeAndSave - no-op for test store" } + } + + override fun findPurchaseInPurchaseHistory( + appUserID: String, + productType: ProductType, + productId: String, + onCompletion: (StoreTransaction) -> Unit, + onError: (PurchasesError) -> Unit, + ) { + debugLog { + "SimulatedStoreBillingAbstract: findPurchaseInPurchaseHistory for product: $productId will always fail" + } + + onError( + PurchasesError( + PurchasesErrorCode.PurchaseNotAllowedError, + "No active purchase found for product: $productId", + ), + ) + } + + override fun makePurchaseAsync( + activity: Activity, + appUserID: String, + purchasingData: PurchasingData, + replaceProductInfo: ReplaceProductInfo?, + presentedOfferingContext: PresentedOfferingContext?, + isPersonalizedPrice: Boolean?, + ) { + debugLog { "SimulatedStoreBillingAbstract: makePurchaseAsync for product: ${purchasingData.productId}" } + val simulatedStorePurchasingData = purchasingData as? SimulatedStorePurchasingData + ?: throw PurchasesException( + PurchasesError( + PurchasesErrorCode.ProductNotAvailableForPurchaseError, + "Purchasing data is not a valid SimulatedStorePurchasingData: ${purchasingData.productId}", + ), + ) + val storeProduct = simulatedStorePurchasingData.storeProduct + showPurchaseDialog(activity, storeProduct, presentedOfferingContext) + } + + override fun isConnected(): Boolean = connected + + override fun queryPurchases( + appUserID: String, + onSuccess: (Map) -> Unit, + onError: (PurchasesError) -> Unit, + ) { + debugLog { "SimulatedStoreBillingAbstract: queryPurchases - returning empty map" } + onSuccess(emptyMap()) + } + + override fun showInAppMessagesIfNeeded( + activity: Activity, + inAppMessageTypes: List, + subscriptionStatusChange: () -> Unit, + ) { + debugLog { "SimulatedStoreBillingAbstract: showInAppMessagesIfNeeded - no-op for test store" } + } + + override fun getStorefront( + onSuccess: (String) -> Unit, + onError: PurchasesErrorCallback, + ) { + debugLog { "SimulatedStoreBillingAbstract: getStorefront - returning US by default" } + onSuccess("US") + } + + private fun showPurchaseDialog( + activity: Activity, + product: StoreProduct, + presentedOfferingContext: PresentedOfferingContext?, + ) { + val message = buildString { + append("Product: ${product.id}\n") + append("Price: ${product.price.formatted}\n") + product.defaultOption?.let { option -> + option.pricingPhases.forEach { phase -> + append("Phase: ${phase.price.formatted} for ${phase.billingPeriod.iso8601}\n") + } + } + } + + dialogHelper.showDialog( + activity = activity, + title = "Test Store Purchase", + message = message, + positiveButtonText = "Test valid Purchase", + negativeButtonText = "Test failed Purchase", + neutralButtonText = "Cancel", + onPositiveButtonClicked = { + completePurchase(product, presentedOfferingContext) + }, + onNegativeButtonClicked = { + purchasesUpdatedListener?.onPurchasesFailedToUpdate( + PurchasesError( + PurchasesErrorCode.ProductNotAvailableForPurchaseError, + "Test purchase failure: no real transaction occurred", + ), + ) + }, + onNeutralButtonClicked = { + purchasesUpdatedListener?.onPurchasesFailedToUpdate( + PurchasesError( + PurchasesErrorCode.PurchaseCancelledError, + "Purchase cancelled by user", + ), + ) + }, + ) + } + + private fun completePurchase( + product: StoreProduct, + presentedOfferingContext: PresentedOfferingContext?, + ) { + val purchaseTime = Date().time + + val purchaseToken = "test_${purchaseTime}_${UUID.randomUUID()}" + + val storeTransaction = StoreTransaction( + orderId = purchaseToken, + productIds = listOf(product.id), + type = product.type, + purchaseTime = purchaseTime, + purchaseToken = purchaseToken, + purchaseState = PurchaseState.PURCHASED, + isAutoRenewing = product.type == ProductType.SUBS, + signature = null, + originalJson = JSONObject().apply { + put("orderId", purchaseToken) + put("productId", product.id) + put("purchaseTime", purchaseTime) + put("purchaseToken", purchaseToken) + put("purchaseState", PurchaseState.PURCHASED.ordinal) + }, + presentedOfferingContext = presentedOfferingContext, + storeUserID = null, + purchaseType = PurchaseType.GOOGLE_PURCHASE, // WIP: Specify a new purchase type for the simulated store + marketplace = null, + subscriptionOptionId = product.defaultOption?.id, + replacementMode = null, + ) + + purchasesUpdatedListener?.onPurchasesUpdated(listOf(storeTransaction)) + } +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/simulatedstore/SimulatedStoreOfferingParser.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/simulatedstore/SimulatedStoreOfferingParser.kt new file mode 100644 index 0000000000..761db7d8a9 --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/simulatedstore/SimulatedStoreOfferingParser.kt @@ -0,0 +1,15 @@ +package com.revenuecat.purchases.simulatedstore + +import com.revenuecat.purchases.common.OfferingParser +import com.revenuecat.purchases.models.StoreProduct +import org.json.JSONObject + +internal class SimulatedStoreOfferingParser : OfferingParser() { + override fun findMatchingProduct( + productsById: Map>, + packageJson: JSONObject, + ): StoreProduct? { + val productIdentifier = packageJson.getString("platform_product_identifier") + return productsById[productIdentifier]?.first() // For simulated store, only one product per id should exist. + } +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/simulatedstore/SimulatedStoreProductConverter.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/simulatedstore/SimulatedStoreProductConverter.kt new file mode 100644 index 0000000000..a40cc937e3 --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/simulatedstore/SimulatedStoreProductConverter.kt @@ -0,0 +1,97 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.simulatedstore + +import com.revenuecat.purchases.PurchasesError +import com.revenuecat.purchases.PurchasesErrorCode +import com.revenuecat.purchases.PurchasesException +import com.revenuecat.purchases.common.networking.WebBillingProductResponse +import com.revenuecat.purchases.models.Period +import com.revenuecat.purchases.models.Price +import com.revenuecat.purchases.models.PricingPhase +import com.revenuecat.purchases.models.RecurrenceMode +import com.revenuecat.purchases.models.TestStoreProduct +import com.revenuecat.purchases.utils.PriceFactory +import java.util.Locale + +internal object SimulatedStoreProductConverter { + + @JvmSynthetic + @Suppress("LongMethod") + @Throws(PurchasesException::class) + fun convertToStoreProduct( + productResponse: WebBillingProductResponse, + locale: Locale = Locale.getDefault(), + ): TestStoreProduct { + val defaultPurchaseOptionId = productResponse.defaultPurchaseOptionId + val purchaseOptions = productResponse.purchaseOptions + + val purchaseOptionKey = defaultPurchaseOptionId ?: purchaseOptions.keys.first() + val purchaseOption = purchaseOptions[purchaseOptionKey] ?: run { + throw PurchasesException( + PurchasesError( + PurchasesErrorCode.ProductNotAvailableForPurchaseError, + "No purchase option found for product ${productResponse.identifier}", + ), + ) + } + + val basePrice: Price? + var period: Period? = null + var freeTrialPricingPhase: PricingPhase? = null + var introPricePricingPhase: PricingPhase? = null + + if (purchaseOption.basePrice != null) { + val basePriceObj = purchaseOption.basePrice + basePrice = PriceFactory.createPrice(basePriceObj.amountMicros, basePriceObj.currency, locale) + } else { + val basePhase = purchaseOption.base + if (basePhase?.price != null) { + val priceObj = basePhase.price + basePrice = PriceFactory.createPrice(priceObj.amountMicros, priceObj.currency, locale) + if (basePhase.periodDuration != null) { + period = Period.create(basePhase.periodDuration) + } + } else { + throw PurchasesException( + PurchasesError( + PurchasesErrorCode.ProductNotAvailableForPurchaseError, + "Base price is required for test subscription products", + ), + ) + } + + val trialPhase = purchaseOption.trial + if (trialPhase?.periodDuration != null) { + freeTrialPricingPhase = PricingPhase( + billingPeriod = Period.create(trialPhase.periodDuration), + recurrenceMode = RecurrenceMode.FINITE_RECURRING, + billingCycleCount = trialPhase.cycleCount, + price = PriceFactory.createPrice(0, basePhase.price.currency, locale), + ) + } + + val introPhase = purchaseOption.introPrice + if (introPhase?.price != null && introPhase.periodDuration != null) { + val priceObj = introPhase.price + introPricePricingPhase = PricingPhase( + billingPeriod = Period.create(introPhase.periodDuration), + recurrenceMode = RecurrenceMode.FINITE_RECURRING, + billingCycleCount = introPhase.cycleCount, + price = PriceFactory.createPrice(priceObj.amountMicros, priceObj.currency, locale), + ) + } + } + + return TestStoreProduct( + id = productResponse.identifier, + name = productResponse.title, + title = productResponse.title, + description = productResponse.description ?: "", + price = basePrice, + period = period, + freeTrialPricingPhase = freeTrialPricingPhase, + introPricePricingPhase = introPricePricingPhase, + ) + } +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/simulatedstore/SimulatedStorePurchasingData.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/simulatedstore/SimulatedStorePurchasingData.kt new file mode 100644 index 0000000000..20a7eaeec9 --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/simulatedstore/SimulatedStorePurchasingData.kt @@ -0,0 +1,11 @@ +package com.revenuecat.purchases.simulatedstore + +import com.revenuecat.purchases.ProductType +import com.revenuecat.purchases.models.PurchasingData +import com.revenuecat.purchases.models.StoreProduct + +internal data class SimulatedStorePurchasingData( + override val productId: String, + override val productType: ProductType, + val storeProduct: StoreProduct, +) : PurchasingData diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/strings/ConfigureStrings.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/strings/ConfigureStrings.kt index 9e86180abc..27e6d81a43 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/strings/ConfigureStrings.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/strings/ConfigureStrings.kt @@ -23,6 +23,11 @@ internal object ConfigureStrings { const val GOOGLE_API_KEY_AMAZON_STORE = "Looks like you're using a Google API key but have configured the SDK " + "for the Amazon app store.\nEither use an Amazon API key which should look like 'amzn_1a2b3c4d5e6f7h' or " + "configure the SDK to use Google.\nSee https://rev.cat/auth for more details." + const val SIMULATED_STORE_API_KEY = "Using a Test Store API key.\n" + + "The Test Store is for development only. Never use a Test Store API key in production. " + + "Our SDK will crash if using it in production. Test Store purchases are simulated, " + + "do not use Google Play or Amazon store, and generate no revenue. " + + "Apps submitted with a Test Store API key will be rejected during App Review." const val INVALID_API_KEY = "The specified API Key is not recognized.\n" + "Ensure that you are using the public app-specific API key, " + "which should look like 'goog_1a2b3c4d5e6f7h' or 'amzn_1a2b3c4d5e6f7h'.\n" + diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/strings/IdentityStrings.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/strings/IdentityStrings.kt index 36802db615..c0b323d929 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/strings/IdentityStrings.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/strings/IdentityStrings.kt @@ -3,6 +3,7 @@ package com.revenuecat.purchases.strings internal object IdentityStrings { const val LOGGING_IN = "Logging in from %s -> %s" const val LOG_IN_SUCCESSFUL = "Logged in successfully as %s. Created: %s" + const val ALIAS_OLD_USER_ID_TO_CURRENT_SUCCESSFUL = "Successfully aliased old user ID %s to current user ID %s." const val LOG_IN_ERROR_MISSING_APP_USER_ID = "Error logging in: appUserID can't be null, empty or blank" const val IDENTIFYING_APP_USER_ID = "Identifying App User ID: %s" const val EMPTY_APP_USER_ID_WILL_BECOME_ANONYMOUS = "Identifying with empty App User ID will be " + diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/strings/RestoreStrings.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/strings/RestoreStrings.kt index cc8fac2e41..6664447b7a 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/strings/RestoreStrings.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/strings/RestoreStrings.kt @@ -11,9 +11,19 @@ internal object RestoreStrings { const val QUERYING_PURCHASE_WITH_TYPE = "Querying Purchase with %s and type %s" const val RESTORING_PURCHASE = "Restoring purchases" const val RESTORING_PURCHASE_ERROR = "Error restoring purchase: %s. Error: %s" + const val RESTORE_PURCHASES_NO_PURCHASES_FOUND = "No purchases found to restore. " + + "This will happen if the user does not have any active subscriptions or unconsumed one-time products." + + "Please make sure you're using the correct Store account (Google/Amazon) and that you have configured your " + + "one-time products correctly as either consumables (that can be purchased multiple times) or non-consumables " + + "(that can only be purchased one by each user) in the RevenueCat dashboard. " + + "This will return the current CustomerInfo." const val SHARING_ACC_RESTORE_FALSE = "allowSharingPlayStoreAccount is set to false and restorePurchases " + "has been called. This will 'alias' any app user id's sharing the same receipt. " + "Are you sure you want to do this? More info here: https://errors.rev.cat/allowsSharingPlayStoreAccount" + const val RESTORE_PURCHASES_SIMULATED_STORE = "Restoring purchases not available in test store. " + + "Returning current CustomerInfo." + const val SYNC_PURCHASES_SIMULATED_STORE = "Syncing purchases not available in test store. " + + "Returning current CustomerInfo." const val QUERYING_PURCHASE_HISTORY = "Querying purchase history for type %s" const val QUERYING_SUBS_ERROR = "Error when querying subscriptions. %s" const val QUERYING_INAPP_ERROR = "Error when querying inapps. %s" diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/strings/VirtualCurrencyStrings.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/strings/VirtualCurrencyStrings.kt new file mode 100644 index 0000000000..b84de016f1 --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/strings/VirtualCurrencyStrings.kt @@ -0,0 +1,13 @@ +package com.revenuecat.purchases.strings + +internal object VirtualCurrencyStrings { + const val INVALIDATING_VIRTUAL_CURRENCIES_CACHE = "Invalidating VirtualCurrencies cache." + const val VENDING_FROM_CACHE = "Vending VirtualCurrencies from cache." + const val NO_CACHED_VIRTUAL_CURRENCIES = "There are no cached VirtualCurrencies." + const val VIRTUAL_CURRENCIES_STALE_UPDATING_FROM_NETWORK = "VirtualCurrencies cache is stale, updating from " + + "network." + const val VIRTUAL_CURRENCIES_UPDATED_FROM_NETWORK = "VirtualCurrencies updated from the network." + const val VIRTUAL_CURRENCIES_UPDATED_FROM_NETWORK_ERROR = "Attempt to update VirtualCurrencies from network " + + "failed. Error: %s" + const val ERROR_DECODING_CACHED_VIRTUAL_CURRENCIES = "Couldn't decode cached VirtualCurrencies. Error: %s" +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/subscriberattributes/SubscriberAttributesManager.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/subscriberattributes/SubscriberAttributesManager.kt index 6551772573..de99b6ab66 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/subscriberattributes/SubscriberAttributesManager.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/subscriberattributes/SubscriberAttributesManager.kt @@ -17,6 +17,7 @@ internal class SubscriberAttributesManager( val deviceCache: SubscriberAttributesCache, val backend: SubscriberAttributesPoster, private val deviceIdentifiersFetcher: DeviceIdentifiersFetcher, + private val automaticDeviceIdentifierCollectionEnabled: Boolean, ) { private val obtainingDeviceIdentifiersObservable = ObtainDeviceIdentifiersObservable() @@ -178,10 +179,17 @@ internal class SubscriberAttributesManager( appUserID: String, applicationContext: Application, ) { - getDeviceIdentifiers(applicationContext) { deviceIdentifiers -> + val setAttributes: (deviceIdentifiers: Map) -> Unit = { deviceIdentifiers -> val attributesToSet = mapOf(attributionKey.backendKey to value) + deviceIdentifiers setAttributes(attributesToSet, appUserID) } + if (automaticDeviceIdentifierCollectionEnabled) { + getDeviceIdentifiers(applicationContext) { deviceIdentifiers -> + setAttributes(deviceIdentifiers) + } + } else { + setAttributes(emptyMap()) + } } private fun getDeviceIdentifiers( diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/utils/AlertDialogHelper.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/utils/AlertDialogHelper.kt new file mode 100644 index 0000000000..ce0664f5ff --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/utils/AlertDialogHelper.kt @@ -0,0 +1,51 @@ +package com.revenuecat.purchases.utils + +import android.app.Activity +import android.app.AlertDialog + +internal interface AlertDialogHelper { + @Suppress("LongParameterList") + fun showDialog( + activity: Activity, + title: String, + message: String, + positiveButtonText: String, + negativeButtonText: String, + neutralButtonText: String, + onPositiveButtonClicked: () -> Unit, + onNegativeButtonClicked: () -> Unit, + onNeutralButtonClicked: () -> Unit, + ) +} + +internal class DefaultAlertDialogHelper : AlertDialogHelper { + + override fun showDialog( + activity: Activity, + title: String, + message: String, + positiveButtonText: String, + negativeButtonText: String, + neutralButtonText: String, + onPositiveButtonClicked: () -> Unit, + onNegativeButtonClicked: () -> Unit, + onNeutralButtonClicked: () -> Unit, + ) { + AlertDialog.Builder(activity) + .setTitle(title) + .setMessage(message) + .setPositiveButton(positiveButtonText) { dialog, _ -> + dialog.dismiss() + onPositiveButtonClicked() + } + .setNegativeButton(negativeButtonText) { dialog, _ -> + dialog.dismiss() + onNegativeButtonClicked() + } + .setNeutralButton(neutralButtonText) { dialog, _ -> + dialog.dismiss() + onNeutralButtonClicked() + } + .show() + } +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PreviewOfferingParser.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PreviewOfferingParser.kt index be278176d7..f0876ae19f 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PreviewOfferingParser.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PreviewOfferingParser.kt @@ -1,10 +1,11 @@ package com.revenuecat.purchases.utils -import com.revenuecat.purchases.InternalRevenueCatAPI import com.revenuecat.purchases.PackageType import com.revenuecat.purchases.common.OfferingParser import com.revenuecat.purchases.models.Period import com.revenuecat.purchases.models.Price +import com.revenuecat.purchases.models.PricingPhase +import com.revenuecat.purchases.models.RecurrenceMode import com.revenuecat.purchases.models.StoreProduct import com.revenuecat.purchases.models.TestStoreProduct import org.json.JSONObject @@ -14,7 +15,6 @@ import org.json.JSONObject * v2 Paywall templates. */ @Suppress("UnusedPrivateClass", "unused", "LongMethod") -@OptIn(InternalRevenueCatAPI::class) private class PreviewOfferingParser : OfferingParser() { override fun findMatchingProduct( productsById: Map>, @@ -30,7 +30,7 @@ private class PreviewOfferingParser : OfferingParser() { id = "com.revenuecat.lifetime_product", name = "Lifetime", title = "Lifetime (App name)", - price = Price(amountMicros = 1_000_000_000, currencyCode = "USD", formatted = "$1,000"), + price = Price(amountMicros = 1_000_000_000, currencyCode = "USD", formatted = "$ 1,000.00"), description = "Lifetime", period = null, ) @@ -39,17 +39,26 @@ private class PreviewOfferingParser : OfferingParser() { id = "com.revenuecat.annual_product", name = "Annual", title = "Annual (App name)", - price = Price(amountMicros = 67_990_000, currencyCode = "USD", formatted = "$67.99"), + price = Price(amountMicros = 67_990_000, currencyCode = "USD", formatted = "$ 67.99"), description = "Annual", period = Period(value = 1, unit = Period.Unit.YEAR, iso8601 = "P1Y"), - freeTrialPeriod = Period(value = 1, unit = Period.Unit.MONTH, iso8601 = "P1M"), + freeTrialPricingPhase = PricingPhase( + billingPeriod = Period(value = 1, unit = Period.Unit.MONTH, iso8601 = "P1M"), + recurrenceMode = RecurrenceMode.FINITE_RECURRING, + billingCycleCount = 1, + price = Price( + amountMicros = 0L, + currencyCode = "USD", + formatted = "Free", + ), + ), ) PackageType.SIX_MONTH -> TestStoreProduct( id = "com.revenuecat.semester_product", name = "6 month", title = "6 month (App name)", - price = Price(amountMicros = 39_990_000, currencyCode = "USD", formatted = "$39.99"), + price = Price(amountMicros = 39_990_000, currencyCode = "USD", formatted = "$ 39.99"), description = "6 month", period = Period(value = 6, unit = Period.Unit.MONTH, iso8601 = "P6M"), ) @@ -58,28 +67,47 @@ private class PreviewOfferingParser : OfferingParser() { id = "com.revenuecat.quarterly_product", name = "3 month", title = "3 month (App name)", - price = Price(amountMicros = 23_990_000, currencyCode = "USD", formatted = "$23.99"), + price = Price(amountMicros = 23_990_000, currencyCode = "USD", formatted = "$ 23.99"), description = "3 month", period = Period(value = 3, unit = Period.Unit.MONTH, iso8601 = "P3M"), - freeTrialPeriod = Period(value = 2, unit = Period.Unit.WEEK, iso8601 = "P2W"), - introPrice = Price(amountMicros = 3_990_000, currencyCode = "USD", formatted = "$3.99"), + freeTrialPricingPhase = PricingPhase( + billingPeriod = Period(value = 2, unit = Period.Unit.WEEK, iso8601 = "P2W"), + recurrenceMode = RecurrenceMode.FINITE_RECURRING, + billingCycleCount = 1, + price = Price( + amountMicros = 0L, + currencyCode = "USD", + formatted = "Free", + ), + ), + introPricePricingPhase = PricingPhase( + billingPeriod = Period(value = 1, unit = Period.Unit.MONTH, iso8601 = "P1M"), + recurrenceMode = RecurrenceMode.FINITE_RECURRING, + billingCycleCount = 1, + price = Price(amountMicros = 3_990_000, currencyCode = "USD", formatted = "$ 3.99"), + ), ) PackageType.TWO_MONTH -> TestStoreProduct( id = "com.revenuecat.bimonthly_product", name = "2 month", title = "2 month (App name)", - price = Price(amountMicros = 15_990_000, currencyCode = "USD", formatted = "$15.99"), + price = Price(amountMicros = 15_990_000, currencyCode = "USD", formatted = "$ 15.99"), description = "2 month", period = Period(value = 2, unit = Period.Unit.MONTH, iso8601 = "P2M"), - introPrice = Price(amountMicros = 3_990_000, currencyCode = "USD", formatted = "$3.99"), + introPricePricingPhase = PricingPhase( + billingPeriod = Period(value = 1, unit = Period.Unit.MONTH, iso8601 = "P1M"), + recurrenceMode = RecurrenceMode.FINITE_RECURRING, + billingCycleCount = 1, + price = Price(amountMicros = 3_990_000, currencyCode = "USD", formatted = "$ 3.99"), + ), ) PackageType.MONTHLY -> TestStoreProduct( id = "com.revenuecat.monthly_product", name = "Monthly", title = "Monthly (App name)", - price = Price(amountMicros = 7_990_000, currencyCode = "USD", formatted = "$7.99"), + price = Price(amountMicros = 7_990_000, currencyCode = "USD", formatted = "$ 7.99"), description = "Monthly", period = Period(value = 1, unit = Period.Unit.MONTH, iso8601 = "P1M"), ) @@ -88,7 +116,7 @@ private class PreviewOfferingParser : OfferingParser() { id = "com.revenuecat.weekly_product", name = "Weekly", title = "Weekly (App name)", - price = Price(amountMicros = 1_490_000, currencyCode = "USD", formatted = "$1.49"), + price = Price(amountMicros = 1_490_000, currencyCode = "USD", formatted = "$ 1.49"), description = "Weekly", period = Period(value = 1, unit = Period.Unit.WEEK, iso8601 = "P1W"), ) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PriceExtensions.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PriceExtensions.kt index 1235a19c9b..cbeb2fc6b0 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PriceExtensions.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PriceExtensions.kt @@ -1,11 +1,8 @@ package com.revenuecat.purchases.utils import com.revenuecat.purchases.InternalRevenueCatAPI -import com.revenuecat.purchases.common.SharedConstants.MICRO_MULTIPLIER import com.revenuecat.purchases.models.Period import com.revenuecat.purchases.models.Price -import java.text.NumberFormat -import java.util.Currency import java.util.Locale @JvmSynthetic @@ -28,19 +25,7 @@ internal fun Price.pricePerYear(billingPeriod: Period, locale: Locale): Price { return pricePerPeriod(billingPeriod.valueInYears, locale) } -@OptIn(InternalRevenueCatAPI::class) private fun Price.pricePerPeriod(units: Double, locale: Locale): Price { - val currency = Currency.getInstance(currencyCode) - val numberFormat = NumberFormat.getCurrencyInstance(locale).apply { - this.currency = currency - // Making sure we do not add spurious digits: - val digits = currency.defaultFractionDigits.coerceAtLeast(0) - maximumFractionDigits = digits - minimumFractionDigits = digits - } - val value = amountMicros / units - val formatted = numberFormat.format(value / MICRO_MULTIPLIER) - - return Price(formatted, (value).toLong(), currencyCode) + return PriceFactory.createPrice(value.toLong(), currencyCode, locale) } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PriceFactory.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PriceFactory.kt new file mode 100644 index 0000000000..9df297f0e1 --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PriceFactory.kt @@ -0,0 +1,34 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.utils + +import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.common.SharedConstants.MICRO_MULTIPLIER +import com.revenuecat.purchases.models.Price +import java.text.NumberFormat +import java.util.Currency +import java.util.Locale + +internal object PriceFactory { + + @JvmSynthetic + @OptIn(InternalRevenueCatAPI::class) + internal fun createPrice( + amountMicros: Long, + currencyCode: String, + locale: Locale, + ): Price { + val currency = Currency.getInstance(currencyCode) + val numberFormat = NumberFormat.getCurrencyInstance(locale).apply { + this.currency = currency + // Making sure we do not add spurious digits: + val digits = currency.defaultFractionDigits.coerceAtLeast(0) + maximumFractionDigits = digits + minimumFractionDigits = digits + } + + val formatted = numberFormat.format(amountMicros / MICRO_MULTIPLIER) + + return Price(formatted, amountMicros, currencyCode) + } +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/virtualcurrencies/VirtualCurrencies.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/virtualcurrencies/VirtualCurrencies.kt new file mode 100644 index 0000000000..aaeac0d14e --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/virtualcurrencies/VirtualCurrencies.kt @@ -0,0 +1,30 @@ +package com.revenuecat.purchases.virtualcurrencies + +import android.os.Parcelable +import com.revenuecat.purchases.InternalRevenueCatAPI +import dev.drewhamilton.poko.Poko +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * This class contains all the virtual currencies associated to the user. + * + * @property all - All of the virtual currencies associated to the user. + */ +@Poko +@Parcelize +@Serializable +class VirtualCurrencies @InternalRevenueCatAPI constructor( + @SerialName("virtual_currencies") + val all: Map, +) : Parcelable { + + /** + * Returns the virtual currency for the given key, or null if it doesn't exist. + * + * @param code The code of the virtual currency to retrieve + * @return The virtual currency, or null if not found + */ + operator fun get(code: String): VirtualCurrency? = all[code] +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/virtualcurrencies/VirtualCurrenciesFactory.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/virtualcurrencies/VirtualCurrenciesFactory.kt new file mode 100644 index 0000000000..d1a30e0776 --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/virtualcurrencies/VirtualCurrenciesFactory.kt @@ -0,0 +1,42 @@ +package com.revenuecat.purchases.virtualcurrencies + +import com.revenuecat.purchases.common.JsonProvider +import com.revenuecat.purchases.common.networking.HTTPResult +import kotlinx.serialization.SerializationException +import org.json.JSONException +import org.json.JSONObject + +/** + * Builds a [VirtualCurrencies] object. + * + * @throws [JSONException] If the JSON is invalid. + * @throws [SerializationException] In case of any decoding-specific error + * @throws [IllegalArgumentException] - if the decoded input is not a valid instance of [VirtualCurrencies] + */ +internal object VirtualCurrenciesFactory { + + @Throws(JSONException::class, SerializationException::class, IllegalArgumentException::class) + fun buildVirtualCurrencies(httpResult: HTTPResult): VirtualCurrencies { + return buildVirtualCurrencies( + body = httpResult.body, + ) + } + + @Throws(JSONException::class, SerializationException::class, IllegalArgumentException::class) + fun buildVirtualCurrencies( + body: JSONObject, + ): VirtualCurrencies { + return JsonProvider.defaultJson.decodeFromString( + body.toString(), + ) + } + + @Throws(JSONException::class, SerializationException::class, IllegalArgumentException::class) + fun buildVirtualCurrencies( + jsonString: String, + ): VirtualCurrencies { + return JsonProvider.defaultJson.decodeFromString( + jsonString, + ) + } +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/virtualcurrencies/VirtualCurrency.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/virtualcurrencies/VirtualCurrency.kt new file mode 100644 index 0000000000..6dfed68d62 --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/virtualcurrencies/VirtualCurrency.kt @@ -0,0 +1,27 @@ +package com.revenuecat.purchases.virtualcurrencies + +import android.os.Parcelable +import com.revenuecat.purchases.InternalRevenueCatAPI +import dev.drewhamilton.poko.Poko +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * A class representing information about a virtual currency in the app. + * + * @property balance The customer's current balance of the virtual currency. + * @property name The virtual currency's name defined in the RevenueCat dashboard. + * @property code The virtual currency's code defined in the RevenueCat dashboard. + * @property serverDescription The virtual currency description defined in the RevenueCat dashboard. + */ +@Poko +@Serializable +@Parcelize +class VirtualCurrency @InternalRevenueCatAPI constructor( + val balance: Int, + val name: String, + val code: String, + @SerialName("description") + val serverDescription: String? = null, +) : Parcelable diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/virtualcurrencies/VirtualCurrencyManager.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/virtualcurrencies/VirtualCurrencyManager.kt new file mode 100644 index 0000000000..89df51cc92 --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/virtualcurrencies/VirtualCurrencyManager.kt @@ -0,0 +1,142 @@ +package com.revenuecat.purchases.virtualcurrencies + +import com.revenuecat.purchases.PurchasesError +import com.revenuecat.purchases.common.AppConfig +import com.revenuecat.purchases.common.Backend +import com.revenuecat.purchases.common.LogIntent +import com.revenuecat.purchases.common.caching.DeviceCache +import com.revenuecat.purchases.common.log +import com.revenuecat.purchases.identity.IdentityManager +import com.revenuecat.purchases.interfaces.GetVirtualCurrenciesCallback +import com.revenuecat.purchases.strings.VirtualCurrencyStrings + +@Suppress("UnusedPrivateProperty") +internal class VirtualCurrencyManager( + private val identityManager: IdentityManager, + private val deviceCache: DeviceCache, + private val backend: Backend, + private val appConfig: AppConfig, +) { + @Synchronized + fun virtualCurrencies( + callback: GetVirtualCurrenciesCallback, + ) { + val appUserID = identityManager.currentAppUserID + val isAppBackgrounded = appConfig.isAppBackgrounded + + val cachedVirtualCurrencies = fetchCachedVirtualCurrencies( + appUserID = appUserID, + isAppBackgrounded = isAppBackgrounded, + allowStaleCache = false, + ) + if (cachedVirtualCurrencies != null) { + log(LogIntent.DEBUG) { + VirtualCurrencyStrings.VENDING_FROM_CACHE + } + callback.onReceived(cachedVirtualCurrencies) + return + } + + log(LogIntent.DEBUG) { + VirtualCurrencyStrings.VIRTUAL_CURRENCIES_STALE_UPDATING_FROM_NETWORK + } + fetchVirtualCurrenciesFromBackend( + appUserID = appUserID, + isAppBackgrounded = isAppBackgrounded, + callback = handleVirtualCurrenciesRequestResult(callback, appUserID), + ) + } + + @Synchronized + fun cachedVirtualCurrencies(): VirtualCurrencies? { + val appUserID = identityManager.currentAppUserID + val isAppBackgrounded = appConfig.isAppBackgrounded + + val cachedVirtualCurrencies: VirtualCurrencies? = fetchCachedVirtualCurrencies( + appUserID = appUserID, + isAppBackgrounded = isAppBackgrounded, + allowStaleCache = true, + ) + + if (cachedVirtualCurrencies != null) { + log(LogIntent.DEBUG) { + VirtualCurrencyStrings.VENDING_FROM_CACHE + } + return cachedVirtualCurrencies + } else { + return null + } + } + + @Synchronized + fun invalidateVirtualCurrenciesCache() { + val appUserID = identityManager.currentAppUserID + + log(LogIntent.DEBUG) { + VirtualCurrencyStrings.INVALIDATING_VIRTUAL_CURRENCIES_CACHE + } + deviceCache.clearVirtualCurrenciesCache(appUserID = appUserID) + } + + private fun cacheVirtualCurrencies( + virtualCurrencies: VirtualCurrencies, + appUserID: String, + ) { + deviceCache.cacheVirtualCurrencies( + appUserID = appUserID, + virtualCurrencies = virtualCurrencies, + ) + } + + private fun fetchCachedVirtualCurrencies( + appUserID: String, + isAppBackgrounded: Boolean, + allowStaleCache: Boolean, + ): VirtualCurrencies? { + if (!allowStaleCache && deviceCache.isVirtualCurrenciesCacheStale(appUserID, isAppBackgrounded)) { + return null + } + + val cachedVirtualCurrencies: VirtualCurrencies? = deviceCache.getCachedVirtualCurrencies(appUserID = appUserID) + if (cachedVirtualCurrencies == null) { + log(LogIntent.DEBUG) { + VirtualCurrencyStrings.NO_CACHED_VIRTUAL_CURRENCIES + } + } + + return cachedVirtualCurrencies + } + + private fun fetchVirtualCurrenciesFromBackend( + appUserID: String, + isAppBackgrounded: Boolean, + callback: GetVirtualCurrenciesCallback, + ) { + backend.getVirtualCurrencies( + appUserID = appUserID, + appInBackground = isAppBackgrounded, + onSuccess = { callback.onReceived(it) }, + onError = { callback.onError(it) }, + ) + } + + private fun handleVirtualCurrenciesRequestResult( + completion: GetVirtualCurrenciesCallback, + appUserID: String, + ): GetVirtualCurrenciesCallback = object : GetVirtualCurrenciesCallback { + override fun onReceived(virtualCurrencies: VirtualCurrencies) { + log(LogIntent.RC_SUCCESS) { + VirtualCurrencyStrings.VIRTUAL_CURRENCIES_UPDATED_FROM_NETWORK + } + + cacheVirtualCurrencies(virtualCurrencies, appUserID) + completion.onReceived(virtualCurrencies) + } + override fun onError(error: PurchasesError) { + log(LogIntent.RC_ERROR) { + VirtualCurrencyStrings.VIRTUAL_CURRENCIES_UPDATED_FROM_NETWORK_ERROR.format(error) + } + completion.onError(error) + } + } +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/APIKeyValidatorTest.kt b/purchases/src/test/java/com/revenuecat/purchases/APIKeyValidatorTest.kt index 685586376d..bb06ba8ad6 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/APIKeyValidatorTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/APIKeyValidatorTest.kt @@ -26,6 +26,20 @@ class APIKeyValidatorTest { ) } + @Test + fun `Validation result is simulated store`() { + assertValidation( + APIKeyValidator.ValidationResult.SIMULATED_STORE, + "test_1a2b3c4d5e6f7h", + Store.PLAY_STORE + ) + assertValidation( + APIKeyValidator.ValidationResult.SIMULATED_STORE, + "test_1a2b3c4d5e6f7h", + Store.AMAZON + ) + } + @Test fun `Validation result is legacy`() { assertValidation(APIKeyValidator.ValidationResult.LEGACY, "1a2b3c4d5e6f7h", Store.PLAY_STORE) @@ -48,7 +62,7 @@ class APIKeyValidatorTest { private fun assertValidation(expected: APIKeyValidator.ValidationResult, apiKey: String, store: Store) { val validator = APIKeyValidator() - val validationResult = validator.validate(apiKey, store) + val validationResult = validator.validateAndLog(apiKey, store) assertThat(validationResult).isEqualTo(expected) } } diff --git a/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt b/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt index f7335516cd..6a59eb9489 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt @@ -8,15 +8,18 @@ package com.revenuecat.purchases import android.Manifest import android.app.Activity import android.app.Application +import android.app.backup.BackupManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.test.platform.app.InstrumentationRegistry import com.android.billingclient.api.Purchase import com.android.billingclient.api.PurchaseHistoryRecord import com.revenuecat.purchases.PurchasesAreCompletedBy.REVENUECAT +import com.revenuecat.purchases.blockstore.BlockstoreHelper import com.revenuecat.purchases.common.AppConfig import com.revenuecat.purchases.common.Backend import com.revenuecat.purchases.common.BillingAbstract +import com.revenuecat.purchases.common.DefaultLocaleProvider import com.revenuecat.purchases.common.DateProvider import com.revenuecat.purchases.common.PlatformInfo import com.revenuecat.purchases.common.caching.DeviceCache @@ -45,6 +48,7 @@ import com.revenuecat.purchases.utils.stubGooglePurchase import com.revenuecat.purchases.utils.stubPurchaseHistoryRecord import com.revenuecat.purchases.utils.stubStoreProduct import com.revenuecat.purchases.utils.stubSubscriptionOption +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencyManager import io.mockk.Runs import io.mockk.clearMocks import io.mockk.every @@ -77,11 +81,14 @@ internal open class BasePurchasesTest { internal val mockPostPendingTransactionsHelper = mockk() internal val mockSyncPurchasesHelper = mockk() protected val mockOfferingsManager = mockk() + protected val mockBackupManager = mockk() internal val mockEventsManager = mockk() internal val mockWebPurchasesRedemptionHelper = mockk() internal val mockLifecycleOwner = mockk() internal val mockLifecycle = mockk() internal val mockFontLoader = mockk() + internal val mockVirtualCurrencyManager = mockk() + private val mockBlockstoreHelper = mockk() private val purchasesStateProvider = PurchasesStateCache(PurchasesState()) protected lateinit var appConfig: AppConfig @@ -138,6 +145,19 @@ internal open class BasePurchasesTest { mockLifecycleOwner.lifecycle } returns mockLifecycle + every { mockBlockstoreHelper.storeUserIdIfNeeded(any()) } just Runs + every { + mockBlockstoreHelper.aliasCurrentAndStoredUserIdsIfNeeded(captureLambda()) + } answers { + lambda<() -> Unit>().captured.invoke() + } + every { + mockBlockstoreHelper.clearUserIdBackupIfNeeded(captureLambda()) + } answers { + lambda<() -> Unit>().captured.invoke() + } + every { mockBackupManager.dataChanged() } just Runs + every { mockLifecycle.addObserver(any()) } just Runs every { mockLifecycle.removeObserver(any()) } just Runs @@ -163,6 +183,8 @@ internal open class BasePurchasesTest { mockLifecycleOwner, mockLifecycle, mockFontLoader, + mockBlockstoreHelper, + mockBackupManager, ) } @@ -407,6 +429,8 @@ internal open class BasePurchasesTest { autoSync: Boolean = true, customEntitlementComputation: Boolean = false, showInAppMessagesAutomatically: Boolean = false, + apiKeyValidationResult: APIKeyValidator.ValidationResult = APIKeyValidator.ValidationResult.VALID, + enableSimulatedStore: Boolean = false, ) { appConfig = AppConfig( context = mockContext, @@ -416,6 +440,7 @@ internal open class BasePurchasesTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = apiKeyValidationResult, dangerousSettings = DangerousSettings( autoSyncPurchases = autoSync, customEntitlementComputation = customEntitlementComputation, @@ -450,8 +475,16 @@ internal open class BasePurchasesTest { webPurchaseRedemptionHelper = mockWebPurchasesRedemptionHelper, processLifecycleOwnerProvider = { mockLifecycleOwner }, fontLoader = mockFontLoader, + localeProvider = DefaultLocaleProvider(), + virtualCurrencyManager = mockVirtualCurrencyManager, + isSimulatedStoreEnabled = { enableSimulatedStore }, + blockstoreHelper = mockBlockstoreHelper, + backupManager = mockBackupManager, + ) + + purchases = Purchases( + purchasesOrchestrator = purchasesOrchestrator, ) - purchases = Purchases(purchasesOrchestrator) Purchases.sharedInstance = purchases purchasesOrchestrator.state = purchasesOrchestrator.state.copy(appInBackground = false) } diff --git a/purchases/src/test/java/com/revenuecat/purchases/BillingFactoryTest.kt b/purchases/src/test/java/com/revenuecat/purchases/BillingFactoryTest.kt index ecd74f06b4..ddb4c5e4d2 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/BillingFactoryTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/BillingFactoryTest.kt @@ -2,6 +2,7 @@ package com.revenuecat.purchases import android.app.Application import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.common.Backend import com.revenuecat.purchases.common.BackendHelper import com.revenuecat.purchases.common.caching.DeviceCache import com.revenuecat.purchases.common.diagnostics.DiagnosticsTracker @@ -18,6 +19,7 @@ class BillingFactoryTest { val mockBackendHelper = mockk(relaxed = true) val mockCache = mockk(relaxed = true) val mockDiagnosticsTracker = mockk(relaxed = true) + val mockBackend = mockk(relaxed = true) BillingFactory.createBilling( Store.PLAY_STORE, @@ -28,6 +30,8 @@ class BillingFactoryTest { mockDiagnosticsTracker, PurchasesStateCache(PurchasesState()), pendingTransactionsForPrepaidPlansEnabled = true, + backend = mockBackend, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) } @@ -36,6 +40,7 @@ class BillingFactoryTest { val mockApplication = mockk(relaxed = true) val mockBackendHelper = mockk(relaxed = true) val mockCache = mockk(relaxed = true) + val mockBackend = mockk(relaxed = true) BillingFactory.createBilling( Store.PLAY_STORE, @@ -46,6 +51,8 @@ class BillingFactoryTest { diagnosticsTrackerIfEnabled = null, PurchasesStateCache(PurchasesState()), pendingTransactionsForPrepaidPlansEnabled = true, + backend = mockBackend, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) } } diff --git a/purchases/src/test/java/com/revenuecat/purchases/OfferingsTest.kt b/purchases/src/test/java/com/revenuecat/purchases/OfferingsTest.kt index 0a6b07a580..7f897d1a3f 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/OfferingsTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/OfferingsTest.kt @@ -41,7 +41,10 @@ class OfferingsTest { private val monthlyPackageID = PackageType.MONTHLY.identifier!! private val annualPackageID = PackageType.ANNUAL.identifier!! - private val offeringsParser = OfferingParserFactory.createOfferingParser(Store.PLAY_STORE) + private val offeringsParser = OfferingParserFactory.createOfferingParser( + Store.PLAY_STORE, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, + ) @Test fun `createPackage returns null if packageJson planIdentifier doesnt match any sub StoreProduct base plan ids`() { diff --git a/purchases/src/test/java/com/revenuecat/purchases/ParcelableTests.kt b/purchases/src/test/java/com/revenuecat/purchases/ParcelableTests.kt index 3832de809a..88ac5dd207 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/ParcelableTests.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/ParcelableTests.kt @@ -11,6 +11,8 @@ import com.revenuecat.purchases.utils.JSONObjectParceler import com.revenuecat.purchases.utils.JSONObjectParceler.write import com.revenuecat.purchases.utils.Responses import com.revenuecat.purchases.utils.testParcelization +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrency import org.assertj.core.api.Assertions import org.json.JSONObject import org.junit.Test @@ -46,7 +48,7 @@ class ParcelableTests { firstSeen = Date(System.currentTimeMillis()), originalAppUserId = "original_app_user_id", managementURL = Uri.parse("https://management.com"), - originalPurchaseDate = Date(System.currentTimeMillis()) + originalPurchaseDate = Date(System.currentTimeMillis()), ) ) @@ -93,6 +95,39 @@ class ParcelableTests { testParcelization(nullMode, true) } + @Test + fun `VirtualCurrency is Parcelable`() { + testParcelization( + VirtualCurrency( + balance = 100, + name = "Coin", + code = "COIN", + serverDescription = "It's a coin" + ) + ) + } + + @Test + fun `VirtualCurrencies is Parcelable`() { + testParcelization( + VirtualCurrencies( + all = mapOf( + "COIN" to VirtualCurrency( + balance = 1, + name = "Coin", + code = "COIN", + serverDescription = "It's a coin" + ), + "RC_COIN" to VirtualCurrency( + balance = 0, + name = "RC Coin", + code = "RC_Coin", + serverDescription = null + )), + ) + ) + } + private fun getEntitlementInfo( identifier: String = "an_identifier" ): EntitlementInfo { diff --git a/purchases/src/test/java/com/revenuecat/purchases/PurchaseParamsTest.kt b/purchases/src/test/java/com/revenuecat/purchases/PurchaseParamsTest.kt index 3b9f45a71a..65288e7b09 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/PurchaseParamsTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/PurchaseParamsTest.kt @@ -49,24 +49,6 @@ class PurchaseParamsTest { assertThat(purchaseProductParams.presentedOfferingContext?.offeringIdentifier).isEqualTo(STUB_OFFERING_IDENTIFIER) } - @Test - fun `Initializing with TestStoreProduct throws error`() { - val storeProduct = TestStoreProduct( - "id", - "name", - "title", - "description", - Price("$1.99", 1_990_000, "US"), - Period(1, Period.Unit.MONTH, "P1M") - ) - try { - PurchaseParams.Builder(mockk(), storeProduct).build() - fail("Expected error") - } catch (e: PurchasesException) { - assertThat(e.code).isEqualTo(PurchasesErrorCode.ProductNotAvailableForPurchaseError) - } - } - @Test fun `Initializing with SubscriptionOption sets proper presentedOfferingIdentifier`() { val storeProduct = stubStoreProduct( @@ -97,25 +79,6 @@ class PurchaseParamsTest { assertThat(purchasePackageParams.purchasingData).isEqualTo(expectedPurchasingData) } - @Test - fun `Initializing with Package containing TestStoreProduct throws error`() { - val storeProduct = TestStoreProduct( - "id", - "name", - "title", - "description", - Price("$1.99", 1_990_000, "US"), - Period(1, Period.Unit.MONTH, "P1M") - ) - val (_, offerings) = stubOfferings(storeProduct) - try { - PurchaseParams.Builder(mockk(), offerings[STUB_OFFERING_IDENTIFIER]!!.monthly!!).build() - fail("Expected error") - } catch (e: PurchasesException) { - assertThat(e.code).isEqualTo(PurchasesErrorCode.ProductNotAvailableForPurchaseError) - } - } - @Test fun `Initializing with product sets proper purchasingData`() { val storeProduct = stubStoreProduct("abc") diff --git a/purchases/src/test/java/com/revenuecat/purchases/PurchasesCanMakePaymentsTest.kt b/purchases/src/test/java/com/revenuecat/purchases/PurchasesCanMakePaymentsTest.kt index 75b3799f48..5d6c30411d 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/PurchasesCanMakePaymentsTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/PurchasesCanMakePaymentsTest.kt @@ -121,6 +121,7 @@ internal class PurchasesCanMakePaymentsTest : BasePurchasesTest() { null, Store.AMAZON, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) Purchases.canMakePayments(mockContext, listOf()) { assertThat(it).isTrue() diff --git a/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt b/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt index 198826f327..ab9f43aea4 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt @@ -1260,6 +1260,32 @@ internal class PurchasesCommonTest: BasePurchasesTest() { } } + @Test + fun `when making a purchase, backup manager is notified of data change`() { + val productId = "onemonth_freetrial" + val purchaseToken = "crazy_purchase_token" + + mockQueryingProductDetails(productId, ProductType.SUBS, null) + + val storeProduct = stubStoreProduct(productId) + val purchaseParams = getPurchaseParams(storeProduct.subscriptionOptions!!.first()) + var callCount = 0 + purchases.purchaseWith( + purchaseParams, + onSuccess = { _, _ -> + callCount++ + }, onError = { _, _ -> fail("should be successful") }) + + capturedPurchasesUpdatedListener.captured.onPurchasesUpdated( + getMockedPurchaseList(productId, purchaseToken, ProductType.SUBS) + ) + + verify(exactly = 1) { + mockBackupManager.dataChanged() + } + assertThat(callCount).isEqualTo(1) + } + // endregion // region customer info @@ -1282,7 +1308,8 @@ internal class PurchasesCommonTest: BasePurchasesTest() { appUserId, CacheFetchPolicy.FETCH_CURRENT, false, - any() + any(), + callback = any(), ) } } diff --git a/purchases/src/test/java/com/revenuecat/purchases/PurchasesConfigurationTest.kt b/purchases/src/test/java/com/revenuecat/purchases/PurchasesConfigurationTest.kt index 4d612034fd..ba693dfa77 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/PurchasesConfigurationTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/PurchasesConfigurationTest.kt @@ -48,6 +48,8 @@ class PurchasesConfigurationTest { assertThat(purchasesConfiguration.dangerousSettings).isEqualTo(DangerousSettings(autoSyncPurchases = true)) assertThat(purchasesConfiguration.showInAppMessagesAutomatically).isTrue assertThat(purchasesConfiguration.pendingTransactionsForPrepaidPlansEnabled).isFalse + assertThat(purchasesConfiguration.automaticDeviceIdentifierCollectionEnabled).isTrue + assertThat(purchasesConfiguration.preferredUILocaleOverride).isNull() } @Test @@ -117,12 +119,31 @@ class PurchasesConfigurationTest { assertThat(purchasesConfiguration.dangerousSettings).isEqualTo(dangerousSettings) } + @Test + fun `PurchasesConfiguration sets automaticDeviceIdentifierCollectionEnabled correctly`() { + val purchasesConfiguration = builder.automaticDeviceIdentifierCollectionEnabled(false).build() + assertThat(purchasesConfiguration.automaticDeviceIdentifierCollectionEnabled).isFalse + } + @Test fun `PurchasesConfiguration trims api key`() { val purchasesConfiguration = PurchasesConfiguration.Builder(context, " test-api-key ").build() assertThat(purchasesConfiguration.apiKey).isEqualTo("test-api-key") } + @Test + fun `PurchasesConfiguration sets preferredUILocaleOverride correctly`() { + val localeOverride = "de_DE" + val purchasesConfiguration = builder.preferredUILocaleOverride(localeOverride).build() + assertThat(purchasesConfiguration.preferredUILocaleOverride).isEqualTo(localeOverride) + } + + @Test + fun `PurchasesConfiguration handles null preferredUILocaleOverride`() { + val purchasesConfiguration = builder.preferredUILocaleOverride(null).build() + assertThat(purchasesConfiguration.preferredUILocaleOverride).isNull() + } + @Test fun `PurchasesConfiguration does not use application context if provided with device-protected storage context`() { // Arrange diff --git a/purchases/src/test/java/com/revenuecat/purchases/PurchasesFactoryTest.kt b/purchases/src/test/java/com/revenuecat/purchases/PurchasesFactoryTest.kt index 171912c031..e644f9d3d5 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/PurchasesFactoryTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/PurchasesFactoryTest.kt @@ -5,13 +5,14 @@ import android.app.Application import android.content.Context import android.content.pm.PackageManager import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.common.PlatformInfo import io.mockk.clearAllMocks import io.mockk.every -import io.mockk.just import io.mockk.mockk -import io.mockk.runs import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatExceptionOfType +import org.assertj.core.api.Assertions.fail import org.junit.After import org.junit.Before import org.junit.Test @@ -24,6 +25,7 @@ class PurchasesFactoryTest { private val contextMock = mockk().apply { every { applicationContext } returns applicationMock every { isDeviceProtectedStorage } returns false + every { checkCallingOrSelfPermission(Manifest.permission.INTERNET) } returns PackageManager.PERMISSION_GRANTED } private val apiKeyValidatorMock = mockk() @@ -33,7 +35,9 @@ class PurchasesFactoryTest { fun setup() { purchasesFactory = PurchasesFactory(isDebugBuild = { true }, apiKeyValidatorMock) - every { apiKeyValidatorMock.validateAndLog("fakeApiKey", Store.PLAY_STORE) } just runs + every { + apiKeyValidatorMock.validateAndLog("fakeApiKey", Store.PLAY_STORE) + } returns APIKeyValidator.ValidationResult.VALID } @After @@ -92,6 +96,39 @@ class PurchasesFactoryTest { verify(exactly = 1) { apiKeyValidatorMock.validateAndLog("fakeApiKey", Store.PLAY_STORE) } } + @Test + fun `configuring SDK with simulated store api key in release mode throws exception`() { + purchasesFactory = PurchasesFactory( + isDebugBuild = { false }, + apiKeyValidator = apiKeyValidatorMock, + isSimulatedStoreEnabled = { true }, + ) + + every { + applicationMock.checkCallingOrSelfPermission(Manifest.permission.INTERNET) + } returns PackageManager.PERMISSION_GRANTED + every { + applicationMock.applicationContext + } returns mockk() + every { + apiKeyValidatorMock.validateAndLog("fakeApiKey", Store.PLAY_STORE) + } returns APIKeyValidator.ValidationResult.SIMULATED_STORE + + try { + purchasesFactory.createPurchases( + createConfiguration(), + PlatformInfo("test-flavor", "test-version"), + proxyURL = null, + ) + fail("Expected error") + } catch (e: PurchasesException) { + assertThat(e.code).isEqualTo(PurchasesErrorCode.ConfigurationError) + assertThat(e.underlyingErrorMessage).isEqualTo( + "Please configure the Play Store/Amazon store app on the RevenueCat dashboard and use its corresponding API key before releasing." + ) + } + } + private fun createConfiguration(testApiKey: String = "fakeApiKey"): PurchasesConfiguration { return PurchasesConfiguration.Builder(contextMock, testApiKey) .appUserID("appUserID") diff --git a/purchases/src/test/java/com/revenuecat/purchases/PurchasesLifecycleTest.kt b/purchases/src/test/java/com/revenuecat/purchases/PurchasesLifecycleTest.kt index f8c2bdfb49..77d3c1cecc 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/PurchasesLifecycleTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/PurchasesLifecycleTest.kt @@ -64,7 +64,8 @@ internal class PurchasesLifecycleTest: BasePurchasesTest() { appUserId, CacheFetchPolicy.FETCH_CURRENT, false, - any() + any(), + callback = any(), ) } verify(exactly = 0) { @@ -108,7 +109,8 @@ internal class PurchasesLifecycleTest: BasePurchasesTest() { appUserId, CacheFetchPolicy.FETCH_CURRENT, false, - any() + any(), + callback = any(), ) } verify(exactly = 1) { @@ -174,6 +176,7 @@ internal class PurchasesLifecycleTest: BasePurchasesTest() { proxyURL = null, Store.AMAZON, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) } diff --git a/purchases/src/test/java/com/revenuecat/purchases/amazon/AmazonOfferingsTest.kt b/purchases/src/test/java/com/revenuecat/purchases/amazon/AmazonOfferingsTest.kt index 767425c12c..573c7bd3cf 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/amazon/AmazonOfferingsTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/amazon/AmazonOfferingsTest.kt @@ -6,6 +6,7 @@ package com.revenuecat.purchases.amazon import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.APIKeyValidator import com.revenuecat.purchases.OfferingParserFactory import com.revenuecat.purchases.Package import com.revenuecat.purchases.PackageType @@ -40,7 +41,10 @@ class AmazonOfferingsTest { private val storeProductMonthly = stubStoreProductForAmazon(monthlyTermSku, period = monthlyPeriod) private val storeProductAnnual = stubStoreProductForAmazon(annualTermSku, period = annualPeriod) - private val offeringsParser = OfferingParserFactory.createOfferingParser(Store.AMAZON) + private val offeringsParser = OfferingParserFactory.createOfferingParser( + Store.AMAZON, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, + ) @Test fun `createPackage returns null if packageJson productIdentifier doesnt match any sub StoreProduct id`() { diff --git a/purchases/src/test/java/com/revenuecat/purchases/amazon/BillingFactoryAmazonTest.kt b/purchases/src/test/java/com/revenuecat/purchases/amazon/BillingFactoryAmazonTest.kt index 0e8d2aa6f3..4bbc049f2b 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/amazon/BillingFactoryAmazonTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/amazon/BillingFactoryAmazonTest.kt @@ -2,10 +2,12 @@ package com.revenuecat.purchases.amazon import android.app.Application import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.APIKeyValidator import com.revenuecat.purchases.BillingFactory import com.revenuecat.purchases.PurchasesState import com.revenuecat.purchases.PurchasesStateCache import com.revenuecat.purchases.Store +import com.revenuecat.purchases.common.Backend import com.revenuecat.purchases.common.BackendHelper import com.revenuecat.purchases.common.caching.DeviceCache import com.revenuecat.purchases.common.diagnostics.DiagnosticsTracker @@ -22,6 +24,7 @@ class BillingFactoryAmazonTest { val mockBackendHelper = mockk(relaxed = true) val mockCache = mockk(relaxed = true) val mockDiagnosticsTracker = mockk(relaxed = true) + val mockBackend = mockk(relaxed = true) BillingFactory.createBilling( Store.AMAZON, @@ -32,6 +35,8 @@ class BillingFactoryAmazonTest { mockDiagnosticsTracker, stateProvider = PurchasesStateCache(PurchasesState()), pendingTransactionsForPrepaidPlansEnabled = true, + backend = mockBackend, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) } @@ -40,6 +45,7 @@ class BillingFactoryAmazonTest { val mockApplication = mockk(relaxed = true) val mockBackendHelper = mockk(relaxed = true) val mockCache = mockk(relaxed = true) + val mockBackend = mockk(relaxed = true) BillingFactory.createBilling( Store.AMAZON, @@ -50,6 +56,8 @@ class BillingFactoryAmazonTest { diagnosticsTrackerIfEnabled = null, stateProvider = PurchasesStateCache(PurchasesState()), pendingTransactionsForPrepaidPlansEnabled = true, + backend = mockBackend, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) } } diff --git a/purchases/src/test/java/com/revenuecat/purchases/backend_integration_tests/BaseBackendIntegrationTest.kt b/purchases/src/test/java/com/revenuecat/purchases/backend_integration_tests/BaseBackendIntegrationTest.kt index bedb267e0e..dd7cd8f33f 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/backend_integration_tests/BaseBackendIntegrationTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/backend_integration_tests/BaseBackendIntegrationTest.kt @@ -6,6 +6,7 @@ import com.revenuecat.purchases.Store import com.revenuecat.purchases.common.AppConfig import com.revenuecat.purchases.common.Backend import com.revenuecat.purchases.common.BackendHelper +import com.revenuecat.purchases.common.DefaultLocaleProvider import com.revenuecat.purchases.common.Dispatcher import com.revenuecat.purchases.common.HTTPClient import com.revenuecat.purchases.common.PlatformInfo @@ -100,7 +101,7 @@ internal abstract class BaseBackendIntegrationTest { eTagManager = ETagManager(mockk(), lazy { sharedPreferences }) signingManager = spyk(SigningManager(signatureVerificationMode, appConfig, apiKey())) deviceCache = DeviceCache(sharedPreferences, apiKey()) - httpClient = HTTPClient(appConfig, eTagManager, diagnosticsTrackerIfEnabled = null, signingManager, deviceCache) + httpClient = HTTPClient(appConfig, eTagManager, diagnosticsTrackerIfEnabled = null, signingManager, deviceCache, localeProvider = DefaultLocaleProvider()) backendHelper = BackendHelper(apiKey(), dispatcher, appConfig, httpClient) backend = Backend(appConfig, dispatcher, diagnosticsDispatcher, httpClient, backendHelper) } diff --git a/purchases/src/test/java/com/revenuecat/purchases/backend_integration_tests/LoadShedderBackendIntegrationTest.kt b/purchases/src/test/java/com/revenuecat/purchases/backend_integration_tests/LoadShedderBackendIntegrationTest.kt index cd8676bb57..c02763759e 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/backend_integration_tests/LoadShedderBackendIntegrationTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/backend_integration_tests/LoadShedderBackendIntegrationTest.kt @@ -131,7 +131,7 @@ internal class LoadShedderBackendIntegrationTest: BaseBackendIntegrationTest() { } verify(exactly = 1) { // Verify we save the backend response in the shared preferences - sharedPreferencesEditor.putString("/v1${Endpoint.GetOfferings("test-user-id").getPath()}", any()) + sharedPreferencesEditor.putString(Endpoint.GetOfferings("test-user-id").getPath(), any()) } verify(exactly = 1) { sharedPreferencesEditor.apply() } assertSigningNotPerformed() @@ -173,7 +173,7 @@ internal class LoadShedderBackendIntegrationTest: BaseBackendIntegrationTest() { } verify(exactly = 1) { // Verify we save the backend response in the shared preferences - sharedPreferencesEditor.putString("/v1${Endpoint.LogIn.getPath()}", any()) + sharedPreferencesEditor.putString(Endpoint.LogIn.getPath(), any()) } verify(exactly = 1) { sharedPreferencesEditor.apply() } assertSigningNotPerformed() @@ -198,7 +198,7 @@ internal class LoadShedderBackendIntegrationTest: BaseBackendIntegrationTest() { } verify(exactly = 1) { // Verify we save the backend response in the shared preferences - sharedPreferencesEditor.putString("/v1${Endpoint.LogIn.getPath()}", any()) + sharedPreferencesEditor.putString(Endpoint.LogIn.getPath(), any()) } verify(exactly = 1) { sharedPreferencesEditor.apply() } assertSigningPerformed() diff --git a/purchases/src/test/java/com/revenuecat/purchases/backend_integration_tests/ProductionBackendIntegrationTest.kt b/purchases/src/test/java/com/revenuecat/purchases/backend_integration_tests/ProductionBackendIntegrationTest.kt index ba36c032d0..51a4360c7d 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/backend_integration_tests/ProductionBackendIntegrationTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/backend_integration_tests/ProductionBackendIntegrationTest.kt @@ -1,6 +1,5 @@ package com.revenuecat.purchases.backend_integration_tests -import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI import com.revenuecat.purchases.PurchasesError import com.revenuecat.purchases.VerificationResult import com.revenuecat.purchases.common.events.BackendEvent @@ -45,7 +44,7 @@ internal class ProductionBackendIntegrationTest: BaseBackendIntegrationTest() { assertThat(error).isNull() verify(exactly = 1) { // Verify we save the backend response in the shared preferences - sharedPreferencesEditor.putString("/v1${Endpoint.GetProductEntitlementMapping.getPath()}", any()) + sharedPreferencesEditor.putString(Endpoint.GetProductEntitlementMapping.getPath(), any()) } verify(exactly = 1) { sharedPreferencesEditor.apply() } assertSigningNotPerformed() @@ -92,7 +91,7 @@ internal class ProductionBackendIntegrationTest: BaseBackendIntegrationTest() { } verify(exactly = 1) { // Verify we save the backend response in the shared preferences - sharedPreferencesEditor.putString("/v1${Endpoint.GetOfferings("test-user-id").getPath()}", any()) + sharedPreferencesEditor.putString(Endpoint.GetOfferings("test-user-id").getPath(), any()) } verify(exactly = 1) { sharedPreferencesEditor.apply() } assertSigningNotPerformed() @@ -134,7 +133,7 @@ internal class ProductionBackendIntegrationTest: BaseBackendIntegrationTest() { } verify(exactly = 1) { // Verify we save the backend response in the shared preferences - sharedPreferencesEditor.putString("/v1${Endpoint.LogIn.getPath()}", any()) + sharedPreferencesEditor.putString(Endpoint.LogIn.getPath(), any()) } verify(exactly = 1) { sharedPreferencesEditor.apply() } assertSigningNotPerformed() @@ -159,7 +158,7 @@ internal class ProductionBackendIntegrationTest: BaseBackendIntegrationTest() { } verify(exactly = 1) { // Verify we save the backend response in the shared preferences - sharedPreferencesEditor.putString("/v1${Endpoint.LogIn.getPath()}", any()) + sharedPreferencesEditor.putString(Endpoint.LogIn.getPath(), any()) } verify(exactly = 1) { sharedPreferencesEditor.apply() } assertSigningPerformed() @@ -198,7 +197,7 @@ internal class ProductionBackendIntegrationTest: BaseBackendIntegrationTest() { } verify(exactly = 1) { // Verify we save the backend response in the shared preferences - sharedPreferencesEditor.putString("/v1${Endpoint.PostPaywallEvents.getPath()}", any()) + sharedPreferencesEditor.putString(Endpoint.PostPaywallEvents.getPath(), any()) } verify(exactly = 1) { sharedPreferencesEditor.apply() } assertSigningNotPerformed() @@ -223,12 +222,11 @@ internal class ProductionBackendIntegrationTest: BaseBackendIntegrationTest() { error("Expected customer center config data") } customerCenterConfigData?.let { - val managementScreen = it.screens[CustomerCenterConfigData.Screen.ScreenType.MANAGEMENT] ?: - fail("Expected management screen") + val managementScreen = it.screens[CustomerCenterConfigData.Screen.ScreenType.MANAGEMENT] ?: fail("Expected management screen") assertThat(managementScreen.type).isEqualTo(CustomerCenterConfigData.Screen.ScreenType.MANAGEMENT) assertThat(managementScreen.paths.size).isEqualTo(4) - val expectedLocalizationKeys = CustomerCenterConfigData.Localization.CommonLocalizedString.values().map { - it.name.lowercase() + val expectedLocalizationKeys = CustomerCenterConfigData.Localization.CommonLocalizedString.values().map { values -> + values.name.lowercase() }.toTypedArray() assertThat(it.localization.localizedStrings.keys).contains(*expectedLocalizationKeys) assertThat(it.support.email).isNull() diff --git a/purchases/src/test/java/com/revenuecat/purchases/backend_integration_tests/ProductionVirtualCurrenciesIntegrationTest.kt b/purchases/src/test/java/com/revenuecat/purchases/backend_integration_tests/ProductionVirtualCurrenciesIntegrationTest.kt new file mode 100644 index 0000000000..de54c40788 --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/backend_integration_tests/ProductionVirtualCurrenciesIntegrationTest.kt @@ -0,0 +1,87 @@ +package com.revenuecat.purchases.backend_integration_tests + + +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrency +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.fail +import org.junit.Test + +internal class ProductionVirtualCurrenciesIntegrationTest: BaseBackendIntegrationTest() { + override fun apiKey() = Constants.apiKey + + @Test + fun `can fetch virtual currencies with a balance of 0`() { + ensureBlockFinishes { latch -> + backend.getVirtualCurrencies( + appUserID = "integrationTestUserWithAllBalancesEqualTo0", + appInBackground = false, + onSuccess = { virtualCurrencies -> + validateVirtualCurrenciesObject( + virtualCurrencies = virtualCurrencies, + testVCBalance = 0, + testVC2Balance = 0 + ) + latch.countDown() + }, + onError = { error -> + fail("Expected success but got error: $error") + }, + ) + } + } + + @Test + fun `can fetch virtual currencies with a balance of greater than 0`() { + ensureBlockFinishes { latch -> + backend.getVirtualCurrencies( + appUserID = "integrationTestUserWithAllBalancesNonZero", + appInBackground = false, + onSuccess = { virtualCurrencies -> + validateVirtualCurrenciesObject( + virtualCurrencies = virtualCurrencies, + testVCBalance = 100, + testVC2Balance = 777, + ) + latch.countDown() + }, + onError = { error -> + fail("Expected success but got error: $error") + }, + ) + } + } + + private fun validateVirtualCurrenciesObject( + virtualCurrencies: VirtualCurrencies, + testVCBalance: Int, + testVC2Balance: Int, + testVC3Balance: Int = 0, + ) { + assert(virtualCurrencies.all.count() == 3) + + val expectedTestVirtualCurrency = VirtualCurrency( + code = "TEST", + name = "Test Currency", + balance = testVCBalance, + serverDescription = "This is a test currency", + ) + assertThat(virtualCurrencies["TEST"]).isEqualTo(expectedTestVirtualCurrency) + + val expectedTestVirtualCurrency2 = VirtualCurrency( + code = "TEST2", + name = "Test Currency 2", + balance = testVC2Balance, + serverDescription = "This is test currency 2", + ) + assertThat(virtualCurrencies["TEST2"]).isEqualTo(expectedTestVirtualCurrency2) + + val expectedTestVirtualCurrency3 = VirtualCurrency( + code = "TEST3", + name = "Test Currency 3", + balance = testVC3Balance, + serverDescription = null, + ) + assertThat(virtualCurrencies["TEST3"]).isEqualTo(expectedTestVirtualCurrency3) + } +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/blockstore/BlockstoreHelperTest.kt b/purchases/src/test/java/com/revenuecat/purchases/blockstore/BlockstoreHelperTest.kt new file mode 100644 index 0000000000..b1003334d7 --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/blockstore/BlockstoreHelperTest.kt @@ -0,0 +1,425 @@ +package com.revenuecat.purchases.blockstore + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.gms.auth.blockstore.BlockstoreClient +import com.google.android.gms.auth.blockstore.DeleteBytesRequest +import com.google.android.gms.auth.blockstore.RetrieveBytesResponse +import com.google.android.gms.tasks.OnFailureListener +import com.google.android.gms.tasks.OnSuccessListener +import com.google.android.gms.tasks.Task +import com.revenuecat.purchases.CustomerInfo +import com.revenuecat.purchases.PurchasesError +import com.revenuecat.purchases.PurchasesException +import com.revenuecat.purchases.PurchasesErrorCode +import com.revenuecat.purchases.identity.IdentityManager +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE) +class BlockstoreHelperTest { + + private lateinit var mockContext: Context + private lateinit var mockIdentityManager: IdentityManager + private lateinit var mockBlockstoreClient: BlockstoreClient + private lateinit var testScope: TestScope + + private lateinit var blockstoreHelper: BlockstoreHelper + + private val testAnonymousUserId = "\$RCAnonymousID:00000000000000000000000000000000" + private val expectedKey = "com.revenuecat.purchases.app_user_id" + + @Before + fun setup() { + mockContext = mockk() + mockIdentityManager = mockk().apply { + every { currentAppUserID } returns testAnonymousUserId + } + mockBlockstoreClient = mockk() + testScope = TestScope() + + blockstoreHelper = BlockstoreHelper( + applicationContext = mockContext, + identityManager = mockIdentityManager, + blockstoreClient = mockBlockstoreClient, + ioScope = testScope, + mainScope = testScope + ) + } + + // region storeUserIdIfNeeded + + @Test + fun `storeUserIdIfNeeded does nothing when current user is not anonymous`() { + every { mockIdentityManager.currentAppUserID } returns "not-anonymous-user-id" + + val mockCustomerInfo = mockk { + every { allPurchasedProductIds } returns setOf("product1") + } + + blockstoreHelper.storeUserIdIfNeeded(mockCustomerInfo) + + verify(exactly = 0) { mockBlockstoreClient.retrieveBytes(any()) } + } + + @Test + fun `storeUserIdIfNeeded does nothing when user has no purchases`() { + val mockCustomerInfo = mockk { + every { allPurchasedProductIds } returns emptySet() + } + + blockstoreHelper.storeUserIdIfNeeded(mockCustomerInfo) + + verify(exactly = 0) { mockBlockstoreClient.retrieveBytes(any()) } + } + + @Test + fun `storeUserIdIfNeeded stores user ID when conditions are met`() = runTest { + val mockCustomerInfo = mockk { + every { allPurchasedProductIds } returns setOf("product1") + } + + val mockRetrieveResponse = mockk { + every { blockstoreDataMap } returns emptyMap() + } + val mockRetrieveTask = mockk>() + every { mockBlockstoreClient.retrieveBytes(any()) } returns mockRetrieveTask + + val retrieveSuccessSlot = slot>() + every { mockRetrieveTask.addOnSuccessListener(capture(retrieveSuccessSlot)) } returns mockRetrieveTask + every { mockRetrieveTask.addOnFailureListener(any()) } returns mockRetrieveTask + + val mockStoreTask = mockk>() + every { mockBlockstoreClient.storeBytes(any()) } returns mockStoreTask + + val storeSuccessSlot = slot>() + every { mockStoreTask.addOnSuccessListener(capture(storeSuccessSlot)) } returns mockStoreTask + every { mockStoreTask.addOnFailureListener(any()) } returns mockStoreTask + + blockstoreHelper.storeUserIdIfNeeded(mockCustomerInfo) + + testScope.advanceUntilIdle() + retrieveSuccessSlot.captured.onSuccess(mockRetrieveResponse) + + testScope.advanceUntilIdle() + storeSuccessSlot.captured.onSuccess(1) + + verify(exactly = 1) { mockBlockstoreClient.retrieveBytes(any()) } + verify(exactly = 1) { mockBlockstoreClient.storeBytes(any()) } + } + + @Test + fun `storeUserIdIfNeeded handles retrieval failure gracefully`() = runTest { + val mockCustomerInfo = mockk { + every { allPurchasedProductIds } returns setOf("product1") + } + + val mockRetrieveTask = mockk>() + every { mockBlockstoreClient.retrieveBytes(any()) } returns mockRetrieveTask + + val retrieveFailureSlot = slot() + every { mockRetrieveTask.addOnSuccessListener(any()) } returns mockRetrieveTask + every { mockRetrieveTask.addOnFailureListener(capture(retrieveFailureSlot)) } returns mockRetrieveTask + + blockstoreHelper.storeUserIdIfNeeded(mockCustomerInfo) + + testScope.advanceUntilIdle() + + retrieveFailureSlot.captured.onFailure(RuntimeException("Test error")) + + verify(exactly = 1) { mockBlockstoreClient.retrieveBytes(any()) } + verify(exactly = 0) { mockBlockstoreClient.storeBytes(any()) } + } + + @Test + fun `storeUserIdIfNeeded does not store when blockstore is full`() = runTest { + val mockCustomerInfo = mockk { + every { allPurchasedProductIds } returns setOf("product1") + } + + val fullBlockstoreMap = (1..16).associateBy( + { "key_$it" }, + { mockk() } + ) + + val mockRetrieveResponse = mockk { + every { blockstoreDataMap } returns fullBlockstoreMap + } + val mockRetrieveTask = mockk>() + every { mockBlockstoreClient.retrieveBytes(any()) } returns mockRetrieveTask + + val retrieveSuccessSlot = slot>() + every { mockRetrieveTask.addOnSuccessListener(capture(retrieveSuccessSlot)) } returns mockRetrieveTask + every { mockRetrieveTask.addOnFailureListener(any()) } returns mockRetrieveTask + + blockstoreHelper.storeUserIdIfNeeded(mockCustomerInfo) + + testScope.advanceUntilIdle() + + retrieveSuccessSlot.captured.onSuccess(mockRetrieveResponse) + + verify(exactly = 1) { mockBlockstoreClient.retrieveBytes(any()) } + verify(exactly = 0) { mockBlockstoreClient.storeBytes(any()) } + } + + @Test + fun `storeUserIdIfNeeded does not store when user ID already exists`() = runTest { + val mockCustomerInfo = mockk { + every { allPurchasedProductIds } returns setOf("product1") + } + + val existingBlockstoreData = mockk() + val blockstoreMap = mapOf(expectedKey to existingBlockstoreData) + + val mockRetrieveResponse = mockk { + every { blockstoreDataMap } returns blockstoreMap + } + val mockRetrieveTask = mockk>() + every { mockBlockstoreClient.retrieveBytes(any()) } returns mockRetrieveTask + + val retrieveSuccessSlot = slot>() + every { mockRetrieveTask.addOnSuccessListener(capture(retrieveSuccessSlot)) } returns mockRetrieveTask + every { mockRetrieveTask.addOnFailureListener(any()) } returns mockRetrieveTask + + blockstoreHelper.storeUserIdIfNeeded(mockCustomerInfo) + + testScope.advanceUntilIdle() + + retrieveSuccessSlot.captured.onSuccess(mockRetrieveResponse) + + verify(exactly = 1) { mockBlockstoreClient.retrieveBytes(any()) } + verify(exactly = 0) { mockBlockstoreClient.storeBytes(any()) } + } + + // endregion storeUserIdIfNeeded + + // region aliasCurrentAndStoredUserIdsIfNeeded + + @Test + fun `aliasCurrentAndStoredUserIdsIfNeeded calls callback immediately when user is not anonymous`() { + every { mockIdentityManager.currentAppUserID } returns "not-anonymous-user-id" + + var callbackCalled = false + blockstoreHelper.aliasCurrentAndStoredUserIdsIfNeeded { + callbackCalled = true + } + + testScope.advanceUntilIdle() + + assertThat(callbackCalled).isTrue() + verify(exactly = 0) { mockBlockstoreClient.retrieveBytes(any()) } + } + + @Test + fun `aliasCurrentAndStoredUserIdsIfNeeded calls callback when retrieval fails`() = runTest { + val mockRetrieveTask = mockk>() + every { mockBlockstoreClient.retrieveBytes(any()) } returns mockRetrieveTask + + val retrieveFailureSlot = slot() + every { mockRetrieveTask.addOnSuccessListener(any()) } returns mockRetrieveTask + every { mockRetrieveTask.addOnFailureListener(capture(retrieveFailureSlot)) } returns mockRetrieveTask + + var callbackCalled = false + blockstoreHelper.aliasCurrentAndStoredUserIdsIfNeeded { + callbackCalled = true + } + + testScope.advanceUntilIdle() + retrieveFailureSlot.captured.onFailure(RuntimeException("Test error")) + testScope.advanceUntilIdle() + + assertThat(callbackCalled).isTrue() + } + + @Test + fun `aliasCurrentAndStoredUserIdsIfNeeded calls callback when no stored user ID found`() = runTest { + val mockRetrieveResponse = mockk { + every { blockstoreDataMap } returns emptyMap() + } + val mockRetrieveTask = mockk>() + every { mockBlockstoreClient.retrieveBytes(any()) } returns mockRetrieveTask + + val retrieveSuccessSlot = slot>() + every { mockRetrieveTask.addOnSuccessListener(capture(retrieveSuccessSlot)) } returns mockRetrieveTask + every { mockRetrieveTask.addOnFailureListener(any()) } returns mockRetrieveTask + + var callbackCalled = false + blockstoreHelper.aliasCurrentAndStoredUserIdsIfNeeded { + callbackCalled = true + } + + testScope.advanceUntilIdle() + retrieveSuccessSlot.captured.onSuccess(mockRetrieveResponse) + testScope.advanceUntilIdle() + + assertThat(callbackCalled).isTrue() + } + + @Test + fun `aliasCurrentAndStoredUserIdsIfNeeded calls callback when stored user ID matches current`() = runTest { + val mockBlockstoreData = mockk { + every { bytes } returns testAnonymousUserId.toByteArray() + } + val blockstoreMap = mapOf(expectedKey to mockBlockstoreData) + + val mockRetrieveResponse = mockk { + every { blockstoreDataMap } returns blockstoreMap + } + val mockRetrieveTask = mockk>() + every { mockBlockstoreClient.retrieveBytes(any()) } returns mockRetrieveTask + + val retrieveSuccessSlot = slot>() + every { mockRetrieveTask.addOnSuccessListener(capture(retrieveSuccessSlot)) } returns mockRetrieveTask + every { mockRetrieveTask.addOnFailureListener(any()) } returns mockRetrieveTask + + var callbackCalled = false + blockstoreHelper.aliasCurrentAndStoredUserIdsIfNeeded { + callbackCalled = true + } + + testScope.advanceUntilIdle() + retrieveSuccessSlot.captured.onSuccess(mockRetrieveResponse) + testScope.advanceUntilIdle() + + assertThat(callbackCalled).isTrue() + } + + @Test + fun `aliasCurrentAndStoredUserIdsIfNeeded aliases different user ID successfully`() = runTest { + val storedUserId = "stored_user_id" + + coEvery { mockIdentityManager.aliasCurrentUserIdTo(storedUserId) } just Runs + + val mockBlockstoreData = mockk { + every { bytes } returns storedUserId.toByteArray() + } + val blockstoreMap = mapOf(expectedKey to mockBlockstoreData) + + val mockRetrieveResponse = mockk { + every { blockstoreDataMap } returns blockstoreMap + } + val mockRetrieveTask = mockk>() + every { mockBlockstoreClient.retrieveBytes(any()) } returns mockRetrieveTask + + val retrieveSuccessSlot = slot>() + every { mockRetrieveTask.addOnSuccessListener(capture(retrieveSuccessSlot)) } returns mockRetrieveTask + every { mockRetrieveTask.addOnFailureListener(any()) } returns mockRetrieveTask + + var callbackCalled = false + blockstoreHelper.aliasCurrentAndStoredUserIdsIfNeeded { + callbackCalled = true + } + + testScope.advanceUntilIdle() + retrieveSuccessSlot.captured.onSuccess(mockRetrieveResponse) + testScope.advanceUntilIdle() + + assertThat(callbackCalled).isTrue() + coVerify(exactly = 1) { mockIdentityManager.aliasCurrentUserIdTo(storedUserId) } + } + + @Test + fun `aliasCurrentAndStoredUserIdsIfNeeded handles alias failure gracefully`() = runTest { + val storedUserId = "stored_user_id" + + coEvery { mockIdentityManager.aliasCurrentUserIdTo(storedUserId) } throws + PurchasesException(PurchasesError(PurchasesErrorCode.InvalidAppUserIdError, "Test error")) + + val mockBlockstoreData = mockk { + every { bytes } returns storedUserId.toByteArray() + } + val blockstoreMap = mapOf(expectedKey to mockBlockstoreData) + + val mockRetrieveResponse = mockk { + every { blockstoreDataMap } returns blockstoreMap + } + val mockRetrieveTask = mockk>() + every { mockBlockstoreClient.retrieveBytes(any()) } returns mockRetrieveTask + + val retrieveSuccessSlot = slot>() + every { mockRetrieveTask.addOnSuccessListener(capture(retrieveSuccessSlot)) } returns mockRetrieveTask + every { mockRetrieveTask.addOnFailureListener(any()) } returns mockRetrieveTask + + var callbackCalled = false + blockstoreHelper.aliasCurrentAndStoredUserIdsIfNeeded { + callbackCalled = true + } + + testScope.advanceUntilIdle() + retrieveSuccessSlot.captured.onSuccess(mockRetrieveResponse) + testScope.advanceUntilIdle() + + assertThat(callbackCalled).isTrue() + } + + // endregion aliasCurrentAndStoredUserIdsIfNeeded + + // region clearUserIdBackupIfNeeded + + @Test + fun `clearUserIdBackupIfNeeded calls delete and handles success`() { + val mockDeleteTask = mockk>() + every { mockBlockstoreClient.deleteBytes(any()) } returns mockDeleteTask + + val deleteSuccessSlot = slot>() + every { mockDeleteTask.addOnSuccessListener(capture(deleteSuccessSlot)) } returns mockDeleteTask + every { mockDeleteTask.addOnFailureListener(any()) } returns mockDeleteTask + + var callbackCalled = false + blockstoreHelper.clearUserIdBackupIfNeeded { + callbackCalled = true + } + + testScope.advanceUntilIdle() + + deleteSuccessSlot.captured.onSuccess(true) + + assertThat(callbackCalled).isTrue() + + val deleteRequestSlot = slot() + verify(exactly = 1) { mockBlockstoreClient.deleteBytes(capture(deleteRequestSlot)) } + + assertThat(deleteRequestSlot.captured.keys).containsExactly(expectedKey) + } + + @Test + fun `clearUserIdBackupIfNeeded calls delete and handles failure`() { + val mockDeleteTask = mockk>() + every { mockBlockstoreClient.deleteBytes(any()) } returns mockDeleteTask + + val deleteFailureSlot = slot() + every { mockDeleteTask.addOnSuccessListener(any()) } returns mockDeleteTask + every { mockDeleteTask.addOnFailureListener(capture(deleteFailureSlot)) } returns mockDeleteTask + + var callbackCalled = false + blockstoreHelper.clearUserIdBackupIfNeeded { + callbackCalled = true + } + + testScope.advanceUntilIdle() + + deleteFailureSlot.captured.onFailure(RuntimeException("Test error")) + + assertThat(callbackCalled).isTrue() + verify(exactly = 1) { mockBlockstoreClient.deleteBytes(any()) } + } + + // endregion clearUserIdBackupIfNeeded +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/AppConfigTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/AppConfigTest.kt index b972a82f5b..bfb04a0fec 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/AppConfigTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/AppConfigTest.kt @@ -2,6 +2,7 @@ package com.revenuecat.purchases.common import android.content.Context import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.APIKeyValidator import com.revenuecat.purchases.DangerousSettings import com.revenuecat.purchases.PurchasesAreCompletedBy.MY_APP import com.revenuecat.purchases.PurchasesAreCompletedBy.REVENUECAT @@ -15,7 +16,6 @@ import org.junit.After import org.junit.Test import org.junit.runner.RunWith import java.net.URL -import java.util.concurrent.atomic.AtomicBoolean @RunWith(AndroidJUnit4::class) class AppConfigTest { @@ -40,6 +40,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(appConfig.languageTag).isEqualTo(expected) } @@ -59,6 +60,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(appConfig.languageTag).isEqualTo(expected) } @@ -81,6 +83,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(appConfig.versionName).isEqualTo(expected) } @@ -102,6 +105,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(appConfig.versionName).isEqualTo(expected) } @@ -124,6 +128,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(appConfig.packageName).isEqualTo(expected) } @@ -138,6 +143,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(appConfig.showInAppMessagesAutomatically).isFalse val appConfig2 = AppConfig( @@ -148,6 +154,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(appConfig2.showInAppMessagesAutomatically).isTrue } @@ -162,6 +169,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(appConfig.finishTransactions).isTrue() } @@ -176,6 +184,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(appConfig.finishTransactions).isFalse() } @@ -191,6 +200,7 @@ class AppConfigTest { proxyURL = expected, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(appConfig.baseURL).isEqualTo(expected) } @@ -206,6 +216,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(appConfig.baseURL).isEqualTo(expected) } @@ -220,6 +231,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(appConfig.forceServerErrors).isFalse } @@ -234,6 +246,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(appConfig.forceSigningErrors).isFalse } @@ -248,6 +261,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(appConfig.isAppBackgrounded).isTrue } @@ -262,6 +276,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(appConfig.isAppBackgrounded).isTrue appConfig.isAppBackgrounded = false @@ -278,6 +293,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, dangerousSettings = DangerousSettings(customEntitlementComputation = true) ) assertThat(appConfig.customEntitlementComputation).isTrue @@ -289,6 +305,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, dangerousSettings = DangerousSettings(customEntitlementComputation = false) ) assertThat(appConfig2.customEntitlementComputation).isFalse @@ -304,6 +321,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) val y = AppConfig( context = mockk(relaxed = true), @@ -313,6 +331,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(x).isEqualTo(y) @@ -328,6 +347,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) var y = AppConfig( context = mockk(relaxed = true), @@ -337,6 +357,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(x).isNotEqualTo(y) @@ -349,6 +370,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(x).isNotEqualTo(y) @@ -361,6 +383,7 @@ class AppConfigTest { proxyURL = URL("https://a.com"), store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(x).isNotEqualTo(y) @@ -373,6 +396,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, dangerousSettings = DangerousSettings(autoSyncPurchases = false) ) @@ -386,6 +410,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = true, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(x).isNotEqualTo(y) @@ -401,6 +426,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) val y = AppConfig( context = mockk(relaxed = true), @@ -410,6 +436,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(x.hashCode()).isEqualTo(y.hashCode()) } @@ -424,6 +451,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(x.toString()).isEqualTo( "AppConfig(" + @@ -436,6 +464,7 @@ class AppConfigTest { "packageName='', " + "finishTransactions=true, " + "showInAppMessagesAutomatically=false, " + + "apiKeyValidationResult=VALID, " + "baseURL=https://api.revenuecat.com/)") } @@ -451,6 +480,7 @@ class AppConfigTest { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(appConfig.fallbackBaseURLs).isEqualTo(listOf(URL("https://api-production.8-lives-cat.io/"))) } @@ -465,6 +495,7 @@ class AppConfigTest { proxyURL = URL("https://proxy.com"), store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) assertThat(appConfig.fallbackBaseURLs).isEmpty() } diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/BaseHTTPClientTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/BaseHTTPClientTest.kt index 5f88306e7c..88b0e7e645 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/BaseHTTPClientTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/BaseHTTPClientTest.kt @@ -1,6 +1,7 @@ package com.revenuecat.purchases.common import android.content.Context +import com.revenuecat.purchases.APIKeyValidator import com.revenuecat.purchases.DangerousSettings import com.revenuecat.purchases.PurchasesAreCompletedBy import com.revenuecat.purchases.PurchasesAreCompletedBy.REVENUECAT @@ -96,6 +97,7 @@ internal abstract class BaseHTTPClientTest { proxyURL = proxyURL, store = store, isDebugBuild = isDebugBuild, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, dangerousSettings = DangerousSettings(customEntitlementComputation = customEntitlementComputation), runningTests = true, forceServerErrors = forceServerErrors, @@ -115,7 +117,7 @@ internal abstract class BaseHTTPClientTest { expectedResult.responseCode, expectedResult.payload, eTagHeader = any(), - "/v1${endpoint.getPath()}", + urlPath = endpoint.getPath(), refreshETag = false, requestDate = requestDateHeader, verificationResult = verificationResult diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/CustomerInfoFactoryTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/CustomerInfoFactoryTest.kt index d48a6b883b..69a564d696 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/CustomerInfoFactoryTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/CustomerInfoFactoryTest.kt @@ -1,7 +1,6 @@ package com.revenuecat.purchases.common import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI import com.revenuecat.purchases.Store import com.revenuecat.purchases.VerificationResult import com.revenuecat.purchases.models.Price diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/DeviceCacheTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/DeviceCacheTest.kt index c17efa0596..33a062e84f 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/DeviceCacheTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/DeviceCacheTest.kt @@ -16,15 +16,22 @@ import com.revenuecat.purchases.common.offlineentitlements.createProductEntitlem import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.utils.Responses import com.revenuecat.purchases.utils.subtract +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrenciesFactory import io.mockk.every +import kotlinx.serialization.SerializationException import io.mockk.just import io.mockk.mockk +import io.mockk.mockkObject import io.mockk.runs +import io.mockk.unmockkObject import io.mockk.slot import io.mockk.verify import io.mockk.verifyAll import org.assertj.core.api.Assertions.assertThat +import org.json.JSONException import org.json.JSONObject +import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -64,6 +71,7 @@ class DeviceCacheTest { @Before fun setup() { + mockkObject(VirtualCurrenciesFactory) mockPrefs = mockk() mockEditor = mockk() mockDateProvider = mockk() @@ -91,6 +99,11 @@ class DeviceCacheTest { cache = DeviceCache(mockPrefs, apiKey, dateProvider = mockDateProvider) } + @After + fun tearDown() { + unmockkObject(VirtualCurrenciesFactory) + } + @Test fun `cache is created properly`() { assertThat(cache).`as`("cache is not null").isNotNull @@ -394,9 +407,13 @@ class DeviceCacheTest { every { mockEditor.putLong(cache.customerInfoLastUpdatedCacheKey("appUserID"), capture(slot())) } returns mockEditor + every { + mockEditor.putLong(cache.virtualCurrenciesLastUpdatedCacheKey("appUserID"), capture(slot())) + } returns mockEditor mockString(cache.appUserIDCacheKey, "appUserID") mockString(cache.legacyAppUserIDCacheKey, "legacyAppUserID") mockString(cache.customerInfoCacheKey(appUserID), null) + mockString(cache.virtualCurrenciesCacheKey(appUserID), null) cache.clearCachesForAppUserID("appUserID") @@ -404,6 +421,10 @@ class DeviceCacheTest { verify { mockEditor.remove(cache.legacyAppUserIDCacheKey) } verify { mockEditor.remove(cache.customerInfoCacheKey("appUserID")) } verify { mockEditor.remove(cache.customerInfoCacheKey("legacyAppUserID")) } + verify { mockEditor.remove(cache.virtualCurrenciesCacheKey("appUserID")) } + verify { mockEditor.remove(cache.virtualCurrenciesCacheKey("legacyAppUserID")) } + verify { mockEditor.remove(cache.virtualCurrenciesLastUpdatedCacheKey("appUserID")) } + verify { mockEditor.remove(cache.virtualCurrenciesLastUpdatedCacheKey("legacyAppUserID")) } } @Test @@ -681,6 +702,153 @@ class DeviceCacheTest { // endregion storefront + // region virtualCurrencies + @Test + fun `given no cached virtual currencies, cached virtual currencies is null`() { + mockString(cache.virtualCurrenciesCacheKey(appUserID), null) + val info = cache.getCachedVirtualCurrencies(appUserID) + assertThat(info).`as`("info is null").isNull() + } + + @Test + fun `given a virtual currencies, the key in the cache is correct`() { + mockString(cache.virtualCurrenciesCacheKey(appUserID), Responses.validFullVirtualCurrenciesResponse) + cache.getCachedVirtualCurrencies(appUserID) + verify { + mockPrefs.getString(cache.virtualCurrenciesCacheKey(appUserID), isNull()) + } + } + + @Test + fun `given a valid VirtualCurrencies, the JSON is parsed correctly`() { + mockString(cache.virtualCurrenciesCacheKey(appUserID), Responses.validFullVirtualCurrenciesResponse) + val vcs = cache.getCachedVirtualCurrencies(appUserID) + assertThat(vcs).`as`("vcs is not null").isNotNull + assertThat(vcs?.all?.size).isEqualTo(2) + } + + @Test + fun `given an invalid VirtualCurrencies JSON string, the returned VirtualCurrencies from the cache is null`() { + mockString(cache.virtualCurrenciesCacheKey(appUserID), "not json") + val vcs = cache.getCachedVirtualCurrencies(appUserID) + assertThat(vcs).`as`("vcs is null").isNull() + } + + @Test + fun `given a VirtualCurrencies, the VirtualCurrencies is cached`() { + val vcs = VirtualCurrenciesFactory.buildVirtualCurrencies( + JSONObject(Responses.validFullVirtualCurrenciesResponse) + ) + + cache.cacheVirtualCurrencies(appUserID, vcs) + assertThat(slotForPutLong.captured).isNotNull + + // The serializer caches the JSON fields in a different order than the network response. Here, we + // check for the expected order. + val expectedCachedJSON = "{\"virtual_currencies\":{\"COIN\":{\"balance\":1,\"name\":\"Coin\",\"code\":" + + "\"COIN\",\"description\":\"It's a coin\"},\"RC_COIN\":{\"balance\":0,\"name\":\"RC Coin\",\"code\"" + + ":\"RC_COIN\"}}}" + verifyAll { + mockEditor.putString(cache.virtualCurrenciesCacheKey(appUserID), expectedCachedJSON) + mockEditor.putLong(cache.virtualCurrenciesLastUpdatedCacheKey(appUserID), slotForPutLong.captured) + mockEditor.apply() + } + } + + @Test + fun `invalidating VirtualCurrencies caches`() { + mockLong(cache.virtualCurrenciesLastUpdatedCacheKey(appUserID), Date(0).time) + assertThat(cache.isVirtualCurrenciesCacheStale(appUserID, appInBackground = false)).isTrue + + mockLong(cache.virtualCurrenciesLastUpdatedCacheKey(appUserID), Date().time) + assertThat(cache.isVirtualCurrenciesCacheStale(appUserID, appInBackground = false)).isFalse + + mockString(cache.appUserIDCacheKey, appUserID) + mockString(cache.legacyAppUserIDCacheKey, null) + + cache.clearVirtualCurrenciesCache(appUserID) + verify { + mockEditor.remove(cache.virtualCurrenciesLastUpdatedCacheKey(appUserID)) + mockEditor.remove(cache.virtualCurrenciesCacheKey(appUserID)) + } + + mockLong(cache.virtualCurrenciesLastUpdatedCacheKey(appUserID), 0L) + assertThat(cache.isVirtualCurrenciesCacheStale(appUserID, appInBackground = false)).isTrue + } + + @Test + fun `isVirtualCurrenciesCacheStale returns true if the cached object is stale`() { + mockLong(cache.virtualCurrenciesLastUpdatedCacheKey(appUserID), Date(0).time) + assertThat(cache.isVirtualCurrenciesCacheStale(appUserID, appInBackground = false)).isTrue + + mockLong(cache.virtualCurrenciesLastUpdatedCacheKey(appUserID), Date().time) + assertThat(cache.isVirtualCurrenciesCacheStale(appUserID, appInBackground = false)).isFalse + } + + @Test + fun `cached VirtualCurrencies are equal to provided value`() { + val expectedVirtualCurrencies = VirtualCurrenciesFactory.buildVirtualCurrencies( + JSONObject(Responses.validFullVirtualCurrenciesResponse) + ) + mockLong(cache.virtualCurrenciesLastUpdatedCacheKey(appUserID), Date().time) + mockString(cache.appUserIDCacheKey, appUserID) + mockString(cache.legacyAppUserIDCacheKey, null) + mockString(cache.virtualCurrenciesCacheKey(appUserID), Responses.validFullVirtualCurrenciesResponse) + + cache.cacheVirtualCurrencies(appUserID, expectedVirtualCurrencies) + val cachedVirtualCurrencies = cache.getCachedVirtualCurrencies(appUserID) + assertThat(cachedVirtualCurrencies).isEqualTo(expectedVirtualCurrencies) + + // The serializer caches the JSON fields in a different order than the network response. Here, we + // check for the expected order. + val expectedCachedJSON = "{\"virtual_currencies\":{\"COIN\":{\"balance\":1,\"name\":\"Coin\",\"code\":" + + "\"COIN\",\"description\":\"It's a coin\"},\"RC_COIN\":{\"balance\":0,\"name\":\"RC Coin\",\"code\"" + + ":\"RC_COIN\"}}}" + verify { + mockEditor.putString( + cache.virtualCurrenciesCacheKey(appUserID), + expectedCachedJSON + ) + } + } + + @Test + fun `getCachedVirtualCurrencies returns null when VirtualCurrenciesFactory throws SerializationException`() { + mockString(cache.virtualCurrenciesCacheKey(appUserID), "{}") + + every { + VirtualCurrenciesFactory.buildVirtualCurrencies(jsonString = any()) + } throws SerializationException("Serialization error") + + val vcs = cache.getCachedVirtualCurrencies(appUserID) + assertThat(vcs).`as`("cached VirtualCurrencies is null when SerializationException is thrown").isNull() + } + + @Test + fun `getCachedVirtualCurrencies returns null when VirtualCurrenciesFactory throws IllegalArgumentException`() { + mockString(cache.virtualCurrenciesCacheKey(appUserID), "{}") + + every { + VirtualCurrenciesFactory.buildVirtualCurrencies(jsonString = any()) + } throws IllegalArgumentException("Invalid input") + + val vcs = cache.getCachedVirtualCurrencies(appUserID) + assertThat(vcs).`as`("cached VirtualCurrencies is null when IllegalArgumentException is thrown").isNull() + } + + @Test + fun `getCachedVirtualCurrencies returns null when VirtualCurrenciesFactory throws JSONException`() { + mockString(cache.virtualCurrenciesCacheKey(appUserID), "{}") + + every { + VirtualCurrenciesFactory.buildVirtualCurrencies(jsonString = any()) + } throws JSONException("JSON exception") + + val vcs = cache.getCachedVirtualCurrencies(appUserID) + assertThat(vcs).`as`("cached VirtualCurrencies is null when JSONException is thrown").isNull() + } + // endregion virtualCurrencies + private fun mockString(key: String, value: String?) { every { mockPrefs.getString(eq(key), isNull()) diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/HTTPClientTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/HTTPClientTest.kt index 83968303ff..e3bb2da14a 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/HTTPClientTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/HTTPClientTest.kt @@ -597,7 +597,7 @@ internal class HTTPClientTest: BaseHTTPClientTest() { RCHTTPStatusCodes.BAD_REQUEST, "not uh json", eTagHeader = any(), - "/v1${endpoint.getPath()}", + urlPath = endpoint.getPath(), refreshETag = false, requestDate = null, verificationResult = VerificationResult.NOT_REQUESTED diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/HTTPClientVerificationTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/HTTPClientVerificationTest.kt index f4e847a9a6..35345a130e 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/HTTPClientVerificationTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/HTTPClientVerificationTest.kt @@ -161,7 +161,7 @@ internal class HTTPClientVerificationTest: BaseHTTPClientTest() { responseCode, expectedResult.payload, eTagHeader = any(), - "/v1${endpoint.getPath()}", + urlPath = endpoint.getPath(), refreshETag = false, requestDate = Date(1234567890L), verificationResult = VerificationResult.VERIFIED @@ -188,7 +188,7 @@ internal class HTTPClientVerificationTest: BaseHTTPClientTest() { assertThat(result.verificationResult).isEqualTo(VerificationResult.VERIFIED) verify(exactly = 1) { mockSigningManager.verifyResponse( - "/v1${endpoint.getPath()}", + urlPath = endpoint.getPath(), "test-signature", "test-nonce", "{\"test-key\":\"test-value\"}", diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/SharedPreferencesManagerTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/SharedPreferencesManagerTest.kt new file mode 100644 index 0000000000..434fa4b86b --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/common/SharedPreferencesManagerTest.kt @@ -0,0 +1,192 @@ +package com.revenuecat.purchases.common + +import android.content.Context +import android.content.SharedPreferences +import android.preference.PreferenceManager +import com.revenuecat.purchases.backup.RevenueCatBackupAgent +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.concurrent.thread + +@RunWith(RobolectricTestRunner::class) +class SharedPreferencesManagerTest { + + private lateinit var mockContext: Context + private lateinit var mockLegacyPrefs: SharedPreferences + private lateinit var mockRevenueCatPrefs: SharedPreferences + private lateinit var mockLegacyEditor: SharedPreferences.Editor + private lateinit var mockRevenueCatEditor: SharedPreferences.Editor + + companion object { + private const val REVENUECAT_KEY_1 = "com.revenuecat.purchases.apikey1" + private const val REVENUECAT_KEY_2 = "com.revenuecat.purchases.apikey1.new" + private const val NON_REVENUECAT_KEY = "some.other.key" + } + + @Before + fun setup() { + mockContext = mockk() + mockLegacyPrefs = mockk() + mockRevenueCatPrefs = mockk() + mockLegacyEditor = mockk(relaxed = true) + mockRevenueCatEditor = mockk(relaxed = true) + + every { mockContext.getSharedPreferences(RevenueCatBackupAgent.REVENUECAT_PREFS_FILE_NAME, Context.MODE_PRIVATE) } returns mockRevenueCatPrefs + every { mockLegacyPrefs.edit() } returns mockLegacyEditor + every { mockRevenueCatPrefs.edit() } returns mockRevenueCatEditor + every { mockRevenueCatEditor.apply() } just Runs + every { mockRevenueCatPrefs.contains(SharedPreferencesManager.EXPECTED_VERSION_KEY) } returns false + + mockkStatic(PreferenceManager::class) + every { PreferenceManager.getDefaultSharedPreferences(mockContext) } returns mockLegacyPrefs + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `getSharedPreferences returns RevenueCat preferences when already has expected version`() { + // RevenueCat preferences have version + every { mockRevenueCatPrefs.contains(SharedPreferencesManager.EXPECTED_VERSION_KEY) } returns true + every { mockLegacyPrefs.all } returns mapOf(REVENUECAT_KEY_1 to "value1") + + val manager = SharedPreferencesManager(mockContext) + val result = manager.getSharedPreferences() + + assertThat(result).isSameAs(mockRevenueCatPrefs) + // No migration should occur but should set the version + verify(exactly = 0) { mockRevenueCatEditor.putInt(any(), any()) } + verify(exactly = 0) { mockRevenueCatEditor.putString(any(), any()) } + verify(exactly = 0) { mockRevenueCatEditor.apply() } + } + + @Test + fun `getSharedPreferences returns RevenueCat preferences when legacy has no RevenueCat data`() { + // RevenueCat preferences do not have version, but legacy has no RevenueCat data + every { mockRevenueCatPrefs.contains(SharedPreferencesManager.EXPECTED_VERSION_KEY) } returns false + every { mockLegacyPrefs.all } returns mapOf(NON_REVENUECAT_KEY to "value") + + val manager = SharedPreferencesManager(mockContext) + val result = manager.getSharedPreferences() + + assertThat(result).isSameAs(mockRevenueCatPrefs) + // No migration should occur but should set the version + verify(exactly = 1) { + mockRevenueCatEditor.putInt( + SharedPreferencesManager.EXPECTED_VERSION_KEY, + SharedPreferencesManager.EXPECTED_VERSION + ) + } + verify(exactly = 0) { mockRevenueCatEditor.putString(any(), any()) } + verify(exactly = 1) { mockRevenueCatEditor.apply() } + } + + @Test + fun `getSharedPreferences performs migration when RevenueCat prefs do not have version and legacy has RevenueCat data`() { + // RevenueCat preferences are empty, legacy has RevenueCat data + every { mockLegacyPrefs.all } returns mapOf( + REVENUECAT_KEY_1 to "string_value", + NON_REVENUECAT_KEY to "should_not_migrate" + ) + + val manager = SharedPreferencesManager(mockContext) + val result = manager.getSharedPreferences() + + assertThat(result).isSameAs(mockRevenueCatPrefs) + // Migration should occur for RevenueCat keys only + verify(exactly = 1) { + mockRevenueCatEditor.putInt( + SharedPreferencesManager.EXPECTED_VERSION_KEY, + SharedPreferencesManager.EXPECTED_VERSION, + ) + } + verify { mockRevenueCatEditor.putString(REVENUECAT_KEY_1, "string_value") } + verify(exactly = 0) { mockRevenueCatEditor.putString(NON_REVENUECAT_KEY, any()) } + verify { mockRevenueCatEditor.apply() } + } + + @Test + fun `getSharedPreferences migrates different data types correctly`() { + every { mockRevenueCatPrefs.all } returns emptyMap() + every { mockLegacyPrefs.all } returns mapOf( + REVENUECAT_KEY_1 to "string_value", + REVENUECAT_KEY_2 to true, + "com.revenuecat.purchases.long_key" to 123L, + "com.revenuecat.purchases.int_key" to 456, + "com.revenuecat.purchases.float_key" to 78.9f, + "com.revenuecat.purchases.set_key" to setOf("a", "b", "c") + ) + + val manager = SharedPreferencesManager(mockContext) + val result = manager.getSharedPreferences() + + assertThat(result).isSameAs(mockRevenueCatPrefs) + verify { mockRevenueCatEditor.putString(REVENUECAT_KEY_1, "string_value") } + verify { mockRevenueCatEditor.putBoolean(REVENUECAT_KEY_2, true) } + verify { mockRevenueCatEditor.putLong("com.revenuecat.purchases.long_key", 123L) } + verify { mockRevenueCatEditor.putInt("com.revenuecat.purchases.int_key", 456) } + verify { mockRevenueCatEditor.putFloat("com.revenuecat.purchases.float_key", 78.9f) } + verify { mockRevenueCatEditor.putStringSet("com.revenuecat.purchases.set_key", setOf("a", "b", "c")) } + verify { mockRevenueCatEditor.apply() } + } + + @Test + fun `getSharedPreferences handles concurrent access safely`() { + // Test that multiple calls work correctly - first call should trigger migration + every { mockRevenueCatPrefs.contains(SharedPreferencesManager.EXPECTED_VERSION_KEY) } returnsMany listOf( + false, // First call - false, triggers migration + true // After migration - true, no more migration + ) + every { mockLegacyPrefs.all } returns mapOf(REVENUECAT_KEY_1 to "value1") + + val manager = SharedPreferencesManager(mockContext) + + var result1: SharedPreferences? = null + val thread1 = thread { result1 = manager.getSharedPreferences() } + + var result2: SharedPreferences? = null + val thread2 = thread { result2 = manager.getSharedPreferences() } + + thread1.join() + thread2.join() + + assertThat(result1).isSameAs(mockRevenueCatPrefs) + assertThat(result2).isSameAs(mockRevenueCatPrefs) + + // Migration should only happen once during the first call + verify(exactly = 1) { + mockRevenueCatEditor.putInt( + SharedPreferencesManager.EXPECTED_VERSION_KEY, + SharedPreferencesManager.EXPECTED_VERSION, + ) + } + verify(exactly = 1) { mockRevenueCatEditor.putString(REVENUECAT_KEY_1, "value1") } + verify(exactly = 2) { mockRevenueCatEditor.apply() } // One for the expected version, another for the actual migration + } + + @Test + fun `getSharedPreferences handles migration failure gracefully`() { + every { mockLegacyPrefs.all } returns mapOf(REVENUECAT_KEY_1 to "value1") + + val manager = SharedPreferencesManager(mockContext) + val result = manager.getSharedPreferences() + + // Should still return the RevenueCat preferences even if migration fails + assertThat(result).isSameAs(mockRevenueCatPrefs) + verify { mockRevenueCatEditor.putString(REVENUECAT_KEY_1, "value1") } + verify { mockRevenueCatEditor.apply() } + } +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendTest.kt index 7b5e551b61..88bd16707c 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendTest.kt @@ -25,6 +25,11 @@ import com.revenuecat.purchases.common.networking.Endpoint import com.revenuecat.purchases.common.networking.HTTPResult import com.revenuecat.purchases.common.networking.PostReceiptResponse import com.revenuecat.purchases.common.networking.RCHTTPStatusCodes +import com.revenuecat.purchases.common.networking.WebBillingPhase +import com.revenuecat.purchases.common.networking.WebBillingPrice +import com.revenuecat.purchases.common.networking.WebBillingProductResponse +import com.revenuecat.purchases.common.networking.WebBillingProductsResponse +import com.revenuecat.purchases.common.networking.WebBillingPurchaseOption import com.revenuecat.purchases.common.offlineentitlements.ProductEntitlementMapping import com.revenuecat.purchases.common.offlineentitlements.createProductEntitlementMapping import com.revenuecat.purchases.common.toMap @@ -43,6 +48,9 @@ import com.revenuecat.purchases.utils.getNullableString import com.revenuecat.purchases.utils.mockProductDetails import com.revenuecat.purchases.utils.stubStoreProduct import com.revenuecat.purchases.utils.stubSubscriptionOption +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrenciesFactory +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrency import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -50,9 +58,11 @@ import io.mockk.slot import io.mockk.spyk import io.mockk.unmockkObject import io.mockk.verify +import kotlinx.serialization.SerializationException import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Fail.fail import org.json.JSONArray +import org.json.JSONException import org.json.JSONObject import org.junit.After import org.junit.Assert.assertFalse @@ -78,16 +88,23 @@ class BackendTest { @Before fun setup() { mockkObject(CustomerInfoFactory) + mockkObject(VirtualCurrenciesFactory) receivedError = null receivedOfferingsJSON = null receivedCustomerInfo = null receivedPostReceiptErrorHandlingBehavior = null receivedCustomerInfoCreated = null receivedIsServerError = null + receivedVirtualCurrencies = null + receivedWebBillingProductsResponse = null + receivedAliasUsersCallCount = 0 } @After - fun tearDown() = unmockkObject(CustomerInfoFactory) + fun tearDown() { + unmockkObject(CustomerInfoFactory) + unmockkObject(VirtualCurrenciesFactory) + } private var mockClient: HTTPClient = mockk(relaxed = true) private val mockBaseURL = URL("http://mock-api-test.revenuecat.com/") @@ -148,6 +165,9 @@ class BackendTest { private var receivedCustomerInfo: CustomerInfo? = null private var receivedCustomerInfoCreated: Boolean? = null + private var receivedVirtualCurrencies: VirtualCurrencies? = null + private var receivedWebBillingProductsResponse: WebBillingProductsResponse? = null + private var receivedAliasUsersCallCount: Int = 0 private var receivedOfferingsJSON: JSONObject? = null private var receivedError: PurchasesError? = null private var receivedPostReceiptErrorHandlingBehavior: PostReceiptErrorHandlingBehavior? = null @@ -195,6 +215,30 @@ class BackendTest { this@BackendTest.receivedError = it } + private val onReceiveVirtualCurrenciesSuccessHandler: (VirtualCurrencies) -> Unit = { info -> + this@BackendTest.receivedVirtualCurrencies = info + } + + private val onReceiveVirtualCurrenciesErrorHandler: (PurchasesError) -> Unit = { error -> + this@BackendTest.receivedError = error + } + + private val onReceiveWebBillingProductsSuccessHandler: (WebBillingProductsResponse) -> Unit = { response -> + this@BackendTest.receivedWebBillingProductsResponse = response + } + + private val onReceiveWebBillingProductsErrorHandler: (PurchasesError) -> Unit = { error -> + this@BackendTest.receivedError = error + } + + private val onReceiveAliasUsersSuccessHandler: () -> Unit = { + this@BackendTest.receivedAliasUsersCallCount += 1 + } + + private val onReceiveAliasUsersErrorHandler: (PurchasesError) -> Unit = { error -> + this@BackendTest.receivedError = error + } + // region general backend functionality @Test fun canBeCreated() { @@ -2405,6 +2449,535 @@ class BackendTest { // endregion + // region getVirtualCurrencies + + @Test + fun getVirtualCurrenciesCallsProperURL() { + val virtualCurrencies = getVirtualCurrencies(200, null, null) + + assertThat(receivedVirtualCurrencies).isNotNull + assertThat(receivedVirtualCurrencies).isEqualTo(virtualCurrencies) + + verify(exactly = 1) { + mockClient.performRequest( + baseURL = mockBaseURL, + endpoint = Endpoint.GetVirtualCurrencies(appUserID), + body = null, + postFieldsToSign = null, + requestHeaders = defaultAuthHeaders + ) + } + } + + @Test + fun `getVirtualCurrencies calls success handler for successful request`() { + mockGetVirtualCurrenciesResponse( + Endpoint.GetVirtualCurrencies(appUserID), + null, + 200, + null, + Responses.validFullVirtualCurrenciesResponse, + true, + shouldMockVirtualCurrencies = false + ) + var successCalled = false + backend.getVirtualCurrencies(appUserID, false, + { + successCalled = true + val expectedVirtualCurrencies = VirtualCurrencies( + all = mapOf( + "COIN" to VirtualCurrency( + balance = 1, + name = "Coin", + code = "COIN", + serverDescription = "It's a coin", + ), + "RC_COIN" to VirtualCurrency( + balance = 0, + name = "RC Coin", + code = "RC_COIN", + serverDescription = null, + ), + ), + ) + assertThat(it).isEqualTo(expectedVirtualCurrencies) + }, + { error -> fail("expected success $error", error) } + ) + assertTrue(successCalled) + } + + @Test + fun getVirtualCurrenciesFailsIf40X() { + val failureCode = 400 + + getVirtualCurrencies(failureCode, null, null) + + assertThat(receivedVirtualCurrencies).isNull() + assertThat(receivedError).`as`("Received error is not null").isNotNull + } + + @Test + fun getVirtualCurrenciesFailsIf50X() { + val failureCode = 500 + + getVirtualCurrencies(failureCode, null, null) + + assertThat(receivedVirtualCurrencies).isNull() + assertThat(receivedError).`as`("Received error is not null").isNotNull + } + + @Test + fun `getVirtualCurrencies calls error handler when a Network error occurs`() { + mockGetVirtualCurrenciesResponse( + Endpoint.GetVirtualCurrencies(appUserID), + null, + 200, + IOException(), + null + ) + var errorCalled = false + backend.getVirtualCurrencies( + appUserID, + appInBackground = false, + { fail("expected error handler to be called") }, + { error -> + errorCalled = true + assertThat(error.code).isEqualTo(PurchasesErrorCode.NetworkError) + } + ) + assertTrue(errorCalled) + } + + @Test + fun `given multiple getVirtualCurrencies calls for same subscriber same body, only one is triggered`() { + mockGetVirtualCurrenciesResponse( + Endpoint.GetVirtualCurrencies(appUserID), + null, + 200, + null, + null, + true + ) + val lock = CountDownLatch(2) + asyncBackend.getVirtualCurrencies(appUserID, appInBackground = false, onSuccess = { + lock.countDown() + }, onError = onReceiveVirtualCurrenciesErrorHandler) + asyncBackend.getVirtualCurrencies(appUserID, appInBackground = false, onSuccess = { + lock.countDown() + }, onError = onReceiveVirtualCurrenciesErrorHandler) + lock.await(defaultTimeout, TimeUnit.MILLISECONDS) + assertThat(lock.count).isEqualTo(0) + verify(exactly = 1) { + mockClient.performRequest( + mockBaseURL, + Endpoint.GetVirtualCurrencies(appUserID), + body = null, + postFieldsToSign = null, + any() + ) + } + } + + @Test + fun `given getVirtualCurrencies call on foreground, then one in background, only one request without delay is triggered`() { + mockGetVirtualCurrenciesResponse( + Endpoint.GetVirtualCurrencies(appUserID), + null, + 200, + null, + null, + true + ) + val lock = CountDownLatch(2) + asyncBackend.getVirtualCurrencies(appUserID, appInBackground = false, onSuccess = { + lock.countDown() + }, onError = onReceiveVirtualCurrenciesErrorHandler) + asyncBackend.getVirtualCurrencies(appUserID, appInBackground = true, onSuccess = { + lock.countDown() + }, onError = onReceiveVirtualCurrenciesErrorHandler) + lock.await(defaultTimeout, TimeUnit.MILLISECONDS) + assertThat(lock.count).isEqualTo(0) + verify(exactly = 1) { + asyncDispatcher.enqueue(any(), Delay.NONE) + } + } + + @Test + fun `given getVirtualCurrencies call on background, then one in foreground, both are executed`() { + mockGetVirtualCurrenciesResponse( + Endpoint.GetVirtualCurrencies(appUserID), + null, + 200, + null, + null, + true + ) + val lock = CountDownLatch(2) + asyncBackend.getVirtualCurrencies(appUserID, appInBackground = true, onSuccess = { + lock.countDown() + }, onError = onReceiveVirtualCurrenciesErrorHandler) + asyncBackend.getVirtualCurrencies(appUserID, appInBackground = false, onSuccess = { + lock.countDown() + }, onError = onReceiveVirtualCurrenciesErrorHandler) + lock.await(defaultTimeout, TimeUnit.MILLISECONDS) + assertThat(lock.count).isEqualTo(0) + verify(exactly = 2) { + mockClient.performRequest( + mockBaseURL, + Endpoint.GetVirtualCurrencies(appUserID), + body = null, + postFieldsToSign = null, + any() + ) + } + } + + @Test + fun `getVirtualCurrencies call is enqueued with delay if on background`() { + dispatcher.calledDelay = null + + getVirtualCurrencies(200, clientException = null, resultBody = null, appInBackground = true) + + val calledWithRandomDelay: Delay? = dispatcher.calledDelay + assertThat(calledWithRandomDelay).isNotNull + assertThat(calledWithRandomDelay).isEqualTo(Delay.DEFAULT) + } + + @Test + fun `getVirtualCurrencies calls error handler when VirtualCurrenciesFactory throws JSONException`() { + mockGetVirtualCurrenciesResponse( + Endpoint.GetVirtualCurrencies(appUserID), + null, + 200, + null, + null, + virtualCurrenciesFactoryException = JSONException("Invalid JSON") + ) + var errorCalled = false + backend.getVirtualCurrencies( + appUserID, + appInBackground = false, + { fail("expected error handler to be called") }, + { error -> + errorCalled = true + assertThat(error.code).isEqualTo(PurchasesErrorCode.NetworkError) + } + ) + assertTrue(errorCalled) + } + + @Test + fun `getVirtualCurrencies calls error handler when VirtualCurrenciesFactory throws SerializationException`() { + mockGetVirtualCurrenciesResponse( + Endpoint.GetVirtualCurrencies(appUserID), + null, + 200, + null, + null, + virtualCurrenciesFactoryException = SerializationException("Serialization error") + ) + var errorCalled = false + backend.getVirtualCurrencies( + appUserID, + appInBackground = false, + { fail("expected error handler to be called") }, + { error -> + errorCalled = true + assertThat(error.code).isEqualTo(PurchasesErrorCode.UnknownError) + } + ) + assertTrue(errorCalled) + } + + @Test + fun `getVirtualCurrencies calls error handler when VirtualCurrenciesFactory throws IllegalArgumentException`() { + mockGetVirtualCurrenciesResponse( + Endpoint.GetVirtualCurrencies(appUserID), + null, + 200, + null, + null, + virtualCurrenciesFactoryException = IllegalArgumentException("Invalid input") + ) + var errorCalled = false + backend.getVirtualCurrencies( + appUserID, + appInBackground = false, + { fail("expected error handler to be called") }, + { error -> + errorCalled = true + assertThat(error.code).isEqualTo(PurchasesErrorCode.UnknownError) + } + ) + assertTrue(errorCalled) + } + // endregion Virtual currencies + + // region WebBilling products + + @Test + fun getWebBillingProductsCallsProperURL() { + val productIDs = setOf("product1", "product2") + val response = getWebBillingProductsResponse(200, productIDs, null, null) + + assertThat(receivedWebBillingProductsResponse).isNotNull + assertThat(receivedWebBillingProductsResponse).isEqualTo(response) + + verify(exactly = 1) { + mockClient.performRequest( + baseURL = mockBaseURL, + endpoint = Endpoint.WebBillingGetProducts(appUserID, productIDs), + body = null, + postFieldsToSign = null, + requestHeaders = defaultAuthHeaders + ) + } + } + + @Test + fun `getWebBillingProducts calls success handler for successful request`() { + val productIDs = setOf("product1", "product2") + mockGetWebBillingProductsResponse( + Endpoint.WebBillingGetProducts(appUserID, productIDs), + 200, + null, + Responses.validWebBillingProductsResponse, + true, + ) + var successCalled = false + backend.getWebBillingProducts(appUserID, productIDs, + { + successCalled = true + val expectedWebBillingProductsResponse = WebBillingProductsResponse( + productDetails = listOf( + WebBillingProductResponse( + identifier = "product1", + productType = "subscription", + title = "Test Monthly Subscription", + description = "A test monthly subscription product", + defaultPurchaseOptionId = "base_option", + purchaseOptions = mapOf( + "base_option" to WebBillingPurchaseOption( + base = WebBillingPhase( + price = WebBillingPrice( + amountMicros = 9990000, + currency = "EUR", + ), + periodDuration = "P1M", + cycleCount = 1, + ) + ) + ), + ), + WebBillingProductResponse( + identifier = "product2", + productType = "subscription", + title = "Test Monthly Subscription", + description = "A test monthly subscription product", + defaultPurchaseOptionId = "base_option", + purchaseOptions = mapOf( + "base_option" to WebBillingPurchaseOption( + base = WebBillingPhase( + price = WebBillingPrice( + amountMicros = 9990000, + currency = "EUR", + ), + periodDuration = "P1M", + cycleCount = 1, + ) + ) + ), + ), + ), + ) + assertThat(it).isEqualTo(expectedWebBillingProductsResponse) + }, + { error -> fail("expected success $error", error) } + ) + assertTrue(successCalled) + } + + @Test + fun getWebBillingProductsFailsIf40X() { + val failureCode = 400 + + getWebBillingProductsResponse(failureCode, emptySet(), null, null) + + assertThat(receivedVirtualCurrencies).isNull() + assertThat(receivedError).`as`("Received error is not null").isNotNull + } + + @Test + fun getWebBillingProductsFailsIf50X() { + val failureCode = 500 + + getWebBillingProductsResponse(failureCode, emptySet(), null, null) + + assertThat(receivedVirtualCurrencies).isNull() + assertThat(receivedError).`as`("Received error is not null").isNotNull + } + + @Test + fun `getWebBillingProducts calls error handler when a Network error occurs`() { + val productIDs = setOf("product1", "product2") + mockGetWebBillingProductsResponse( + Endpoint.WebBillingGetProducts(appUserID, productIDs), + 200, + IOException(), + null + ) + var errorCalled = false + backend.getWebBillingProducts( + appUserID, + productIDs, + { fail("expected error handler to be called") }, + { error -> + errorCalled = true + assertThat(error.code).isEqualTo(PurchasesErrorCode.NetworkError) + } + ) + assertTrue(errorCalled) + } + + @Test + fun `given multiple getWebBillingProduct calls for same subscriber same body, only one is triggered`() { + val productIDs = setOf("product1", "product2") + mockGetWebBillingProductsResponse( + Endpoint.WebBillingGetProducts(appUserID, productIDs), + 200, + null, + null, + true + ) + val lock = CountDownLatch(2) + asyncBackend.getWebBillingProducts(appUserID, productIDs, onSuccess = { + lock.countDown() + }, onError = onReceiveWebBillingProductsErrorHandler) + asyncBackend.getWebBillingProducts(appUserID, productIDs, onSuccess = { + lock.countDown() + }, onError = onReceiveWebBillingProductsErrorHandler) + lock.await(defaultTimeout, TimeUnit.MILLISECONDS) + assertThat(lock.count).isEqualTo(0) + verify(exactly = 1) { + mockClient.performRequest( + mockBaseURL, + Endpoint.WebBillingGetProducts(appUserID, productIDs), + body = null, + postFieldsToSign = null, + any() + ) + } + } + // endregion WebBilling Products + + // region Alias Users + + @Test + fun getAliasUsersCallsProperURL() { + postAliasUsers(responseCode = 200) + + assertThat(receivedAliasUsersCallCount).isEqualTo(1) + + verify(exactly = 1) { + mockClient.performRequest( + baseURL = mockBaseURL, + endpoint = Endpoint.AliasUsers("test-old-app-user-id"), + body = mapOf("app_user_id" to "test-old-app-user-id", "new_app_user_id" to "test-new-app-user-id"), + postFieldsToSign = null, + requestHeaders = defaultAuthHeaders + ) + } + } + + @Test + fun `getAliasUsers calls success handler for successful request`() { + mockAliasUsersResponse( + Endpoint.AliasUsers(appUserID), + 200, + null, + body = mapOf("app_user_id" to appUserID, "new_app_user_id" to "test-new-user-id"), + true, + ) + var successCalled = false + backend.aliasUsers(appUserID, "test-new-user-id", + { successCalled = true }, + { error -> fail("expected success $error", error) } + ) + assertTrue(successCalled) + } + + @Test + fun getAliasUsersProductsFailsIf40X() { + val failureCode = 400 + + postAliasUsers(responseCode = failureCode) + + assertThat(receivedAliasUsersCallCount).isEqualTo(0) + assertThat(receivedError).`as`("Received error is not null").isNotNull + } + + @Test + fun getAliasUsersProductsFailsIf50X() { + val failureCode = 500 + + postAliasUsers(responseCode = failureCode) + + assertThat(receivedAliasUsersCallCount).isEqualTo(0) + assertThat(receivedError).`as`("Received error is not null").isNotNull + } + + @Test + fun `getAliasUsers calls error handler when a Network error occurs`() { + mockAliasUsersResponse( + Endpoint.AliasUsers(appUserID), + 200, + IOException(), + body = mapOf("app_user_id" to appUserID, "new_app_user_id" to "test-new-user-id") + ) + var errorCalled = false + backend.aliasUsers( + appUserID, + "test-new-user-id", + { fail("expected error handler to be called") }, + { error -> + errorCalled = true + assertThat(error.code).isEqualTo(PurchasesErrorCode.NetworkError) + } + ) + assertTrue(errorCalled) + } + + @Test + fun `given multiple getAliasUsers calls for same subscriber same body, only one is triggered`() { + mockAliasUsersResponse( + Endpoint.AliasUsers(appUserID), + 200, + null, + body = mapOf("app_user_id" to appUserID, "new_app_user_id" to "test-new-user-id"), + true + ) + val lock = CountDownLatch(2) + asyncBackend.aliasUsers(appUserID, newAppUserID = "test-new-user-id", onSuccessHandler = { + lock.countDown() + }, onErrorHandler = onReceiveAliasUsersErrorHandler) + asyncBackend.aliasUsers(appUserID, newAppUserID = "test-new-user-id", onSuccessHandler = { + lock.countDown() + }, onErrorHandler = onReceiveAliasUsersErrorHandler) + lock.await(defaultTimeout, TimeUnit.MILLISECONDS) + assertThat(lock.count).isEqualTo(0) + verify(exactly = 1) { + mockClient.performRequest( + mockBaseURL, + Endpoint.AliasUsers(appUserID), + body = mapOf("app_user_id" to appUserID, "new_app_user_id" to "test-new-user-id"), + postFieldsToSign = null, + any() + ) + } + } + // endregion AliasUsers + // region helpers private fun mockResponse( @@ -2551,5 +3124,183 @@ class BackendTest { return info } + private fun getVirtualCurrencies( + responseCode: Int, + clientException: Exception?, + resultBody: String?, + appInBackground: Boolean = false + ): VirtualCurrencies { + val virtualCurrencies = mockGetVirtualCurrenciesResponse( + Endpoint.GetVirtualCurrencies(appUserID), + null, + responseCode, + clientException, + resultBody + ) + + backend.getVirtualCurrencies( + appUserID, + appInBackground, + onReceiveVirtualCurrenciesSuccessHandler, + onReceiveVirtualCurrenciesErrorHandler + ) + + return virtualCurrencies + } + + private fun getWebBillingProductsResponse( + responseCode: Int, + productIDs: Set = emptySet(), + clientException: Exception?, + resultBody: String?, + ): WebBillingProductsResponse { + val productsResponse = mockGetWebBillingProductsResponse( + Endpoint.WebBillingGetProducts(appUserID, productIDs), + responseCode, + clientException, + resultBody + ) + + backend.getWebBillingProducts( + appUserID, + productIDs, + onReceiveWebBillingProductsSuccessHandler, + onReceiveWebBillingProductsErrorHandler, + ) + + return productsResponse + } + + private fun postAliasUsers( + responseCode: Int, + oldAppUserID: String = "test-old-app-user-id", + newAppUserID: String = "test-new-app-user-id", + clientException: Exception? = null, + ) { + mockAliasUsersResponse( + Endpoint.AliasUsers(oldAppUserID), + responseCode, + clientException, + body = mapOf("app_user_id" to oldAppUserID, "new_app_user_id" to newAppUserID), + ) + + backend.aliasUsers( + oldAppUserID, + newAppUserID, + onReceiveAliasUsersSuccessHandler, + onReceiveAliasUsersErrorHandler, + ) + } + + private fun mockGetVirtualCurrenciesResponse( + endpoint: Endpoint, + body: Map?, + responseCode: Int, + clientException: Exception?, + resultBody: String?, + delayed: Boolean = false, + shouldMockVirtualCurrencies: Boolean = true, + virtualCurrenciesFactoryException: Exception? = null, + baseURL: URL = mockBaseURL + ): VirtualCurrencies { + val virtualCurrencies: VirtualCurrencies = mockk() + + val result = HTTPResult.createResult(responseCode, resultBody ?: "{\"virtual_currencies\":{}}") + + if (virtualCurrenciesFactoryException != null) { + every { + VirtualCurrenciesFactory.buildVirtualCurrencies(result) + } throws virtualCurrenciesFactoryException + } else if (shouldMockVirtualCurrencies) { + every { + VirtualCurrenciesFactory.buildVirtualCurrencies(result) + } returns virtualCurrencies + } + val everyMockedCall = every { + mockClient.performRequest( + eq(baseURL), + eq(endpoint), + (if (body == null) any() else capture(requestBodySlot)), + any(), + capture(headersSlot) + ) + } + + if (clientException == null) { + everyMockedCall answers { + if (delayed) Thread.sleep(200) + result + } + } else { + everyMockedCall throws clientException + } + + return virtualCurrencies + } + + private fun mockGetWebBillingProductsResponse( + endpoint: Endpoint, + responseCode: Int, + clientException: Exception?, + resultBody: String?, + delayed: Boolean = false, + baseURL: URL = mockBaseURL + ): WebBillingProductsResponse { + val response = WebBillingProductsResponse(productDetails = emptyList()) + + val result = HTTPResult.createResult(responseCode, resultBody ?: "{\"product_details\":[]}") + + val everyMockedCall = every { + mockClient.performRequest( + eq(baseURL), + eq(endpoint), + null, + any(), + capture(headersSlot) + ) + } + + if (clientException == null) { + everyMockedCall answers { + if (delayed) Thread.sleep(200) + result + } + } else { + everyMockedCall throws clientException + } + + return response + } + + private fun mockAliasUsersResponse( + endpoint: Endpoint, + responseCode: Int, + clientException: Exception?, + body: Map?, + delayed: Boolean = false, + baseURL: URL = mockBaseURL + ) { + val result = HTTPResult.createResult(responseCode, "{}") + + val everyMockedCall = every { + mockClient.performRequest( + eq(baseURL), + eq(endpoint), + body, + any(), + capture(headersSlot) + ) + } + + if (clientException == null) { + everyMockedCall answers { + if (delayed) Thread.sleep(200) + result + } + } else { + everyMockedCall throws clientException + } + } + // endregion } diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/diagnostics/DiagnosticsTrackerTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/diagnostics/DiagnosticsTrackerTest.kt index f30ad47eaf..6e4e93e6e5 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/diagnostics/DiagnosticsTrackerTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/diagnostics/DiagnosticsTrackerTest.kt @@ -3,6 +3,7 @@ package com.revenuecat.purchases.common.diagnostics import android.content.Context import android.content.SharedPreferences import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.APIKeyValidator import com.revenuecat.purchases.CacheFetchPolicy import com.revenuecat.purchases.CustomerInfo import com.revenuecat.purchases.EntitlementInfos @@ -922,6 +923,7 @@ class DiagnosticsTrackerTest { proxyURL = null, store = store, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) } diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/networking/ETagManagerTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/networking/ETagManagerTest.kt index 8952431ea9..f7f791c857 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/networking/ETagManagerTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/networking/ETagManagerTest.kt @@ -348,7 +348,7 @@ class ETagManagerTest { responseCode = RCHTTPStatusCodes.SUCCESS, payload = responsePayload, eTagHeader = eTagInResponse, - urlPathWithVersion = path, + urlPath = path, refreshETag = false, requestDate = null, verificationResult = NOT_REQUESTED @@ -370,7 +370,7 @@ class ETagManagerTest { responseCode = RCHTTPStatusCodes.NOT_MODIFIED, payload = responsePayload, eTagHeader = eTagInResponse, - urlPathWithVersion = path, + urlPath = path, refreshETag = false, requestDate = null, verificationResult = NOT_REQUESTED @@ -394,7 +394,7 @@ class ETagManagerTest { responseCode = RCHTTPStatusCodes.NOT_MODIFIED, payload = responsePayload, eTagHeader = eTagInResponse, - urlPathWithVersion = path, + urlPath = path, refreshETag = false, requestDate = null, verificationResult = NOT_REQUESTED @@ -419,7 +419,7 @@ class ETagManagerTest { responseCode = RCHTTPStatusCodes.NOT_MODIFIED, payload = responsePayload, eTagHeader = eTagInResponse, - urlPathWithVersion = path, + urlPath = path, refreshETag = true, requestDate = null, verificationResult = NOT_REQUESTED @@ -443,7 +443,7 @@ class ETagManagerTest { responseCode = RCHTTPStatusCodes.SUCCESS, payload = responsePayload, eTagHeader = eTagInResponse, - urlPathWithVersion = path, + urlPath = path, refreshETag = false, requestDate = null, verificationResult = NOT_REQUESTED @@ -467,7 +467,7 @@ class ETagManagerTest { responseCode = RCHTTPStatusCodes.SUCCESS, payload = responsePayload, eTagHeader = eTagInResponse, - urlPathWithVersion = path, + urlPath = path, refreshETag = true, requestDate = null, verificationResult = NOT_REQUESTED @@ -487,7 +487,7 @@ class ETagManagerTest { responseCode = RCHTTPStatusCodes.SUCCESS, payload = "", eTagHeader = "etag", - urlPathWithVersion = "/v1/subscribers/appUserID", + urlPath = "/v1/subscribers/appUserID", refreshETag = false, requestDate = null, verificationResult = VERIFIED @@ -504,7 +504,7 @@ class ETagManagerTest { responseCode = RCHTTPStatusCodes.SUCCESS, payload = "", eTagHeader = "etag", - urlPathWithVersion = "/v1/subscribers/appUserID", + urlPath = "/v1/subscribers/appUserID", refreshETag = false, requestDate = expectedDate, verificationResult = NOT_REQUESTED @@ -525,7 +525,7 @@ class ETagManagerTest { responseCode = RCHTTPStatusCodes.SUCCESS, payload = "", eTagHeader = "etag", - urlPathWithVersion = "/v1/subscribers/appUserID", + urlPath = "/v1/subscribers/appUserID", refreshETag = false, requestDate = expectedDate, verificationResult = NOT_REQUESTED @@ -573,7 +573,7 @@ class ETagManagerTest { responseCode = RCHTTPStatusCodes.NOT_MODIFIED, payload = "", eTagHeader = "etag", - urlPathWithVersion = "/v1/subscribers/appUserID", + urlPath = "/v1/subscribers/appUserID", refreshETag = false, requestDate = null, verificationResult = backendVerificationResult diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/networking/EndpointTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/networking/EndpointTest.kt index f115440475..aef59e23d9 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/networking/EndpointTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/networking/EndpointTest.kt @@ -21,78 +21,114 @@ class EndpointTest { Endpoint.PostDiagnostics, Endpoint.PostPaywallEvents, Endpoint.PostRedeemWebPurchase, + Endpoint.GetVirtualCurrencies("test-user-id"), + Endpoint.AliasUsers("test-user-id") ) @Test fun `GetCustomerInfo has correct path`() { val endpoint = Endpoint.GetCustomerInfo("test user-id") - val expectedPath = "/subscribers/test%20user-id" + val expectedPath = "/v1/subscribers/test%20user-id" assertThat(endpoint.getPath()).isEqualTo(expectedPath) } @Test fun `PostReceipt has correct path`() { val endpoint = Endpoint.PostReceipt - val expectedPath = "/receipts" + val expectedPath = "/v1/receipts" assertThat(endpoint.getPath()).isEqualTo(expectedPath) } @Test fun `GetOfferings has correct path`() { val endpoint = Endpoint.GetOfferings("test user-id") - val expectedPath = "/subscribers/test%20user-id/offerings" + val expectedPath = "/v1/subscribers/test%20user-id/offerings" assertThat(endpoint.getPath()).isEqualTo(expectedPath) } @Test fun `LogIn has correct path`() { val endpoint = Endpoint.LogIn - val expectedPath = "/subscribers/identify" + val expectedPath = "/v1/subscribers/identify" assertThat(endpoint.getPath()).isEqualTo(expectedPath) } @Test fun `Diagnostics has correct path`() { val endpoint = Endpoint.PostDiagnostics - val expectedPath = "/diagnostics" + val expectedPath = "/v1/diagnostics" assertThat(endpoint.getPath()).isEqualTo(expectedPath) } @Test fun `Paywall events has correct path`() { val endpoint = Endpoint.PostPaywallEvents - val expectedPath = "/events" + val expectedPath = "/v1/events" assertThat(endpoint.getPath()).isEqualTo(expectedPath) } @Test fun `PostAttributes has correct path`() { val endpoint = Endpoint.PostAttributes("test user-id") - val expectedPath = "/subscribers/test%20user-id/attributes" + val expectedPath = "/v1/subscribers/test%20user-id/attributes" assertThat(endpoint.getPath()).isEqualTo(expectedPath) } @Test fun `GetAmazonReceipt has correct path`() { val endpoint = Endpoint.GetAmazonReceipt("test user-id", "test-receipt-id") - val expectedPath = "/receipts/amazon/test%20user-id/test-receipt-id" + val expectedPath = "/v1/receipts/amazon/test%20user-id/test-receipt-id" assertThat(endpoint.getPath()).isEqualTo(expectedPath) } @Test fun `GetProductEntitlementMapping has correct path`() { val endpoint = Endpoint.GetProductEntitlementMapping - val expectedPath = "/product_entitlement_mapping" + val expectedPath = "/v1/product_entitlement_mapping" assertThat(endpoint.getPath()).isEqualTo(expectedPath) } @Test fun `PostRedeemWebPurchase has correct path`() { val endpoint = Endpoint.PostRedeemWebPurchase - val expectedPath = "/subscribers/redeem_purchase" + val expectedPath = "/v1/subscribers/redeem_purchase" assertThat(endpoint.getPath()).isEqualTo(expectedPath) } + @Test + fun `GetVirtualCurrencies has correct path`() { + val endpoint = Endpoint.GetVirtualCurrencies(userId = "test user-id") + val expectedPath = "/v1/subscribers/test%20user-id/virtual_currencies" + assertThat(endpoint.getPath()).isEqualTo(expectedPath) + } + + @Test + fun `GetVirtualCurrencies has correct name`() { + val endpoint = Endpoint.GetVirtualCurrencies(userId = "test user-id") + val expectedName = "get_virtual_currencies" + assertThat(endpoint.name).isEqualTo(expectedName) + } + + @Test + fun `WebBillingGetProducts has correct path`() { + val endpoint = Endpoint.WebBillingGetProducts(userId = "test user-id", linkedSetOf("product1", "product2")) + val expectedPath = "/rcbilling/v1/subscribers/test%20user-id/products?id=product1&id=product2" + assertThat(endpoint.getPath()).isEqualTo(expectedPath) + } + + @Test + fun `AliasUsers has correct path`() { + val endpoint = Endpoint.AliasUsers(userId = "test user-id") + val expectedPath = "/v1/subscribers/test%20user-id/alias" + assertThat(endpoint.getPath()).isEqualTo(expectedPath) + } + + @Test + fun `AliasUsers has correct name`() { + val endpoint = Endpoint.AliasUsers(userId = "test user-id") + assertThat(endpoint.name).isEqualTo("alias_users") + } + @Test fun `supportsSignatureVerification returns true for expected values`() { val expectedSupportsValidationEndpoints = listOf( @@ -102,6 +138,7 @@ class EndpointTest { Endpoint.GetOfferings("test-user-id"), Endpoint.GetProductEntitlementMapping, Endpoint.PostRedeemWebPurchase, + Endpoint.GetVirtualCurrencies(userId = "test-user-id"), ) for (endpoint in expectedSupportsValidationEndpoints) { assertThat(endpoint.supportsSignatureVerification) @@ -117,6 +154,8 @@ class EndpointTest { Endpoint.PostAttributes("test-user-id"), Endpoint.PostDiagnostics, Endpoint.PostPaywallEvents, + Endpoint.WebBillingGetProducts("test-user-id", setOf("product1", "product2")), + Endpoint.AliasUsers("test-user-id"), ) for (endpoint in expectedNotSupportsValidationEndpoints) { assertThat(endpoint.supportsSignatureVerification) @@ -143,6 +182,7 @@ class EndpointTest { Endpoint.LogIn, Endpoint.PostReceipt, Endpoint.PostRedeemWebPurchase, + Endpoint.GetVirtualCurrencies(userId = "test-user-id"), ) for (endpoint in expectedEndpoints) { assertThat(endpoint.needsNonceToPerformSigning) @@ -160,6 +200,8 @@ class EndpointTest { Endpoint.PostAttributes("test-user-id"), Endpoint.PostDiagnostics, Endpoint.PostPaywallEvents, + Endpoint.WebBillingGetProducts("test-user-id", setOf("product1", "product2")), + Endpoint.AliasUsers("test-user-id"), ) for (endpoint in expectedEndpoints) { assertThat(endpoint.needsNonceToPerformSigning) diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/offerings/OfferingsCacheTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/offerings/OfferingsCacheTest.kt index ca58e72cf1..3734f8129f 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/offerings/OfferingsCacheTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/offerings/OfferingsCacheTest.kt @@ -3,6 +3,7 @@ package com.revenuecat.purchases.common.offerings import androidx.test.ext.junit.runners.AndroidJUnit4 import com.revenuecat.purchases.Offerings import com.revenuecat.purchases.common.DateProvider +import com.revenuecat.purchases.common.DefaultLocaleProvider import com.revenuecat.purchases.common.FakeLocaleProvider import com.revenuecat.purchases.common.caching.DeviceCache import com.revenuecat.purchases.utils.add @@ -41,7 +42,7 @@ class OfferingsCacheTest { get() = currentDate } - offeringsCache = OfferingsCache(deviceCache, dateProvider = dateProvider) + offeringsCache = OfferingsCache(deviceCache, dateProvider = dateProvider, localeProvider = DefaultLocaleProvider()) } @Test diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/offerings/OfferingsFactoryTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/offerings/OfferingsFactoryTest.kt index bee79084b5..44d4f61406 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/offerings/OfferingsFactoryTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/offerings/OfferingsFactoryTest.kt @@ -2,6 +2,7 @@ package com.revenuecat.purchases.common.offerings import androidx.test.ext.junit.runners.AndroidJUnit4 import com.revenuecat.purchases.Offerings +import com.revenuecat.purchases.PresentedOfferingContext import com.revenuecat.purchases.ProductType import com.revenuecat.purchases.PurchasesError import com.revenuecat.purchases.PurchasesErrorCode @@ -381,6 +382,43 @@ class OfferingsFactoryTest { ).isNull() } + @Test + fun `copy offering can create a copy with a different presented offering context`() { + val productIds = listOf(productId) + mockStoreProduct(productIds, productIds, ProductType.SUBS) + + var offerings: Offerings? = null + offeringsFactory.createOfferings( + offeringsJSON = oneOfferingResponse, + onError = { fail("Expected success. Got error: $it") }, + onSuccess = { offerings = it.offerings } + ) + + assertThat(offerings).isNotNull + assertThat(offerings!!.all.size).isEqualTo(1) + + val offering = offerings!![STUB_OFFERING_IDENTIFIER]!! + val originalPresentedOfferingContext = PresentedOfferingContext( + offeringIdentifier = STUB_OFFERING_IDENTIFIER, + placementIdentifier = null, + targetingContext = null + ) + assertThat(offering.availablePackages).allMatch { + it.presentedOfferingContext == originalPresentedOfferingContext && + it.product.presentedOfferingContext == originalPresentedOfferingContext + } + val newPresentedOfferingContext = PresentedOfferingContext( + offeringIdentifier = STUB_OFFERING_IDENTIFIER, + placementIdentifier = "new_placement", + targetingContext = PresentedOfferingContext.TargetingContext(1, "new_rule") + ) + val modifiedOffering = offering.copy(newPresentedOfferingContext) + assertThat(modifiedOffering.availablePackages).allMatch { + it.presentedOfferingContext == newPresentedOfferingContext && + it.product.presentedOfferingContext == newPresentedOfferingContext + } + } + // region helpers private fun mockStoreProduct( diff --git a/purchases/src/test/java/com/revenuecat/purchases/customercenter/ScreenOfferingExtensionsTest.kt b/purchases/src/test/java/com/revenuecat/purchases/customercenter/ScreenOfferingExtensionsTest.kt new file mode 100644 index 0000000000..3c20ce75a9 --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/customercenter/ScreenOfferingExtensionsTest.kt @@ -0,0 +1,238 @@ +package com.revenuecat.purchases.customercenter + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.Offering +import com.revenuecat.purchases.Offerings +import com.revenuecat.purchases.Package +import com.revenuecat.purchases.Purchases +import com.revenuecat.purchases.PurchasesError +import com.revenuecat.purchases.PurchasesErrorCode +import com.revenuecat.purchases.interfaces.ReceiveOfferingsCallback +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE) +class ScreenOfferingExtensionsTest { + + private val mockPurchases = mockk() + private val mockOfferings = mockk() + private val mockCurrentOffering = mockk() + private val mockSpecificOffering = mockk() + + @Before + fun setUp() { + mockkObject(Purchases) + every { Purchases.sharedInstance } returns mockPurchases + + every { mockOfferings.current } returns mockCurrentOffering + every { mockOfferings.all } returns mapOf( + "premium_monthly" to mockSpecificOffering, + "premium_yearly" to mockk() + ) + + every { mockCurrentOffering.identifier } returns "current_offering" + every { mockSpecificOffering.identifier } returns "premium_monthly" + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `resolveOffering - CURRENT type returns current offering`() { + val screen = CustomerCenterConfigData.Screen( + type = CustomerCenterConfigData.Screen.ScreenType.NO_ACTIVE, + title = "Test Screen", + paths = emptyList(), + offering = CustomerCenterConfigData.ScreenOffering( + type = CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.CURRENT + ) + ) + + val onErrorSlot = slot<(PurchasesError) -> Unit>() + val onSuccessSlot = slot<(Offerings) -> Unit>() + + every { + mockPurchases.getOfferings(any()) + } answers { + val callback = arg(0) + callback.onReceived(mockOfferings) + } + + var resultOffering: Offering? = null + var errorReceived: PurchasesError? = null + + screen.resolveOffering( + purchases = mockPurchases, + onError = { error -> errorReceived = error }, + onSuccess = { offering -> resultOffering = offering } + ) + + assertThat(resultOffering).isEqualTo(mockCurrentOffering) + assertThat(errorReceived).isNull() + } + + @Test + fun `resolveOffering - SPECIFIC type returns specific offering`() { + val screen = CustomerCenterConfigData.Screen( + type = CustomerCenterConfigData.Screen.ScreenType.NO_ACTIVE, + title = "Test Screen", + paths = emptyList(), + offering = CustomerCenterConfigData.ScreenOffering( + type = CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.SPECIFIC, + offeringId = "premium_monthly" + ) + ) + + every { + mockPurchases.getOfferings(any()) + } answers { + val callback = arg(0) + callback.onReceived(mockOfferings) + } + + var resultOffering: Offering? = null + var errorReceived: PurchasesError? = null + + screen.resolveOffering( + purchases = mockPurchases, + onError = { error -> errorReceived = error }, + onSuccess = { offering -> resultOffering = offering } + ) + + assertThat(resultOffering).isEqualTo(mockSpecificOffering) + assertThat(errorReceived).isNull() + } + + @Test + fun `resolveOffering - SPECIFIC type with non-existent offering ID returns null`() { + val screen = CustomerCenterConfigData.Screen( + type = CustomerCenterConfigData.Screen.ScreenType.NO_ACTIVE, + title = "Test Screen", + paths = emptyList(), + offering = CustomerCenterConfigData.ScreenOffering( + type = CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.SPECIFIC, + offeringId = "non_existent_offering" + ) + ) + + every { + mockPurchases.getOfferings(any()) + } answers { + val callback = arg(0) + callback.onReceived(mockOfferings) + } + + var resultOffering: Offering? = null + var errorReceived: PurchasesError? = null + + screen.resolveOffering( + purchases = mockPurchases, + onError = { error -> errorReceived = error }, + onSuccess = { offering -> resultOffering = offering } + ) + + assertThat(resultOffering).isNull() + assertThat(errorReceived).isNull() + } + + @Test + fun `resolveOffering - SPECIFIC type with null offering ID returns null`() { + val screen = CustomerCenterConfigData.Screen( + type = CustomerCenterConfigData.Screen.ScreenType.NO_ACTIVE, + title = "Test Screen", + paths = emptyList(), + offering = CustomerCenterConfigData.ScreenOffering( + type = CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.SPECIFIC, + offeringId = null + ) + ) + + every { + mockPurchases.getOfferings(any()) + } answers { + val callback = arg(0) + callback.onReceived(mockOfferings) + } + + var resultOffering: Offering? = null + var errorReceived: PurchasesError? = null + + screen.resolveOffering( + purchases = mockPurchases, + onError = { error -> errorReceived = error }, + onSuccess = { offering -> resultOffering = offering } + ) + + assertThat(resultOffering).isNull() + assertThat(errorReceived).isNull() + } + + @Test + fun `resolveOffering - no offering specified returns null`() { + val screen = CustomerCenterConfigData.Screen( + type = CustomerCenterConfigData.Screen.ScreenType.MANAGEMENT, + title = "Test Screen", + paths = emptyList(), + offering = null + ) + + var resultOffering: Offering? = null + var errorReceived: PurchasesError? = null + + screen.resolveOffering( + purchases = mockPurchases, + onError = { error -> errorReceived = error }, + onSuccess = { offering -> resultOffering = offering } + ) + + assertThat(resultOffering).isNull() + assertThat(errorReceived).isNull() + } + + @Test + fun `resolveOffering - API error calls onError`() { + val screen = CustomerCenterConfigData.Screen( + type = CustomerCenterConfigData.Screen.ScreenType.NO_ACTIVE, + title = "Test Screen", + paths = emptyList(), + offering = CustomerCenterConfigData.ScreenOffering( + type = CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.CURRENT + ) + ) + + val expectedError = PurchasesError(PurchasesErrorCode.NetworkError, "Network error") + + every { + mockPurchases.getOfferings(any()) + } answers { + val callback = arg(0) + callback.onError(expectedError) + } + + var resultOffering: Offering? = null + var errorReceived: PurchasesError? = null + + screen.resolveOffering( + purchases = mockPurchases, + onError = { error -> errorReceived = error }, + onSuccess = { offering -> resultOffering = offering } + ) + + assertThat(resultOffering).isNull() + assertThat(errorReceived).isEqualTo(expectedError) + } + +} \ No newline at end of file diff --git a/purchases/src/test/java/com/revenuecat/purchases/customercenter/ScreenOfferingTest.kt b/purchases/src/test/java/com/revenuecat/purchases/customercenter/ScreenOfferingTest.kt new file mode 100644 index 0000000000..6ce725b569 --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/customercenter/ScreenOfferingTest.kt @@ -0,0 +1,254 @@ +package com.revenuecat.purchases.customercenter + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.customercenter.CustomerCenterConfigData +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE) +class ScreenOfferingTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `ScreenOffering serialization - CURRENT type`() { + val screenOffering = CustomerCenterConfigData.ScreenOffering( + type = CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.CURRENT + ) + + val serialized = json.encodeToString(screenOffering) + val deserialized = json.decodeFromString(serialized) + + assertThat(deserialized.type).isEqualTo(CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.CURRENT) + assertThat(deserialized.offeringId).isNull() + } + + @Test + fun `ScreenOffering serialization - SPECIFIC type with offering ID`() { + val screenOffering = CustomerCenterConfigData.ScreenOffering( + type = CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.SPECIFIC, + offeringId = "premium_monthly_plan" + ) + + val serialized = json.encodeToString(screenOffering) + val deserialized = json.decodeFromString(serialized) + + assertThat(deserialized.type).isEqualTo(CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.SPECIFIC) + assertThat(deserialized.offeringId).isEqualTo("premium_monthly_plan") + } + + @Test + fun `Screen with new offering field - JSON parsing`() { + val jsonString = """ + { + "type": "NO_ACTIVE", + "title": "No Active Screen", + "subtitle": "No active subscriptions", + "paths": [], + "offering": { + "type": "CURRENT" + } + } + """.trimIndent() + + val screen = json.decodeFromString(jsonString) + + assertThat(screen.type).isEqualTo(CustomerCenterConfigData.Screen.ScreenType.NO_ACTIVE) + assertThat(screen.offering).isNotNull + assertThat(screen.offering?.type).isEqualTo(CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.CURRENT) + assertThat(screen.offering?.offeringId).isNull() + } + + @Test + fun `Screen with specific offering - JSON parsing`() { + val jsonString = """ + { + "type": "NO_ACTIVE", + "title": "No Active Screen", + "paths": [], + "offering": { + "type": "SPECIFIC", + "offering_id": "premium_monthly_plan" + } + } + """.trimIndent() + + val screen = json.decodeFromString(jsonString) + + assertThat(screen.offering).isNotNull + assertThat(screen.offering?.type).isEqualTo(CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.SPECIFIC) + assertThat(screen.offering?.offeringId).isEqualTo("premium_monthly_plan") + } + + + @Test + fun `Screen no offering specified - JSON parsing`() { + val jsonString = """ + { + "type": "MANAGEMENT", + "title": "Management Screen", + "paths": [] + } + """.trimIndent() + + val screen = json.decodeFromString(jsonString) + + assertThat(screen.offering).isNull() + } + + + @Test + fun `Screen with invalid offering type - throws exception`() { + val jsonString = """ + { + "type": "NO_ACTIVE", + "title": "No Active Screen", + "paths": [], + "offering": { + "type": "INVALID_TYPE" + } + } + """.trimIndent() + + try { + json.decodeFromString(jsonString) + assertThat(false).describedAs("Expected exception to be thrown").isTrue() + } catch (e: Exception) { + assertThat(e).isInstanceOf(kotlinx.serialization.SerializationException::class.java) + } + } + + @Test + fun `Screen with malformed JSON - throws exception`() { + val jsonString = """ + { + "type": "NO_ACTIVE", + "title": "No Active Screen", + "paths": [], + "offering": "invalid_offering_format" + } + """.trimIndent() + + try { + json.decodeFromString(jsonString) + assertThat(false).describedAs("Expected exception to be thrown").isTrue() + } catch (e: Exception) { + // Expected - malformed JSON should throw an exception + assertThat(e).isNotNull() + } + } + + @Test + fun `ScreenOfferingType enum values`() { + assertThat(CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.CURRENT.value).isEqualTo("CURRENT") + assertThat(CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.SPECIFIC.value).isEqualTo("SPECIFIC") + } + + @Test + fun `ScreenOffering with buttonText - serialization and deserialization`() { + val screenOffering = CustomerCenterConfigData.ScreenOffering( + type = CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.CURRENT, + buttonText = "Get Started" + ) + + val serialized = json.encodeToString(screenOffering) + val deserialized = json.decodeFromString(serialized) + + assertThat(deserialized.type).isEqualTo(CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.CURRENT) + assertThat(deserialized.offeringId).isNull() + assertThat(deserialized.buttonText).isEqualTo("Get Started") + } + + @Test + fun `ScreenOffering without buttonText - backward compatibility`() { + val screenOffering = CustomerCenterConfigData.ScreenOffering( + type = CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.SPECIFIC, + offeringId = "premium_plan" + ) + + val serialized = json.encodeToString(screenOffering) + val deserialized = json.decodeFromString(serialized) + + assertThat(deserialized.type).isEqualTo(CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.SPECIFIC) + assertThat(deserialized.offeringId).isEqualTo("premium_plan") + assertThat(deserialized.buttonText).isNull() + } + + @Test + fun `ScreenOffering JSON parsing with button_text`() { + val jsonString = """ + { + "type": "CURRENT", + "button_text": "Subscribe Now" + } + """.trimIndent() + + val screenOffering = json.decodeFromString(jsonString) + + assertThat(screenOffering.type).isEqualTo(CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.CURRENT) + assertThat(screenOffering.offeringId).isNull() + assertThat(screenOffering.buttonText).isEqualTo("Subscribe Now") + } + + @Test + fun `ScreenOffering JSON parsing without button_text - backward compatibility`() { + val jsonString = """ + { + "type": "SPECIFIC", + "offering_id": "monthly_plan" + } + """.trimIndent() + + val screenOffering = json.decodeFromString(jsonString) + + assertThat(screenOffering.type).isEqualTo(CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.SPECIFIC) + assertThat(screenOffering.offeringId).isEqualTo("monthly_plan") + assertThat(screenOffering.buttonText).isNull() + } + + @Test + fun `Screen with offering containing buttonText - JSON parsing`() { + val jsonString = """ + { + "type": "NO_ACTIVE", + "title": "No Active Screen", + "subtitle": "No active subscriptions", + "paths": [], + "offering": { + "type": "CURRENT", + "button_text": "Start Subscription" + } + } + """.trimIndent() + + val screen = json.decodeFromString(jsonString) + + assertThat(screen.type).isEqualTo(CustomerCenterConfigData.Screen.ScreenType.NO_ACTIVE) + assertThat(screen.offering).isNotNull + assertThat(screen.offering?.type).isEqualTo(CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.CURRENT) + assertThat(screen.offering?.offeringId).isNull() + assertThat(screen.offering?.buttonText).isEqualTo("Start Subscription") + } + + @Test + fun `ScreenOffering with all fields - serialization and deserialization`() { + val screenOffering = CustomerCenterConfigData.ScreenOffering( + type = CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.SPECIFIC, + offeringId = "premium_yearly", + buttonText = "Upgrade to Premium" + ) + + val serialized = json.encodeToString(screenOffering) + val deserialized = json.decodeFromString(serialized) + + assertThat(deserialized.type).isEqualTo(CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.SPECIFIC) + assertThat(deserialized.offeringId).isEqualTo("premium_yearly") + assertThat(deserialized.buttonText).isEqualTo("Upgrade to Premium") + } +} \ No newline at end of file diff --git a/purchases/src/test/java/com/revenuecat/purchases/identity/IdentityManagerTests.kt b/purchases/src/test/java/com/revenuecat/purchases/identity/IdentityManagerTests.kt index 2deab01bef..8111af522f 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/identity/IdentityManagerTests.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/identity/IdentityManagerTests.kt @@ -4,9 +4,9 @@ import android.content.SharedPreferences.Editor import androidx.test.ext.junit.runners.AndroidJUnit4 import com.revenuecat.purchases.CustomerInfo import com.revenuecat.purchases.EntitlementInfos -import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI import com.revenuecat.purchases.PurchasesError import com.revenuecat.purchases.PurchasesErrorCode +import com.revenuecat.purchases.PurchasesException import com.revenuecat.purchases.VerificationResult import com.revenuecat.purchases.common.Backend import com.revenuecat.purchases.common.caching.DeviceCache @@ -23,7 +23,9 @@ import io.mockk.just import io.mockk.mockk import io.mockk.slot import io.mockk.verify +import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.fail import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -82,6 +84,14 @@ class IdentityManagerTests { identityManager = createIdentityManager() } + @Test + fun testIsUserIdAnonymousWorksAsExpected() { + assertThat(IdentityManager.isUserIDAnonymous(stubAnonymousID)).isTrue() + assertThat(IdentityManager.isUserIDAnonymous("")).isFalse() + assertThat(IdentityManager.isUserIDAnonymous("test-user-id")).isFalse() + assertThat(IdentityManager.isUserIDAnonymous("\$RCAnonymousID:12345678901234567890123456789012")).isTrue() + } + @Test fun testConfigureWithAnonymousUserIDGeneratesAnAppUserID() { mockCleanCaches() @@ -631,6 +641,82 @@ class IdentityManagerTests { } // endregion + // region aliasCurrentUserIdTo + + @Test + fun `aliasCurrentUserIdTo finishes successfully and clears proper caches`() = runTest { + val oldAppUserID = "test-old-app-user-id" + val newAppUserId = "test-new-app-user-id" + + mockIdentifiedUser(newAppUserId) + + every { mockDeviceCache.clearCustomerInfoCache(newAppUserId) } just Runs + every { + mockBackend.aliasUsers( + oldAppUserID = oldAppUserID, + newAppUserID = newAppUserId, + onSuccessHandler = captureLambda(), + onErrorHandler = any(), + ) + } answers { + lambda<() -> Unit>().captured.invoke() + } + + identityManager.aliasCurrentUserIdTo(oldAppUserID) + + verify(exactly = 1) { + mockBackend.aliasUsers( + oldAppUserID = oldAppUserID, + newAppUserID = newAppUserId, + onSuccessHandler = any(), + onErrorHandler = any(), + ) + } + verify(exactly = 1) { mockOfferingsCache.clearCache() } + verify(exactly = 1) { mockDeviceCache.clearCustomerInfoCache(newAppUserId) } + verify(exactly = 1) { mockOfflineEntitlementsManager.resetOfflineCustomerInfoCache() } + } + + @Test + fun `aliasCurrentUserIdTo finishes with errors`() = runTest { + val oldAppUserID = "test-old-app-user-id" + val newAppUserId = "test-new-app-user-id" + + mockIdentifiedUser(newAppUserId) + + every { + mockBackend.aliasUsers( + oldAppUserID = oldAppUserID, + newAppUserID = newAppUserId, + onSuccessHandler = any(), + onErrorHandler = captureLambda(), + ) + } answers { + lambda<(PurchasesError) -> Unit>().captured.invoke(PurchasesError(PurchasesErrorCode.NetworkError)) + } + + try { + identityManager.aliasCurrentUserIdTo(oldAppUserID) + fail("Expected an error") + } catch (e: PurchasesException) { + assertThat(e.code).isEqualTo(PurchasesErrorCode.NetworkError) + } + + verify(exactly = 1) { + mockBackend.aliasUsers( + oldAppUserID = oldAppUserID, + newAppUserID = newAppUserId, + onSuccessHandler = any(), + onErrorHandler = any(), + ) + } + verify(exactly = 0) { mockOfferingsCache.clearCache() } + verify(exactly = 0) { mockDeviceCache.clearCustomerInfoCache(newAppUserId) } + verify(exactly = 0) { mockOfflineEntitlementsManager.resetOfflineCustomerInfoCache() } + } + + // endregion aliasCurrentUserIdTo + // region helper functions private fun setupCustomerInfoCacheInvalidationTest( diff --git a/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/PaywallTransitionTest.kt b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/PaywallTransitionTest.kt new file mode 100644 index 0000000000..77dcd529c9 --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/PaywallTransitionTest.kt @@ -0,0 +1,101 @@ +package com.revenuecat.purchases.paywalls.components + +import com.revenuecat.purchases.JsonTools +import org.intellij.lang.annotations.Language +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +class PaywallTransitionTest(@Suppress("UNUSED_PARAMETER") name: String, private val args: PaywallTransitionTest.Args) { + + class Args( + @Language("json") + val json: String, + val expected: PaywallTransition, + ) + + companion object { + + @Suppress("LongMethod") + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun parameters(): Collection<*> = listOf( + arrayOf( + "fade_and_scale ease-in-out", + Args( + json = """ + { + "animation": { + "ms_delay": 1500, + "ms_duration": 300, + "type": "ease_in_out" + }, + "displacement_strategy": "greedy", + "type": "fade_and_scale" + } + """.trimIndent(), + expected = PaywallTransition( + type = PaywallTransition.TransitionType.FADE_AND_SCALE, + displacementStrategy = PaywallTransition.DisplacementStrategy.GREEDY, + animation = PaywallAnimation( + type = PaywallAnimation.AnimationType.EASE_IN_OUT, + msDelay = 1500, + msDuration = 300, + ), + ), + ), + ), + arrayOf( + "fade null", + Args( + json = """ + { + "displacement_strategy": "lazy", + "type": "fade" + } + """.trimIndent(), + expected = PaywallTransition( + type = PaywallTransition.TransitionType.FADE, + displacementStrategy = PaywallTransition.DisplacementStrategy.LAZY, + animation = null, + ), + ), + ), + arrayOf( + "custom custom -> default serialization take effect", + Args( + json = """ + { + "animation": { + "ms_delay": 0, + "ms_duration": 100, + "type": "custom" + }, + "displacement_strategy": "greedy", + "type": "custom" + } + """.trimIndent(), + expected = PaywallTransition( + type = PaywallTransition.TransitionType.FADE, + displacementStrategy = PaywallTransition.DisplacementStrategy.GREEDY, + animation = PaywallAnimation( + type = PaywallAnimation.AnimationType.EASE_IN_OUT, + msDelay = 0, + msDuration = 100, + ), + ), + ), + ), + ) + } + + @Test + fun `Should properly deserialize PaywallTransition`() { + // Arrange, Act + val actual = JsonTools.json.decodeFromString(args.json) + + // Assert + assert(actual == args.expected) + } +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/simulatedstore/SimulatedStoreBillingWrapperTest.kt b/purchases/src/test/java/com/revenuecat/purchases/simulatedstore/SimulatedStoreBillingWrapperTest.kt new file mode 100644 index 0000000000..067f395c92 --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/simulatedstore/SimulatedStoreBillingWrapperTest.kt @@ -0,0 +1,330 @@ +package com.revenuecat.purchases.simulatedstore + +import android.app.Activity +import android.os.Handler +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.PresentedOfferingContext +import com.revenuecat.purchases.ProductType +import com.revenuecat.purchases.PurchasesError +import com.revenuecat.purchases.PurchasesErrorCode +import com.revenuecat.purchases.PurchasesStateProvider +import com.revenuecat.purchases.common.Backend +import com.revenuecat.purchases.common.BillingAbstract +import com.revenuecat.purchases.common.caching.DeviceCache +import com.revenuecat.purchases.common.networking.WebBillingPhase +import com.revenuecat.purchases.common.networking.WebBillingPrice +import com.revenuecat.purchases.common.networking.WebBillingProductResponse +import com.revenuecat.purchases.common.networking.WebBillingProductsResponse +import com.revenuecat.purchases.common.networking.WebBillingPurchaseOption +import com.revenuecat.purchases.models.StoreTransaction +import com.revenuecat.purchases.utils.AlertDialogHelper +import com.revenuecat.purchases.utils.UrlConnectionFactory +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.fail +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE) +class SimulatedStoreBillingWrapperTest { + + private lateinit var deviceCache: DeviceCache + private lateinit var mainHandler: Handler + private lateinit var purchasesStateProvider: PurchasesStateProvider + private lateinit var urlConnectionFactory: UrlConnectionFactory + private lateinit var backend: Backend + private lateinit var purchaseDialogHelper: AlertDialogHelper + private lateinit var testStoreBilling: SimulatedStoreBillingWrapper + private lateinit var stateListener: BillingAbstract.StateListener + private lateinit var purchasesUpdatedListener: BillingAbstract.PurchasesUpdatedListener + + @Before + fun setup() { + deviceCache = mockk() + mainHandler = mockk() + purchasesStateProvider = mockk() + urlConnectionFactory = mockk() + backend = mockk() + purchaseDialogHelper = mockk() + stateListener = mockk() + + // Create actual listener object for testing purchase flows + purchasesUpdatedListener = this@SimulatedStoreBillingWrapperTest.TestPurchasesListener() + + every { mainHandler.postDelayed(any(), any()) } answers { + val runnable = firstArg() + runnable.run() + true + } + + every { mainHandler.post(any()) } answers { + val runnable = firstArg() + runnable.run() + true + } + + testStoreBilling = SimulatedStoreBillingWrapper( + deviceCache = deviceCache, + mainHandler = mainHandler, + purchasesStateProvider = purchasesStateProvider, + backend = backend, + dialogHelper = purchaseDialogHelper + ) + + testStoreBilling.stateListener = stateListener + testStoreBilling.purchasesUpdatedListener = purchasesUpdatedListener + } + + @Test + fun `startConnection sets connected to true and notifies listener`() { + every { stateListener.onConnected() } just Runs + + assertThat(testStoreBilling.isConnected()).isFalse() + + testStoreBilling.startConnection() + + assertThat(testStoreBilling.isConnected()).isTrue() + verify { stateListener.onConnected() } + } + + @Test + fun `startConnectionOnMainThread posts delayed and starts connection`() { + every { stateListener.onConnected() } just Runs + + testStoreBilling.startConnectionOnMainThread(100) + + verify { mainHandler.postDelayed(any(), 100) } + verify { stateListener.onConnected() } + } + + @Test + fun `close sets connected to false`() { + every { stateListener.onConnected() } just Runs + + testStoreBilling.startConnection() + assertThat(testStoreBilling.isConnected()).isTrue() + + testStoreBilling.close() + assertThat(testStoreBilling.isConnected()).isFalse() + } + + @Test + fun `getStorefront returns US`() { + var result: String? = null + + testStoreBilling.getStorefront( + onSuccess = { result = it }, + onError = { } + ) + + assertThat(result).isEqualTo("US") + } + + @Test + fun `makePurchaseAsync with dialog cancellation calls onPurchasesFailedToUpdate with cancelled error`() { + // Given + val activity = mockk() + val productId = "test_product_123" + val presentedOfferingContext = mockk() + + // Mock product response from backend + val productResponse = createMockProductResponse(productId) + val product = SimulatedStoreProductConverter.convertToStoreProduct(productResponse) + val purchasingData = product.purchasingData + val billingResponse = WebBillingProductsResponse(listOf(productResponse)) + + every { deviceCache.getCachedAppUserID() } returns "test_user" + every { backend.getWebBillingProducts(any(), any(), any(), any()) } answers { + val onSuccess = thirdArg<(WebBillingProductsResponse) -> Unit>() + onSuccess(billingResponse) + } + + // Mock dialog helper to simulate cancellation + every { + purchaseDialogHelper.showDialog( + any(), any(), any(), any(), any(), any(), any(), any(), any(), + ) + } answers { + val onNegativeClicked = lastArg<() -> Unit>() + onNegativeClicked() + } + + // When + testStoreBilling.makePurchaseAsync( + activity = activity, + appUserID = "test_user", + purchasingData = purchasingData, + replaceProductInfo = null, + presentedOfferingContext = presentedOfferingContext, + isPersonalizedPrice = null + ) + + // Then + val listenerImpl = purchasesUpdatedListener as SimulatedStoreBillingWrapperTest.TestPurchasesListener + assertThat(listenerImpl.lastError).isNotNull() + assertThat(listenerImpl.lastError?.code).isEqualTo(PurchasesErrorCode.PurchaseCancelledError) + assertThat(listenerImpl.lastError?.underlyingErrorMessage).isEqualTo("Purchase cancelled by user") + assertThat(listenerImpl.lastPurchases).isNull() + } + + @Test + fun `makePurchaseAsync with successful purchase calls onPurchasesUpdated with transaction`() { + // Given + val activity = mockk() + val productId = "test_product_456" + val presentedOfferingContext = mockk() + + // Mock product response from backend + val productResponse = createMockProductResponse(productId) + val product = SimulatedStoreProductConverter.convertToStoreProduct(productResponse) + val purchasingData = product.purchasingData + val billingResponse = WebBillingProductsResponse(listOf(productResponse)) + + every { deviceCache.getCachedAppUserID() } returns "test_user" + every { backend.getWebBillingProducts(any(), any(), any(), any()) } answers { + val onSuccess = thirdArg<(WebBillingProductsResponse) -> Unit>() + onSuccess(billingResponse) + } + + // Mock dialog helper to simulate successful purchase + every { + purchaseDialogHelper.showDialog( + any(), any(), any(), any(), any(), any(), any(), any(), any(), + ) + } answers { + val onPositiveClicked = arg<() -> Unit>(6) + onPositiveClicked() + } + + // When + testStoreBilling.makePurchaseAsync( + activity = activity, + appUserID = "test_user", + purchasingData = purchasingData, + replaceProductInfo = null, + presentedOfferingContext = presentedOfferingContext, + isPersonalizedPrice = null + ) + + // Then + val listenerImpl = purchasesUpdatedListener as SimulatedStoreBillingWrapperTest.TestPurchasesListener + assertThat(listenerImpl.lastPurchases).isNotNull() + assertThat(listenerImpl.lastPurchases).hasSize(1) + + val transaction = listenerImpl.lastPurchases?.first() + assertThat(transaction?.productIds).containsExactly(productId) + assertThat(transaction?.type).isEqualTo(ProductType.SUBS) + assertThat(transaction?.purchaseToken).isNotNull() + assertThat(listenerImpl.lastError).isNull() + } + + private fun createMockProductResponse(productId: String): WebBillingProductResponse { + return WebBillingProductResponse( + identifier = productId, + productType = "subs", + title = "Test Product", + description = "Test product description", + defaultPurchaseOptionId = "option1", + purchaseOptions = mapOf( + "option1" to WebBillingPurchaseOption( + basePrice = null, + base = WebBillingPhase( + price = WebBillingPrice( + amountMicros = 999000, + currency = "USD" + ), + periodDuration = "P1M", + ), + trial = null, + introPrice = null + ) + ) + ) + } + + @Test + fun `queryAllPurchases returns empty list`() { + var cachedPurchases: List? = null + testStoreBilling.queryAllPurchases( + appUserID = "test_user", + onReceivePurchaseHistory = { cachedPurchases = it }, + onReceivePurchaseHistoryError = { fail("Expected success") } + ) + + assertThat(cachedPurchases).isNotNull() + assertThat(cachedPurchases).hasSize(0) + } + + @Test + fun `queryPurchases returns empty map`() { + var cachedPurchases: Map? = null + testStoreBilling.queryPurchases( + appUserID = "test_user", + onSuccess = { cachedPurchases = it }, + onError = { fail("Should succeed") } + ) + + assertThat(cachedPurchases).isNotNull() + assertThat(cachedPurchases).hasSize(0) + } + + @Test + fun `findPurchaseInPurchaseHistory returns error`() { + var error: PurchasesError? = null + testStoreBilling.findPurchaseInPurchaseHistory( + appUserID = "test_user", + productType = ProductType.SUBS, + productId = "test-product-id", + onCompletion = { fail("Should error") }, + onError = { error = it } + ) + + assertThat(error).isNotNull() + assertThat(error?.code).isEqualTo(PurchasesErrorCode.PurchaseNotAllowedError) + assertThat(error?.underlyingErrorMessage).isEqualTo("No active purchase found for product: test-product-id") + } + + @Test + fun `findPurchaseInPurchaseHistory returns error when purchase not found`() { + // Given - no purchases in cache + val nonExistentProductId = "non_existent_product" + + // When - try to find non-existent purchase + var foundPurchase: StoreTransaction? = null + var errorFound: PurchasesError? = null + + testStoreBilling.findPurchaseInPurchaseHistory( + appUserID = "test_user", + productType = ProductType.SUBS, + productId = nonExistentProductId, + onCompletion = { foundPurchase = it }, + onError = { errorFound = it } + ) + + // Then - should return error + assertThat(foundPurchase).isNull() + assertThat(errorFound).isNotNull() + assertThat(errorFound?.code).isEqualTo(PurchasesErrorCode.PurchaseNotAllowedError) + assertThat(errorFound?.underlyingErrorMessage).contains(nonExistentProductId) + } + + private inner class TestPurchasesListener : BillingAbstract.PurchasesUpdatedListener { + var lastPurchases: List? = null + var lastError: PurchasesError? = null + + override fun onPurchasesUpdated(purchases: List) { + lastPurchases = purchases + } + + override fun onPurchasesFailedToUpdate(purchasesError: PurchasesError) { + lastError = purchasesError + } + } +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/simulatedstore/SimulatedStoreOfferingParserTest.kt b/purchases/src/test/java/com/revenuecat/purchases/simulatedstore/SimulatedStoreOfferingParserTest.kt new file mode 100644 index 0000000000..5d7ca050ae --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/simulatedstore/SimulatedStoreOfferingParserTest.kt @@ -0,0 +1,106 @@ +package com.revenuecat.purchases.simulatedstore + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.PresentedOfferingContext +import com.revenuecat.purchases.models.StoreProduct +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.json.JSONObject +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE) +class SimulatedStoreOfferingParserTest { + + private lateinit var parser: SimulatedStoreOfferingParser + private lateinit var mockProduct1: StoreProduct + private lateinit var mockProduct2: StoreProduct + + @Before + fun setup() { + parser = SimulatedStoreOfferingParser() + mockProduct1 = mockk(relaxed = true) { + every { copyWithPresentedOfferingContext(any()) } returns this@mockk + } + mockProduct2 = mockk(relaxed = true) { + every { copyWithPresentedOfferingContext(any()) } returns this@mockk + } + } + + @Test + fun `createPackage returns package when product exists`() { + val productId = "test_product_id" + val packageIdentifier = "test_package" + val productsById = mapOf( + productId to listOf(mockProduct1, mockProduct2) + ) + val packageJson = JSONObject().apply { + put("platform_product_identifier", productId) + put("identifier", packageIdentifier) + } + val presentedOfferingContext = PresentedOfferingContext("test_offering") + + val result = parser.createPackage(packageJson, productsById, presentedOfferingContext) + + assertThat(result).isNotNull + assertThat(result?.identifier).isEqualTo(packageIdentifier) + assertThat(result?.product).isEqualTo(mockProduct1) + } + + @Test + fun `createPackage returns null when product does not exist`() { + val productId = "nonexistent_product_id" + val packageIdentifier = "test_package" + val productsById = mapOf( + "other_product_id" to listOf(mockProduct1) + ) + val packageJson = JSONObject().apply { + put("platform_product_identifier", productId) + put("identifier", packageIdentifier) + } + val presentedOfferingContext = PresentedOfferingContext("test_offering") + + val result = parser.createPackage(packageJson, productsById, presentedOfferingContext) + + assertThat(result).isNull() + } + + @Test + fun `createPackage returns null when productsById is empty`() { + val productId = "test_product_id" + val packageIdentifier = "test_package" + val productsById = emptyMap>() + val packageJson = JSONObject().apply { + put("platform_product_identifier", productId) + put("identifier", packageIdentifier) + } + val presentedOfferingContext = PresentedOfferingContext("test_offering") + + val result = parser.createPackage(packageJson, productsById, presentedOfferingContext) + + assertThat(result).isNull() + } + + @Test + fun `createPackage returns package with first product when multiple products exist for same id`() { + val productId = "test_product_id" + val packageIdentifier = "test_package" + val productsById = mapOf( + productId to listOf(mockProduct1, mockProduct2) + ) + val packageJson = JSONObject().apply { + put("platform_product_identifier", productId) + put("identifier", packageIdentifier) + } + val presentedOfferingContext = PresentedOfferingContext("test_offering") + + val result = parser.createPackage(packageJson, productsById, presentedOfferingContext) + + assertThat(result).isNotNull + assertThat(result?.product).isEqualTo(mockProduct1) + } +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/simulatedstore/SimulatedStoreProductConverterTest.kt b/purchases/src/test/java/com/revenuecat/purchases/simulatedstore/SimulatedStoreProductConverterTest.kt new file mode 100644 index 0000000000..17be2576f8 --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/simulatedstore/SimulatedStoreProductConverterTest.kt @@ -0,0 +1,378 @@ +package com.revenuecat.purchases.simulatedstore + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.PurchasesErrorCode +import com.revenuecat.purchases.PurchasesException +import com.revenuecat.purchases.common.networking.WebBillingPhase +import com.revenuecat.purchases.common.networking.WebBillingPrice +import com.revenuecat.purchases.common.networking.WebBillingProductResponse +import com.revenuecat.purchases.common.networking.WebBillingPurchaseOption +import com.revenuecat.purchases.models.Period +import com.revenuecat.purchases.models.Price +import com.revenuecat.purchases.models.PricingPhase +import com.revenuecat.purchases.models.RecurrenceMode +import com.revenuecat.purchases.models.TestStoreProduct +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.fail +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Locale + +@RunWith(AndroidJUnit4::class) +class SimulatedStoreProductConverterTest { + + private val testLocale = Locale.US + + @Test + fun `converts one time product correctly`() { + val productResponse = WebBillingProductResponse( + identifier = "test_product", + productType = "subscription", + title = "Test Product", + description = "Test Description", + defaultPurchaseOptionId = "option1", + purchaseOptions = mapOf( + "option1" to WebBillingPurchaseOption( + basePrice = WebBillingPrice( + amountMicros = 9990000L, + currency = "USD" + ) + ) + ) + ) + + val result = convertToStoreProduct(productResponse) + + assertThat(result).isInstanceOf(TestStoreProduct::class.java) + assertThat(result.id).isEqualTo("test_product") + assertThat(result.title).isEqualTo("Test Product") + assertThat(result.name).isEqualTo("Test Product") + assertThat(result.description).isEqualTo("Test Description") + assertThat(result.price.formatted).isEqualTo("$9.99") + assertThat(result.price.amountMicros).isEqualTo(9990000L) + assertThat(result.price.currencyCode).isEqualTo("USD") + } + + @Test + fun `converts subscription product correctly`() { + val productResponse = WebBillingProductResponse( + identifier = "sub_product", + productType = "subscription", + title = "Sub Product", + description = null, + defaultPurchaseOptionId = null, + purchaseOptions = mapOf( + "option1" to WebBillingPurchaseOption( + base = WebBillingPhase( + price = WebBillingPrice( + amountMicros = 4990000L, + currency = "EUR" + ), + periodDuration = "P1M", + ) + ) + ) + ) + + val result = convertToStoreProduct(productResponse) + + val expectedPrice = Price( + formatted = "€4.99", + amountMicros = 4990000L, + currencyCode = "EUR" + ) + assertThat(result.id).isEqualTo("sub_product") + assertThat(result.description).isEqualTo("") + assertThat(result.price).isEqualTo(expectedPrice) + assertThat(result.period).isEqualTo(Period.create("P1M")) + assertThat(result.defaultOption?.pricingPhases?.size).isEqualTo(1) + assertThat(result.defaultOption?.pricingPhases?.get(0)).isEqualTo( + PricingPhase( + billingPeriod = Period.create("P1M"), + recurrenceMode = RecurrenceMode.INFINITE_RECURRING, + billingCycleCount = null, + price = expectedPrice, + ) + ) + } + + @Test + fun `converts product with free trial correctly`() { + val productResponse = WebBillingProductResponse( + identifier = "trial_product", + productType = "subscription", + title = "Trial Product", + description = "With trial", + defaultPurchaseOptionId = "option1", + purchaseOptions = mapOf( + "option1" to WebBillingPurchaseOption( + base = WebBillingPhase( + price = WebBillingPrice( + amountMicros = 9990000L, + currency = "USD" + ), + periodDuration = "P1M" + ), + trial = WebBillingPhase( + periodDuration = "P7D", + cycleCount = 2 + ) + ) + ) + ) + + val result = convertToStoreProduct(productResponse) + + assertThat(result.defaultOption?.freePhase).isEqualTo( + PricingPhase( + billingPeriod = Period.create("P7D"), + recurrenceMode = RecurrenceMode.FINITE_RECURRING, + billingCycleCount = 2, + price = Price( + formatted = "$0.00", + amountMicros = 0L, + currencyCode = "USD" + ) + ) + ) + assertThat(result.defaultOption?.introPhase).isNull() + assertThat(result.defaultOption?.fullPricePhase).isEqualTo( + PricingPhase( + billingPeriod = Period.create("P1M"), + recurrenceMode = RecurrenceMode.INFINITE_RECURRING, + billingCycleCount = null, + price = Price( + formatted = "$9.99", + amountMicros = 9990000L, + currencyCode = "USD" + ) + ) + ) + } + + @Test + fun `converts product with intro price correctly`() { + val productResponse = WebBillingProductResponse( + identifier = "intro_product", + productType = "subscription", + title = "Intro Product", + description = "With intro", + defaultPurchaseOptionId = "option1", + purchaseOptions = mapOf( + "option1" to WebBillingPurchaseOption( + base = WebBillingPhase( + price = WebBillingPrice( + amountMicros = 9990000L, + currency = "USD" + ), + periodDuration = "P1M" + ), + introPrice = WebBillingPhase( + price = WebBillingPrice( + amountMicros = 1990000L, + currency = "USD" + ), + periodDuration = "P1M", + cycleCount = 3 + ) + ) + ) + ) + + val result = convertToStoreProduct(productResponse) + + assertThat(result.defaultOption?.freePhase).isNull() + assertThat(result.defaultOption?.introPhase).isEqualTo( + PricingPhase( + billingPeriod = Period.create("P1M"), + recurrenceMode = RecurrenceMode.FINITE_RECURRING, + billingCycleCount = 3, + price = Price( + formatted = "$1.99", + amountMicros = 1990000L, + currencyCode = "USD" + ) + ) + ) + assertThat(result.defaultOption?.fullPricePhase).isEqualTo( + PricingPhase( + billingPeriod = Period.create("P1M"), + recurrenceMode = RecurrenceMode.INFINITE_RECURRING, + billingCycleCount = null, + price = Price( + formatted = "$9.99", + amountMicros = 9990000L, + currencyCode = "USD" + ) + ) + ) + } + + @Test + fun `throws exception when defaultPurchaseOptionId is invalid`() { + val productResponse = WebBillingProductResponse( + identifier = "test_product", + productType = "subscription", + title = "Test Product", + description = "Test", + defaultPurchaseOptionId = "missing_option", + purchaseOptions = mapOf( + "option1" to WebBillingPurchaseOption( + basePrice = WebBillingPrice( + amountMicros = 9990000L, + currency = "USD" + ) + ) + ) + ) + + try { + convertToStoreProduct(productResponse) + fail("Expected PurchasesException to be thrown") + } catch (e: PurchasesException) { + assertThat(e.error.code).isEqualTo(PurchasesErrorCode.ProductNotAvailableForPurchaseError) + assertThat(e.error.underlyingErrorMessage).isEqualTo("No purchase option found for product test_product") + } + } + + @Test + fun `handles missing base price gracefully`() { + val productResponse = WebBillingProductResponse( + identifier = "no_price", + productType = "subscription", + title = "No Price", + description = "Test", + defaultPurchaseOptionId = "option1", + purchaseOptions = mapOf( + "option1" to WebBillingPurchaseOption( + base = WebBillingPhase( + price = null, + periodDuration = "P1M" + ) + ) + ) + ) + + try { + val result = convertToStoreProduct(productResponse) + fail("Expected PurchasesException to be thrown, but got result: $result") + } catch (e: PurchasesException) { + assertThat(e.error.code).isEqualTo(PurchasesErrorCode.ProductNotAvailableForPurchaseError) + assertThat(e.error.underlyingErrorMessage).isEqualTo("Base price is required for test subscription products") + } + } + + @Test + fun `handles missing trial phase data gracefully`() { + val productResponse = WebBillingProductResponse( + identifier = "incomplete_trial", + productType = "subscription", + title = "Incomplete Trial", + description = "Test", + defaultPurchaseOptionId = "option1", + purchaseOptions = mapOf( + "option1" to WebBillingPurchaseOption( + base = WebBillingPhase( + price = WebBillingPrice( + amountMicros = 9990000L, + currency = "USD" + ), + periodDuration = "P1M", + ), + trial = WebBillingPhase( + cycleCount = 1, // No period provided + ) + ) + ) + ) + + val result = convertToStoreProduct(productResponse) + + assertThat(result.subscriptionOptions?.freeTrial).isNull() + } + + @Test + fun `handles missing intro price data gracefully`() { + val productResponse = WebBillingProductResponse( + identifier = "incomplete_intro", + productType = "subscription", + title = "Incomplete Intro", + description = "Test", + defaultPurchaseOptionId = "option1", + purchaseOptions = mapOf( + "option1" to WebBillingPurchaseOption( + base = WebBillingPhase( + price = WebBillingPrice( + amountMicros = 9990000L, + currency = "USD" + ), + periodDuration = "P1M" + ), + introPrice = WebBillingPhase( + price = null, // No price provided + periodDuration = "P1M", + cycleCount = 3 + ) + ) + ) + ) + + val result = convertToStoreProduct(productResponse) + + assertThat(result.subscriptionOptions?.introOffer).isNull() + } + + @Test + fun `formatPrice formats correctly`() { + val productResponse = WebBillingProductResponse( + identifier = "format_test", + productType = "subscription", + title = "Format Test", + description = "Test", + defaultPurchaseOptionId = "option1", + purchaseOptions = mapOf( + "option1" to WebBillingPurchaseOption( + basePrice = WebBillingPrice( + amountMicros = 12345678L, + currency = "JPY" + ) + ) + ) + ) + + val result = convertToStoreProduct(productResponse) + + assertThat(result.price.formatted).isEqualTo("¥12") + } + + @Test + fun `handles zero price correctly`() { + val productResponse = WebBillingProductResponse( + identifier = "free_product", + productType = "subscription", + title = "Free Product", + description = "Test", + defaultPurchaseOptionId = "option1", + purchaseOptions = mapOf( + "option1" to WebBillingPurchaseOption( + basePrice = WebBillingPrice( + amountMicros = 0L, + currency = "USD" + ) + ) + ) + ) + + val result = convertToStoreProduct(productResponse) + + assertThat(result.price.formatted).isEqualTo("$0.00") + assertThat(result.price.amountMicros).isEqualTo(0L) + } + + private fun convertToStoreProduct( + productResponse: WebBillingProductResponse, + locale: Locale = testLocale + ): TestStoreProduct { + return SimulatedStoreProductConverter.convertToStoreProduct(productResponse, locale) + } +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/subscriberattributes/SubscriberAttributesManagerTests.kt b/purchases/src/test/java/com/revenuecat/purchases/subscriberattributes/SubscriberAttributesManagerTests.kt index 72e609af3a..8ed1b72937 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/subscriberattributes/SubscriberAttributesManagerTests.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/subscriberattributes/SubscriberAttributesManagerTests.kt @@ -41,7 +41,8 @@ class SubscriberAttributesManagerTests { underTest = SubscriberAttributesManager( mockDeviceCache, mockBackend, - mockDeviceIdentifiersFetcher + mockDeviceIdentifiersFetcher, + automaticDeviceIdentifierCollectionEnabled = true, ) } @@ -427,6 +428,40 @@ class SubscriberAttributesManagerTests { assertThat(unsyncedAttributes).isEqualTo(expected) } + @Test + fun `setting attribution id does not get device identifiers if disabled`() { + underTest = SubscriberAttributesManager( + mockDeviceCache, + mockBackend, + mockDeviceIdentifiersFetcher, + automaticDeviceIdentifierCollectionEnabled = false, + ) + + val mockContext = mockk(relaxed = true) + every { + mockDeviceCache.getUnsyncedSubscriberAttributes(appUserID) + } returns emptyMap() + + val slot = mockSettingAttributesOnEmptyCache() + + underTest.setAttributionID( + SubscriberAttributeKey.AttributionIds.Adjust, + "test-adjust-id", + appUserID, + mockContext + ) + + + verify(exactly = 0) { mockDeviceIdentifiersFetcher.getDeviceIdentifiers(any(), any()) } + verify(exactly = 1) { mockDeviceCache.setAttributes(appUserID, any()) } + + val capturedAttributes = slot.captured + val adjustAttribute = capturedAttributes[SubscriberAttributeKey.AttributionIds.Adjust.backendKey] + assertThat(adjustAttribute).isNotNull + assertThat(adjustAttribute!!.key.backendKey).isEqualTo(SubscriberAttributeKey.AttributionIds.Adjust.backendKey) + assertThat(adjustAttribute.value).isEqualTo("test-adjust-id") + } + @Test fun `getting unsynchronized attributes calls completion only once`() { val mockContext = mockk(relaxed = true) diff --git a/purchases/src/test/java/com/revenuecat/purchases/utils/PriceFactoryTest.kt b/purchases/src/test/java/com/revenuecat/purchases/utils/PriceFactoryTest.kt new file mode 100644 index 0000000000..cabbda203f --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/utils/PriceFactoryTest.kt @@ -0,0 +1,127 @@ +package com.revenuecat.purchases.utils + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.models.Price +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Locale + +@RunWith(AndroidJUnit4::class) +class PriceFactoryTest { + + @Test + fun `creates price with US locale correctly`() { + val price = PriceFactory.createPrice(9990000L, "USD", Locale.US) + + assertThat(price.formatted).isEqualTo("$9.99") + assertThat(price.amountMicros).isEqualTo(9990000L) + assertThat(price.currencyCode).isEqualTo("USD") + } + + @Test + fun `creates price with EUR currency correctly`() { + val price = PriceFactory.createPrice(4990000L, "EUR", Locale.GERMANY) + + assertThat(price.formatted).isEqualTo("4,99 €") + assertThat(price.amountMicros).isEqualTo(4990000L) + assertThat(price.currencyCode).isEqualTo("EUR") + } + + @Test + fun `creates price with JPY currency correctly`() { + val price = PriceFactory.createPrice(12345678L, "JPY", Locale.JAPAN) + + assertThat(price.formatted).isEqualTo("¥12") + assertThat(price.amountMicros).isEqualTo(12345678L) + assertThat(price.currencyCode).isEqualTo("JPY") + } + + @Test + fun `creates zero price correctly`() { + val price = PriceFactory.createPrice(0L, "USD", Locale.US) + + assertThat(price.formatted).isEqualTo("$0.00") + assertThat(price.amountMicros).isEqualTo(0L) + assertThat(price.currencyCode).isEqualTo("USD") + } + + @Test + fun `creates price with large amount correctly`() { + val price = PriceFactory.createPrice(99999990000L, "USD", Locale.US) + + assertThat(price.formatted).isEqualTo("$99,999.99") + assertThat(price.amountMicros).isEqualTo(99999990000L) + assertThat(price.currencyCode).isEqualTo("USD") + } + + @Test + fun `creates price with different locale formats USD correctly`() { + val priceUS = PriceFactory.createPrice(9990000L, "USD", Locale.US) + val priceUK = PriceFactory.createPrice(9990000L, "USD", Locale.UK) + + assertThat(priceUS.formatted).isEqualTo("$9.99") + assertThat(priceUK.formatted).isEqualTo("US$9.99") + assertThat(priceUS.amountMicros).isEqualTo(priceUK.amountMicros) + assertThat(priceUS.currencyCode).isEqualTo(priceUK.currencyCode) + } + + @Test + fun `creates price with GBP currency correctly`() { + val price = PriceFactory.createPrice(7990000L, "GBP", Locale.UK) + + assertThat(price.formatted).isEqualTo("£7.99") + assertThat(price.amountMicros).isEqualTo(7990000L) + assertThat(price.currencyCode).isEqualTo("GBP") + } + + @Test + fun `creates price with CAD currency correctly`() { + val price = PriceFactory.createPrice(12990000L, "CAD", Locale.CANADA) + + assertThat(price.formatted).isEqualTo("$12.99") + assertThat(price.amountMicros).isEqualTo(12990000L) + assertThat(price.currencyCode).isEqualTo("CAD") + } + + @Test + fun `creates price with BRL currency correctly`() { + val price = PriceFactory.createPrice(24990000L, "BRL", Locale("pt", "BR")) + + // BRL formatting varies by locale - just check structure + assertThat(price.formatted).contains("24,99") + assertThat(price.formatted).contains("R$") + assertThat(price.amountMicros).isEqualTo(24990000L) + assertThat(price.currencyCode).isEqualTo("BRL") + } + + @Test + fun `creates price with KRW currency correctly`() { + val price = PriceFactory.createPrice(15000000000L, "KRW", Locale.KOREA) + + assertThat(price.formatted).isEqualTo("₩15,000") + assertThat(price.amountMicros).isEqualTo(15000000000L) + assertThat(price.currencyCode).isEqualTo("KRW") + } + + @Test + fun `creates price with very small amount correctly`() { + val price = PriceFactory.createPrice(10000L, "USD", Locale.US) + + assertThat(price.formatted).isEqualTo("$0.01") + assertThat(price.amountMicros).isEqualTo(10000L) + assertThat(price.currencyCode).isEqualTo("USD") + } + + @Test + fun `creates price with fraction digits for different currencies`() { + val usdPrice = PriceFactory.createPrice(9990000L, "USD", Locale.US) + val jpyPrice = PriceFactory.createPrice(999000000L, "JPY", Locale.JAPAN) + + // USD has 2 fraction digits + assertThat(usdPrice.formatted).isEqualTo("$9.99") + + // JPY has 0 fraction digits + assertThat(jpyPrice.formatted).isEqualTo("¥999") + } +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/utils/Responses.kt b/purchases/src/test/java/com/revenuecat/purchases/utils/Responses.kt index e58c9c19a1..779ad9e365 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/utils/Responses.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/utils/Responses.kt @@ -298,6 +298,133 @@ object Responses { "message": "Missing required params." } """.removeJSONFormatting() + + val validFullVirtualCurrenciesResponse = """ + { + "virtual_currencies": { + "COIN": { + "balance": 1, + "code": "COIN", + "description": "It's a coin", + "name": "Coin" + }, + "RC_COIN": { + "balance": 0, + "code": "RC_COIN", + "name": "RC Coin" + } + } + } + """.removeJSONFormatting() + + val validEmptyVirtualCurrenciesResponse = """ + { + "virtual_currencies": {} + } + """.removeJSONFormatting() + + val validWebBillingProductsResponse = """ + { + "product_details": [ + { + "current_price": { + "amount": 999, + "amount_micros": 9990000, + "currency": "EUR" + }, + "default_purchase_option_id": "base_option", + "default_subscription_option_id": null, + "description": "A test monthly subscription product", + "identifier": "product1", + "normal_period_duration": "P1M", + "product_type": "subscription", + "purchase_options": { + "base_option": { + "base": { + "cycle_count": 1, + "period_duration": "P1M", + "price": { + "amount": 999, + "amount_micros": 9990000, + "currency": "EUR" + } + }, + "id": "base_option", + "intro_price": null, + "price_id": "test_price_id", + "trial": null + } + }, + "subscription_options": { + "base_option": { + "base": { + "cycle_count": 1, + "period_duration": "P1M", + "price": { + "amount": 999, + "amount_micros": 9990000, + "currency": "EUR" + } + }, + "id": "base_option", + "intro_price": null, + "price_id": "test_price_id", + "trial": null + } + }, + "title": "Test Monthly Subscription" + }, + { + "current_price": { + "amount": 999, + "amount_micros": 9990000, + "currency": "EUR" + }, + "default_purchase_option_id": "base_option", + "default_subscription_option_id": null, + "description": "A test monthly subscription product", + "identifier": "product2", + "normal_period_duration": "P1M", + "product_type": "subscription", + "purchase_options": { + "base_option": { + "base": { + "cycle_count": 1, + "period_duration": "P1M", + "price": { + "amount": 999, + "amount_micros": 9990000, + "currency": "EUR" + } + }, + "id": "base_option", + "intro_price": null, + "price_id": "test_price_id", + "trial": null + } + }, + "subscription_options": { + "base_option": { + "base": { + "cycle_count": 1, + "period_duration": "P1M", + "price": { + "amount": 999, + "amount_micros": 9990000, + "currency": "EUR" + } + }, + "id": "base_option", + "intro_price": null, + "price_id": "test_price_id", + "trial": null + } + }, + "title": "Test Monthly Subscription" + } + ] + } + """.removeJSONFormatting() } private fun String.removeJSONFormatting(): String = JSONObject(this).toString() diff --git a/purchases/src/test/java/com/revenuecat/purchases/virtualcurrencies/VirtualCurrenciesFactoryTest.kt b/purchases/src/test/java/com/revenuecat/purchases/virtualcurrencies/VirtualCurrenciesFactoryTest.kt new file mode 100644 index 0000000000..e9dece3efe --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/virtualcurrencies/VirtualCurrenciesFactoryTest.kt @@ -0,0 +1,71 @@ +package com.revenuecat.purchases.virtualcurrencies + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.utils.Responses +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.MissingFieldException +import kotlinx.serialization.SerializationException +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.assertj.core.api.Assertions.fail +import org.json.JSONException +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class VirtualCurrenciesFactoryTest { + private val defaultVirtualCurrencies = VirtualCurrenciesFactory.buildVirtualCurrencies( + JSONObject(Responses.validFullVirtualCurrenciesResponse) + ) + + private val emptyVirtualCurrencies = VirtualCurrenciesFactory.buildVirtualCurrencies( + JSONObject(Responses.validEmptyVirtualCurrenciesResponse) + ) + + @Test + fun `correctly parses virtual currencies`() { + assertThat(defaultVirtualCurrencies.all.size).isEqualTo(2) + + val coinVC: VirtualCurrency? = defaultVirtualCurrencies["COIN"] + assertThat(coinVC).isNotNull + assertThat(coinVC?.name).isEqualTo("Coin") + assertThat(coinVC?.balance).isEqualTo(1) + assertThat(coinVC?.serverDescription).isEqualTo("It's a coin") + assertThat(coinVC?.code).isEqualTo("COIN") + + val rcCoinVC: VirtualCurrency? = defaultVirtualCurrencies["RC_COIN"] + assertThat(rcCoinVC).isNotNull + assertThat(rcCoinVC?.name).isEqualTo("RC Coin") + assertThat(rcCoinVC?.balance).isEqualTo(0) + assertThat(rcCoinVC?.serverDescription).isNull() + assertThat(rcCoinVC?.code).isEqualTo("RC_COIN") + + val nonExistentVC: VirtualCurrency? = defaultVirtualCurrencies["asdf"] + assertThat(nonExistentVC).isNull() + } + + @Test + fun `correctly parses empty virtual currencies`() { + assertThat(emptyVirtualCurrencies.all.size).isEqualTo(0) + } + + @OptIn(ExperimentalSerializationApi::class) + @Test + fun `throws MissingFieldException for valid JSON that is missing fields`() { + assertThatThrownBy { + VirtualCurrenciesFactory.buildVirtualCurrencies( + JSONObject("{}") + ) + }.isInstanceOf(MissingFieldException::class.java) + } + + @Test + fun `throws JSONException for invalid JSON`() { + assertThatThrownBy { + VirtualCurrenciesFactory.buildVirtualCurrencies( + JSONObject("asdf") + ) + }.isInstanceOf(JSONException::class.java) + } +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/virtualcurrencies/VirtualCurrenciesTest.kt b/purchases/src/test/java/com/revenuecat/purchases/virtualcurrencies/VirtualCurrenciesTest.kt new file mode 100644 index 0000000000..e03fd4553f --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/virtualcurrencies/VirtualCurrenciesTest.kt @@ -0,0 +1,103 @@ +package com.revenuecat.purchases.virtualcurrencies + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class VirtualCurrenciesTest { + + private val virtualCurrency = VirtualCurrency( + name = "Test VC", + balance = 100, + code = "TEST", + serverDescription = "hello world" + ) + + // region Subscript Tests + @Test + fun `subscript returns correct virtual currency`() { + val code = "COIN" + val currency = VirtualCurrency( + balance = 100, + name = "Coins", + code = code, + serverDescription = "Coin currency" + ) + val virtualCurrencies = VirtualCurrencies( + all = mapOf(code to currency), + ) + + val result = virtualCurrencies[code] + assertEquals(currency, result) + } + + @Test + fun `subscript returns null for non-existent currency`() { + val code = "COIN" + val currency = VirtualCurrency( + balance = 100, + name = "Coins", + code = code, + serverDescription = "Coin currency" + ) + val virtualCurrencies = VirtualCurrencies( + all = mapOf(code to currency), + ) + + assertNull(virtualCurrencies["NON_EXISTENT"]) + } + // endregion + + // region Equality Tests + @Test + fun `equals is true for VirtualCurrencies objects with identical VCs`() { + + val vcClone = VirtualCurrency( + name = "Test VC", + balance = 100, + code = "TEST", + serverDescription = "hello world" + ) + + val virtualCurrencies1 = VirtualCurrencies( + all = mapOf("TEST" to virtualCurrency) + ) + val virtualCurrencies2 = VirtualCurrencies( + all = mapOf("TEST" to virtualCurrency) + ) + val virtualCurrencies3 = VirtualCurrencies( + all = mapOf("TEST" to vcClone) + ) + + assertTrue(virtualCurrencies1 == virtualCurrencies2) + assertTrue(virtualCurrencies1 == virtualCurrencies3) + } + + @Test + fun `equals is false for VirtualCurrencies objects with different VCs`() { + + val differentVirtualCurrency = VirtualCurrency( + name = "Test VC 2", + balance = 200, + code = "TEST2", + serverDescription = "lorem ipsum" + ) + + val virtualCurrencies1 = VirtualCurrencies( + all = mapOf("TEST" to virtualCurrency) + ) + val virtualCurrencies2 = VirtualCurrencies( + all = mapOf("TEST2" to differentVirtualCurrency) + ) + + assertFalse(virtualCurrencies1 == virtualCurrencies2) + } + + // endregion +} \ No newline at end of file diff --git a/purchases/src/test/java/com/revenuecat/purchases/virtualcurrencies/VirtualCurrencyManagerTest.kt b/purchases/src/test/java/com/revenuecat/purchases/virtualcurrencies/VirtualCurrencyManagerTest.kt new file mode 100644 index 0000000000..bbb667f9a7 --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/virtualcurrencies/VirtualCurrencyManagerTest.kt @@ -0,0 +1,300 @@ +package com.revenuecat.purchases.virtualcurrencies + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.PurchasesError +import com.revenuecat.purchases.PurchasesErrorCode +import com.revenuecat.purchases.common.AppConfig +import com.revenuecat.purchases.common.Backend +import com.revenuecat.purchases.common.caching.DeviceCache +import com.revenuecat.purchases.identity.IdentityManager +import com.revenuecat.purchases.interfaces.GetVirtualCurrenciesCallback +import com.revenuecat.purchases.utils.Responses +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import net.bytebuddy.implementation.bind.MethodDelegationBinder.MethodInvoker.Virtual +import org.assertj.core.api.Assertions.assertThat +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class VirtualCurrencyManagerTest { + + private val virtualCurrencies = VirtualCurrenciesFactory.buildVirtualCurrencies( + JSONObject(Responses.validFullVirtualCurrenciesResponse) + ) + + // region virtualCurrencies + @Test + fun `virtualCurrencies returns cached VirtualCurrencies when cache is not stale`() { + val appUserID = "appUserID" + + val mockDeviceCache = mockk() + every { + mockDeviceCache.getCachedVirtualCurrencies(any()) + } returns this.virtualCurrencies + every { + mockDeviceCache.isVirtualCurrenciesCacheStale(any(), any()) + } returns false + + val mockIdentityManager = mockk() + every { + mockIdentityManager.currentAppUserID + } returns appUserID + + val mockAppConfig = mockk() + every { + mockAppConfig.isAppBackgrounded + } returns false + + val mockCallback = mockk() + every { + mockCallback.onReceived(any()) + } returns Unit + + val virtualCurrencyManager = VirtualCurrencyManager( + identityManager = mockIdentityManager, + deviceCache = mockDeviceCache, + backend = mockk(), + appConfig = mockAppConfig + ) + + virtualCurrencyManager.virtualCurrencies(mockCallback) + + verify { + mockDeviceCache.getCachedVirtualCurrencies(appUserID = appUserID) + mockDeviceCache.isVirtualCurrenciesCacheStale(appUserID = appUserID, appInBackground = false) + mockCallback.onReceived(this@VirtualCurrencyManagerTest.virtualCurrencies) + } + } + + @Test + fun `virtualCurrencies fetches VirtualCurrencies from network when cache is stale`() { + val appUserID = "appUserID" + + val mockDeviceCache = mockk() + every { + mockDeviceCache.getCachedVirtualCurrencies(any()) + } returns this.virtualCurrencies + every { + mockDeviceCache.isVirtualCurrenciesCacheStale(any(), any()) + } returns true + every { + mockDeviceCache.cacheVirtualCurrencies(any(), any()) + } returns Unit + + val mockIdentityManager = mockk() + every { + mockIdentityManager.currentAppUserID + } returns appUserID + + val mockAppConfig = mockk() + every { + mockAppConfig.isAppBackgrounded + } returns false + + val mockBackend = mockk() + every { + mockBackend.getVirtualCurrencies(any(), any(), any(), any()) + } answers { + val onSuccess = arg<(VirtualCurrencies) -> Unit>(2) + onSuccess(this@VirtualCurrencyManagerTest.virtualCurrencies) + } + + val mockCallback = mockk() + every { + mockCallback.onReceived(any()) + } returns Unit + + val virtualCurrencyManager = VirtualCurrencyManager( + identityManager = mockIdentityManager, + deviceCache = mockDeviceCache, + backend = mockBackend, + appConfig = mockAppConfig + ) + + virtualCurrencyManager.virtualCurrencies(mockCallback) + + verify(exactly = 1) { + mockDeviceCache.isVirtualCurrenciesCacheStale(appUserID = appUserID, appInBackground = false) + mockBackend.getVirtualCurrencies( + appUserID = appUserID, + appInBackground = false, + onSuccess = any(), + onError = any() + ) + mockDeviceCache.cacheVirtualCurrencies( + appUserID = appUserID, + virtualCurrencies = this@VirtualCurrencyManagerTest.virtualCurrencies + ) + mockCallback.onReceived(this@VirtualCurrencyManagerTest.virtualCurrencies) + } + + verify(exactly = 0) { + mockDeviceCache.getCachedVirtualCurrencies(any()) + mockCallback.onError(any()) + } + } + + @Test + fun `virtualCurrencies passes error when network request fails`() { + val appUserID = "appUserID" + val expectedError = PurchasesError( + code = PurchasesErrorCode.NetworkError, + underlyingErrorMessage = "Mock error" + ) + + val mockDeviceCache = mockk() + every { + mockDeviceCache.isVirtualCurrenciesCacheStale(any(), any()) + } returns true + + val mockIdentityManager = mockk() + every { + mockIdentityManager.currentAppUserID + } returns appUserID + + val mockAppConfig = mockk() + every { + mockAppConfig.isAppBackgrounded + } returns false + + val mockBackend = mockk() + every { + mockBackend.getVirtualCurrencies(any(), any(), any(), any()) + } answers { + val onError = arg<(PurchasesError) -> Unit>(3) + onError(expectedError) + } + + val mockCallback = mockk() + every { + mockCallback.onReceived(any()) + } returns Unit + every { + mockCallback.onError(any()) + } returns Unit + + val virtualCurrencyManager = VirtualCurrencyManager( + identityManager = mockIdentityManager, + deviceCache = mockDeviceCache, + backend = mockBackend, + appConfig = mockAppConfig + ) + + virtualCurrencyManager.virtualCurrencies(mockCallback) + + verify(exactly = 1) { + mockDeviceCache.isVirtualCurrenciesCacheStale(appUserID = appUserID, appInBackground = false) + mockBackend.getVirtualCurrencies( + appUserID = appUserID, + appInBackground = false, + onSuccess = any(), + onError = any() + ) + mockCallback.onError(expectedError) + } + + verify(exactly = 0) { + mockDeviceCache.getCachedVirtualCurrencies(any()) + mockDeviceCache.cacheVirtualCurrencies( + appUserID = appUserID, + virtualCurrencies = any() + ) + mockCallback.onReceived(any()) + } + } + + // endregion + + // region invalidateVirtualCurrenciesCache + @Test + fun `invalidateVirtualCurrenciesCache clears the virtual currencies cache`() { + val appUserID = "appUserID" + + val mockDeviceCache = mockk() + every { + mockDeviceCache.clearVirtualCurrenciesCache(any()) + } returns Unit + + val mockIdentityManager = mockk() + every { + mockIdentityManager.currentAppUserID + } returns appUserID + + val virtualCurrencyManager = VirtualCurrencyManager( + identityManager = mockIdentityManager, + deviceCache = mockDeviceCache, + backend = mockk(), + appConfig = mockk() + ) + + virtualCurrencyManager.invalidateVirtualCurrenciesCache() + + verify(exactly = 1) { + mockDeviceCache.clearVirtualCurrenciesCache(appUserID = appUserID) + } + } + // endregion + + // region cachedVirtualCurrencies + @Test + fun `cachedVirtualCurrencies returns cached VirtualCurrencies when present`() { + val appUserID = "appUserID" + + val mockDeviceCache = mockk() + every { + mockDeviceCache.getCachedVirtualCurrencies(any()) + } returns this.virtualCurrencies + + val mockIdentityManager = mockk() + every { + mockIdentityManager.currentAppUserID + } returns appUserID + + val mockAppConfig = mockk() + every { + mockAppConfig.isAppBackgrounded + } returns false + + val virtualCurrencyManager = VirtualCurrencyManager( + identityManager = mockIdentityManager, + deviceCache = mockDeviceCache, + backend = mockk(), + appConfig = mockAppConfig + ) + + assertThat(virtualCurrencyManager.cachedVirtualCurrencies()).isEqualTo(virtualCurrencies) + } + + @Test + fun `cachedVirtualCurrencies returns null when virtual currencies cache is empty`() { + val appUserID = "appUserID" + + val mockDeviceCache = mockk() + every { + mockDeviceCache.getCachedVirtualCurrencies(any()) + } returns null + + val mockIdentityManager = mockk() + every { + mockIdentityManager.currentAppUserID + } returns appUserID + + val mockAppConfig = mockk() + every { + mockAppConfig.isAppBackgrounded + } returns false + + val virtualCurrencyManager = VirtualCurrencyManager( + identityManager = mockIdentityManager, + deviceCache = mockDeviceCache, + backend = mockk(), + appConfig = mockAppConfig + ) + + assertThat(virtualCurrencyManager.cachedVirtualCurrencies()).isNull() + } + // endregion +} \ No newline at end of file diff --git a/purchases/src/test/java/com/revenuecat/purchases/virtualcurrencies/VirtualCurrencyTest.kt b/purchases/src/test/java/com/revenuecat/purchases/virtualcurrencies/VirtualCurrencyTest.kt new file mode 100644 index 0000000000..a016232feb --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/virtualcurrencies/VirtualCurrencyTest.kt @@ -0,0 +1,57 @@ +package com.revenuecat.purchases.virtualcurrencies + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class VirtualCurrencyTest { + + private val virtualCurrency = VirtualCurrency( + name = "Test VC", + balance = 100, + code = "TEST", + serverDescription = "hello world" + ) + + + // region Equality Tests + @Test + fun `equals is true for two VirtualCurrency instances with the same values`() { + val virtualCurrencyCopy = VirtualCurrency( + name = "Test VC", + balance = 100, + code = "TEST", + serverDescription = "hello world" + ) + + assertTrue(virtualCurrency == virtualCurrencyCopy) + } + + @Test + fun `equals is false for two VirtualCurrency instances with the same metadata but different balances`() { + val virtualCurrencyCopy = VirtualCurrency( + name = "Test VC", + balance = 777, + code = "TEST", + serverDescription = "hello world" + ) + + assertFalse(virtualCurrency == virtualCurrencyCopy) + } + + @Test + fun `equals is false for two VirtualCurrency instances with different values`() { + val virtualCurrency2 = VirtualCurrency( + name = "Test VC 2", + balance = 200, + code = "TEST2", + serverDescription = "lorem ipsum" + ) + + assertFalse(virtualCurrency == virtualCurrency2) + } + //endregion +} \ No newline at end of file diff --git a/purchases/src/testDefaults/kotlin/com/revenuecat/purchases/PurchasesTest.kt b/purchases/src/testDefaults/kotlin/com/revenuecat/purchases/PurchasesTest.kt index bc60a9ae3b..b6c932a65e 100644 --- a/purchases/src/testDefaults/kotlin/com/revenuecat/purchases/PurchasesTest.kt +++ b/purchases/src/testDefaults/kotlin/com/revenuecat/purchases/PurchasesTest.kt @@ -21,6 +21,7 @@ import com.revenuecat.purchases.interfaces.LogInCallback import com.revenuecat.purchases.interfaces.PurchaseCallback import com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback import com.revenuecat.purchases.interfaces.RedeemWebPurchaseListener +import com.revenuecat.purchases.interfaces.SyncPurchasesCallback import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.StoreProduct import com.revenuecat.purchases.models.StoreTransaction @@ -524,6 +525,28 @@ internal class PurchasesTest : BasePurchasesTest() { } } + @Test + fun `login called with different appUserID notifies backup manager`() { + val mockCreated = Random.nextBoolean() + every { mockIdentityManager.currentAppUserID } returns "oldAppUserID" + + every { + mockIdentityManager.logIn(any(), onSuccess = captureLambda(), any()) + } answers { + lambda<(CustomerInfo, Boolean) -> Unit>().captured.invoke(mockInfo, mockCreated) + } + + val mockCompletion = mockk(relaxed = true) + val newAppUserID = "newAppUserID" + mockOfferingsManagerFetchOfferings(newAppUserID) + + purchases.logIn(newAppUserID, mockCompletion) + + verify(exactly = 1) { + mockBackupManager.dataChanged() + } + } + @Test fun `login successful with new appUserID calls customer info updater to update delegate if changed`() { purchases.updatedCustomerInfoListener = updatedCustomerInfoListener @@ -594,6 +617,9 @@ internal class PurchasesTest : BasePurchasesTest() { verify(exactly = 1) { mockOfferingsManager.fetchAndCacheOfferings(appUserID, false, any(), any()) } + verify(exactly = 1) { + mockBackupManager.dataChanged() + } } @Test @@ -1644,6 +1670,56 @@ internal class PurchasesTest : BasePurchasesTest() { // endregion Paywall fonts + // region Simulated store + + @Test + fun `syncing transactions on simulated store does not sync purchases`() { + buildPurchases( + anonymous = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.SIMULATED_STORE, + enableSimulatedStore = true, + ) + + var receivedCustomerInfo: CustomerInfo? = null + purchases.syncPurchases(object: SyncPurchasesCallback { + override fun onSuccess(customerInfo: CustomerInfo) { + receivedCustomerInfo = customerInfo + } + + override fun onError(error: PurchasesError) { + fail("Expected succeess. Got $error") + } + }) + + verify(exactly = 0) { mockSyncPurchasesHelper.syncPurchases(any(), any(), any(), any()) } + assertThat(receivedCustomerInfo).isNotNull + } + + @Test + fun `restore transactions on simulated store does not restore purchases`() { + buildPurchases( + anonymous = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.SIMULATED_STORE, + enableSimulatedStore = true, + ) + + var receivedCustomerInfo: CustomerInfo? = null + purchases.restorePurchases(object: ReceiveCustomerInfoCallback { + override fun onReceived(customerInfo: CustomerInfo) { + receivedCustomerInfo = customerInfo + } + + override fun onError(error: PurchasesError) { + fail("Expected succeess. Got $error") + } + }) + + verify(exactly = 0) { mockBillingAbstract.queryAllPurchases(any(), any(), any()) } + assertThat(receivedCustomerInfo).isNotNull + } + + // endregion Simulated store + // region Private Methods private fun getMockedPurchaseHistoryList( diff --git a/purchases/src/testDefaults/kotlin/com/revenuecat/purchases/attributes/SubscriberAttributesPurchasesTests.kt b/purchases/src/testDefaults/kotlin/com/revenuecat/purchases/attributes/SubscriberAttributesPurchasesTests.kt index bb49e347c5..4209b5624e 100644 --- a/purchases/src/testDefaults/kotlin/com/revenuecat/purchases/attributes/SubscriberAttributesPurchasesTests.kt +++ b/purchases/src/testDefaults/kotlin/com/revenuecat/purchases/attributes/SubscriberAttributesPurchasesTests.kt @@ -2,6 +2,7 @@ package com.revenuecat.purchases.attributes import android.app.Application import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.APIKeyValidator import com.revenuecat.purchases.CacheFetchPolicy import com.revenuecat.purchases.CustomerInfoHelper import com.revenuecat.purchases.CustomerInfoUpdateHandler @@ -18,6 +19,7 @@ import com.revenuecat.purchases.Store import com.revenuecat.purchases.common.AppConfig import com.revenuecat.purchases.common.Backend import com.revenuecat.purchases.common.BillingAbstract +import com.revenuecat.purchases.common.DefaultLocaleProvider import com.revenuecat.purchases.common.PlatformInfo import com.revenuecat.purchases.common.caching.DeviceCache import com.revenuecat.purchases.common.offerings.OfferingsManager @@ -28,6 +30,7 @@ import com.revenuecat.purchases.paywalls.PaywallPresentedCache import com.revenuecat.purchases.paywalls.FontLoader import com.revenuecat.purchases.subscriberattributes.SubscriberAttributesManager import com.revenuecat.purchases.utils.SyncDispatcher +import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencyManager import io.mockk.Runs import io.mockk.clearMocks import io.mockk.every @@ -54,6 +57,7 @@ class SubscriberAttributesPurchasesTests { private val postReceiptHelperMock = mockk() private val offeringsManagerMock = mockk() private val fontLoaderMock = mockk() + private val virtualCurrencyManagerMock = mockk() private lateinit var applicationMock: Application @Before @@ -72,6 +76,7 @@ class SubscriberAttributesPurchasesTests { proxyURL = null, store = Store.PLAY_STORE, isDebugBuild = false, + apiKeyValidationResult = APIKeyValidator.ValidationResult.VALID, ) val identityManager = mockk(relaxed = true).apply { every { currentAppUserID } returns appUserId @@ -113,6 +118,8 @@ class SubscriberAttributesPurchasesTests { dispatcher = SyncDispatcher(), initialConfiguration = PurchasesConfiguration.Builder(context, "mock-api-key").build(), fontLoader = fontLoaderMock, + localeProvider = DefaultLocaleProvider(), + virtualCurrencyManager = virtualCurrencyManagerMock, ) underTest = Purchases(purchasesOrchestrator) @@ -202,7 +209,13 @@ class SubscriberAttributesPurchasesTests { subscriberAttributesManagerMock.synchronizeSubscriberAttributesForAllUsers(appUserId) } just Runs every { - customerInfoHelperMock.retrieveCustomerInfo(appUserId, CacheFetchPolicy.FETCH_CURRENT, false, any()) + customerInfoHelperMock.retrieveCustomerInfo( + appUserId, + CacheFetchPolicy.FETCH_CURRENT, + appInBackground = false, + allowSharingPlayStoreAccount = any(), + callback = any(), + ) } just Runs every { offeringsManagerMock.onAppForeground(appUserId) diff --git a/scripts/api-check.sh b/scripts/api-check.sh old mode 100644 new mode 100755 index 5f4683a5dc..65cc3d72a4 --- a/scripts/api-check.sh +++ b/scripts/api-check.sh @@ -2,7 +2,12 @@ exit_code=0 -./gradlew metalavaCheckCompatibilityDefaultsRelease || exit_code=$? -./gradlew metalavaCheckCompatibilityCustomEntitlementComputationRelease || exit_code=$? +# Run the api-dump.sh script to generate the API dump +./scripts/api-dump.sh || exit_code=$? +# Check if there are any dirty changes in git +if ! git diff --quiet; then + echo "API dump has changes, run the scripts/api-dump.sh script to generate the API dump, review and commit them." + exit_code=1 +fi -exit $exit_code \ No newline at end of file +exit $exit_code diff --git a/scripts/api-dump.sh b/scripts/api-dump.sh old mode 100644 new mode 100755 diff --git a/scripts/test_d2d.sh b/scripts/test_d2d.sh new file mode 100755 index 0000000000..f3628b6a42 --- /dev/null +++ b/scripts/test_d2d.sh @@ -0,0 +1,38 @@ +#!/bin/bash -eu +: "${1?"Usage: $0 package name"}" + +# This script test the D2D backup and restore functionality for a given Android package. +# It was obtained from https://developer.android.com/identity/data/testingbackup + +# Initialize and create a backup +adb shell bmgr enable true +adb shell settings put secure backup_enable_d2d_test_mode 1 +adb shell bmgr transport com.google.android.gms/.backup.migrate.service.D2dTransport +adb shell bmgr init com.google.android.gms/.backup.migrate.service.D2dTransport +adb shell bmgr list transports | grep -q -F " * com.google.android.gms/.backup.migrate.service.D2dTransport" || (echo "Failed to select and initialize backup transport"; exit 1) +adb shell bmgr backupnow "$1" | grep -F "Package $1 with result: Success" || (echo "Backup failed"; exit 1) + +# Uninstall and reinstall the app to clear the data and trigger a restore +apk_path_list=$(adb shell pm path "$1") +OIFS=$IFS +IFS=$'\n' +apk_number=0 +for apk_line in $apk_path_list +do + (( ++apk_number )) + apk_path=${apk_line:8:1000} + adb pull "$apk_path" "myapk${apk_number}.apk" +done +IFS=$OIFS +adb shell pm uninstall --user 0 "$1" +adb shell bmgr transport com.google.android.gms/.backup.BackupTransportService +apks=$(seq -f 'myapk%.f.apk' 1 $apk_number) +adb install-multiple -t --user 0 $apks + +# Clean up +adb shell bmgr init com.google.android.gms/.backup.migrate.service.D2dTransport +adb shell settings put secure backup_enable_d2d_test_mode 0 +adb shell bmgr transport com.google.android.gms/.backup.BackupTransportService +rm $apks + +echo "Done" diff --git a/test-apps/testpurchasesandroidcompatibility/build.gradle.kts b/test-apps/testpurchasesandroidcompatibility/build.gradle.kts index f176494c3b..94a2f46a83 100644 --- a/test-apps/testpurchasesandroidcompatibility/build.gradle.kts +++ b/test-apps/testpurchasesandroidcompatibility/build.gradle.kts @@ -30,8 +30,11 @@ android { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } - kotlinOptions { - jvmTarget = "1.8" +} + +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8) } } diff --git a/test-apps/testpurchasesuiandroidcompatibility/build.gradle.kts b/test-apps/testpurchasesuiandroidcompatibility/build.gradle.kts index 64448ce032..93eb23ae5f 100644 --- a/test-apps/testpurchasesuiandroidcompatibility/build.gradle.kts +++ b/test-apps/testpurchasesuiandroidcompatibility/build.gradle.kts @@ -33,9 +33,6 @@ android { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } - kotlinOptions { - jvmTarget = "1.8" - } buildFeatures { compose = true } @@ -49,6 +46,12 @@ android { } } +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8) + } +} + emerge { apiToken.set(System.getenv("EMERGE_API_TOKEN")) diff --git a/ui/debugview/build.gradle b/ui/debugview/build.gradle deleted file mode 100644 index 72e5b9a742..0000000000 --- a/ui/debugview/build.gradle +++ /dev/null @@ -1,66 +0,0 @@ -plugins { - alias libs.plugins.android.library - alias libs.plugins.kotlin.android - alias libs.plugins.paparazzi -} - -if (!project.getProperties()["ANDROID_VARIANT_TO_PUBLISH"].contains("customEntitlementComputation")) { - apply plugin: "com.vanniktech.maven.publish" -} - -apply from: "$rootProject.projectDir/library.gradle" - -android { - namespace 'com.revenuecat.purchases.ui.debugview' - - flavorDimensions = ["apis"] - productFlavors { - defaults { - dimension "apis" - getIsDefault().set(true) - } - } - - defaultConfig { - minSdkVersion 21 // Compose requires minSdkVersion 21 - } - - buildFeatures { - compose true - } - composeOptions { - kotlinCompilerExtensionVersion = "1.4.8" - } -} - -dependencies { - implementation project(path: ':purchases') - - implementation libs.androidx.core - implementation platform(libs.compose.bom) - implementation libs.compose.ui - implementation libs.compose.ui.graphics - implementation libs.compose.ui.tooling.preview - implementation libs.compose.material - implementation libs.compose.material3 - implementation libs.androidx.lifecycle.runtime.ktx - implementation libs.androidx.lifecycle.viewmodel - implementation libs.androidx.lifecycle.viewmodel.compose - debugImplementation libs.compose.ui.tooling - debugImplementation libs.androidx.test.compose.manifest - - testImplementation platform(libs.compose.bom) - testImplementation libs.androidx.appcompat - testImplementation libs.androidx.lifecycle.runtime.ktx - testImplementation libs.androidx.test.espresso.core - testImplementation libs.androidx.test.runner - testImplementation libs.androidx.test.rules - testImplementation libs.androidx.test.junit - testImplementation libs.androidx.test.compose - - testImplementation libs.assertJ - testImplementation libs.mockk.android - testImplementation libs.mockk.agent - - testImplementation libs.androidx.legacy.core.ui -} diff --git a/ui/debugview/build.gradle.kts b/ui/debugview/build.gradle.kts new file mode 100644 index 0000000000..3a84931e53 --- /dev/null +++ b/ui/debugview/build.gradle.kts @@ -0,0 +1,70 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.paparazzi) +} + +if (!(project.properties["ANDROID_VARIANT_TO_PUBLISH"] as String).contains("customEntitlementComputation")) { + apply(plugin = "com.vanniktech.maven.publish") +} + +apply(from = "${rootProject.projectDir}/library.gradle") + +android { + namespace = "com.revenuecat.purchases.ui.debugview" + + flavorDimensions += "apis" + productFlavors { + create("defaults") { + dimension = "apis" + isDefault = true + } + } + + defaultConfig { + minSdk = 21 + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.4.8" + } +} + +dependencies { + implementation(project(":purchases")) + + implementation(libs.androidx.core) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.androidx.lifecycle.viewmodel.compose) + + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material) + implementation(libs.compose.material3) + + debugImplementation(libs.compose.ui.tooling) + debugImplementation(libs.androidx.test.compose.manifest) + + testImplementation(libs.androidx.appcompat) + testImplementation(libs.androidx.lifecycle.runtime.ktx) + testImplementation(libs.androidx.test.espresso.core) + testImplementation(libs.androidx.test.runner) + testImplementation(libs.androidx.test.rules) + testImplementation(libs.androidx.test.junit) + + testImplementation(platform(libs.compose.bom)) + testImplementation(libs.androidx.test.compose) + + testImplementation(libs.assertJ) + testImplementation(libs.mockk.android) + testImplementation(libs.mockk.agent) + + testImplementation(libs.androidx.legacy.core.ui) +} diff --git a/ui/debugview/proguard-rules.pro b/ui/debugview/proguard-rules.pro index 481bb43481..ff59496d81 100644 --- a/ui/debugview/proguard-rules.pro +++ b/ui/debugview/proguard-rules.pro @@ -1,6 +1,6 @@ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. +# proguardFiles setting in build.gradle.kts. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html diff --git a/ui/revenuecatui/api.txt b/ui/revenuecatui/api.txt index 4c27c5d21d..b652409724 100644 --- a/ui/revenuecatui/api.txt +++ b/ui/revenuecatui/api.txt @@ -8,7 +8,7 @@ package com.revenuecat.purchases.ui.revenuecatui { method @androidx.compose.runtime.Composable public static void PaywallDialog(com.revenuecat.purchases.ui.revenuecatui.PaywallDialogOptions paywallDialogOptions); } - public final class PaywallDialogOptions { + @androidx.compose.runtime.Immutable public final class PaywallDialogOptions { ctor public PaywallDialogOptions(com.revenuecat.purchases.ui.revenuecatui.PaywallDialogOptions.Builder builder); method public kotlin.jvm.functions.Function1? component1(); method public kotlin.jvm.functions.Function0? component2(); @@ -66,7 +66,7 @@ package com.revenuecat.purchases.ui.revenuecatui { method public default void onRestoreStarted(); } - public final class PaywallOptions { + @androidx.compose.runtime.Immutable public final class PaywallOptions { ctor public PaywallOptions(com.revenuecat.purchases.ui.revenuecatui.PaywallOptions.Builder builder); method public com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider? component3(); method public com.revenuecat.purchases.ui.revenuecatui.PaywallListener? component4(); @@ -139,7 +139,9 @@ package com.revenuecat.purchases.ui.revenuecatui.activity { method public void launch(optional com.revenuecat.purchases.Offering? offering); method public void launch(optional com.revenuecat.purchases.Offering? offering, optional com.revenuecat.purchases.ui.revenuecatui.fonts.ParcelizableFontProvider? fontProvider); method public void launch(optional com.revenuecat.purchases.Offering? offering, optional com.revenuecat.purchases.ui.revenuecatui.fonts.ParcelizableFontProvider? fontProvider, optional boolean shouldDisplayDismissButton); - method @kotlin.jvm.JvmSynthetic public void launch(String offeringIdentifier, optional com.revenuecat.purchases.ui.revenuecatui.fonts.ParcelizableFontProvider? fontProvider, optional boolean shouldDisplayDismissButton); + method public void launch(optional com.revenuecat.purchases.Offering? offering, optional com.revenuecat.purchases.ui.revenuecatui.fonts.ParcelizableFontProvider? fontProvider, optional boolean shouldDisplayDismissButton, optional boolean edgeToEdge); + method @Deprecated @kotlin.jvm.JvmSynthetic public void launch(String offeringIdentifier, optional com.revenuecat.purchases.ui.revenuecatui.fonts.ParcelizableFontProvider? fontProvider, optional boolean shouldDisplayDismissButton, optional boolean edgeToEdge); + method public void launchIfNeeded(optional com.revenuecat.purchases.Offering? offering, optional com.revenuecat.purchases.ui.revenuecatui.fonts.ParcelizableFontProvider? fontProvider, optional boolean shouldDisplayDismissButton, optional boolean edgeToEdge, kotlin.jvm.functions.Function1 shouldDisplayBlock); method public void launchIfNeeded(optional com.revenuecat.purchases.Offering? offering, optional com.revenuecat.purchases.ui.revenuecatui.fonts.ParcelizableFontProvider? fontProvider, optional boolean shouldDisplayDismissButton, kotlin.jvm.functions.Function1 shouldDisplayBlock); method public void launchIfNeeded(optional com.revenuecat.purchases.Offering? offering, optional com.revenuecat.purchases.ui.revenuecatui.fonts.ParcelizableFontProvider? fontProvider, kotlin.jvm.functions.Function1 shouldDisplayBlock); method public void launchIfNeeded(optional com.revenuecat.purchases.Offering? offering, kotlin.jvm.functions.Function1 shouldDisplayBlock); @@ -147,8 +149,9 @@ package com.revenuecat.purchases.ui.revenuecatui.activity { method public void launchIfNeeded(String requiredEntitlementIdentifier, optional com.revenuecat.purchases.Offering? offering); method public void launchIfNeeded(String requiredEntitlementIdentifier, optional com.revenuecat.purchases.Offering? offering, optional com.revenuecat.purchases.ui.revenuecatui.fonts.ParcelizableFontProvider? fontProvider); method public void launchIfNeeded(String requiredEntitlementIdentifier, optional com.revenuecat.purchases.Offering? offering, optional com.revenuecat.purchases.ui.revenuecatui.fonts.ParcelizableFontProvider? fontProvider, optional boolean shouldDisplayDismissButton); - method public void launchIfNeeded(String requiredEntitlementIdentifier, optional com.revenuecat.purchases.Offering? offering, optional com.revenuecat.purchases.ui.revenuecatui.fonts.ParcelizableFontProvider? fontProvider, optional boolean shouldDisplayDismissButton, optional com.revenuecat.purchases.ui.revenuecatui.activity.PaywallDisplayCallback? paywallDisplayCallback); - method @kotlin.jvm.JvmSynthetic public void launchIfNeeded(String requiredEntitlementIdentifier, String offeringIdentifier, optional com.revenuecat.purchases.ui.revenuecatui.fonts.ParcelizableFontProvider? fontProvider, optional boolean shouldDisplayDismissButton, optional com.revenuecat.purchases.ui.revenuecatui.activity.PaywallDisplayCallback? paywallDisplayCallback); + method public void launchIfNeeded(String requiredEntitlementIdentifier, optional com.revenuecat.purchases.Offering? offering, optional com.revenuecat.purchases.ui.revenuecatui.fonts.ParcelizableFontProvider? fontProvider, optional boolean shouldDisplayDismissButton, optional boolean edgeToEdge); + method public void launchIfNeeded(String requiredEntitlementIdentifier, optional com.revenuecat.purchases.Offering? offering, optional com.revenuecat.purchases.ui.revenuecatui.fonts.ParcelizableFontProvider? fontProvider, optional boolean shouldDisplayDismissButton, optional boolean edgeToEdge, optional com.revenuecat.purchases.ui.revenuecatui.activity.PaywallDisplayCallback? paywallDisplayCallback); + method @Deprecated @kotlin.jvm.JvmSynthetic public void launchIfNeeded(String requiredEntitlementIdentifier, String offeringIdentifier, optional com.revenuecat.purchases.ui.revenuecatui.fonts.ParcelizableFontProvider? fontProvider, optional boolean shouldDisplayDismissButton, optional boolean edgeToEdge, optional com.revenuecat.purchases.ui.revenuecatui.activity.PaywallDisplayCallback? paywallDisplayCallback); method public void launchIfNeeded(kotlin.jvm.functions.Function1 shouldDisplayBlock); } @@ -374,12 +377,16 @@ package com.revenuecat.purchases.ui.revenuecatui.fonts { package com.revenuecat.purchases.ui.revenuecatui.views { - public final class CustomerCenterView extends androidx.compose.ui.platform.AbstractComposeView { + public final class CustomerCenterView extends androidx.compose.ui.platform.AbstractComposeView implements androidx.lifecycle.LifecycleOwner androidx.savedstate.SavedStateRegistryOwner androidx.lifecycle.ViewModelStoreOwner { ctor public CustomerCenterView(android.content.Context context); ctor public CustomerCenterView(android.content.Context context, android.util.AttributeSet? attrs); ctor public CustomerCenterView(android.content.Context context, android.util.AttributeSet? attrs, int defStyleAttr); ctor public CustomerCenterView(android.content.Context context, optional kotlin.jvm.functions.Function0? dismissHandler); method @androidx.compose.runtime.Composable public void Content(); + method public androidx.lifecycle.Lifecycle getLifecycle(); + method public androidx.savedstate.SavedStateRegistry getSavedStateRegistry(); + method public androidx.lifecycle.ViewModelStore getViewModelStore(); + method public void onBackPressed(); method public void setDismissHandler(kotlin.jvm.functions.Function0? dismissHandler); } @@ -394,7 +401,7 @@ package com.revenuecat.purchases.ui.revenuecatui.views { ctor public OriginalTemplatePaywallFooterView(android.content.Context context, optional com.revenuecat.purchases.Offering? offering, optional com.revenuecat.purchases.ui.revenuecatui.PaywallListener? listener, optional com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider? fontProvider, optional boolean condensed, optional kotlin.jvm.functions.Function0? dismissHandler); method public final void setDismissHandler(kotlin.jvm.functions.Function0? dismissHandler); method public final void setFontProvider(com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider? fontProvider); - method public final void setOfferingId(String? offeringId); + method @Deprecated public final void setOfferingId(String? offeringId); method public final void setPaywallListener(com.revenuecat.purchases.ui.revenuecatui.PaywallListener? listener); } @@ -409,7 +416,7 @@ package com.revenuecat.purchases.ui.revenuecatui.views { ctor @Deprecated public PaywallFooterView(android.content.Context context, optional com.revenuecat.purchases.Offering? offering, optional com.revenuecat.purchases.ui.revenuecatui.PaywallListener? listener, optional com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider? fontProvider, optional boolean condensed, optional kotlin.jvm.functions.Function0? dismissHandler); } - public final class PaywallView extends androidx.compose.ui.platform.AbstractComposeView { + public final class PaywallView extends androidx.compose.ui.platform.AbstractComposeView implements androidx.lifecycle.LifecycleOwner androidx.savedstate.SavedStateRegistryOwner androidx.lifecycle.ViewModelStoreOwner { ctor public PaywallView(android.content.Context context); ctor public PaywallView(android.content.Context context, android.util.AttributeSet? attrs); ctor public PaywallView(android.content.Context context, android.util.AttributeSet? attrs, int defStyleAttr); @@ -419,10 +426,15 @@ package com.revenuecat.purchases.ui.revenuecatui.views { ctor public PaywallView(android.content.Context context, optional com.revenuecat.purchases.Offering? offering, optional com.revenuecat.purchases.ui.revenuecatui.PaywallListener? listener, optional com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider? fontProvider, optional Boolean? shouldDisplayDismissButton); ctor public PaywallView(android.content.Context context, optional com.revenuecat.purchases.Offering? offering, optional com.revenuecat.purchases.ui.revenuecatui.PaywallListener? listener, optional com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider? fontProvider, optional Boolean? shouldDisplayDismissButton, optional kotlin.jvm.functions.Function0? dismissHandler); method @androidx.compose.runtime.Composable public void Content(); + method public androidx.lifecycle.Lifecycle getLifecycle(); + method public androidx.savedstate.SavedStateRegistry getSavedStateRegistry(); + method public androidx.lifecycle.ViewModelStore getViewModelStore(); + method public void onBackPressed(); method public void setDismissHandler(kotlin.jvm.functions.Function0? dismissHandler); method public void setDisplayDismissButton(boolean shouldDisplayDismissButton); method public void setFontProvider(com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider? fontProvider); method public void setOfferingId(String? offeringId); + method public void setOfferingId(String? offeringId, optional com.revenuecat.purchases.PresentedOfferingContext? presentedOfferingContext); method public void setPaywallListener(com.revenuecat.purchases.ui.revenuecatui.PaywallListener? listener); } diff --git a/ui/revenuecatui/build.gradle.kts b/ui/revenuecatui/build.gradle.kts index b0bd69aa61..d8b6b21538 100644 --- a/ui/revenuecatui/build.gradle.kts +++ b/ui/revenuecatui/build.gradle.kts @@ -90,12 +90,16 @@ metalava { } tasks.withType().configureEach { - kotlinOptions { + compilerOptions { if (project.findProperty("revenuecat.enableComposeCompilerReports") == "true") { val composeMetricsDir = "${project.buildDir.absolutePath}/compose_metrics" - freeCompilerArgs += listOf( - "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=$composeMetricsDir", - "-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=$composeMetricsDir", + freeCompilerArgs.addAll( + listOf( + "-P", + "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=$composeMetricsDir", + "-P", + "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=$composeMetricsDir", + ), ) } } diff --git a/ui/revenuecatui/src/debug/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/TemplatePreviews.kt b/ui/revenuecatui/src/debug/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/TemplatePreviews.kt index 3b7b7c8297..c84a6bfd07 100644 --- a/ui/revenuecatui/src/debug/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/TemplatePreviews.kt +++ b/ui/revenuecatui/src/debug/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/TemplatePreviews.kt @@ -5,7 +5,7 @@ package com.revenuecat.purchases.ui.revenuecatui.components import android.content.Context import android.graphics.BitmapFactory import androidx.compose.foundation.layout.Column -import androidx.compose.material.Text +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview @@ -19,6 +19,7 @@ import com.revenuecat.purchases.Offering import com.revenuecat.purchases.Offerings import com.revenuecat.purchases.models.StoreProduct import com.revenuecat.purchases.ui.revenuecatui.BuildConfig +import com.revenuecat.purchases.ui.revenuecatui.data.MockPurchasesType import com.revenuecat.purchases.ui.revenuecatui.helpers.ProvidePreviewImageLoader import com.revenuecat.purchases.ui.revenuecatui.helpers.Result import com.revenuecat.purchases.ui.revenuecatui.helpers.toComponentsPaywallState @@ -177,10 +178,9 @@ internal fun PaywallComponentsTemplate_Preview( val validationResult = result.value val state = offering.toComponentsPaywallState( validationResult = validationResult, - activelySubscribedProductIds = emptySet(), - purchasedNonSubscriptionProductIds = emptySet(), storefrontCountryCode = "US", dateProvider = { Date(MILLIS_2025_04_23) }, + purchases = MockPurchasesType(), ) ProvidePreviewImageLoader(PaywallTemplateImageLoader(LocalContext.current, parentFolder)) { diff --git a/ui/revenuecatui/src/main/baseline-prof.txt b/ui/revenuecatui/src/main/baseline-prof.txt index fe31c1563e..8fede0298e 100644 --- a/ui/revenuecatui/src/main/baseline-prof.txt +++ b/ui/revenuecatui/src/main/baseline-prof.txt @@ -7,14 +7,14 @@ HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt;->access$Template HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt;->configurationWithOverriddenLocale(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState$Loaded$Legacy;Landroidx/compose/runtime/Composer;I)Landroid/content/res/Configuration; HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt;->contextWithConfiguration(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState$Loaded$Legacy;Landroid/content/res/Configuration;Landroidx/compose/runtime/Composer;I)Landroid/content/Context; HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt;->getPaywallViewModel(Lcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel; -Lcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$1; -HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$1;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;)V -HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$1;->invoke()Ljava/lang/Object; -HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$1;->invoke()V -Lcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$2; -HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$2;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;Landroidx/compose/material3/ColorScheme;Z)V -HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$2;->invoke()Ljava/lang/Object; -HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$2;->invoke()V +Lcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$1$1; +HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$1$1;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;)V +HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$1$1;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$1$1;->invoke()V +Lcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$2$1; +HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$2$1;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;Landroidx/compose/material3/ColorScheme;Z)V +HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$2$1;->invoke()Ljava/lang/Object; +HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$2$1;->invoke()V Lcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$3; HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$3;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;)V HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$3;->invoke(Landroidx/compose/runtime/Composer;I)V @@ -22,15 +22,15 @@ HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$3 Lcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$3$1; HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$3$1;->(Lcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;)V Lcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$4; -HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$4;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;I)V +HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$4;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;)V HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$4;->invoke(Landroidx/compose/runtime/Composer;I)V HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$4;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Lcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$4$1; -HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$4$1;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;I)V +HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$4$1;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;)V HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$4$1;->invoke(Landroidx/compose/animation/AnimatedVisibilityScope;Landroidx/compose/runtime/Composer;I)V HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$4$1;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Lcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$5; -HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$5;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;I)V +HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$5;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;)V Lcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$8; HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$InternalPaywall$8;->(Lcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;II)V Lcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$LoadedPaywall$1$1; @@ -42,16 +42,17 @@ HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$LoadedPaywall$2$1 HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$LoadedPaywall$2$1;->invoke(Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$LoadedPaywall$2$1;->invoke(Ljava/lang/Object;)Ljava/lang/Object; Lcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$LoadedPaywall$3$1; -HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$LoadedPaywall$3$1;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState$Loaded$Legacy;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;I)V +HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$LoadedPaywall$3$1;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState$Loaded$Legacy;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;)V HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$LoadedPaywall$3$1;->invoke(Landroidx/compose/runtime/Composer;I)V HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$LoadedPaywall$3$1;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; -Lcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$LoadedPaywall$3$2; -HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$LoadedPaywall$3$2;->(Ljava/lang/Object;)V +Lcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$LoadedPaywall$3$2$1; +HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$LoadedPaywall$3$2$1;->(Ljava/lang/Object;)V Lcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$LoadedPaywall$4; HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$LoadedPaywall$4;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState$Loaded$Legacy;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;I)V Lcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$WhenMappings; HSPLcom/revenuecat/purchases/ui/revenuecatui/InternalPaywallKt$WhenMappings;->()V Lcom/revenuecat/purchases/ui/revenuecatui/OfferingSelection; +HSPLcom/revenuecat/purchases/ui/revenuecatui/OfferingSelection;->()V HSPLcom/revenuecat/purchases/ui/revenuecatui/OfferingSelection;->()V HSPLcom/revenuecat/purchases/ui/revenuecatui/OfferingSelection;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V HSPLcom/revenuecat/purchases/ui/revenuecatui/OfferingSelection;->getOffering()Lcom/revenuecat/purchases/Offering; @@ -73,13 +74,13 @@ HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallDialogKt;->access$PaywallDia HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallDialogKt;->getDialogMaxHeightPercentage(Landroidx/compose/runtime/Composer;I)F HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallDialogKt;->shouldUsePlatformDefaultWidth(Landroidx/compose/runtime/Composer;I)Z Lcom/revenuecat/purchases/ui/revenuecatui/PaywallDialogKt$DialogScaffold$1; -HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallDialogKt$DialogScaffold$1;->(Lcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;I)V +HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallDialogKt$DialogScaffold$1;->(Lcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;)V HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallDialogKt$DialogScaffold$1;->invoke(Landroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/runtime/Composer;I)V HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallDialogKt$DialogScaffold$1;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Lcom/revenuecat/purchases/ui/revenuecatui/PaywallDialogKt$DialogScaffold$2; HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallDialogKt$DialogScaffold$2;->(Lcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;I)V -Lcom/revenuecat/purchases/ui/revenuecatui/PaywallDialogKt$PaywallDialog$2; -HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallDialogKt$PaywallDialog$2;->(Lkotlin/jvm/functions/Function0;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallDialogOptions;)V +Lcom/revenuecat/purchases/ui/revenuecatui/PaywallDialogKt$PaywallDialog$2$1; +HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallDialogKt$PaywallDialog$2$1;->(Lkotlin/jvm/functions/Function0;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallDialogOptions;)V Lcom/revenuecat/purchases/ui/revenuecatui/PaywallDialogKt$PaywallDialog$3; HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallDialogKt$PaywallDialog$3;->(Lcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;)V HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallDialogKt$PaywallDialog$3;->invoke(Landroidx/compose/runtime/Composer;I)V @@ -145,15 +146,14 @@ Lcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions; HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;->()V HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;->(Lcom/revenuecat/purchases/ui/revenuecatui/OfferingSelection;ZLcom/revenuecat/purchases/ui/revenuecatui/fonts/FontProvider;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallListener;Lcom/revenuecat/purchases/ui/revenuecatui/PurchaseLogic;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallMode;Lkotlin/jvm/functions/Function0;)V HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;->(Lcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions$Builder;)V -HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;->copy$default(Lcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;Lcom/revenuecat/purchases/ui/revenuecatui/OfferingSelection;ZLcom/revenuecat/purchases/ui/revenuecatui/fonts/FontProvider;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallListener;Lcom/revenuecat/purchases/ui/revenuecatui/PurchaseLogic;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallMode;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions; -HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;->copy(Lcom/revenuecat/purchases/ui/revenuecatui/OfferingSelection;ZLcom/revenuecat/purchases/ui/revenuecatui/fonts/FontProvider;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallListener;Lcom/revenuecat/purchases/ui/revenuecatui/PurchaseLogic;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallMode;Lkotlin/jvm/functions/Function0;)Lcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions; -HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;->equals(Ljava/lang/Object;)Z -HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;->getDataHash$revenuecatui_defaultsRelease()Ljava/lang/String; +HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;->copy$revenuecatui_defaultsRelease$default(Lcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;Lcom/revenuecat/purchases/ui/revenuecatui/OfferingSelection;ZLcom/revenuecat/purchases/ui/revenuecatui/fonts/FontProvider;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallListener;Lcom/revenuecat/purchases/ui/revenuecatui/PurchaseLogic;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallMode;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions; +HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;->copy$revenuecatui_defaultsRelease(Lcom/revenuecat/purchases/ui/revenuecatui/OfferingSelection;ZLcom/revenuecat/purchases/ui/revenuecatui/fonts/FontProvider;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallListener;Lcom/revenuecat/purchases/ui/revenuecatui/PurchaseLogic;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallMode;Lkotlin/jvm/functions/Function0;)Lcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions; HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;->getDismissRequest()Lkotlin/jvm/functions/Function0; HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;->getFontProvider()Lcom/revenuecat/purchases/ui/revenuecatui/fonts/FontProvider; HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;->getMode$revenuecatui_defaultsRelease()Lcom/revenuecat/purchases/ui/revenuecatui/PaywallMode; HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;->getOfferingSelection$revenuecatui_defaultsRelease()Lcom/revenuecat/purchases/ui/revenuecatui/OfferingSelection; HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;->getShouldDisplayDismissButton$revenuecatui_defaultsRelease()Z +HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;->hashCode()I Lcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions$Builder; HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions$Builder;->()V HSPLcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions$Builder;->(Lkotlin/jvm/functions/Function0;)V @@ -191,6 +191,7 @@ Lcom/revenuecat/purchases/ui/revenuecatui/activity/PaywallActivityLauncher; HSPLcom/revenuecat/purchases/ui/revenuecatui/activity/PaywallActivityLauncher;->()V HSPLcom/revenuecat/purchases/ui/revenuecatui/activity/PaywallActivityLauncher;->(Landroidx/activity/result/ActivityResultCaller;Lcom/revenuecat/purchases/ui/revenuecatui/activity/PaywallResultHandler;)V Lcom/revenuecat/purchases/ui/revenuecatui/activity/PaywallContract; +HSPLcom/revenuecat/purchases/ui/revenuecatui/activity/PaywallContract;->()V HSPLcom/revenuecat/purchases/ui/revenuecatui/activity/PaywallContract;->()V Lcom/revenuecat/purchases/ui/revenuecatui/activity/PaywallResultHandler; Lcom/revenuecat/purchases/ui/revenuecatui/composables/AdaptiveComposableKt; @@ -245,28 +246,28 @@ HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$1$1$1;- Lcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$1$2$1; HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$1$2$1;->(Ljava/lang/String;)V Lcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$1$3; -HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$1$3;->([ILandroidx/compose/ui/Modifier;JI)V +HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$1$3;->([ILandroidx/compose/ui/Modifier;J)V HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$1$3;->invoke(Landroidx/compose/foundation/layout/RowScope;Landroidx/compose/runtime/Composer;I)V HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$1$3;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Lcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$1$3$1; HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$1$3$1;->()V HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$1$3$1;->()V Lcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$1$3$2$1; -HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$1$3$2$1;->(ILandroidx/compose/ui/Modifier;JI)V +HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$1$3$2$1;->(ILandroidx/compose/ui/Modifier;J)V HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$1$3$2$1;->invoke(Landroidx/compose/runtime/Composer;I)V HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$1$3$2$1;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Lcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$1$3$3$1; -HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$1$3$3$1;->(ILandroidx/compose/ui/Modifier;JI)V +HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$1$3$3$1;->(ILandroidx/compose/ui/Modifier;J)V HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$1$3$3$1;->invoke(Landroidx/compose/runtime/Composer;I)V HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$1$3$3$1;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Lcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$2; HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Button$2;->(Landroidx/compose/foundation/layout/RowScope;JLandroidx/compose/ui/Modifier;[ILkotlin/jvm/functions/Function0;I)V Lcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Footer$2; HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Footer$2;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;Landroidx/compose/ui/Modifier;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$Colors;Lkotlin/jvm/functions/Function0;II)V -Lcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Footer$3$1; -HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Footer$3$1;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;)V -Lcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Footer$3$2$1; -HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Footer$3$2$1;->(Landroid/content/Context;Ljava/net/URL;)V +Lcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Footer$3$1$1; +HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Footer$3$1$1;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;)V +Lcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Footer$3$2$1$1; +HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/FooterKt$Footer$3$2$1$1;->(Landroid/content/Context;Ljava/net/URL;)V Lcom/revenuecat/purchases/ui/revenuecatui/composables/ImageSource; HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/ImageSource;->()V HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/ImageSource;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -281,7 +282,7 @@ Lcom/revenuecat/purchases/ui/revenuecatui/composables/IntroEligibilityStateViewK HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/IntroEligibilityStateViewKt;->IntroEligibilityStateView-QETHhvg(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/revenuecat/purchases/ui/revenuecatui/composables/IntroOfferEligibility;JLandroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/font/FontWeight;Landroidx/compose/ui/text/style/TextAlign;ZLandroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/IntroEligibilityStateViewKt;->introEligibilityText(Lcom/revenuecat/purchases/ui/revenuecatui/composables/IntroOfferEligibility;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; Lcom/revenuecat/purchases/ui/revenuecatui/composables/IntroEligibilityStateViewKt$IntroEligibilityStateView$1; -HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/IntroEligibilityStateViewKt$IntroEligibilityStateView$1;->(Landroidx/compose/ui/Modifier;JLandroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/font/FontWeight;Landroidx/compose/ui/text/style/TextAlign;ZI)V +HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/IntroEligibilityStateViewKt$IntroEligibilityStateView$1;->(Landroidx/compose/ui/Modifier;JLandroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/font/FontWeight;Landroidx/compose/ui/text/style/TextAlign;Z)V HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/IntroEligibilityStateViewKt$IntroEligibilityStateView$1;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/IntroEligibilityStateViewKt$IntroEligibilityStateView$1;->invoke(Ljava/lang/String;Landroidx/compose/runtime/Composer;I)V Lcom/revenuecat/purchases/ui/revenuecatui/composables/IntroEligibilityStateViewKt$WhenMappings; @@ -315,26 +316,26 @@ Lcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt; HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt;->LoadingSpinner(Landroidx/compose/foundation/layout/BoxScope;ZLcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$Colors;Landroidx/compose/runtime/Composer;I)V HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt;->PurchaseButton-WH-ejsw(Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$Colors;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$PackageConfiguration;Landroidx/compose/runtime/MutableState;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;FLandroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt;->PurchaseButton-hGBTI10(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState$Loaded$Legacy;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;Landroidx/compose/ui/Modifier;FLcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$Colors;Landroidx/compose/runtime/Composer;II)V -HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt;->PurchaseButton_WH_ejsw$lambda$5$lambda$0(Landroidx/compose/runtime/State;)F -HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt;->PurchaseButton_WH_ejsw$lambda$5$lambda$1(Landroidx/compose/runtime/State;)J +HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt;->PurchaseButton_WH_ejsw$lambda$6$lambda$0(Landroidx/compose/runtime/State;)F +HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt;->PurchaseButton_WH_ejsw$lambda$6$lambda$1(Landroidx/compose/runtime/State;)J HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt;->access$LoadingSpinner(Landroidx/compose/foundation/layout/BoxScope;ZLcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$Colors;Landroidx/compose/runtime/Composer;I)V HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt;->access$PurchaseButton-WH-ejsw(Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$Colors;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$PackageConfiguration;Landroidx/compose/runtime/MutableState;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;FLandroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V -HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt;->access$PurchaseButton_WH_ejsw$lambda$5$lambda$0(Landroidx/compose/runtime/State;)F +HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt;->access$PurchaseButton_WH_ejsw$lambda$6$lambda$0(Landroidx/compose/runtime/State;)F HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt;->buttonBrush-A47ccPs(JLandroidx/compose/ui/graphics/Color;Landroidx/compose/runtime/Composer;I)Landroidx/compose/ui/graphics/Brush; Lcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt$LoadingSpinner$1; HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt$LoadingSpinner$1;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$Colors;Landroidx/compose/foundation/layout/BoxScope;)V Lcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt$PurchaseButton$1; -HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt$PurchaseButton$1;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$Colors;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState$Loaded$Legacy;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;FLandroidx/compose/ui/Modifier;I)V +HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt$PurchaseButton$1;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$Colors;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState$Loaded$Legacy;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;FLandroidx/compose/ui/Modifier;)V HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt$PurchaseButton$1;->invoke(Landroidx/compose/runtime/Composer;I)V HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt$PurchaseButton$1;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Lcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt$PurchaseButton$2; HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt$PurchaseButton$2;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState$Loaded$Legacy;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;Landroidx/compose/ui/Modifier;FLcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$Colors;II)V Lcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt$PurchaseButton$3$1$1; HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt$PurchaseButton$3$1$1;->(Landroidx/compose/runtime/MutableState;)V -Lcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt$PurchaseButton$3$2; -HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt$PurchaseButton$3$2;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;Landroid/app/Activity;)V +Lcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt$PurchaseButton$3$2$1; +HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt$PurchaseButton$3$2$1;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;Landroid/app/Activity;)V Lcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt$PurchaseButton$3$3; -HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt$PurchaseButton$3$3;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$PackageConfiguration;Landroidx/compose/runtime/MutableState;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$Colors;Landroidx/compose/runtime/State;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;I)V +HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt$PurchaseButton$3$3;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$PackageConfiguration;Landroidx/compose/runtime/MutableState;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$Colors;Landroidx/compose/runtime/State;Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel;)V HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt$PurchaseButton$3$3;->invoke(Landroidx/compose/foundation/layout/RowScope;Landroidx/compose/runtime/Composer;I)V HSPLcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt$PurchaseButton$3$3;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Lcom/revenuecat/purchases/ui/revenuecatui/composables/PurchaseButtonKt$PurchaseButton$3$3$1; @@ -379,17 +380,19 @@ HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallStateKt;->getSelectedLo HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallStateKt;->isInFullScreenMode(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState$Loaded$Legacy;)Z Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel; Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelFactory; +HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelFactory;->()V HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelFactory;->(Lcom/revenuecat/purchases/ui/revenuecatui/helpers/ResourceProvider;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;Landroidx/compose/material3/ColorScheme;ZLkotlin/jvm/functions/Function1;Z)V HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelFactory;->create(Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl; +HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl;->()V HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl;->(Lcom/revenuecat/purchases/ui/revenuecatui/helpers/ResourceProvider;Lcom/revenuecat/purchases/ui/revenuecatui/data/PurchasesType;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;Landroidx/compose/material3/ColorScheme;ZLkotlin/jvm/functions/Function1;Z)V HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl;->(Lcom/revenuecat/purchases/ui/revenuecatui/helpers/ResourceProvider;Lcom/revenuecat/purchases/ui/revenuecatui/data/PurchasesType;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions;Landroidx/compose/material3/ColorScheme;ZLkotlin/jvm/functions/Function1;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V -HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl;->access$calculateState(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl;Lcom/revenuecat/purchases/Offering;Lcom/revenuecat/purchases/CustomerInfo;Landroidx/compose/material3/ColorScheme;Ljava/lang/String;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallMode;)Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState; +HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl;->access$calculateState(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl;Lcom/revenuecat/purchases/Offering;Landroidx/compose/material3/ColorScheme;Ljava/lang/String;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallMode;)Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl;->access$getOptions$p(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl;)Lcom/revenuecat/purchases/ui/revenuecatui/PaywallOptions; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl;->access$getPurchases$p(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl;)Lcom/revenuecat/purchases/ui/revenuecatui/data/PurchasesType; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl;->access$get_colorScheme$p(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl;)Lkotlinx/coroutines/flow/MutableStateFlow; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl;->access$get_state$p(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl;)Lkotlinx/coroutines/flow/MutableStateFlow; -HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl;->calculateState(Lcom/revenuecat/purchases/Offering;Lcom/revenuecat/purchases/CustomerInfo;Landroidx/compose/material3/ColorScheme;Ljava/lang/String;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallMode;)Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState; +HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl;->calculateState(Lcom/revenuecat/purchases/Offering;Landroidx/compose/material3/ColorScheme;Ljava/lang/String;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallMode;)Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl;->closePaywall()V HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl;->createEventData()Lcom/revenuecat/purchases/paywalls/events/PaywallEvent$Data; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl;->createEventData(Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState$Loaded$Legacy;)Lcom/revenuecat/purchases/paywalls/events/PaywallEvent$Data; @@ -412,15 +415,13 @@ HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl$updateSta HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl$updateState$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelImpl$updateState$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; Lcom/revenuecat/purchases/ui/revenuecatui/data/PurchasesImpl; +HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PurchasesImpl;->()V HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PurchasesImpl;->(Lcom/revenuecat/purchases/Purchases;)V HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PurchasesImpl;->(Lcom/revenuecat/purchases/Purchases;ILkotlin/jvm/internal/DefaultConstructorMarker;)V -HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PurchasesImpl;->awaitCustomerInfo(Lcom/revenuecat/purchases/CacheFetchPolicy;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PurchasesImpl;->getPurchasesAreCompletedBy()Lcom/revenuecat/purchases/PurchasesAreCompletedBy; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PurchasesImpl;->getStorefrontCountryCode()Ljava/lang/String; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PurchasesImpl;->track(Lcom/revenuecat/purchases/common/events/FeatureEvent;)V Lcom/revenuecat/purchases/ui/revenuecatui/data/PurchasesType; -Lcom/revenuecat/purchases/ui/revenuecatui/data/PurchasesType$DefaultImpls; -HSPLcom/revenuecat/purchases/ui/revenuecatui/data/PurchasesType$DefaultImpls;->awaitCustomerInfo$default(Lcom/revenuecat/purchases/ui/revenuecatui/data/PurchasesType;Lcom/revenuecat/purchases/CacheFetchPolicy;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/ColorsFactory; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/ColorsFactory;->()V HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/ColorsFactory;->()V @@ -428,17 +429,13 @@ HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/ColorsFactory;->crea Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationFactory; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationFactory;->()V HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationFactory;->()V -HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationFactory;->createPackageConfiguration-tZkwj4A(Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/VariableDataProvider;Ljava/util/List;Ljava/util/Set;Ljava/util/Set;Ljava/util/List;Ljava/lang/String;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationType;Lcom/revenuecat/purchases/paywalls/PaywallData;Ljava/lang/String;)Ljava/lang/Object; -HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationFactory;->makePackageInfo(Ljava/util/List;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/VariableDataProvider;Ljava/util/Set;Ljava/util/Set;Lcom/revenuecat/purchases/paywalls/PaywallData;Ljava/lang/String;)Lkotlin/Pair; -HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationFactory;->makeSinglePackageConfiguration-hUnOzRk(Ljava/util/List;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/VariableDataProvider;Ljava/util/Set;Ljava/util/Set;Lcom/revenuecat/purchases/paywalls/PaywallData;Ljava/lang/String;)Ljava/lang/Object; +HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationFactory;->createPackageConfiguration-bMdYcbs(Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/VariableDataProvider;Ljava/util/List;Ljava/util/List;Ljava/lang/String;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationType;Lcom/revenuecat/purchases/paywalls/PaywallData;Ljava/lang/String;)Ljava/lang/Object; +HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationFactory;->makePackageInfo(Ljava/util/List;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/VariableDataProvider;Lcom/revenuecat/purchases/paywalls/PaywallData;Ljava/lang/String;)Lkotlin/Pair; +HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationFactory;->makeSinglePackageConfiguration-BWLJW6A(Ljava/util/List;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/VariableDataProvider;Lcom/revenuecat/purchases/paywalls/PaywallData;Ljava/lang/String;)Ljava/lang/Object; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationFactory;->mostExpensivePricePerMonth(Ljava/util/List;)Lcom/revenuecat/purchases/models/Price; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationFactory;->productDiscount(Lcom/revenuecat/purchases/models/Price;Lcom/revenuecat/purchases/models/Price;)Ljava/lang/Double; Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationFactory$WhenMappings; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationFactory$WhenMappings;->()V -Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationFactoryKt; -HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationFactoryKt;->currentlySubscribed(Lcom/revenuecat/purchases/Package;Ljava/util/Set;Ljava/util/Set;)Z -Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationFactoryKt$WhenMappings; -HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationFactoryKt$WhenMappings;->()V Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationType; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationType;->$values()[Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationType; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationType;->()V @@ -472,6 +469,7 @@ HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/ProcessedLocalizedCo HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/ProcessedLocalizedConfiguration$Companion;->create$processVariables(Ljava/lang/String;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/VariableDataProvider;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/VariableProcessor$PackageContext;Lcom/revenuecat/purchases/Package;Ljava/util/Locale;)Ljava/lang/String; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/ProcessedLocalizedConfiguration$Companion;->create(Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/VariableDataProvider;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/VariableProcessor$PackageContext;Lcom/revenuecat/purchases/paywalls/PaywallData$LocalizedConfiguration;Lcom/revenuecat/purchases/Package;Ljava/util/Locale;)Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/ProcessedLocalizedConfiguration; Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration; +HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration;->()V HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration;->(Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/PaywallTemplate;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallMode;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$PackageConfiguration;Lcom/revenuecat/purchases/paywalls/PaywallData$Configuration;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$Images;Ljava/util/Map;Lcom/revenuecat/purchases/paywalls/PaywallData$Configuration$ColorInformation;Ljava/util/Locale;)V HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration;->getConfiguration()Lcom/revenuecat/purchases/paywalls/PaywallData$Configuration; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration;->getCurrentColors(Landroidx/compose/runtime/Composer;I)Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$Colors; @@ -505,15 +503,16 @@ HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguratio HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$PackageConfiguration$Single;->getDefault()Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$PackageInfo; Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$PackageInfo; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$PackageInfo;->()V -HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$PackageInfo;->(Lcom/revenuecat/purchases/Package;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/ProcessedLocalizedConfiguration;ZLjava/lang/Double;)V +HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$PackageInfo;->(Lcom/revenuecat/purchases/Package;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/ProcessedLocalizedConfiguration;Ljava/lang/Double;)V HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$PackageInfo;->getLocalization()Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/ProcessedLocalizedConfiguration; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration$PackageInfo;->getRcPackage()Lcom/revenuecat/purchases/Package; Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfigurationFactory; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfigurationFactory;->()V HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfigurationFactory;->()V -HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfigurationFactory;->create-eH_QyT8(Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/VariableDataProvider;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallMode;Lcom/revenuecat/purchases/paywalls/PaywallData;Ljava/util/List;Ljava/util/Set;Ljava/util/Set;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/PaywallTemplate;Ljava/lang/String;)Ljava/lang/Object; +HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfigurationFactory;->create-hUnOzRk(Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/VariableDataProvider;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallMode;Lcom/revenuecat/purchases/paywalls/PaywallData;Ljava/util/List;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/PaywallTemplate;Ljava/lang/String;)Ljava/lang/Object; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfigurationFactory;->getUriFromImage(Lcom/revenuecat/purchases/paywalls/PaywallData;Ljava/lang/String;)Landroid/net/Uri; Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/VariableDataProvider; +HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/VariableDataProvider;->()V HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/VariableDataProvider;->(Lcom/revenuecat/purchases/ui/revenuecatui/helpers/ResourceProvider;Z)V HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/VariableDataProvider;->firstIntroductoryOfferDuration(Lcom/revenuecat/purchases/Package;Ljava/util/Locale;)Ljava/lang/String; HSPLcom/revenuecat/purchases/ui/revenuecatui/data/processed/VariableDataProvider;->getFirstIntroOfferToApply(Lcom/revenuecat/purchases/Package;)Lcom/revenuecat/purchases/models/PricingPhase; @@ -604,7 +603,7 @@ HSPLcom/revenuecat/purchases/ui/revenuecatui/helpers/Logger;->()V HSPLcom/revenuecat/purchases/ui/revenuecatui/helpers/Logger;->d(Ljava/lang/String;)V Lcom/revenuecat/purchases/ui/revenuecatui/helpers/NonEmptyList; Lcom/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapperKt; -HSPLcom/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapperKt;->toLegacyPaywallState(Lcom/revenuecat/purchases/Offering;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/VariableDataProvider;Ljava/util/Set;Ljava/util/Set;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallMode;Lcom/revenuecat/purchases/paywalls/PaywallData;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/PaywallTemplate;ZLjava/lang/String;)Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState; +HSPLcom/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapperKt;->toLegacyPaywallState(Lcom/revenuecat/purchases/Offering;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/VariableDataProvider;Lcom/revenuecat/purchases/ui/revenuecatui/PaywallMode;Lcom/revenuecat/purchases/paywalls/PaywallData;Lcom/revenuecat/purchases/ui/revenuecatui/data/processed/PaywallTemplate;ZLjava/lang/String;)Lcom/revenuecat/purchases/ui/revenuecatui/data/PaywallState; HSPLcom/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapperKt;->validate(Lcom/revenuecat/purchases/paywalls/PaywallData$LocalizedConfiguration;)Lcom/revenuecat/purchases/ui/revenuecatui/errors/PaywallValidationError; HSPLcom/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapperKt;->validate(Lcom/revenuecat/purchases/paywalls/PaywallData;)Ljava/lang/Object; HSPLcom/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapperKt;->validate(Lcom/revenuecat/purchases/paywalls/PaywallData;Landroidx/compose/material3/ColorScheme;Lcom/revenuecat/purchases/ui/revenuecatui/helpers/ResourceProvider;)Lcom/revenuecat/purchases/ui/revenuecatui/helpers/PaywallValidationResult$Legacy; @@ -617,6 +616,7 @@ HSPLcom/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapperKt;->v Lcom/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapperKt$WhenMappings; HSPLcom/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapperKt$WhenMappings;->()V Lcom/revenuecat/purchases/ui/revenuecatui/helpers/PaywallResourceProvider; +HSPLcom/revenuecat/purchases/ui/revenuecatui/helpers/PaywallResourceProvider;->()V HSPLcom/revenuecat/purchases/ui/revenuecatui/helpers/PaywallResourceProvider;->(Landroid/content/Context;)V HSPLcom/revenuecat/purchases/ui/revenuecatui/helpers/PaywallResourceProvider;->(Ljava/lang/String;Ljava/lang/String;Landroid/content/res/Resources;)V Lcom/revenuecat/purchases/ui/revenuecatui/helpers/PaywallResourceProviderKt; diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/LoadingPaywall.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/LoadingPaywall.kt index e8324c71b5..31f25a717b 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/LoadingPaywall.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/LoadingPaywall.kt @@ -67,8 +67,6 @@ internal fun LoadingPaywall( resourceProvider, isInPreviewMode(), ), - activelySubscribedProductIdentifiers = setOf(), - nonSubscriptionProductIdentifiers = setOf(), mode = mode, validatedPaywallData = paywallData, template = LoadingPaywallConstants.template, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallDialog.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallDialog.kt index 6e948bea24..15fd2a31d2 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallDialog.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallDialog.kt @@ -1,10 +1,15 @@ package com.revenuecat.purchases.ui.revenuecatui +import android.os.Build import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -14,8 +19,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import com.revenuecat.purchases.ui.revenuecatui.extensions.conditional import com.revenuecat.purchases.ui.revenuecatui.helpers.hasCompactDimension import com.revenuecat.purchases.ui.revenuecatui.helpers.shouldDisplayPaywall import com.revenuecat.purchases.ui.revenuecatui.helpers.windowAspectRatio @@ -55,30 +64,52 @@ fun PaywallDialog( shouldDisplayBlock = paywallDialogOptions.shouldDisplayBlock, ) + // This is needed because of this issue: https://issuetracker.google.com/issues/246909281. + // This is fixed in a newer version of Compose, but to avoid a breaking change, + // we are applying a workaround for now. + // This should be removed once we update Compose in the next major. + val dialogBottomPadding = if (Build.VERSION.SDK_INT > Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + } else { + 0.dp + } + Dialog( onDismissRequest = { dismissRequest() viewModel.closePaywall() paywallDialogOptions.dismissRequest?.invoke() }, - properties = DialogProperties(usePlatformDefaultWidth = shouldUsePlatformDefaultWidth()), + properties = DialogProperties( + usePlatformDefaultWidth = shouldUsePlatformDefaultWidth(), + decorFitsSystemWindows = Build.VERSION.SDK_INT <= Build.VERSION_CODES.UPSIDE_DOWN_CAKE, + ), ) { - DialogScaffold(paywallOptions) + DialogScaffold(paywallOptions, dialogBottomPadding) } } } @Composable -private fun DialogScaffold(paywallOptions: PaywallOptions) { +private fun DialogScaffold(paywallOptions: PaywallOptions, dialogBottomPadding: Dp) { Scaffold( modifier = Modifier .fillMaxWidth() .fillMaxHeight(getDialogMaxHeightPercentage()), + // This is needed for Android 35+ but using an older version of Compose. In those cases, + // the dialog doesn't properly extend edge to edge, leaving some spacing at the bottom since we changed + // the decorFitsSystemWindows setting of the Dialog. This is added to mimick the dim effect that we get + // at the top of the dialog in this case. This should be removed once we update Compose in the next major. + containerColor = Color.Black.copy(alpha = 0.4f), ) { paddingValues -> + val shouldApplyDialogBottomPadding = paddingValues.calculateBottomPadding() == 0.dp && + paddingValues.calculateTopPadding() == 0.dp Box( modifier = Modifier .fillMaxSize() - .padding(paddingValues), + .conditional(Build.VERSION.SDK_INT <= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { padding(paddingValues) } + .padding(bottom = if (shouldApplyDialogBottomPadding) dialogBottomPadding else 0.dp), ) { Paywall(paywallOptions) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptions.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptions.kt index 02d09a7470..2f03445601 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptions.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptions.kt @@ -1,9 +1,12 @@ package com.revenuecat.purchases.ui.revenuecatui +import android.os.Parcelable import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.revenuecat.purchases.Offering +import com.revenuecat.purchases.PresentedOfferingContext import com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider +import kotlinx.parcelize.Parcelize @Stable internal sealed class OfferingSelection { @@ -11,8 +14,12 @@ internal sealed class OfferingSelection { @Immutable data class OfferingType(val offeringType: Offering) : OfferingSelection() + @Parcelize @Immutable - data class OfferingId(val offeringId: String) : OfferingSelection() + data class IdAndPresentedOfferingContext( + val offeringId: String, + val presentedOfferingContext: PresentedOfferingContext?, + ) : Parcelable, OfferingSelection() @Immutable object None : OfferingSelection() @@ -20,14 +27,14 @@ internal sealed class OfferingSelection { val offering: Offering? get() = when (this) { is OfferingType -> offeringType - is OfferingId -> null + is IdAndPresentedOfferingContext -> null None -> null } val offeringIdentifier: String? get() = when (this) { is OfferingType -> offeringType.identifier - is OfferingId -> offeringId + is IdAndPresentedOfferingContext -> offeringId None -> null } } @@ -81,9 +88,10 @@ data class PaywallOptions internal constructor( ?: OfferingSelection.None } - internal fun setOfferingId(offeringId: String?) = apply { - this.offeringSelection = offeringId?.let { OfferingSelection.OfferingId(it) } - ?: OfferingSelection.None + internal fun setOfferingIdAndPresentedOfferingContext( + idAndPresentedOfferingContext: OfferingSelection.IdAndPresentedOfferingContext?, + ) = apply { + this.offeringSelection = idAndPresentedOfferingContext ?: OfferingSelection.None } /** diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/activity/PaywallActivity.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/activity/PaywallActivity.kt index 54c8a374b1..8e51c3138b 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/activity/PaywallActivity.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/activity/PaywallActivity.kt @@ -7,6 +7,7 @@ import android.os.Parcelable import android.view.Window import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -31,6 +32,7 @@ import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.ui.revenuecatui.Paywall import com.revenuecat.purchases.ui.revenuecatui.PaywallListener import com.revenuecat.purchases.ui.revenuecatui.PaywallOptions +import com.revenuecat.purchases.ui.revenuecatui.extensions.conditional import com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider import com.revenuecat.purchases.ui.revenuecatui.fonts.GoogleFontProvider import com.revenuecat.purchases.ui.revenuecatui.fonts.PaywallFont @@ -112,8 +114,12 @@ internal class PaywallActivity : ComponentActivity(), PaywallListener { configureSdkWithSavedData(savedInstanceState) } val args = getArgs() + val edgeToEdge = args?.edgeToEdge == true + if (edgeToEdge) { + enableEdgeToEdge() + } val paywallOptions = PaywallOptions.Builder(dismissRequest = ::finish) - .setOfferingId(args?.offeringId) + .setOfferingIdAndPresentedOfferingContext(args?.offeringIdAndPresentedOfferingContext) .setFontProvider(getFontProvider()) .setShouldDisplayDismissButton(args?.shouldDisplayDismissButton ?: DEFAULT_DISPLAY_DISMISS_BUTTON) .setListener(this) @@ -121,7 +127,13 @@ internal class PaywallActivity : ComponentActivity(), PaywallListener { setContent { MaterialTheme { Scaffold { paddingValues -> - Box(Modifier.fillMaxSize().padding(paddingValues)) { + Box( + Modifier + .fillMaxSize() + .conditional(!edgeToEdge) { + padding(paddingValues) + }, + ) { Paywall(paywallOptions) } } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/activity/PaywallActivityArgs.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/activity/PaywallActivityArgs.kt index 3f701eb39c..4acf834db8 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/activity/PaywallActivityArgs.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/activity/PaywallActivityArgs.kt @@ -1,29 +1,35 @@ package com.revenuecat.purchases.ui.revenuecatui.activity +import android.os.Build import android.os.Parcelable +import com.revenuecat.purchases.ui.revenuecatui.OfferingSelection import com.revenuecat.purchases.ui.revenuecatui.fonts.ParcelizableFontProvider import com.revenuecat.purchases.ui.revenuecatui.fonts.PaywallFontFamily import com.revenuecat.purchases.ui.revenuecatui.fonts.TypographyType import kotlinx.parcelize.Parcelize internal const val DEFAULT_DISPLAY_DISMISS_BUTTON = true +internal val defaultEdgeToEdge = Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM @Parcelize internal data class PaywallActivityArgs( val requiredEntitlementIdentifier: String? = null, - val offeringId: String? = null, + val offeringIdAndPresentedOfferingContext: OfferingSelection.IdAndPresentedOfferingContext? = null, val fonts: Map? = null, val shouldDisplayDismissButton: Boolean = DEFAULT_DISPLAY_DISMISS_BUTTON, + val edgeToEdge: Boolean = defaultEdgeToEdge, ) : Parcelable { constructor( requiredEntitlementIdentifier: String? = null, - offeringId: String? = null, + offeringIdAndPresentedOfferingContext: OfferingSelection.IdAndPresentedOfferingContext? = null, fontProvider: ParcelizableFontProvider?, shouldDisplayDismissButton: Boolean = DEFAULT_DISPLAY_DISMISS_BUTTON, + edgeToEdge: Boolean = defaultEdgeToEdge, ) : this( requiredEntitlementIdentifier, - offeringId, + offeringIdAndPresentedOfferingContext, fontProvider?.let { TypographyType.values().associateBy({ it }, { fontProvider.getFont(it) }) }, shouldDisplayDismissButton, + edgeToEdge, ) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/activity/PaywallActivityLauncher.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/activity/PaywallActivityLauncher.kt index a3569301a0..4474cac5e4 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/activity/PaywallActivityLauncher.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/activity/PaywallActivityLauncher.kt @@ -7,7 +7,10 @@ import androidx.activity.result.ActivityResultCaller import androidx.activity.result.ActivityResultLauncher import androidx.fragment.app.Fragment import com.revenuecat.purchases.CustomerInfo +import com.revenuecat.purchases.InternalRevenueCatAPI import com.revenuecat.purchases.Offering +import com.revenuecat.purchases.PresentedOfferingContext +import com.revenuecat.purchases.ui.revenuecatui.OfferingSelection import com.revenuecat.purchases.ui.revenuecatui.fonts.ParcelizableFontProvider import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger import com.revenuecat.purchases.ui.revenuecatui.helpers.shouldDisplayBlockForEntitlementIdentifier @@ -52,18 +55,27 @@ class PaywallActivityLauncher(resultCaller: ActivityResultCaller, resultHandler: * will be used. Only available for original template paywalls. Ignored for v2 Paywalls. * @param shouldDisplayDismissButton Whether to display the dismiss button in the paywall. * Only available for original template paywalls. Ignored for v2 Paywalls. + * @param edgeToEdge Whether to display the paywall in edge-to-edge mode. + * Default is true for Android 15+, false otherwise. */ @JvmOverloads fun launch( offering: Offering? = null, fontProvider: ParcelizableFontProvider? = null, shouldDisplayDismissButton: Boolean = DEFAULT_DISPLAY_DISMISS_BUTTON, + edgeToEdge: Boolean = defaultEdgeToEdge, ) { activityResultLauncher.launch( PaywallActivityArgs( - offeringId = offering?.identifier, + offeringIdAndPresentedOfferingContext = offering?.let { + OfferingSelection.IdAndPresentedOfferingContext( + offeringId = it.identifier, + presentedOfferingContext = it.availablePackages.firstOrNull()?.presentedOfferingContext, + ) + }, fontProvider = fontProvider, shouldDisplayDismissButton = shouldDisplayDismissButton, + edgeToEdge = edgeToEdge, ), ) } @@ -79,18 +91,54 @@ class PaywallActivityLauncher(resultCaller: ActivityResultCaller, resultHandler: * will be used. Only available for original template paywalls. Ignored for v2 Paywalls. * @param shouldDisplayDismissButton Whether to display the dismiss button in the paywall. Only available for * original template paywalls. Ignored for v2 Paywalls. + * @param edgeToEdge Whether to display the paywall in edge-to-edge mode. + * Default is true for Android 15+, false otherwise. */ + @Deprecated( + message = "Use launch with offering instead", + replaceWith = ReplaceWith( + expression = "launch(offering, fontProvider, shouldDisplayDismissButton, edgeToEdge)", + imports = ["com.revenuecat.purchases.ui.revenuecatui.activity.PaywallActivityLauncher"], + ), + ) @JvmSynthetic fun launch( offeringIdentifier: String, fontProvider: ParcelizableFontProvider? = null, shouldDisplayDismissButton: Boolean = DEFAULT_DISPLAY_DISMISS_BUTTON, + edgeToEdge: Boolean = defaultEdgeToEdge, ) { activityResultLauncher.launch( PaywallActivityArgs( - offeringId = offeringIdentifier, + offeringIdAndPresentedOfferingContext = OfferingSelection.IdAndPresentedOfferingContext( + offeringId = offeringIdentifier, + presentedOfferingContext = null, + ), fontProvider = fontProvider, shouldDisplayDismissButton = shouldDisplayDismissButton, + edgeToEdge = edgeToEdge, + ), + ) + } + + @InternalRevenueCatAPI + @JvmSynthetic + fun launch( + offeringIdentifier: String, + presentedOfferingContext: PresentedOfferingContext, + fontProvider: ParcelizableFontProvider? = null, + shouldDisplayDismissButton: Boolean = DEFAULT_DISPLAY_DISMISS_BUTTON, + edgeToEdge: Boolean = defaultEdgeToEdge, + ) { + activityResultLauncher.launch( + PaywallActivityArgs( + offeringIdAndPresentedOfferingContext = OfferingSelection.IdAndPresentedOfferingContext( + offeringId = offeringIdentifier, + presentedOfferingContext = presentedOfferingContext, + ), + fontProvider = fontProvider, + shouldDisplayDismissButton = shouldDisplayDismissButton, + edgeToEdge = edgeToEdge, ), ) } @@ -104,14 +152,18 @@ class PaywallActivityLauncher(resultCaller: ActivityResultCaller, resultHandler: * have this entitlement active. * @param shouldDisplayDismissButton Whether to display the dismiss button in the paywall. Only available for * original template paywalls. Ignored for v2 Paywalls. + * @param edgeToEdge Whether to display the paywall in edge-to-edge mode. + * Default is true for Android 15+, false otherwise. * @param paywallDisplayCallback Callback that will be called with true if the paywall was displayed */ + @Suppress("LongParameterList") @JvmOverloads fun launchIfNeeded( requiredEntitlementIdentifier: String, offering: Offering? = null, fontProvider: ParcelizableFontProvider? = null, shouldDisplayDismissButton: Boolean = DEFAULT_DISPLAY_DISMISS_BUTTON, + edgeToEdge: Boolean = defaultEdgeToEdge, paywallDisplayCallback: PaywallDisplayCallback? = null, ) { val shouldDisplayBlock = shouldDisplayBlockForEntitlementIdentifier(requiredEntitlementIdentifier) @@ -121,9 +173,15 @@ class PaywallActivityLauncher(resultCaller: ActivityResultCaller, resultHandler: launchPaywallWithArgs( PaywallActivityArgs( requiredEntitlementIdentifier = requiredEntitlementIdentifier, - offeringId = offering?.identifier, + offeringIdAndPresentedOfferingContext = offering?.let { + OfferingSelection.IdAndPresentedOfferingContext( + offeringId = it.identifier, + presentedOfferingContext = it.availablePackages.firstOrNull()?.presentedOfferingContext, + ) + }, fontProvider = fontProvider, shouldDisplayDismissButton = shouldDisplayDismissButton, + edgeToEdge = edgeToEdge, ), ) } @@ -143,14 +201,60 @@ class PaywallActivityLauncher(resultCaller: ActivityResultCaller, resultHandler: * have this entitlement active. * @param shouldDisplayDismissButton Whether to display the dismiss button in the paywall. Only available for * original template paywalls. Ignored for v2 Paywalls. + * @param edgeToEdge Whether to display the paywall in edge-to-edge mode. + * Default is true for Android 15+, false otherwise. * @param paywallDisplayCallback Callback that will be called with true if the paywall was displayed */ + @Deprecated( + message = "Use launchIfNeeded with offering instead", + replaceWith = ReplaceWith( + expression = "launchIfNeeded(" + + "requiredEntitlementIdentifier, offering, fontProvider, " + + "shouldDisplayDismissButton, edgeToEdge, paywallDisplayCallback" + + ")", + imports = ["com.revenuecat.purchases.ui.revenuecatui.activity.PaywallActivityLauncher"], + ), + ) + @Suppress("LongParameterList") + @JvmSynthetic + fun launchIfNeeded( + requiredEntitlementIdentifier: String, + offeringIdentifier: String, + fontProvider: ParcelizableFontProvider? = null, + shouldDisplayDismissButton: Boolean = DEFAULT_DISPLAY_DISMISS_BUTTON, + edgeToEdge: Boolean = defaultEdgeToEdge, + paywallDisplayCallback: PaywallDisplayCallback? = null, + ) { + val shouldDisplayBlock = shouldDisplayBlockForEntitlementIdentifier(requiredEntitlementIdentifier) + shouldDisplayPaywall(shouldDisplayBlock) { shouldDisplay -> + paywallDisplayCallback?.onPaywallDisplayResult(shouldDisplay) + if (shouldDisplay) { + launchPaywallWithArgs( + PaywallActivityArgs( + requiredEntitlementIdentifier = requiredEntitlementIdentifier, + offeringIdAndPresentedOfferingContext = OfferingSelection.IdAndPresentedOfferingContext( + offeringId = offeringIdentifier, + presentedOfferingContext = null, + ), + fontProvider = fontProvider, + shouldDisplayDismissButton = shouldDisplayDismissButton, + edgeToEdge = edgeToEdge, + ), + ) + } + } + } + + @Suppress("LongParameterList") + @InternalRevenueCatAPI @JvmSynthetic fun launchIfNeeded( requiredEntitlementIdentifier: String, offeringIdentifier: String, + presentedOfferingContext: PresentedOfferingContext, fontProvider: ParcelizableFontProvider? = null, shouldDisplayDismissButton: Boolean = DEFAULT_DISPLAY_DISMISS_BUTTON, + edgeToEdge: Boolean = defaultEdgeToEdge, paywallDisplayCallback: PaywallDisplayCallback? = null, ) { val shouldDisplayBlock = shouldDisplayBlockForEntitlementIdentifier(requiredEntitlementIdentifier) @@ -160,9 +264,13 @@ class PaywallActivityLauncher(resultCaller: ActivityResultCaller, resultHandler: launchPaywallWithArgs( PaywallActivityArgs( requiredEntitlementIdentifier = requiredEntitlementIdentifier, - offeringId = offeringIdentifier, + offeringIdAndPresentedOfferingContext = OfferingSelection.IdAndPresentedOfferingContext( + offeringId = offeringIdentifier, + presentedOfferingContext = presentedOfferingContext, + ), fontProvider = fontProvider, shouldDisplayDismissButton = shouldDisplayDismissButton, + edgeToEdge = edgeToEdge, ), ) } @@ -176,6 +284,8 @@ class PaywallActivityLauncher(resultCaller: ActivityResultCaller, resultHandler: * will be used. Only available for original template paywalls. Ignored for v2 Paywalls. * @param shouldDisplayDismissButton Whether to display the dismiss button in the paywall. Only available for * original template paywalls. Ignored for v2 Paywalls. + * @param edgeToEdge Whether to display the paywall in edge-to-edge mode. + * Default is true for Android 15+, false otherwise. * @param shouldDisplayBlock the paywall will be displayed only if this returns true. */ @JvmOverloads @@ -183,15 +293,22 @@ class PaywallActivityLauncher(resultCaller: ActivityResultCaller, resultHandler: offering: Offering? = null, fontProvider: ParcelizableFontProvider? = null, shouldDisplayDismissButton: Boolean = DEFAULT_DISPLAY_DISMISS_BUTTON, + edgeToEdge: Boolean = defaultEdgeToEdge, shouldDisplayBlock: (CustomerInfo) -> Boolean, ) { shouldDisplayPaywall(shouldDisplayBlock) { shouldDisplay -> if (shouldDisplay) { launchPaywallWithArgs( PaywallActivityArgs( - offeringId = offering?.identifier, + offeringIdAndPresentedOfferingContext = offering?.let { + OfferingSelection.IdAndPresentedOfferingContext( + offeringId = it.identifier, + presentedOfferingContext = it.availablePackages.firstOrNull()?.presentedOfferingContext, + ) + }, fontProvider = fontProvider, shouldDisplayDismissButton = shouldDisplayDismissButton, + edgeToEdge = edgeToEdge, ), ) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponents.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponents.kt index e2c08aa5e4..ab0ab42948 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponents.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponents.kt @@ -52,6 +52,7 @@ import com.revenuecat.purchases.ui.revenuecatui.components.properties.rememberBa import com.revenuecat.purchases.ui.revenuecatui.components.style.ButtonComponentStyle import com.revenuecat.purchases.ui.revenuecatui.composables.SimpleBottomSheetScaffold import com.revenuecat.purchases.ui.revenuecatui.composables.SimpleSheetState +import com.revenuecat.purchases.ui.revenuecatui.data.MockPurchasesType import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState import com.revenuecat.purchases.ui.revenuecatui.data.testdata.TestData import com.revenuecat.purchases.ui.revenuecatui.extensions.applyIfNotNull @@ -364,10 +365,9 @@ private fun LoadedPaywallComponents_Preview_Bless() { val validated = offering.validatePaywallComponentsDataOrNullForPreviews()?.getOrThrow()!! val state = offering.toComponentsPaywallState( validationResult = validated, - activelySubscribedProductIds = emptySet(), - purchasedNonSubscriptionProductIds = emptySet(), storefrontCountryCode = null, dateProvider = { Date(MILLIS_2025_01_25) }, + purchases = MockPurchasesType(), ) LoadedPaywallComponents( @@ -452,10 +452,9 @@ private fun previewHelloWorldPaywallState(): PaywallState.Loaded.Components { val validated = offering.validatePaywallComponentsDataOrNullForPreviews()?.getOrThrow()!! return offering.toComponentsPaywallState( validationResult = validated, - activelySubscribedProductIds = emptySet(), - purchasedNonSubscriptionProductIds = emptySet(), storefrontCountryCode = null, dateProvider = { Date(MILLIS_2025_01_25) }, + purchases = MockPurchasesType(), ) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PreviewHelpers.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PreviewHelpers.kt index f052efaa18..8ea8208268 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PreviewHelpers.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PreviewHelpers.kt @@ -60,6 +60,7 @@ import com.revenuecat.purchases.ui.revenuecatui.components.style.ComponentStyle import com.revenuecat.purchases.ui.revenuecatui.components.style.IconComponentStyle import com.revenuecat.purchases.ui.revenuecatui.components.style.StackComponentStyle import com.revenuecat.purchases.ui.revenuecatui.components.style.TextComponentStyle +import com.revenuecat.purchases.ui.revenuecatui.data.MockPurchasesType import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState import com.revenuecat.purchases.ui.revenuecatui.data.testdata.TestData import com.revenuecat.purchases.ui.revenuecatui.errors.PaywallValidationError @@ -69,6 +70,7 @@ import com.revenuecat.purchases.ui.revenuecatui.helpers.PaywallResourceProvider import com.revenuecat.purchases.ui.revenuecatui.helpers.PaywallValidationResult import com.revenuecat.purchases.ui.revenuecatui.helpers.Result import com.revenuecat.purchases.ui.revenuecatui.helpers.getOrThrow +import com.revenuecat.purchases.ui.revenuecatui.helpers.map import com.revenuecat.purchases.ui.revenuecatui.helpers.nonEmptyMapOf import com.revenuecat.purchases.ui.revenuecatui.helpers.toComponentsPaywallState import com.revenuecat.purchases.ui.revenuecatui.helpers.toNonEmptyMapOrNull @@ -80,7 +82,7 @@ private const val MILLIS_2025_01_25 = 1737763200000 @Composable @JvmSynthetic -internal fun previewEmptyState(): PaywallState.Loaded.Components { +internal fun previewEmptyState(initialSelectedTabIndex: Int? = null): PaywallState.Loaded.Components { val data = PaywallComponentsData( templateName = "template", assetBaseURL = URL("https://assets.pawwalls.com"), @@ -111,13 +113,18 @@ internal fun previewEmptyState(): PaywallState.Loaded.Components { data = data, ), ) - val validated = offering.validatePaywallComponentsDataOrNullForPreviews()?.getOrThrow()!! + val validated = offering.validatePaywallComponentsDataOrNullForPreviews()?.map { + if (initialSelectedTabIndex != null) { + it.copy(initialSelectedTabIndex = initialSelectedTabIndex) + } else { + it + } + }?.getOrThrow()!! return offering.toComponentsPaywallState( validationResult = validated, - activelySubscribedProductIds = emptySet(), - purchasedNonSubscriptionProductIds = emptySet(), storefrontCountryCode = null, dateProvider = { Date(MILLIS_2025_01_25) }, + purchases = MockPurchasesType(), ) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/TransitionView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/TransitionView.kt new file mode 100644 index 0000000000..cb974ba92e --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/TransitionView.kt @@ -0,0 +1,124 @@ +package com.revenuecat.purchases.ui.revenuecatui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.scaleIn +import androidx.compose.animation.slideIn +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.IntOffset +import com.revenuecat.purchases.paywalls.components.PaywallAnimation +import com.revenuecat.purchases.paywalls.components.PaywallAnimation.AnimationType +import com.revenuecat.purchases.paywalls.components.PaywallTransition +import com.revenuecat.purchases.paywalls.components.PaywallTransition.TransitionType +@Suppress("ModifierMissing") +@Composable +internal fun TransitionView(transition: PaywallTransition?, content: @Composable () -> Unit) { + if (transition == null) { + content() + } else { + if (transition.displacementStrategy == PaywallTransition.DisplacementStrategy.GREEDY) { + Box { + Box(modifier = Modifier.hidden()) { + content() + } + + transition.AnimatedVisibility { content() } + } + } else { + transition.AnimatedVisibility { content() } + } + } +} + +private fun Modifier.hidden(): Modifier = this.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + + layout(placeable.width, placeable.height) { + // do not insert anything into the view + } +} + +@Suppress("ModifierMissing") +@Composable +private fun PaywallTransition.AnimatedVisibility(content: @Composable () -> Unit) { + var shouldShow by remember(this) { mutableStateOf(false) } + LaunchedEffect(this) { + shouldShow = true + } + AnimatedVisibility( + visible = shouldShow, + enter = enterTransition(), + ) { + content() + } +} + +@Suppress("CyclomaticComplexMethod") +private fun PaywallTransition.enterTransition(): EnterTransition = when (type) { + TransitionType.FADE -> fadeIn( + tween( + animation?.msDuration ?: SensibleDefaults.DURATION, + delayMillis = animation?.msDelay ?: SensibleDefaults.ZERO, + easing = animation.easing(), + ), + ) + + TransitionType.FADE_AND_SCALE -> fadeIn( + tween( + animation?.msDuration ?: SensibleDefaults.DURATION, + delayMillis = animation?.msDelay ?: SensibleDefaults.ZERO, + easing = animation.easing(), + ), + ) + scaleIn( + tween( + animation?.msDuration ?: SensibleDefaults.DURATION, + delayMillis = animation?.msDelay ?: SensibleDefaults.ZERO, + easing = animation.easing(), + ), + ) + + TransitionType.SCALE -> scaleIn( + tween( + animation?.msDuration ?: SensibleDefaults.DURATION, + delayMillis = animation?.msDelay ?: SensibleDefaults.ZERO, + easing = animation.easing(), + ), + ) + + TransitionType.SLIDE -> slideIn( + tween( + animation?.msDuration ?: SensibleDefaults.DURATION, + delayMillis = animation?.msDelay ?: SensibleDefaults.ZERO, + easing = animation.easing(), + ), + ) { IntOffset(-SensibleDefaults.X_OFFSET, SensibleDefaults.ZERO) } +} + +private object SensibleDefaults { + const val DURATION = 300 + const val ZERO = 0 + const val X_OFFSET = 180 +} + +private fun PaywallAnimation?.easing(): Easing = this?.getEasing() ?: LinearOutSlowInEasing +private fun PaywallAnimation.getEasing(): Easing = when (type) { + AnimationType.EASE_IN -> FastOutSlowInEasing + AnimationType.EASE_OUT -> FastOutLinearInEasing + AnimationType.EASE_IN_OUT -> LinearOutSlowInEasing + AnimationType.LINEAR -> LinearEasing +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt index 4299be4d2a..af81671a9d 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt @@ -35,6 +35,7 @@ import com.revenuecat.purchases.paywalls.components.properties.Shape import com.revenuecat.purchases.paywalls.components.properties.Size import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fit import com.revenuecat.purchases.ui.revenuecatui.components.PaywallAction +import com.revenuecat.purchases.ui.revenuecatui.components.TransitionView import com.revenuecat.purchases.ui.revenuecatui.components.previewEmptyState import com.revenuecat.purchases.ui.revenuecatui.components.previewStackComponentStyle import com.revenuecat.purchases.ui.revenuecatui.components.previewTextComponentStyle @@ -70,105 +71,107 @@ internal fun ButtonComponentView( onClick: suspend (PaywallAction) -> Unit, modifier: Modifier = Modifier, ) { - // Get a ButtonComponentState that calculates the stateful properties we should use. - val buttonState = rememberButtonComponentState( - style = style, - paywallState = state, - ) + TransitionView(transition = style.transition) { + // Get a ButtonComponentState that calculates the stateful properties we should use. + val buttonState = rememberButtonComponentState( + style = style, + paywallState = state, + ) - val coroutineScope = rememberCoroutineScope() - // Whether there's an action in progress anywhere on the paywall. - val anyActionInProgress by state::actionInProgress - // Whether this button's action is in progress. - var myActionInProgress by remember { mutableStateOf(false) } - val contentAlpha by remember { - derivedStateOf { if (myActionInProgress) 0f else if (anyActionInProgress) ALPHA_DISABLED else 1f } - } - val progressAlpha by remember { derivedStateOf { if (myActionInProgress) 1f else 0f } } - val animatedContentAlpha by animateFloatAsState(targetValue = contentAlpha) - val animatedProgressAlpha by animateFloatAsState(targetValue = progressAlpha) + val coroutineScope = rememberCoroutineScope() + // Whether there's an action in progress anywhere on the paywall. + val anyActionInProgress by state::actionInProgress + // Whether this button's action is in progress. + var myActionInProgress by remember { mutableStateOf(false) } + val contentAlpha by remember { + derivedStateOf { if (myActionInProgress) 0f else if (anyActionInProgress) ALPHA_DISABLED else 1f } + } + val progressAlpha by remember { derivedStateOf { if (myActionInProgress) 1f else 0f } } + val animatedContentAlpha by animateFloatAsState(targetValue = contentAlpha) + val animatedProgressAlpha by animateFloatAsState(targetValue = progressAlpha) - val layoutDirection = LocalLayoutDirection.current - val marginTop = remember(style.stackComponentStyle.margin) { - style.stackComponentStyle.margin.calculateTopPadding() - } - val marginBottom = remember(style.stackComponentStyle.margin) { - style.stackComponentStyle.margin.calculateBottomPadding() - } - val marginStart = remember(style.stackComponentStyle.margin, layoutDirection) { - style.stackComponentStyle.margin.calculateStartPadding(layoutDirection) - } - val marginEnd = remember(style.stackComponentStyle.margin, layoutDirection) { - style.stackComponentStyle.margin.calculateEndPadding(layoutDirection) - } + val layoutDirection = LocalLayoutDirection.current + val marginTop = remember(style.stackComponentStyle.margin) { + style.stackComponentStyle.margin.calculateTopPadding() + } + val marginBottom = remember(style.stackComponentStyle.margin) { + style.stackComponentStyle.margin.calculateBottomPadding() + } + val marginStart = remember(style.stackComponentStyle.margin, layoutDirection) { + style.stackComponentStyle.margin.calculateStartPadding(layoutDirection) + } + val marginEnd = remember(style.stackComponentStyle.margin, layoutDirection) { + style.stackComponentStyle.margin.calculateEndPadding(layoutDirection) + } - // We are using a custom Layout instead of a Box to properly handle the case where the StackComponentView is - // smaller than the CircularProgressIndicator, in either dimension. In this case, we want the - // CircularProgressIndicator to shrink so it doesn't exceed the StackComponentView's bounds. Using IntrinsicSize - // and matchParentSize() was considered, but in the end a custom Layout seemed to be the only reliable option. - Layout( - content = { - StackComponentView( - style = style.stackComponentStyle, - state = state, - // We're the button, so we're handling the click already. - clickHandler = { }, - contentAlpha = animatedContentAlpha, - ) - CircularProgressIndicator( - modifier = Modifier.alpha(animatedProgressAlpha), - color = progressColorFor(style.stackComponentStyle.background), - ) - }, - modifier = modifier.clickable(enabled = !anyActionInProgress) { - myActionInProgress = true - state.update(actionInProgress = true) - coroutineScope.launch { - onClick(buttonState.action) - myActionInProgress = false - state.update(actionInProgress = false) - } - }, - measurePolicy = { measurables, constraints -> - val stack = measurables[0].measure(constraints) - // Ensure that the progress indicator is not bigger than the stack. - val marginStartPx = marginStart.toPx() - val marginEndPx = marginEnd.toPx() - val marginTopPx = marginTop.toPx() - val marginBottomPx = marginBottom.toPx() - val progressSize = progressSize( - stackWidthPx = stack.width, - stackHeightPx = stack.height, - stackMarginStartPx = marginStartPx, - stackMarginEndPx = marginEndPx, - stackMarginTopPx = marginTopPx, - stackMarginBottomPx = marginBottomPx, - ) - val progress = measurables[1].measure( - Constraints( - minWidth = progressSize, - maxWidth = progressSize, - minHeight = progressSize, - maxHeight = progressSize, - ), - ) - val totalWidth = stack.width - val totalHeight = stack.height - val stackHeightMinusMargin = totalHeight - marginTopPx - marginBottomPx - val stackWidthMinusMargin = totalWidth - marginStartPx - marginEndPx - layout( - width = totalWidth, - height = totalHeight, - ) { - stack.placeRelative(x = 0, y = 0) - // Center the progress indicator. - progress.placeRelative( - x = marginStartPx.toInt() + ((stackWidthMinusMargin / 2f) - (progress.width / 2f)).roundToInt(), - y = marginTopPx.toInt() + ((stackHeightMinusMargin / 2f) - (progress.height / 2f)).roundToInt(), + // We are using a custom Layout instead of a Box to properly handle the case where the StackComponentView is + // smaller than the CircularProgressIndicator, in either dimension. In this case, we want the + // CircularProgressIndicator to shrink so it doesn't exceed the StackComponentView's bounds. Using IntrinsicSize + // and matchParentSize() was considered, but in the end a custom Layout seemed to be the only reliable option. + Layout( + content = { + StackComponentView( + style = style.stackComponentStyle, + state = state, + // We're the button, so we're handling the click already. + clickHandler = { }, + contentAlpha = animatedContentAlpha, ) - } - }, - ) + CircularProgressIndicator( + modifier = Modifier.alpha(animatedProgressAlpha), + color = progressColorFor(style.stackComponentStyle.background), + ) + }, + modifier = modifier.clickable(enabled = !anyActionInProgress) { + myActionInProgress = true + state.update(actionInProgress = true) + coroutineScope.launch { + onClick(buttonState.action) + myActionInProgress = false + state.update(actionInProgress = false) + } + }, + measurePolicy = { measurables, constraints -> + val stack = measurables[0].measure(constraints) + // Ensure that the progress indicator is not bigger than the stack. + val marginStartPx = marginStart.toPx() + val marginEndPx = marginEnd.toPx() + val marginTopPx = marginTop.toPx() + val marginBottomPx = marginBottom.toPx() + val progressSize = progressSize( + stackWidthPx = stack.width, + stackHeightPx = stack.height, + stackMarginStartPx = marginStartPx, + stackMarginEndPx = marginEndPx, + stackMarginTopPx = marginTopPx, + stackMarginBottomPx = marginBottomPx, + ) + val progress = measurables[1].measure( + Constraints( + minWidth = progressSize, + maxWidth = progressSize, + minHeight = progressSize, + maxHeight = progressSize, + ), + ) + val totalWidth = stack.width + val totalHeight = stack.height + val stackHeightMinusMargin = totalHeight - marginTopPx - marginBottomPx + val stackWidthMinusMargin = totalWidth - marginStartPx - marginEndPx + layout( + width = totalWidth, + height = totalHeight, + ) { + stack.placeRelative(x = 0, y = 0) + // Center the progress indicator. + progress.placeRelative( + x = marginStartPx.toInt() + ((stackWidthMinusMargin / 2f) - (progress.width / 2f)).roundToInt(), + y = marginTopPx.toInt() + ((stackHeightMinusMargin / 2f) - (progress.height / 2f)).roundToInt(), + ) + } + }, + ) + } } @Suppress("LongParameterList") diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ktx/Localization.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ktx/Localization.kt index 4215b71ff8..a81592ad95 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ktx/Localization.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ktx/Localization.kt @@ -95,6 +95,10 @@ internal fun ComposeLocale.toLocaleId(): LocaleId = internal fun ComposeLocale.toJavaLocale(): JavaLocale = JavaLocale.forLanguageTag(toLanguageTag()) +@JvmSynthetic +internal fun JavaLocale.toComposeLocale(): ComposeLocale = + ComposeLocale(toLanguageTag()) + @JvmSynthetic internal fun Map.getBestMatch(localeId: LocaleId): V? = keys.getBestMatch(localeId)?.let { bestMatch -> get(bestMatch) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/properties/FontSpec.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/properties/FontSpec.kt index 6b8104138c..50df8d273a 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/properties/FontSpec.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/properties/FontSpec.kt @@ -34,14 +34,10 @@ private val GoogleFontsProvider: GoogleFont.Provider = GoogleFont.Provider( * (e.g. "sans-serif"), a font resource provided by the app, or a device font provided by the OEM. * * Determining this is relatively costly for font resources. So this abstraction allows us to perform this logic only - * once for each one (see [determineFontSpecs]) in the validation step, before resolving the actual font where needed. - * - * It also allows us to defer resolving the actual font to the UI layer, as only at that time do we know the exact - * override that's being used. We need to know this, because we need to know the [FontWeight] for which to resolve the - * font. + * once for each one (see [determineFontSpecs]) in the validation step. */ internal sealed interface FontSpec { - data class Resource(@get:JvmSynthetic val id: Int) : FontSpec + data class Resource(@get:JvmSynthetic val fontFamily: FontFamily) : FontSpec data class Asset(@get:JvmSynthetic val path: String) : FontSpec data class Google(@get:JvmSynthetic val name: String) : FontSpec sealed interface Generic : FontSpec { @@ -58,14 +54,71 @@ internal sealed interface FontSpec { internal fun Map.determineFontSpecs( resourceProvider: ResourceProvider, ): Map { + val resourceFontFamilies = values + .toSet() + .mapNotNull { fontsConfig -> + val fontInfo = fontsConfig.android as? FontInfo.Name + fontInfo?.family?.let { family -> family to fontInfo } + } + .groupBy({ (family, _) -> family }, { (_, fontInfo) -> fontInfo }) + .mapNotNull { (family, fontInfos) -> + fontInfos.toFontSpecResource(resourceProvider)?.let { + family to it + } + }.associateBy({ it.first }, { it.second }) + // Get unique FontsConfigs, and determine their FontSpec. val configToSpec: Map = values.toSet().associateWith { fontsConfig -> - resourceProvider.determineFontSpec(fontsConfig) + resourceProvider.determineFontSpec(fontsConfig, resourceFontFamilies) } // Create a map of FontAliases to FontSpecs. return mapValues { (_, fontsConfig) -> configToSpec.getValue(fontsConfig) } } +private fun List.toFontSpecResource( + resourceProvider: ResourceProvider, +): FontSpec.Resource? { + val fontResourceData = toFontResourceIdAndData(resourceProvider) + return if (fontResourceData.isEmpty()) { + null + } else { + if (fontResourceData.size == 1) { + resourceProvider.getXmlFontFamily(fontResourceData.first().first)?.let { + return FontSpec.Resource(it) + } + } + FontSpec.Resource( + FontFamily( + fontResourceData.map { resourceFont -> + Font( + resId = resourceFont.first, + weight = resourceFont.second?.let { FontWeight(it) } ?: FontWeight.Normal, + style = resourceFont.third ?: FontStyle.Normal, + ) + }, + ), + ) + } +} + +private fun List.toFontResourceIdAndData( + resourceProvider: ResourceProvider, +): List> { + val resourceNamesSeen = mutableSetOf() + return mapNotNull { fontInfo -> + if (fontInfo.value in resourceNamesSeen) { + null + } else { + resourceProvider.getResourceIdentifier(name = fontInfo.value, type = "font") + .takeIf { it != 0 } + ?.also { resourceNamesSeen.add(fontInfo.value) } + ?.let { + Triple(it, fontInfo.weight, fontInfo.style?.toComposeFontStyle()) + } + } + } +} + /** * Retrieves a [FontSpec] from this map, and returns a [PaywallValidationError.MissingFontAlias] if it doesn't exist. * If you want to treat blank [FontAlias]es as a null [FontSpec], chain it with [recoverFromFontAliasError]. @@ -111,7 +164,7 @@ internal fun FontSpec.resolve( weight: FontWeight, style: FontStyle, ): FontFamily = when (this) { - is FontSpec.Resource -> FontFamily(Font(resId = id, weight = weight, style = style)) + is FontSpec.Resource -> fontFamily is FontSpec.Asset -> FontFamily(Font(path = path, assetManager = assets, weight = weight, style = style)) is FontSpec.Google -> FontFamily( Font(googleFont = GoogleFont(name), fontProvider = GoogleFontsProvider, weight = weight, style = style), @@ -138,10 +191,16 @@ internal fun FontSpec.resolve( ) } -private fun ResourceProvider.determineFontSpec(fontsConfig: FontsConfig): FontSpec { +private fun ResourceProvider.determineFontSpec( + fontsConfig: FontsConfig, + resourceFontFamilies: Map, +): FontSpec { return when (val fontInfo = fontsConfig.android) { is FontInfo.GoogleFonts -> FontSpec.Google(name = fontInfo.value) - is FontInfo.Name -> getBundledFontSpec(fontInfo) + is FontInfo.Name -> getGenericFontSpec(fontInfo) + ?: fontInfo.family?.let { resourceFontFamilies[fontInfo.family] } + ?: getAssetFontPath(name = fontInfo.value) + ?.let { path -> FontSpec.Asset(path = path) } ?: getDownloadedFontSpec(fontInfo) ?: FontSpec.System(name = fontInfo.value).also { Logger.d( @@ -154,20 +213,14 @@ private fun ResourceProvider.determineFontSpec(fontsConfig: FontsConfig): FontSp } } -@Suppress("NestedBlockDepth") -private fun ResourceProvider.getBundledFontSpec( +private fun getGenericFontSpec( fontInfo: FontInfo.Name, -): FontSpec? { - return when (fontInfo.value.takeIf { it.isNotEmpty() }) { - null -> null // No font specified, return null. +): FontSpec.Generic? { + return when (fontInfo.value) { FontFamily.SansSerif.name -> FontSpec.Generic.SansSerif FontFamily.Serif.name -> FontSpec.Generic.Serif FontFamily.Monospace.name -> FontSpec.Generic.Monospace - else -> getResourceIdentifier(name = fontInfo.value, type = "font") - .takeIf { it != 0 } - ?.let { fontId -> FontSpec.Resource(id = fontId) } - ?: getAssetFontPath(name = fontInfo.value) - ?.let { path -> FontSpec.Asset(path = path) } + else -> null // Not a generic font. } } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/ButtonComponentStyle.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/ButtonComponentStyle.kt index a91d59edd8..73dbf217c6 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/ButtonComponentStyle.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/ButtonComponentStyle.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import com.revenuecat.purchases.Package import com.revenuecat.purchases.paywalls.components.ButtonComponent import com.revenuecat.purchases.paywalls.components.ButtonComponent.Destination +import com.revenuecat.purchases.paywalls.components.PaywallTransition import com.revenuecat.purchases.paywalls.components.common.LocaleId import com.revenuecat.purchases.paywalls.components.properties.Size import com.revenuecat.purchases.ui.revenuecatui.helpers.NonEmptyMap @@ -15,6 +16,8 @@ internal data class ButtonComponentStyle( val stackComponentStyle: StackComponentStyle, @get:JvmSynthetic val action: Action, + @get:JvmSynthetic + val transition: PaywallTransition? = null, ) : ComponentStyle { internal sealed interface Action { diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt index cdea6e5a55..e07b36472c 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt @@ -421,6 +421,7 @@ internal class StyleFactory( ButtonComponentStyle( stackComponentStyle = stack, action = action, + transition = component.transition, ) } } @@ -823,7 +824,6 @@ internal class StyleFactory( ) { thumbColorOn, thumbColorOff, trackColorOn, trackColorOff -> defaultTabIndex = if (component.defaultValue) 1 else 0 TabControlToggleComponentStyle( - defaultValue = component.defaultValue, thumbColorOn = thumbColorOn, thumbColorOff = thumbColorOff, trackColorOn = trackColorOn, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/TabsComponentStyle.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/TabsComponentStyle.kt index 4d0d7ba934..7edd440e7e 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/TabsComponentStyle.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/TabsComponentStyle.kt @@ -28,8 +28,6 @@ internal data class TabControlButtonComponentStyle( @Immutable internal class TabControlToggleComponentStyle( - @get:JvmSynthetic - val defaultValue: Boolean, @get:JvmSynthetic val thumbColorOn: ColorStyles, @get:JvmSynthetic diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabControlToggleView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabControlToggleView.kt index b625c0d771..b7bb3be6bd 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabControlToggleView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabControlToggleView.kt @@ -58,9 +58,9 @@ private class CheckedPreviewProvider : PreviewParameterProvider { private fun TabControlToggleView_Preview( @PreviewParameter(CheckedPreviewProvider::class) checked: Boolean, ) { + val initialTabIndex = if (checked) 1 else 0 TabControlToggleView( style = TabControlToggleComponentStyle( - defaultValue = checked, thumbColorOn = ColorStyles( light = ColorStyle.Solid(color = Color.Red), dark = ColorStyle.Solid(color = Color.Blue), @@ -78,7 +78,7 @@ private fun TabControlToggleView_Preview( dark = ColorStyle.Solid(color = Color.Yellow), ), ), - state = previewEmptyState(), + state = previewEmptyState(initialSelectedTabIndex = initialTabIndex), ) } @@ -102,7 +102,6 @@ private fun TabControlToggleView_Gradient_Preview() { TabControlToggleView( style = TabControlToggleComponentStyle( - defaultValue = false, thumbColorOn = ColorStyles( light = ColorInfo.Gradient.Radial( points = pointsRgb, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt index 8feb5ebb6a..5fbcccc445 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt @@ -113,7 +113,8 @@ private fun rememberProcessedText( val processedText by remember(state, textState) { derivedStateOf { textState.applicablePackage?.let { packageToUse -> - val locale = state.locale.toJavaLocale() + val dateLocale = state.locale.toJavaLocale() + val currencyLocale = state.currencyLocale.toJavaLocale() val introEligibility = packageToUse.introEligibility @@ -139,7 +140,8 @@ private fun rememberProcessedText( variableDataProvider = state.variableDataProvider, packageContext = variableContext, rcPackage = packageToUse, - locale = locale, + currencyLocale = currencyLocale, + dateLocale = dateLocale, date = state.currentDate, ) } ?: textState.text diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/CustomerCenterConstants.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/CustomerCenterConstants.kt index 76eaa16611..a2b6669353 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/CustomerCenterConstants.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/CustomerCenterConstants.kt @@ -15,6 +15,8 @@ internal object CustomerCenterConstants { const val COLOR_BADGE_FREE_TRIAL = 0x5BF5CA5C const val COLOR_BADGE_ACTIVE = 0x9911D483 const val COLOR_BADGE_EXPIRED = 0xFFF2F2F7 + const val COLOR_LIFETIME_BORDER = 0xFF3C3C43 + const val LIFETIME_BORDER_ALPHA = 0.29f } object Layout { diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/CustomerCenterUIConstants.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/CustomerCenterUIConstants.kt index ea81873f2e..c988e266c5 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/CustomerCenterUIConstants.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/CustomerCenterUIConstants.kt @@ -10,9 +10,9 @@ import androidx.compose.ui.unit.sp **/ internal object CustomerCenterUIConstants { + private val PaddingTiny = 4.dp private val PaddingSmall = 8.dp private val PaddingMedium = 16.dp - private val PaddingLarge = 24.dp private val PaddingXL = 32.dp val ManagementViewTitleTopPadding = 64.dp @@ -26,10 +26,10 @@ internal object CustomerCenterUIConstants { val SubscriptionViewHorizontalSpace = PaddingSmall val SubscriptionViewIconSize = 24.dp - val ContentUnavailableViewPadding = PaddingMedium - val ContentUnavailableViewPaddingTopTitle = PaddingSmall - val ContentUnavailableViewPaddingTopDescription = PaddingLarge - val ContentUnavailableIconSize = 56.dp + val ContentUnavailableViewPaddingHorizontal = 16.dp + val ContentUnavailableViewPaddingVertical = 20.dp + val ContentUnavailableViewPaddingText = PaddingTiny + val ContentUnavailableIconSize = 24.dp val ManagementViewHorizontalPadding = PaddingMedium val ManagementViewSpacer = PaddingXL diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/InternalCustomerCenter.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/InternalCustomerCenter.kt index bb3e5b311b..2edd7605db 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/InternalCustomerCenter.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/InternalCustomerCenter.kt @@ -77,14 +77,19 @@ internal fun InternalCustomerCenter( ), onDismiss: () -> Unit, ) { - viewModel.refreshStateIfColorsChanged(MaterialTheme.colorScheme, isSystemInDarkTheme()) + val colorScheme = MaterialTheme.colorScheme + val isDark = isSystemInDarkTheme() + + LaunchedEffect(colorScheme, isDark) { + viewModel.refreshColors(colorScheme, isDark) + } val state by viewModel.state.collectAsStateWithLifecycle() val coroutineScope = rememberCoroutineScope() val context = LocalContext.current - if (state is CustomerCenterState.NotLoaded) { - coroutineScope.launch { + LaunchedEffect(state !is CustomerCenterState.Success) { + if (state is CustomerCenterState.NotLoaded) { viewModel.loadCustomerCenter() } } @@ -139,7 +144,11 @@ internal fun InternalCustomerCenter( viewModel.onAcceptedPromotionalOffer(action.subscriptionOption, activity) } } + is CustomerCenterAction.CustomActionSelected -> { + viewModel.onCustomActionSelected(action.customActionData) + } is CustomerCenterAction.SelectPurchase -> viewModel.selectPurchase(action.purchase) + is CustomerCenterAction.ShowPaywall -> viewModel.showPaywall(context) } }, ) @@ -264,9 +273,7 @@ private fun CustomerCenterScaffold( } Scaffold( - modifier = Modifier.applyIfNotNull(scrollBehavior) { - modifier.nestedScroll(it.nestedScrollConnection) - }, + modifier = modifier.applyIfNotNull(scrollBehavior) { nestedScroll(it.nestedScrollConnection) }, topBar = { CustomerCenterTopBar( scaffoldConfig = scaffoldConfig, @@ -474,12 +481,11 @@ private fun MainScreenContent( } else { configuration.getNoActiveScreen()?.let { noActiveScreen -> NoActiveUserManagementView( - screenTitle = noActiveScreen.title, - screenSubtitle = noActiveScreen.subtitle, + screen = noActiveScreen, contactEmail = configuration.support.email, localization = configuration.localization, - noActiveScreen.paths, - onAction, + offering = (state as? CustomerCenterState.Success)?.noActiveScreenOffering, + onAction = onAction, ) } ?: run { // Fallback with a restore button diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/ScreenOfferingExtensions.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/ScreenOfferingExtensions.kt new file mode 100644 index 0000000000..bc5a61026f --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/ScreenOfferingExtensions.kt @@ -0,0 +1,39 @@ +package com.revenuecat.purchases.ui.revenuecatui.customercenter + +import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.Offering +import com.revenuecat.purchases.customercenter.CustomerCenterConfigData +import com.revenuecat.purchases.ui.revenuecatui.data.PurchasesType + +@InternalRevenueCatAPI +internal suspend fun CustomerCenterConfigData.Screen.resolveOfferingSuspend( + purchases: PurchasesType, +): Offering? { + val screenOffering = this.offering ?: return null + + val offerings = purchases.awaitOfferings() + + return when (screenOffering.type) { + CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.CURRENT -> { + offerings.current + } + CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.SPECIFIC -> { + val offeringId = screenOffering.offeringId + if (offeringId != null) { + offerings.all[offeringId] + } else { + null + } + } + } +} + +@InternalRevenueCatAPI +internal fun CustomerCenterConfigData.Screen.resolveButtonText( + localization: CustomerCenterConfigData.Localization, +): String { + return offering?.buttonText + ?: localization.commonLocalizedString( + CustomerCenterConfigData.Localization.CommonLocalizedString.BUY_SUBSCRIPTION, + ) +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/actions/CustomerCenterAction.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/actions/CustomerCenterAction.kt index 61c1d05795..a0f2ff8e20 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/actions/CustomerCenterAction.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/actions/CustomerCenterAction.kt @@ -1,5 +1,6 @@ package com.revenuecat.purchases.ui.revenuecatui.customercenter.actions +import com.revenuecat.purchases.customercenter.CustomActionData import com.revenuecat.purchases.customercenter.CustomerCenterConfigData import com.revenuecat.purchases.models.SubscriptionOption import com.revenuecat.purchases.ui.revenuecatui.customercenter.data.PurchaseInformation @@ -16,5 +17,7 @@ internal sealed class CustomerCenterAction { data class PurchasePromotionalOffer(val subscriptionOption: SubscriptionOption) : CustomerCenterAction() data class DismissPromotionalOffer(val originalPath: CustomerCenterConfigData.HelpPath) : CustomerCenterAction() data class OpenURL(val url: String) : CustomerCenterAction() + data class CustomActionSelected(val customActionData: CustomActionData) : CustomerCenterAction() object NavigationButtonPressed : CustomerCenterAction() + object ShowPaywall : CustomerCenterAction() } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterConfigTestData.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterConfigTestData.kt index f1e68007b4..1c06e03154 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterConfigTestData.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterConfigTestData.kt @@ -145,6 +145,7 @@ internal object CustomerCenterConfigTestData { isExpired = false, isTrial = false, isCancelled = false, + isLifetime = false, ) val purchaseInformationYearlyExpiring = PurchaseInformation( @@ -165,6 +166,7 @@ internal object CustomerCenterConfigTestData { isExpired = false, isTrial = false, isCancelled = true, + isLifetime = false, ) val purchaseInformationYearlyExpired = PurchaseInformation( @@ -185,6 +187,7 @@ internal object CustomerCenterConfigTestData { isExpired = true, isTrial = false, isCancelled = true, + isLifetime = false, ) val purchaseInformationLifetime = PurchaseInformation( @@ -198,6 +201,7 @@ internal object CustomerCenterConfigTestData { isExpired = false, isTrial = false, isCancelled = false, + isLifetime = true, ) val purchaseInformationPromotional = PurchaseInformation( @@ -211,5 +215,6 @@ internal object CustomerCenterConfigTestData { isExpired = false, isTrial = false, isCancelled = true, + isLifetime = false, ) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterState.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterState.kt index 405f0a4db0..1d6670eb27 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterState.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterState.kt @@ -1,5 +1,6 @@ package com.revenuecat.purchases.ui.revenuecatui.customercenter.data +import com.revenuecat.purchases.Offering import com.revenuecat.purchases.PurchasesError import com.revenuecat.purchases.customercenter.CustomerCenterConfigData import com.revenuecat.purchases.models.SubscriptionOption @@ -29,6 +30,7 @@ internal sealed class CustomerCenterState( @get:JvmSynthetic val mainScreenPaths: List = emptyList(), @get:JvmSynthetic val detailScreenPaths: List = emptyList(), @get:JvmSynthetic val restorePurchasesState: RestorePurchasesState? = null, + @get:JvmSynthetic val noActiveScreenOffering: Offering? = null, @get:JvmSynthetic val navigationState: CustomerCenterNavigationState = CustomerCenterNavigationState( showingActivePurchasesScreen = purchases.isNotEmpty(), managementScreenTitle = customerCenterConfigData.getManagementScreen()?.title, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/PathUtils.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/PathUtils.kt index 63e98ed47b..ab534ca92b 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/PathUtils.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/PathUtils.kt @@ -57,6 +57,7 @@ internal object PathUtils { return when (path.type) { CustomerCenterConfigData.HelpPath.PathType.MISSING_PURCHASE, CustomerCenterConfigData.HelpPath.PathType.CUSTOM_URL, + CustomerCenterConfigData.HelpPath.PathType.CUSTOM_ACTION, CustomerCenterConfigData.HelpPath.PathType.UNKNOWN, -> true CustomerCenterConfigData.HelpPath.PathType.CANCEL, @@ -77,6 +78,7 @@ internal object PathUtils { -> true CustomerCenterConfigData.HelpPath.PathType.MISSING_PURCHASE, CustomerCenterConfigData.HelpPath.PathType.CUSTOM_URL, + CustomerCenterConfigData.HelpPath.PathType.CUSTOM_ACTION, CustomerCenterConfigData.HelpPath.PathType.UNKNOWN, -> false } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/PurchaseInformation.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/PurchaseInformation.kt index c2a06ea3fa..c810eb29ce 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/PurchaseInformation.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/PurchaseInformation.kt @@ -36,6 +36,11 @@ internal data class PurchaseInformation( * until the end of the billing period. */ val isCancelled: Boolean, + /** + * Indicates whether this is a lifetime purchase. + * This is true for promotional lifetime purchases or non-subscription purchases attached to an entitlement. + */ + val isLifetime: Boolean, ) { constructor( @@ -44,16 +49,13 @@ internal data class PurchaseInformation( transaction: TransactionDetails, dateFormatter: DateFormatter = DefaultDateFormatter(), locale: Locale, + localization: CustomerCenterConfigData.Localization, ) : this( - title = determineTitle(entitlementInfo, subscribedProduct, transaction), + title = determineTitle(entitlementInfo, subscribedProduct, transaction, localization), expirationOrRenewal = determineExpirationOrRenewal(entitlementInfo, transaction, dateFormatter, locale), product = subscribedProduct, store = entitlementInfo?.store ?: transaction.store, - pricePaid = entitlementInfo?.priceBestEffort(subscribedProduct) ?: if (transaction.store == Store.PROMOTIONAL) { - PriceDetails.Free - } else { - subscribedProduct?.let { PriceDetails.Paid(it.price.formatted) } ?: PriceDetails.Unknown - }, + pricePaid = determinePrice(subscribedProduct, transaction), isSubscription = transaction is TransactionDetails.Subscription && transaction.store != Store.PROMOTIONAL, managementURL = (transaction as? TransactionDetails.Subscription)?.managementURL, isExpired = entitlementInfo?.isActive?.let { !it } @@ -63,6 +65,7 @@ internal data class PurchaseInformation( }, isTrial = determineTrialStatus(entitlementInfo, transaction), isCancelled = determineCancellationStatus(entitlementInfo, transaction), + isLifetime = determineLifetimeStatus(entitlementInfo, transaction), ) fun renewalString( @@ -73,9 +76,15 @@ internal data class PurchaseInformation( PriceDetails.Free, PriceDetails.Unknown -> localization.commonLocalizedString( CustomerCenterConfigData.Localization.CommonLocalizedString.RENEWS_ON_DATE, ).replace("{{ date }}", renewalDate) - is PriceDetails.Paid -> localization.commonLocalizedString( - CustomerCenterConfigData.Localization.CommonLocalizedString.RENEWS_ON_DATE_FOR_PRICE, - ).replace("{{ date }}", renewalDate).replace("{{ price }}", pricePaid.price) + is PriceDetails.Paid -> { + val lastChargeText = localization.commonLocalizedString( + CustomerCenterConfigData.Localization.CommonLocalizedString.LAST_CHARGE_WAS, + ).replace("{{ price }}", pricePaid.price) + val nextBillingText = localization.commonLocalizedString( + CustomerCenterConfigData.Localization.CommonLocalizedString.NEXT_BILLING_DATE_ON, + ).replace("{{ date }}", renewalDate) + "$lastChargeText\n$nextBillingText" + } } } @@ -95,25 +104,56 @@ internal data class PurchaseInformation( } } +private fun determinePrice( + subscribedProduct: StoreProduct?, + transaction: TransactionDetails, +): PriceDetails { + return when { + transaction.store == Store.PROMOTIONAL -> PriceDetails.Free + + transaction.price?.amountMicros?.let { it > 0L } == true -> { + transaction.price?.let { PriceDetails.Paid(it.formatted) } ?: PriceDetails.Unknown + } + + // In sandbox, we don't know if the price is actually free or not (it's always 0) + // So we fall back to the product price. + transaction.price?.amountMicros == 0L && !transaction.isSandbox -> { + PriceDetails.Free + } + + subscribedProduct != null -> { + if (subscribedProduct.price.amountMicros == 0L) { + PriceDetails.Free + } else { + PriceDetails.Paid(subscribedProduct.price.formatted) + } + } + + else -> PriceDetails.Unknown + } +} + private fun determineTitle( entitlementInfo: EntitlementInfo?, subscribedProduct: StoreProduct?, transaction: TransactionDetails, + localization: CustomerCenterConfigData.Localization, ): String { if (transaction.store == Store.PROMOTIONAL && entitlementInfo != null) { return entitlementInfo.identifier } - return subscribedProduct?.title ?: transaction.productIdentifier -} -private fun EntitlementInfo.priceBestEffort(subscribedProduct: StoreProduct?): PriceDetails { - return subscribedProduct?.let { - PriceDetails.Paid(it.price.formatted) - } ?: if (store == Store.PROMOTIONAL) { - PriceDetails.Free - } else { - PriceDetails.Unknown - } + return subscribedProduct?.title + ?: when (transaction) { + is TransactionDetails.Subscription -> + localization.commonLocalizedString( + CustomerCenterConfigData.Localization.CommonLocalizedString.TYPE_SUBSCRIPTION, + ) + is TransactionDetails.NonSubscription -> + localization.commonLocalizedString( + CustomerCenterConfigData.Localization.CommonLocalizedString.TYPE_ONE_TIME_PURCHASE, + ) + } } private fun EntitlementInfo.expirationDate(dateFormatter: DateFormatter, locale: Locale): String? { @@ -184,6 +224,19 @@ private fun determineCancellationStatus( return entitlementCancelled || transactionCancelled } +private fun determineLifetimeStatus( + entitlementInfo: EntitlementInfo?, + transaction: TransactionDetails, +): Boolean { + val isPromotionalLifetime = entitlementInfo?.isPromotionalLifetime() == true + + val isNonSubscriptionWithEntitlement = transaction !is TransactionDetails.Subscription && + transaction.store != Store.PROMOTIONAL && + entitlementInfo != null + + return isPromotionalLifetime || isNonSubscriptionWithEntitlement +} + internal sealed class PriceDetails { object Free : PriceDetails() data class Paid(val price: String) : PriceDetails() diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/theme/CustomerCenterPreviewTheme.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/theme/CustomerCenterPreviewTheme.kt index 38bd89086a..6ecb40aa2d 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/theme/CustomerCenterPreviewTheme.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/theme/CustomerCenterPreviewTheme.kt @@ -1,7 +1,9 @@ package com.revenuecat.purchases.ui.revenuecatui.customercenter.theme import android.app.Activity +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect @@ -16,25 +18,38 @@ private val LightColorScheme = lightColorScheme( onPrimary = Color.White, surface = Color(0xFFF0F4F9), onSurface = Color(0xFF1F1F1F), - background = Color.Red, + background = Color(0xFFFFFFFF), onBackground = Color(0xFF1F1F1F), ) +@SuppressWarnings("MagicNumber") +private val DarkColorScheme = darkColorScheme( + primary = Color(0xFF81C784), + onPrimary = Color.Black, + surface = Color(0xFF2A2A2A), + onSurface = Color(0xFFE0E0E0), + background = Color(0xFF121212), + onBackground = Color(0xFFE0E0E0), +) + @Composable internal fun CustomerCenterPreviewTheme( content: @Composable () -> Unit, ) { + val isDarkTheme = isSystemInDarkTheme() + val colorScheme = if (isDarkTheme) DarkColorScheme else LightColorScheme + val view = LocalView.current if (!view.isInEditMode) { SideEffect { val window = (view.context as Activity).window - window.statusBarColor = LightColorScheme.primary.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = false + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !isDarkTheme } } MaterialTheme( - colorScheme = LightColorScheme, + colorScheme = colorScheme, content = content, ) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/viewmodel/CustomerCenterViewModel.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/viewmodel/CustomerCenterViewModel.kt index 9882f3e6fd..f35cad944c 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/viewmodel/CustomerCenterViewModel.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/viewmodel/CustomerCenterViewModel.kt @@ -16,25 +16,34 @@ import androidx.lifecycle.viewModelScope import com.revenuecat.purchases.CacheFetchPolicy import com.revenuecat.purchases.CustomerInfo import com.revenuecat.purchases.EntitlementInfo +import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.Offering import com.revenuecat.purchases.PeriodType import com.revenuecat.purchases.PurchaseParams +import com.revenuecat.purchases.Purchases import com.revenuecat.purchases.PurchasesError import com.revenuecat.purchases.PurchasesErrorCode import com.revenuecat.purchases.PurchasesException import com.revenuecat.purchases.Store import com.revenuecat.purchases.SubscriptionInfo import com.revenuecat.purchases.common.SharedConstants +import com.revenuecat.purchases.customercenter.CustomActionData import com.revenuecat.purchases.customercenter.CustomerCenterConfigData import com.revenuecat.purchases.customercenter.CustomerCenterConfigData.HelpPath import com.revenuecat.purchases.customercenter.CustomerCenterListener import com.revenuecat.purchases.customercenter.CustomerCenterManagementOption import com.revenuecat.purchases.customercenter.events.CustomerCenterImpressionEvent import com.revenuecat.purchases.customercenter.events.CustomerCenterSurveyOptionChosenEvent +import com.revenuecat.purchases.getOfferingsWith import com.revenuecat.purchases.models.GoogleSubscriptionOption +import com.revenuecat.purchases.models.Price import com.revenuecat.purchases.models.StoreProduct import com.revenuecat.purchases.models.SubscriptionOption import com.revenuecat.purchases.models.Transaction import com.revenuecat.purchases.models.googleProduct +import com.revenuecat.purchases.ui.revenuecatui.OfferingSelection +import com.revenuecat.purchases.ui.revenuecatui.activity.PaywallActivity +import com.revenuecat.purchases.ui.revenuecatui.activity.PaywallActivityArgs import com.revenuecat.purchases.ui.revenuecatui.customercenter.data.CustomerCenterState import com.revenuecat.purchases.ui.revenuecatui.customercenter.data.FeedbackSurveyData import com.revenuecat.purchases.ui.revenuecatui.customercenter.data.PathUtils @@ -43,8 +52,10 @@ import com.revenuecat.purchases.ui.revenuecatui.customercenter.data.PurchaseInfo import com.revenuecat.purchases.ui.revenuecatui.customercenter.dialogs.RestorePurchasesState import com.revenuecat.purchases.ui.revenuecatui.customercenter.extensions.getLocalizedDescription import com.revenuecat.purchases.ui.revenuecatui.customercenter.navigation.CustomerCenterDestination +import com.revenuecat.purchases.ui.revenuecatui.customercenter.resolveOfferingSuspend import com.revenuecat.purchases.ui.revenuecatui.data.PurchasesType import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger +import com.revenuecat.purchases.ui.revenuecatui.helpers.createLocaleFromString import com.revenuecat.purchases.ui.revenuecatui.utils.DateFormatter import com.revenuecat.purchases.ui.revenuecatui.utils.DefaultDateFormatter import com.revenuecat.purchases.ui.revenuecatui.utils.URLOpener @@ -67,7 +78,7 @@ internal interface CustomerCenterViewModel { fun pathButtonPressed( context: Context, - path: CustomerCenterConfigData.HelpPath, + path: HelpPath, product: PurchaseInformation?, ) @@ -79,34 +90,42 @@ internal interface CustomerCenterViewModel { suspend fun loadAndDisplayPromotionalOffer( context: Context, product: StoreProduct, - promotionalOffer: CustomerCenterConfigData.HelpPath.PathDetail.PromotionalOffer, - originalPath: CustomerCenterConfigData.HelpPath, + promotionalOffer: HelpPath.PathDetail.PromotionalOffer, + originalPath: HelpPath, purchaseInformation: PurchaseInformation? = null, ): Boolean suspend fun onAcceptedPromotionalOffer(subscriptionOption: SubscriptionOption, activity: Activity?) - fun dismissPromotionalOffer(context: Context, originalPath: CustomerCenterConfigData.HelpPath) + fun dismissPromotionalOffer(context: Context, originalPath: HelpPath) fun onNavigationButtonPressed(context: Context, onDismiss: () -> Unit) + + @InternalRevenueCatAPI suspend fun loadCustomerCenter() fun openURL( context: Context, url: String, - method: CustomerCenterConfigData.HelpPath.OpenMethod = CustomerCenterConfigData.HelpPath.OpenMethod.EXTERNAL, + method: HelpPath.OpenMethod = HelpPath.OpenMethod.EXTERNAL, ) fun clearActionError() + fun onCustomActionSelected(customActionData: CustomActionData) + // trigger state refresh fun refreshStateIfLocaleChanged() - fun refreshStateIfColorsChanged(colorScheme: ColorScheme, isDark: Boolean) + fun refreshColors(currentColorScheme: ColorScheme, isSystemInDarkTheme: Boolean) // tracks customer center impression the first time is shown fun trackImpressionIfNeeded() + + fun showPaywall(context: Context) } internal sealed class TransactionDetails( open val productIdentifier: String, open val store: Store, + open val price: Price?, + open val isSandbox: Boolean, ) { data class Subscription( override val productIdentifier: String, @@ -117,12 +136,16 @@ internal sealed class TransactionDetails( val expiresDate: Date?, val isTrial: Boolean, val managementURL: Uri?, - ) : TransactionDetails(productIdentifier, store) + override val price: Price?, + override val isSandbox: Boolean, + ) : TransactionDetails(productIdentifier, store, price, isSandbox) data class NonSubscription( override val productIdentifier: String, override val store: Store, - ) : TransactionDetails(productIdentifier, store) + override val price: Price?, + override val isSandbox: Boolean, + ) : TransactionDetails(productIdentifier, store, price, isSandbox) } @Suppress("TooManyFunctions", "LargeClass") @@ -158,10 +181,10 @@ internal class CustomerCenterViewModelImpl( override fun pathButtonPressed( context: Context, - path: CustomerCenterConfigData.HelpPath, + path: HelpPath, purchaseInformation: PurchaseInformation?, ) { - notifyListenersForManagementOptionSelected(path) + notifyListenersForManagementOptionSelected(path, purchaseInformation) path.feedbackSurvey?.let { feedbackSurvey -> displayFeedbackSurvey(feedbackSurvey, onAnswerSubmitted = { option -> option?.let { @@ -213,7 +236,11 @@ internal class CustomerCenterViewModelImpl( if (currentState is CustomerCenterState.Success) { val screen = currentState.customerCenterConfigData.getManagementScreen() if (screen != null) { - val baseSupportedPaths = supportedPaths(purchase, screen) + val baseSupportedPaths = supportedPaths( + purchase, + screen, + currentState.customerCenterConfigData.localization, + ) // For detail screen: only show subscription-specific actions val detailSupportedPaths = PathUtils.filterSubscriptionSpecificPaths(baseSupportedPaths) @@ -240,6 +267,10 @@ internal class CustomerCenterViewModelImpl( } } + override fun onCustomActionSelected(customActionData: CustomActionData) { + notifyListenersForCustomActionSelected(customActionData) + } + private fun handleCancelPath(context: Context, purchaseInformation: PurchaseInformation? = null) { val currentState = _state.value as? CustomerCenterState.Success ?: return val purchaseInfo = purchaseInformation ?: when (val destination = currentState.currentDestination) { @@ -272,17 +303,17 @@ internal class CustomerCenterViewModelImpl( openURL( context, managementURL.toString(), - CustomerCenterConfigData.HelpPath.OpenMethod.EXTERNAL, + HelpPath.OpenMethod.EXTERNAL, ) } private fun mainPathAction( - path: CustomerCenterConfigData.HelpPath, + path: HelpPath, context: Context, purchaseInformation: PurchaseInformation? = null, ) { when (path.type) { - CustomerCenterConfigData.HelpPath.PathType.MISSING_PURCHASE -> { + HelpPath.PathType.MISSING_PURCHASE -> { _state.update { currentState -> when (currentState) { is CustomerCenterState.Success -> { @@ -293,15 +324,25 @@ internal class CustomerCenterViewModelImpl( } } - CustomerCenterConfigData.HelpPath.PathType.CANCEL -> handleCancelPath(context, purchaseInformation) + HelpPath.PathType.CANCEL -> handleCancelPath(context, purchaseInformation) - CustomerCenterConfigData.HelpPath.PathType.CUSTOM_URL -> { + HelpPath.PathType.CUSTOM_URL -> { path.url?.let { openURL( context, it, - path.openMethod ?: CustomerCenterConfigData.HelpPath.OpenMethod.EXTERNAL, + path.openMethod ?: HelpPath.OpenMethod.EXTERNAL, + ) + } + } + + HelpPath.PathType.CUSTOM_ACTION -> { + path.actionIdentifier?.let { actionIdentifier -> + val customActionData = CustomActionData( + actionIdentifier = actionIdentifier, + purchaseIdentifier = purchaseInformation?.product?.id, ) + onCustomActionSelected(customActionData) } } @@ -368,29 +409,54 @@ internal class CustomerCenterViewModelImpl( private fun supportedPaths( selectedPurchaseInformation: PurchaseInformation?, screen: CustomerCenterConfigData.Screen, + localization: CustomerCenterConfigData.Localization, ): List { return screen.paths .filter { isPathAllowedForStore(it, selectedPurchaseInformation) } - .filter { isPathAllowedForLifetimeSubscription(it, selectedPurchaseInformation) } + .filter { isPathAllowedForSubscriptionState(it, selectedPurchaseInformation) } + .transformPathsOnSubscriptionState(selectedPurchaseInformation, localization) + } + + private fun List.transformPathsOnSubscriptionState( + selectedPurchaseInformation: PurchaseInformation?, + localization: CustomerCenterConfigData.Localization, + ): List { + return map { path -> + // For cancelled subscriptions, show "Resubscribe" instead of "Cancel" + if (path.type == HelpPath.PathType.CANCEL && + selectedPurchaseInformation?.isCancelled == true + ) { + path.copy( + title = localization.commonLocalizedString( + CustomerCenterConfigData.Localization.CommonLocalizedString.RESUBSCRIBE, + ), + feedbackSurvey = null, + promotionalOffer = null, + ) + } else { + path + } + } } - private fun isPathAllowedForLifetimeSubscription( - path: CustomerCenterConfigData.HelpPath, + private fun isPathAllowedForSubscriptionState( + path: HelpPath, purchaseInformation: PurchaseInformation?, ): Boolean { - if (path.type == CustomerCenterConfigData.HelpPath.PathType.CANCEL) { - return purchaseInformation?.isSubscription == true + if (path.type == HelpPath.PathType.CANCEL) { + return purchaseInformation?.isSubscription == true && !purchaseInformation.isExpired } return true } private fun isPathAllowedForStore( - path: CustomerCenterConfigData.HelpPath, + path: HelpPath, purchaseInformation: PurchaseInformation?, ): Boolean { return when (path.type) { HelpPath.PathType.MISSING_PURCHASE, HelpPath.PathType.CUSTOM_URL, + HelpPath.PathType.CUSTOM_ACTION, -> true HelpPath.PathType.CANCEL -> purchaseInformation?.store == Store.PLAY_STORE || purchaseInformation?.managementURL != null @@ -401,15 +467,20 @@ internal class CustomerCenterViewModelImpl( } } - private fun computeMainScreenPaths(state: CustomerCenterState.Success): List { - val managementScreen = state.customerCenterConfigData.getManagementScreen() - val baseSupportedPaths = managementScreen?.let { screen -> + private fun computeMainScreenPaths(state: CustomerCenterState.Success): List { + val screenToUse = if (state.purchases.isNotEmpty() && state.purchases.any { !it.isExpired }) { + state.customerCenterConfigData.getManagementScreen() + } else { + state.customerCenterConfigData.getNoActiveScreen() + } + + val baseSupportedPaths = screenToUse?.let { screen -> val selectedPurchase = if (state.purchases.size == 1) { state.purchases.first() } else { null } - supportedPaths(selectedPurchase, screen) + supportedPaths(selectedPurchase, screen, state.customerCenterConfigData.localization) } ?: emptyList() // For main screen: if multiple purchases, show only general paths @@ -424,6 +495,7 @@ internal class CustomerCenterViewModelImpl( private suspend fun loadPurchases( dateFormatter: DateFormatter, locale: Locale, + localization: CustomerCenterConfigData.Localization, ): List { val customerInfo = purchases.awaitCustomerInfo(fetchPolicy = CacheFetchPolicy.FETCH_CURRENT) @@ -443,6 +515,7 @@ internal class CustomerCenterViewModelImpl( entitlement, dateFormatter, locale, + localization, ) } } else { @@ -450,7 +523,24 @@ internal class CustomerCenterViewModelImpl( } } - return emptyList() + // If no active purchases found, try to find the latest expired subscription + val latestExpiredTransaction = findLatestExpiredSubscription(customerInfo) + return if (latestExpiredTransaction != null) { + val entitlement = customerInfo.entitlements.all.values + .firstOrNull { it.productIdentifier == latestExpiredTransaction.productIdentifier } + + listOf( + createPurchaseInformation( + latestExpiredTransaction, + entitlement, + dateFormatter, + locale, + localization, + ), + ) + } else { + emptyList() + } } private fun findActiveTransactions(customerInfo: CustomerInfo): List { @@ -468,20 +558,13 @@ internal class CustomerCenterViewModelImpl( return prioritized.mapNotNull { when (it) { - is SubscriptionInfo -> TransactionDetails.Subscription( - productIdentifier = it.productIdentifier, - productPlanIdentifier = it.productPlanIdentifier, - store = it.store, - isActive = it.isActive, - willRenew = it.willRenew, - expiresDate = it.expiresDate, - isTrial = it.periodType == PeriodType.TRIAL, - managementURL = it.managementURL, - ) + is SubscriptionInfo -> it.asTransactionDetails() is Transaction -> TransactionDetails.NonSubscription( productIdentifier = it.productIdentifier, store = it.store, + price = it.price, + isSandbox = it.isSandbox, ) else -> null @@ -489,11 +572,18 @@ internal class CustomerCenterViewModelImpl( } } + private fun findLatestExpiredSubscription(customerInfo: CustomerInfo): TransactionDetails.Subscription? { + return customerInfo.subscriptionsByProductIdentifier.values + .filter { !it.isActive && it.expiresDate != null } + .maxByOrNull { it.expiresDate!! }?.asTransactionDetails() + } + private suspend fun createPurchaseInformation( transaction: TransactionDetails, entitlement: EntitlementInfo?, dateFormatter: DateFormatter, locale: Locale, + localization: CustomerCenterConfigData.Localization, ): PurchaseInformation { val product = if (transaction.store == Store.PLAY_STORE) { purchases.awaitGetProduct( @@ -517,6 +607,7 @@ internal class CustomerCenterViewModelImpl( transaction = transaction, dateFormatter = dateFormatter, locale = locale, + localization = localization, ) } @@ -530,10 +621,10 @@ internal class CustomerCenterViewModelImpl( } @SuppressWarnings("ForbiddenComment") - override fun openURL(context: Context, url: String, method: CustomerCenterConfigData.HelpPath.OpenMethod) { + override fun openURL(context: Context, url: String, method: HelpPath.OpenMethod) { val openingMethod = when (method) { - CustomerCenterConfigData.HelpPath.OpenMethod.IN_APP -> URLOpeningMethod.IN_APP_BROWSER - CustomerCenterConfigData.HelpPath.OpenMethod.EXTERNAL, + HelpPath.OpenMethod.IN_APP -> URLOpeningMethod.IN_APP_BROWSER + HelpPath.OpenMethod.EXTERNAL, -> URLOpeningMethod.EXTERNAL_BROWSER } URLOpener.openURL(context, url, openingMethod) @@ -547,8 +638,8 @@ internal class CustomerCenterViewModelImpl( override suspend fun loadAndDisplayPromotionalOffer( context: Context, product: StoreProduct, - promotionalOffer: CustomerCenterConfigData.HelpPath.PathDetail.PromotionalOffer, - originalPath: CustomerCenterConfigData.HelpPath, + promotionalOffer: HelpPath.PathDetail.PromotionalOffer, + originalPath: HelpPath, purchaseInformation: PurchaseInformation?, ): Boolean { if (!promotionalOffer.eligible) { @@ -619,7 +710,7 @@ internal class CustomerCenterViewModelImpl( override fun dismissPromotionalOffer( context: Context, - originalPath: CustomerCenterConfigData.HelpPath, + originalPath: HelpPath, ) { val purchaseInfo = (_state.value as? CustomerCenterState.Success).let { currentState -> when (val destination = currentState?.currentDestination) { @@ -689,6 +780,7 @@ internal class CustomerCenterViewModelImpl( } } + @InternalRevenueCatAPI override suspend fun loadCustomerCenter() { _state.update { state -> if (state !is CustomerCenterState.Loading) { @@ -699,12 +791,23 @@ internal class CustomerCenterViewModelImpl( } try { val customerCenterConfigData = purchases.awaitCustomerCenterConfigData() - val purchases = loadPurchases(dateFormatter, locale) + val purchaseInformationList = loadPurchases(dateFormatter, locale, customerCenterConfigData.localization) + + // Resolve NO_ACTIVE screen offering if it exists + val noActiveScreenOffering = customerCenterConfigData.getNoActiveScreen()?.let { noActiveScreen -> + try { + noActiveScreen.resolveOfferingSuspend(purchases) + } catch (e: PurchasesException) { + Logger.d("Failed to resolve NO_ACTIVE screen offering: $e") + null + } + } val successState = CustomerCenterState.Success( customerCenterConfigData, - purchases, + purchaseInformationList, mainScreenPaths = emptyList(), // Will be computed below detailScreenPaths = emptyList(), // Will be computed when a purchase is selected + noActiveScreenOffering = noActiveScreenOffering, ) val mainScreenPaths = computeMainScreenPaths(successState) @@ -725,14 +828,12 @@ internal class CustomerCenterViewModelImpl( } } - override fun refreshStateIfColorsChanged(colorScheme: ColorScheme, isDark: Boolean) { - if (isDarkMode != isDark) { - isDarkMode = isDark - } - - if (_colorScheme.value != colorScheme) { - _colorScheme.value = colorScheme - } + override fun refreshColors( + currentColorScheme: ColorScheme, + isSystemInDarkTheme: Boolean, + ) { + isDarkMode = isSystemInDarkTheme + _colorScheme.value = currentColorScheme } override fun trackImpressionIfNeeded() { @@ -752,7 +853,7 @@ internal class CustomerCenterViewModelImpl( } private fun trackCustomerCenterEventOptionChosen( - path: CustomerCenterConfigData.HelpPath.PathType, + path: HelpPath.PathType, url: String?, surveyOptionID: String, ) { @@ -771,12 +872,23 @@ internal class CustomerCenterViewModelImpl( } private fun getCurrentLocaleList(): LocaleListCompat { - return LocaleListCompat.getDefault() + val preferredLocale = purchases.preferredUILocaleOverride + if (preferredLocale == null) { + return LocaleListCompat.getDefault() + } + + return try { + val locale = createLocaleFromString(preferredLocale) + LocaleListCompat.create(locale) + } catch (@Suppress("SwallowedException") e: IllegalArgumentException) { + Logger.w("Invalid preferred locale format: $preferredLocale. Using system default.") + LocaleListCompat.getDefault() + } } private fun displayFeedbackSurvey( - feedbackSurvey: CustomerCenterConfigData.HelpPath.PathDetail.FeedbackSurvey, - onAnswerSubmitted: (CustomerCenterConfigData.HelpPath.PathDetail.FeedbackSurvey.Option?) -> Unit, + feedbackSurvey: HelpPath.PathDetail.FeedbackSurvey, + onAnswerSubmitted: (HelpPath.PathDetail.FeedbackSurvey.Option?) -> Unit, ) { _state.update { currentState -> if (currentState is CustomerCenterState.Success) { @@ -796,7 +908,7 @@ internal class CustomerCenterViewModelImpl( @SuppressWarnings("ReturnCount") private suspend fun getPromotionalSubscriptionOption( - promotionalOffer: CustomerCenterConfigData.HelpPath.PathDetail.PromotionalOffer, + promotionalOffer: HelpPath.PathDetail.PromotionalOffer, product: StoreProduct, ): SubscriptionOption? { val googleProduct = product.googleProduct @@ -873,8 +985,8 @@ internal class CustomerCenterViewModelImpl( private suspend fun handlePromotionalOffer( context: Context, product: StoreProduct?, - promotionalOffer: CustomerCenterConfigData.HelpPath.PathDetail.PromotionalOffer?, - path: CustomerCenterConfigData.HelpPath, + promotionalOffer: HelpPath.PathDetail.PromotionalOffer?, + path: HelpPath, purchaseInformation: PurchaseInformation?, ): Boolean { if (product != null && promotionalOffer != null) { @@ -941,19 +1053,27 @@ internal class CustomerCenterViewModelImpl( purchases.customerCenterListener?.onFeedbackSurveyCompleted(feedbackSurveyOptionId) } - private fun notifyListenersForManagementOptionSelected(path: CustomerCenterConfigData.HelpPath) { + private fun notifyListenersForManagementOptionSelected(path: HelpPath, purchaseInformation: PurchaseInformation?) { val action = when (path.type) { - CustomerCenterConfigData.HelpPath.PathType.MISSING_PURCHASE -> + HelpPath.PathType.MISSING_PURCHASE -> CustomerCenterManagementOption.MissingPurchase - CustomerCenterConfigData.HelpPath.PathType.CANCEL -> + HelpPath.PathType.CANCEL -> CustomerCenterManagementOption.Cancel - CustomerCenterConfigData.HelpPath.PathType.CUSTOM_URL -> + HelpPath.PathType.CUSTOM_URL -> path.url?.let { CustomerCenterManagementOption.CustomUrl(it.toUri()) } + HelpPath.PathType.CUSTOM_ACTION -> + path.actionIdentifier?.let { actionIdentifier -> + CustomerCenterManagementOption.CustomAction( + actionIdentifier = actionIdentifier, + purchaseIdentifier = purchaseInformation?.product?.id, + ) + } + else -> null } if (action != null) { @@ -961,4 +1081,93 @@ internal class CustomerCenterViewModelImpl( purchases.customerCenterListener?.onManagementOptionSelected(action) } } + + private fun notifyListenersForCustomActionSelected( + customActionData: CustomActionData, + ) { + listener?.onCustomActionSelected(customActionData.actionIdentifier, customActionData.purchaseIdentifier) + purchases.customerCenterListener?.onCustomActionSelected( + customActionData.actionIdentifier, + customActionData.purchaseIdentifier, + ) + } + + override fun showPaywall(context: Context) { + val currentState = _state.value + if (currentState !is CustomerCenterState.Success) return + + val offering = currentState.noActiveScreenOffering + if (offering != null) { + launchPaywallActivity(context, offering) + } else { + // Fallback to current offering if no screen-specific offering is configured + tryFallbackToCurrentOffering(context) + } + } + + private fun tryFallbackToCurrentOffering(context: Context) { + Purchases.sharedInstance.getOfferingsWith( + onError = { error -> + handlePaywallError("Failed to get current offering: ${error.message}", error.code) + }, + onSuccess = { offerings -> + val currentOffering = offerings.current + if (currentOffering != null) { + Logger.d("Falling back to current offering: ${currentOffering.identifier}") + launchPaywallActivity(context, currentOffering) + } else { + handlePaywallError( + "No offering available for paywall presentation", + PurchasesErrorCode.ConfigurationError, + ) + } + }, + ) + } + + private fun launchPaywallActivity(context: Context, offering: Offering) { + try { + Logger.d("Showing paywall for offering: ${offering.identifier}") + + val paywallArgs = PaywallActivityArgs( + offeringIdAndPresentedOfferingContext = OfferingSelection.IdAndPresentedOfferingContext( + offeringId = offering.identifier, + presentedOfferingContext = offering.availablePackages.firstOrNull()?.presentedOfferingContext, + ), + shouldDisplayDismissButton = true, + ) + + val intent = Intent(context, PaywallActivity::class.java).apply { + putExtra(PaywallActivity.ARGS_EXTRA, paywallArgs) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + context.startActivity(intent) + Logger.d("Successfully launched paywall for offering: ${offering.identifier}") + } catch (e: ActivityNotFoundException) { + handlePaywallError("PaywallActivity not found: ${e.message}", PurchasesErrorCode.ConfigurationError) + } catch (e: SecurityException) { + handlePaywallError("Security error launching paywall: ${e.message}", PurchasesErrorCode.UnknownError) + } catch (e: IllegalArgumentException) { + handlePaywallError("Invalid argument for paywall: ${e.message}", PurchasesErrorCode.UnknownError) + } + } + + private fun handlePaywallError(message: String, errorCode: PurchasesErrorCode) { + Logger.e(message) + _actionError.value = PurchasesError(errorCode, message) + } + + private fun SubscriptionInfo.asTransactionDetails() = TransactionDetails.Subscription( + productIdentifier = productIdentifier, + productPlanIdentifier = productPlanIdentifier, + store = store, + isActive = isActive, + willRenew = willRenew, + expiresDate = expiresDate, + isTrial = periodType == PeriodType.TRIAL, + managementURL = managementURL, + price = price, + isSandbox = isSandbox, + ) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/NoActiveUserManagementView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/NoActiveUserManagementView.kt index 1d57a08fa0..916b4f331d 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/NoActiveUserManagementView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/NoActiveUserManagementView.kt @@ -1,47 +1,77 @@ package com.revenuecat.purchases.ui.revenuecatui.customercenter.views +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Warning +import androidx.compose.material.icons.rounded.Info import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import com.revenuecat.purchases.Offering import com.revenuecat.purchases.customercenter.CustomerCenterConfigData -import com.revenuecat.purchases.customercenter.CustomerCenterConfigData.HelpPath +import com.revenuecat.purchases.ui.revenuecatui.customercenter.CustomerCenterConstants import com.revenuecat.purchases.ui.revenuecatui.customercenter.CustomerCenterUIConstants.ContentUnavailableIconSize -import com.revenuecat.purchases.ui.revenuecatui.customercenter.CustomerCenterUIConstants.ContentUnavailableViewPadding -import com.revenuecat.purchases.ui.revenuecatui.customercenter.CustomerCenterUIConstants.ContentUnavailableViewPaddingTopDescription -import com.revenuecat.purchases.ui.revenuecatui.customercenter.CustomerCenterUIConstants.ContentUnavailableViewPaddingTopTitle +import com.revenuecat.purchases.ui.revenuecatui.customercenter.CustomerCenterUIConstants.ContentUnavailableViewPaddingHorizontal +import com.revenuecat.purchases.ui.revenuecatui.customercenter.CustomerCenterUIConstants.ContentUnavailableViewPaddingText +import com.revenuecat.purchases.ui.revenuecatui.customercenter.CustomerCenterUIConstants.ContentUnavailableViewPaddingVertical import com.revenuecat.purchases.ui.revenuecatui.customercenter.CustomerCenterUIConstants.ManagementViewHorizontalPadding import com.revenuecat.purchases.ui.revenuecatui.customercenter.actions.CustomerCenterAction +import com.revenuecat.purchases.ui.revenuecatui.customercenter.composables.SettingsButton +import com.revenuecat.purchases.ui.revenuecatui.customercenter.composables.SettingsButtonStyle +import com.revenuecat.purchases.ui.revenuecatui.customercenter.data.CustomerCenterConfigTestData +import com.revenuecat.purchases.ui.revenuecatui.customercenter.resolveButtonText +import com.revenuecat.purchases.ui.revenuecatui.customercenter.theme.CustomerCenterPreviewTheme @Suppress("LongParameterList") @Composable internal fun NoActiveUserManagementView( - screenTitle: String, - screenSubtitle: String?, + screen: CustomerCenterConfigData.Screen, contactEmail: String?, localization: CustomerCenterConfigData.Localization, - supportedPaths: List, + offering: Offering?, onAction: (CustomerCenterAction) -> Unit, ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { ContentUnavailableView( - title = screenTitle, - description = screenSubtitle, - modifier = Modifier.padding(ManagementViewHorizontalPadding), + title = screen.title, + description = screen.subtitle, + modifier = Modifier.padding( + top = ManagementViewHorizontalPadding, + start = ManagementViewHorizontalPadding, + end = ManagementViewHorizontalPadding, + ), ) + // Subscribe button if offering is available + offering?.let { + SettingsButton( + onClick = { onAction(CustomerCenterAction.ShowPaywall) }, + title = screen.resolveButtonText(localization), + style = SettingsButtonStyle.FILLED, + modifier = Modifier.padding( + top = ManagementViewHorizontalPadding, + start = ManagementViewHorizontalPadding, + end = ManagementViewHorizontalPadding, + ), + ) + } + ManageSubscriptionsButtonsView( associatedPurchaseInformation = null, - supportedPaths = supportedPaths, + supportedPaths = screen.paths, localization = localization, contactEmail = contactEmail, addContactButton = true, @@ -56,30 +86,63 @@ private fun ContentUnavailableView( description: String?, modifier: Modifier = Modifier, ) { - Column( - modifier = modifier - .fillMaxWidth() - .padding(ContentUnavailableViewPadding), - horizontalAlignment = Alignment.CenterHorizontally, + Surface( + modifier = modifier, + shape = RoundedCornerShape(CustomerCenterConstants.Card.ROUNDED_CORNER_SIZE), + color = MaterialTheme.colorScheme.surface, ) { - Icon( - imageVector = Icons.Rounded.Warning, - contentDescription = null, - modifier = Modifier.size(ContentUnavailableIconSize), - ) - - Text( - text = title, - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(top = ContentUnavailableViewPaddingTopTitle), - ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + vertical = ContentUnavailableViewPaddingVertical, + horizontal = ContentUnavailableViewPaddingHorizontal, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Rounded.Info, + contentDescription = null, + modifier = Modifier.size(ContentUnavailableIconSize), + ) - description?.let { Text( - text = it, - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - modifier = Modifier.padding(top = ContentUnavailableViewPaddingTopDescription), + text = title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = ContentUnavailableViewPaddingText), + ) + + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = ContentUnavailableViewPaddingText), + ) + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO or Configuration.UI_MODE_TYPE_NORMAL) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +private fun NoActiveUserManagementView_Preview() { + val testData = CustomerCenterConfigTestData.customerCenterData() + val noActiveScreen = + testData.screens[CustomerCenterConfigData.Screen.ScreenType.NO_ACTIVE]!! + CustomerCenterPreviewTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + NoActiveUserManagementView( + screen = noActiveScreen, + contactEmail = "support@example.com", + localization = testData.localization, + offering = null, // No offering in preview + onAction = { }, ) } } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/PurchaseInformationCardView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/PurchaseInformationCardView.kt index 24a42a9737..2aeefa4da7 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/PurchaseInformationCardView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/PurchaseInformationCardView.kt @@ -79,11 +79,23 @@ internal fun PurchaseInformationCardView( modifier = Modifier.weight(1f), ) when { - !purchaseInformation.isSubscription && !isDetailedView -> Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - ) + !purchaseInformation.isSubscription && !isDetailedView -> { + Row( + horizontalArrangement = Arrangement.spacedBy( + CustomerCenterConstants.Card.BADGE_HORIZONTAL_PADDING, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + if (purchaseInformation.isLifetime) { + PurchaseStatusBadge(purchaseInformation, localization) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } else -> PurchaseStatusBadge(purchaseInformation, localization) } } @@ -133,7 +145,8 @@ private fun getStoreText(store: Store, localization: CustomerCenterConfigData.Lo Store.PADDLE, Store.RC_BILLING, -> CustomerCenterConfigData.Localization.CommonLocalizedString.WEB_STORE - Store.UNKNOWN_STORE -> CustomerCenterConfigData.Localization.CommonLocalizedString.UNKNOWN_STORE + Store.UNKNOWN_STORE, + -> CustomerCenterConfigData.Localization.CommonLocalizedString.UNKNOWN_STORE } return localization.commonLocalizedString(key) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/PurchaseStatusBadge.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/PurchaseStatusBadge.kt index 671818dfcb..c117ef9b26 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/PurchaseStatusBadge.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/PurchaseStatusBadge.kt @@ -1,5 +1,6 @@ package com.revenuecat.purchases.ui.revenuecatui.customercenter.views +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme @@ -8,6 +9,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import com.revenuecat.purchases.Store import com.revenuecat.purchases.customercenter.CustomerCenterConfigData import com.revenuecat.purchases.ui.revenuecatui.customercenter.CustomerCenterConstants @@ -19,56 +21,21 @@ internal fun PurchaseStatusBadge( localization: CustomerCenterConfigData.Localization, ) { val status = determinePurchaseStatus(purchaseInformation) - var text: String? = null - var color: Color? = null - when (status) { - PurchaseStatus.EXPIRED -> { - text = localization.commonLocalizedString( - CustomerCenterConfigData.Localization.CommonLocalizedString.EXPIRED, - ) - color = Color(CustomerCenterConstants.Card.COLOR_BADGE_EXPIRED) - } - PurchaseStatus.FREE_TRIAL_CANCELLED -> { - text = localization.commonLocalizedString( - CustomerCenterConfigData.Localization.CommonLocalizedString.BADGE_FREE_TRIAL_CANCELLED, - ) - color = Color(CustomerCenterConstants.Card.COLOR_BADGE_CANCELLED) - } - PurchaseStatus.CANCELLED -> { - text = localization.commonLocalizedString( - CustomerCenterConfigData.Localization.CommonLocalizedString.BADGE_CANCELLED, - ) - color = Color(CustomerCenterConstants.Card.COLOR_BADGE_CANCELLED) - } - PurchaseStatus.FREE_TRIAL -> { - text = localization.commonLocalizedString( - CustomerCenterConfigData.Localization.CommonLocalizedString.BADGE_FREE_TRIAL, - ) - color = Color(CustomerCenterConstants.Card.COLOR_BADGE_FREE_TRIAL) - } - PurchaseStatus.ACTIVE -> { - text = localization.commonLocalizedString( - CustomerCenterConfigData.Localization.CommonLocalizedString.ACTIVE, - ) - color = Color(CustomerCenterConstants.Card.COLOR_BADGE_ACTIVE) - } - PurchaseStatus.NONE -> { - text = null - color = null - } - } + val badgeInfo = getBadgeInfo(status, localization) - if (text == null || color == null) { + if (badgeInfo.text == null || badgeInfo.color == null) { return } Surface( shape = RoundedCornerShape(CustomerCenterConstants.Card.BADGE_CORNER_SIZE), - color = color, + color = badgeInfo.color, + border = badgeInfo.border, ) { Text( - text = text, + text = badgeInfo.text, style = MaterialTheme.typography.labelLarge, + color = badgeInfo.textColor ?: MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding( horizontal = CustomerCenterConstants.Card.BADGE_HORIZONTAL_PADDING, vertical = CustomerCenterConstants.Card.BADGE_VERTICAL_PADDING, @@ -77,12 +44,71 @@ internal fun PurchaseStatusBadge( } } +private data class BadgeInfo( + val text: String?, + val color: Color?, + val border: BorderStroke? = null, + val textColor: Color? = null, +) + +private fun getBadgeInfo( + status: PurchaseStatus, + localization: CustomerCenterConfigData.Localization, +): BadgeInfo { + return when (status) { + PurchaseStatus.EXPIRED -> BadgeInfo( + text = localization.commonLocalizedString( + CustomerCenterConfigData.Localization.CommonLocalizedString.EXPIRED, + ), + color = Color(CustomerCenterConstants.Card.COLOR_BADGE_EXPIRED), + ) + PurchaseStatus.FREE_TRIAL_CANCELLED -> BadgeInfo( + text = localization.commonLocalizedString( + CustomerCenterConfigData.Localization.CommonLocalizedString.BADGE_FREE_TRIAL_CANCELLED, + ), + color = Color(CustomerCenterConstants.Card.COLOR_BADGE_CANCELLED), + ) + PurchaseStatus.CANCELLED -> BadgeInfo( + text = localization.commonLocalizedString( + CustomerCenterConfigData.Localization.CommonLocalizedString.BADGE_CANCELLED, + ), + color = Color(CustomerCenterConstants.Card.COLOR_BADGE_CANCELLED), + ) + PurchaseStatus.FREE_TRIAL -> BadgeInfo( + text = localization.commonLocalizedString( + CustomerCenterConfigData.Localization.CommonLocalizedString.BADGE_FREE_TRIAL, + ), + color = Color(CustomerCenterConstants.Card.COLOR_BADGE_FREE_TRIAL), + ) + PurchaseStatus.LIFETIME -> BadgeInfo( + text = localization.commonLocalizedString( + CustomerCenterConfigData.Localization.CommonLocalizedString.BADGE_LIFETIME, + ), + color = Color.Transparent, + border = BorderStroke( + 1.dp, + Color(CustomerCenterConstants.Card.COLOR_LIFETIME_BORDER).copy( + alpha = CustomerCenterConstants.Card.LIFETIME_BORDER_ALPHA, + ), + ), + ) + PurchaseStatus.ACTIVE -> BadgeInfo( + text = localization.commonLocalizedString( + CustomerCenterConfigData.Localization.CommonLocalizedString.ACTIVE, + ), + color = Color(CustomerCenterConstants.Card.COLOR_BADGE_ACTIVE), + ) + PurchaseStatus.NONE -> BadgeInfo(text = null, color = null) + } +} + private fun determinePurchaseStatus(purchaseInformation: PurchaseInformation): PurchaseStatus { return when { purchaseInformation.isExpired -> PurchaseStatus.EXPIRED purchaseInformation.isCancelled && purchaseInformation.isTrial -> PurchaseStatus.FREE_TRIAL_CANCELLED purchaseInformation.isCancelled && purchaseInformation.store != Store.PROMOTIONAL -> PurchaseStatus.CANCELLED purchaseInformation.isTrial -> PurchaseStatus.FREE_TRIAL + purchaseInformation.isLifetime -> PurchaseStatus.LIFETIME purchaseInformation.expirationOrRenewal != null -> PurchaseStatus.ACTIVE else -> PurchaseStatus.NONE } @@ -93,6 +119,7 @@ private enum class PurchaseStatus { FREE_TRIAL_CANCELLED, CANCELLED, FREE_TRIAL, + LIFETIME, ACTIVE, NONE, } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt new file mode 100644 index 0000000000..e72558dbbe --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt @@ -0,0 +1,48 @@ +package com.revenuecat.purchases.ui.revenuecatui.data + +import com.revenuecat.purchases.CacheFetchPolicy +import com.revenuecat.purchases.CustomerInfo +import com.revenuecat.purchases.Offerings +import com.revenuecat.purchases.PurchaseParams +import com.revenuecat.purchases.PurchaseResult +import com.revenuecat.purchases.PurchasesAreCompletedBy +import com.revenuecat.purchases.common.events.FeatureEvent +import com.revenuecat.purchases.customercenter.CustomerCenterConfigData +import com.revenuecat.purchases.customercenter.CustomerCenterListener +import com.revenuecat.purchases.models.StoreProduct + +/** + * Mock implementation of [PurchasesType] for previews and test data + * NOTE: This is only used for UI previews and test data, not for actual testing + */ +internal class MockPurchasesType( + override val preferredUILocaleOverride: String? = null, + override val purchasesAreCompletedBy: PurchasesAreCompletedBy = PurchasesAreCompletedBy.REVENUECAT, + override val storefrontCountryCode: String? = null, + override val customerCenterListener: CustomerCenterListener? = null, +) : PurchasesType { + override suspend fun awaitPurchase(purchaseParams: PurchaseParams.Builder): PurchaseResult { + throw NotImplementedError("Mock implementation for previews only") + } + override suspend fun awaitRestore(): CustomerInfo { + throw NotImplementedError("Mock implementation for previews only") + } + override suspend fun awaitOfferings(): Offerings { + throw NotImplementedError("Mock implementation for previews only") + } + override suspend fun awaitCustomerInfo(fetchPolicy: CacheFetchPolicy): CustomerInfo { + throw NotImplementedError("Mock implementation for previews only") + } + override suspend fun awaitCustomerCenterConfigData(): CustomerCenterConfigData { + throw NotImplementedError("Mock implementation for previews only") + } + override suspend fun awaitGetProduct(productId: String, basePlan: String?): StoreProduct? { + throw NotImplementedError("Mock implementation for previews only") + } + override fun track(event: FeatureEvent) { + // No-op for mock + } + override fun syncPurchases() { + // No-op for mock + } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt index f0eaca4901..ced7082a0f 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt @@ -18,6 +18,7 @@ import com.revenuecat.purchases.UiConfig.VariableConfig import com.revenuecat.purchases.paywalls.components.common.LocaleId import com.revenuecat.purchases.ui.revenuecatui.components.ktx.getBestMatch import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toComposeLocale +import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toJavaLocale import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toLocaleId import com.revenuecat.purchases.ui.revenuecatui.components.properties.BackgroundStyles import com.revenuecat.purchases.ui.revenuecatui.components.style.ComponentStyle @@ -25,12 +26,14 @@ import com.revenuecat.purchases.ui.revenuecatui.composables.SimpleSheetState import com.revenuecat.purchases.ui.revenuecatui.data.processed.ProcessedLocalizedConfiguration import com.revenuecat.purchases.ui.revenuecatui.data.processed.TemplateConfiguration import com.revenuecat.purchases.ui.revenuecatui.data.processed.VariableDataProvider -import com.revenuecat.purchases.ui.revenuecatui.data.processed.currentlySubscribed import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger import com.revenuecat.purchases.ui.revenuecatui.helpers.NonEmptySet +import com.revenuecat.purchases.ui.revenuecatui.helpers.createLocaleFromString import com.revenuecat.purchases.ui.revenuecatui.isFullScreen import java.util.Date +import java.util.Locale import android.os.LocaleList as FrameworkLocaleList + @Stable internal sealed interface PaywallState { @@ -92,13 +95,13 @@ internal sealed interface PaywallState { * All locales that this paywall supports, with `locales.head` being the default one. */ private val locales: NonEmptySet, - val activelySubscribedProductIds: Set, - val purchasedNonSubscriptionProductIds: Set, + private val storefrontCountryCode: String?, private val dateProvider: () -> Date, private val packages: AvailablePackages, initialLocaleList: LocaleList = LocaleList.current, initialSelectedTabIndex: Int? = null, initialSheetState: SimpleSheetState = SimpleSheetState(), + private val purchases: PurchasesType, ) : Loaded { data class AvailablePackages( @@ -127,7 +130,6 @@ internal sealed interface PaywallState { data class SelectedPackageInfo( val rcPackage: Package, - val currentlySubscribed: Boolean, ) private val initialSelectedPackageOutsideTabs = packages.packagesOutsideTabs @@ -147,8 +149,49 @@ internal sealed interface PaywallState { private var localeId by mutableStateOf(initialLocaleList.toLocaleId()) + // We find all available device locales with the same country as the storefront country. + private val availableStorefrontCountryLocalesByLanguage: Map by lazy { + if (storefrontCountryCode.isNullOrBlank()) { + emptyMap() + } else { + buildMap { + Locale.getAvailableLocales().forEach { availableLocale -> + if (availableLocale.country.equals(storefrontCountryCode, ignoreCase = true)) { + put(availableLocale.language.lowercase(), availableLocale) + } + } + } + } + } + + /** + * The locale to use for the paywall's localized content, such as text. + */ val locale by derivedStateOf { localeId.toComposeLocale() } + /** + * The locale to use when formatting currencies. This corresponds to the user's storefront country, to + * avoid discrepancies between calculated prices (per period) and the price coming directly from the store. + */ + val currencyLocale by derivedStateOf { + if (storefrontCountryCode.isNullOrBlank()) { + locale + } else { + val deviceLanguageCode = locale.language.lowercase() + + // We pick the one with the same language as the device if available. If not, we just pick the + // first. If the list is empty, we use the device locale with the storefront country. + val javaLocale = availableStorefrontCountryLocalesByLanguage[deviceLanguageCode] + ?: availableStorefrontCountryLocalesByLanguage.values.firstOrNull() + ?: Locale.Builder() + .setLocale(locale.toJavaLocale()) + .setRegion(storefrontCountryCode.uppercase()) + .build() + + javaLocale.toComposeLocale() + } + } + private val selectedPackageByTab = mutableStateMapOf().apply { putAll( packages.packagesByTab.mapValues { (_, packages) -> @@ -167,13 +210,7 @@ internal sealed interface PaywallState { val selectedPackageInfo by derivedStateOf { selectedPackage?.let { rcPackage -> - SelectedPackageInfo( - rcPackage = rcPackage, - currentlySubscribed = rcPackage.currentlySubscribed( - activelySubscribedProductIdentifiers = activelySubscribedProductIds, - nonSubscriptionProductIdentifiers = purchasedNonSubscriptionProductIds, - ), - ) + SelectedPackageInfo(rcPackage = rcPackage) } } @@ -220,11 +257,30 @@ internal sealed interface PaywallState { if (currentTabContainsThisPackage) selectedPackageByTab[currentTabIndex] = selectedPackage } - private fun LocaleList.toLocaleId(): LocaleId = - // Configured locales take precedence over the default one. - map { it.toLocaleId() }.plus(locales.head) - // Find the first locale we have a LocalizationDictionary for. - .firstNotNullOf { locale -> locales.getBestMatch(locale) } + private fun LocaleList.toLocaleId(): LocaleId { + val preferredOverride = purchases.preferredUILocaleOverride + val deviceLocales = map { it.toLocaleId() }.plus(locales.head) + + val allLocales = if (preferredOverride != null) { + // Parse preferred locale override and put it first in priority + val preferredLocaleId = try { + createLocaleFromString(preferredOverride).toComposeLocale().toLocaleId() + } catch (@Suppress("SwallowedException", "TooGenericExceptionCaught") e: Exception) { + // Fallback to null if preferred locale string is malformed + null + } + if (preferredLocaleId != null) { + listOf(preferredLocaleId) + deviceLocales + } else { + deviceLocales + } + } else { + deviceLocales + } + + // Find the first locale we have a LocalizationDictionary for. + return allLocales.firstNotNullOf { locale -> locales.getBestMatch(locale) } + } private fun List.mostExpensivePricePerMonthMicros(): Long? = asSequence() diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt index 11a3b33dbf..5daef28b3f 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt @@ -19,6 +19,7 @@ import com.revenuecat.purchases.PurchasesErrorCode import com.revenuecat.purchases.PurchasesException import com.revenuecat.purchases.paywalls.events.PaywallEvent import com.revenuecat.purchases.paywalls.events.PaywallEventType +import com.revenuecat.purchases.ui.revenuecatui.OfferingSelection import com.revenuecat.purchases.ui.revenuecatui.PaywallListener import com.revenuecat.purchases.ui.revenuecatui.PaywallMode import com.revenuecat.purchases.ui.revenuecatui.PaywallOptions @@ -26,11 +27,11 @@ import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogic import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogicResult import com.revenuecat.purchases.ui.revenuecatui.data.processed.TemplateConfiguration import com.revenuecat.purchases.ui.revenuecatui.data.processed.VariableDataProvider -import com.revenuecat.purchases.ui.revenuecatui.data.processed.currentlySubscribed import com.revenuecat.purchases.ui.revenuecatui.errors.PaywallValidationError import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger import com.revenuecat.purchases.ui.revenuecatui.helpers.PaywallValidationResult import com.revenuecat.purchases.ui.revenuecatui.helpers.ResourceProvider +import com.revenuecat.purchases.ui.revenuecatui.helpers.createLocaleFromString import com.revenuecat.purchases.ui.revenuecatui.helpers.fallbackPaywall import com.revenuecat.purchases.ui.revenuecatui.helpers.toComponentsPaywallState import com.revenuecat.purchases.ui.revenuecatui.helpers.toLegacyPaywallState @@ -123,9 +124,17 @@ internal class PaywallViewModelImpl( } override fun refreshStateIfLocaleChanged() { - if (_lastLocaleList.value != getCurrentLocaleList()) { - _lastLocaleList.value = getCurrentLocaleList() - updateState() + val currentLocaleList = getCurrentLocaleList() + if (_lastLocaleList.value != currentLocaleList) { + _lastLocaleList.value = currentLocaleList + + // If we have a Components paywall state, update its locale instead of recreating the entire state + val currentState = _state.value + if (currentState is PaywallState.Loaded.Components) { + currentState.update(localeList = currentLocaleList.toFrameworkLocaleList()) + } else { + updateState() + } } } @@ -265,17 +274,13 @@ internal class PaywallViewModelImpl( when (val currentState = _state.value) { is PaywallState.Loaded.Legacy -> { val selectedPackage = currentState.selectedPackage.value - performPurchaseIfNecessary(activity, selectedPackage) + performPurchase(activity, selectedPackage.rcPackage) } is PaywallState.Loaded.Components -> { // Purchase the provided package if not null, otherwise purchase the selected package. val selectedPackageInfo = pkg?.let { PaywallState.Loaded.Components.SelectedPackageInfo( rcPackage = it, - currentlySubscribed = it.currentlySubscribed( - activelySubscribedProductIdentifiers = currentState.activelySubscribedProductIds, - nonSubscriptionProductIdentifiers = currentState.purchasedNonSubscriptionProductIds, - ), ) } ?: currentState.selectedPackageInfo performPurchaseIfNecessary(activity, selectedPackageInfo) @@ -287,25 +292,12 @@ internal class PaywallViewModelImpl( finishAction() } - private suspend fun performPurchaseIfNecessary( - activity: Activity, - packageInfo: TemplateConfiguration.PackageInfo, - ) { - if (!packageInfo.currentlySubscribed) { - performPurchase(activity, packageInfo.rcPackage) - } else { - Logger.d("Ignoring purchase request for already subscribed package") - } - } - private suspend fun performPurchaseIfNecessary( activity: Activity, packageInfo: PaywallState.Loaded.Components.SelectedPackageInfo?, ) { if (packageInfo == null) { Logger.w("Ignoring purchase request as no package is selected") - } else if (packageInfo.currentlySubscribed) { - Logger.d("Ignoring purchase request for already subscribed package") } else { performPurchase(activity, packageInfo.rcPackage) } @@ -380,11 +372,20 @@ internal class PaywallViewModelImpl( private fun updateState() { viewModelScope.launch { try { - var currentOffering = options.offeringSelection.offering - if (currentOffering == null) { - val offerings = purchases.awaitOfferings() - currentOffering = options.offeringSelection.offeringIdentifier?.let { offerings[it] } - ?: offerings.current + val currentOffering: Offering? = when (val offeringSelection = options.offeringSelection) { + is OfferingSelection.OfferingType -> offeringSelection.offeringType + is OfferingSelection.IdAndPresentedOfferingContext -> { + val offerings = purchases.awaitOfferings() + val presentedOfferingContext = offeringSelection.presentedOfferingContext + val offering = offerings[offeringSelection.offeringId] ?: offerings.current + presentedOfferingContext?.let { + offering?.copy(presentedOfferingContext) + } ?: offering + } + is OfferingSelection.None -> { + val offerings = purchases.awaitOfferings() + offerings.current + } } if (currentOffering == null) { @@ -394,7 +395,6 @@ internal class PaywallViewModelImpl( } else { _state.value = calculateState( currentOffering, - purchases.awaitCustomerInfo(), _colorScheme.value, purchases.storefrontCountryCode, options.mode, @@ -409,12 +409,26 @@ internal class PaywallViewModelImpl( } private fun getCurrentLocaleList(): LocaleListCompat { - return LocaleListCompat.getDefault() + val preferredLocale = purchases.preferredUILocaleOverride ?: return LocaleListCompat.getDefault() + + return try { + val locale = createLocaleFromString(preferredLocale) + val localeList = LocaleListCompat.create(locale) + localeList + } catch (e: IllegalArgumentException) { + Logger.e("Invalid preferred locale format: $preferredLocale. Using system default.", e) + LocaleListCompat.getDefault() + } + } + + @Suppress("SpreadOperator") + private fun LocaleListCompat.toFrameworkLocaleList(): android.os.LocaleList { + val locales = Array(size()) { i -> get(i)!! } + return android.os.LocaleList(*locales) } private fun calculateState( offering: Offering, - customerInfo: CustomerInfo, colorScheme: ColorScheme, storefrontCountryCode: String?, mode: PaywallMode, @@ -439,15 +453,9 @@ internal class PaywallViewModelImpl( Logger.e(PaywallValidationErrorStrings.DISPLAYING_DEFAULT) } - val activelySubscribedProductIds = customerInfo.activeSubscriptions - val purchasedNonSubscriptionProductIds: Set = customerInfo.nonSubscriptionTransactions - .mapTo(mutableSetOf()) { it.productIdentifier } - return when (validationResult) { is PaywallValidationResult.Legacy -> offering.toLegacyPaywallState( variableDataProvider = variableDataProvider, - activelySubscribedProductIdentifiers = activelySubscribedProductIds, - nonSubscriptionProductIdentifiers = purchasedNonSubscriptionProductIds, mode = mode, validatedPaywallData = validationResult.displayablePaywall, template = validationResult.template, @@ -456,10 +464,9 @@ internal class PaywallViewModelImpl( ) is PaywallValidationResult.Components -> offering.toComponentsPaywallState( validationResult = validationResult, - activelySubscribedProductIds = activelySubscribedProductIds, - purchasedNonSubscriptionProductIds = purchasedNonSubscriptionProductIds, storefrontCountryCode = storefrontCountryCode, dateProvider = { Date() }, + purchases = purchases, ) } } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PurchasesType.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PurchasesType.kt index 04a4a25bc9..ce1a2fefa5 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PurchasesType.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PurchasesType.kt @@ -46,6 +46,8 @@ internal interface PurchasesType { val storefrontCountryCode: String? val customerCenterListener: CustomerCenterListener? + + val preferredUILocaleOverride: String? } internal class PurchasesImpl(private val purchases: Purchases = Purchases.sharedInstance) : PurchasesType { @@ -96,4 +98,7 @@ internal class PurchasesImpl(private val purchases: Purchases = Purchases.shared override val customerCenterListener: CustomerCenterListener? get() = purchases.customerCenterListener + + override val preferredUILocaleOverride: String? + get() = purchases.preferredUILocaleOverride } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationFactory.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationFactory.kt index b36d8d666a..e0c6c3cca5 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationFactory.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/PackageConfigurationFactory.kt @@ -3,7 +3,6 @@ package com.revenuecat.purchases.ui.revenuecatui.data.processed import com.revenuecat.purchases.Package -import com.revenuecat.purchases.PackageType import com.revenuecat.purchases.models.Price import com.revenuecat.purchases.paywalls.PaywallData import com.revenuecat.purchases.ui.revenuecatui.errors.PackageConfigurationError @@ -15,8 +14,6 @@ internal object PackageConfigurationFactory { fun createPackageConfiguration( variableDataProvider: VariableDataProvider, availablePackages: List, - activelySubscribedProductIdentifiers: Set, - nonSubscriptionProductIdentifiers: Set, packageIdsInConfig: List, default: String?, configurationType: PackageConfigurationType, @@ -44,8 +41,6 @@ internal object PackageConfigurationFactory { makeSinglePackageConfiguration( filteredRCPackages, variableDataProvider, - activelySubscribedProductIdentifiers, - nonSubscriptionProductIdentifiers, paywallData, storefrontCountryCode, ) @@ -54,8 +49,6 @@ internal object PackageConfigurationFactory { makeMultiplePackageConfiguration( filteredRCPackages, variableDataProvider, - activelySubscribedProductIdentifiers, - nonSubscriptionProductIdentifiers, paywallData, default, storefrontCountryCode, @@ -67,8 +60,6 @@ internal object PackageConfigurationFactory { packageIdsInConfig, availablePackages, variableDataProvider, - activelySubscribedProductIdentifiers, - nonSubscriptionProductIdentifiers, storefrontCountryCode, ) } @@ -81,8 +72,6 @@ internal object PackageConfigurationFactory { packageIdsInConfig: List, availablePackages: List, variableDataProvider: VariableDataProvider, - activelySubscribedProductIdentifiers: Set, - nonSubscriptionProductIdentifiers: Set, storefrontCountryCode: String?, ): Result> { val tiers = paywallData.config.tiers ?: return Result.failure( @@ -102,8 +91,6 @@ internal object PackageConfigurationFactory { filter = tier.packageIds, localization = localizationForTier, variableDataProvider = variableDataProvider, - activelySubscribedProductIdentifiers = activelySubscribedProductIdentifiers, - nonSubscriptionProductIdentifiers = nonSubscriptionProductIdentifiers, locale = locale, storefrontCountryCode = storefrontCountryCode, zeroDecimalPlaceCountries = paywallData.zeroDecimalPlaceCountries, @@ -163,8 +150,6 @@ internal object PackageConfigurationFactory { private fun makeMultiplePackageConfiguration( filteredRCPackages: List, variableDataProvider: VariableDataProvider, - activelySubscribedProductIdentifiers: Set, - nonSubscriptionProductIdentifiers: Set, paywallData: PaywallData, default: String?, storefrontCountryCode: String?, @@ -172,8 +157,6 @@ internal object PackageConfigurationFactory { val (locale, packageInfos) = makePackageInfo( packages = filteredRCPackages, variableDataProvider = variableDataProvider, - activelySubscribedProductIdentifiers = activelySubscribedProductIdentifiers, - nonSubscriptionProductIdentifiers = nonSubscriptionProductIdentifiers, paywallData = paywallData, storefrontCountryCode = storefrontCountryCode, ) @@ -196,16 +179,12 @@ internal object PackageConfigurationFactory { private fun makeSinglePackageConfiguration( filteredRCPackages: List, variableDataProvider: VariableDataProvider, - activelySubscribedProductIdentifiers: Set, - nonSubscriptionProductIdentifiers: Set, paywallData: PaywallData, storefrontCountryCode: String?, ): Result> { val (locale, packageInfos) = makePackageInfo( packages = filteredRCPackages, variableDataProvider = variableDataProvider, - activelySubscribedProductIdentifiers = activelySubscribedProductIdentifiers, - nonSubscriptionProductIdentifiers = nonSubscriptionProductIdentifiers, paywallData = paywallData, storefrontCountryCode = storefrontCountryCode, ) @@ -223,8 +202,6 @@ internal object PackageConfigurationFactory { private fun makePackageInfo( packages: List, variableDataProvider: VariableDataProvider, - activelySubscribedProductIdentifiers: Set, - nonSubscriptionProductIdentifiers: Set, paywallData: PaywallData, storefrontCountryCode: String?, ): Pair> { @@ -232,11 +209,6 @@ internal object PackageConfigurationFactory { val (locale, localization) = paywallData.localizedConfiguration val packageInfos = packages.map { - val currentlySubscribed = it.currentlySubscribed( - activelySubscribedProductIdentifiers, - nonSubscriptionProductIdentifiers, - ) - val discountRelativeToMostExpensivePerMonth = productDiscount( it.product.pricePerMonth(), mostExpensivePricePerMonth, @@ -259,7 +231,6 @@ internal object PackageConfigurationFactory { rcPackage = it, locale = locale, ), - currentlySubscribed = currentlySubscribed, discountRelativeToMostExpensivePerMonth = discountRelativeToMostExpensivePerMonth, ) } @@ -273,8 +244,6 @@ internal object PackageConfigurationFactory { filter: List, localization: PaywallData.LocalizedConfiguration, variableDataProvider: VariableDataProvider, - activelySubscribedProductIdentifiers: Set, - nonSubscriptionProductIdentifiers: Set, locale: Locale, storefrontCountryCode: String?, zeroDecimalPlaceCountries: List, @@ -289,11 +258,6 @@ internal object PackageConfigurationFactory { mostExpensive = mostExpensivePricePerMonth, ) - val currentlySubscribed = it.currentlySubscribed( - activelySubscribedProductIdentifiers, - nonSubscriptionProductIdentifiers, - ) - val shouldRound = if (storefrontCountryCode != null) { zeroDecimalPlaceCountries.contains( storefrontCountryCode, @@ -311,7 +275,6 @@ internal object PackageConfigurationFactory { rcPackage = it, locale = locale, ), - currentlySubscribed = currentlySubscribed, discountRelativeToMostExpensivePerMonth = discount, ) } @@ -335,24 +298,3 @@ internal object PackageConfigurationFactory { } } } - -@JvmSynthetic -internal fun Package.currentlySubscribed( - activelySubscribedProductIdentifiers: Set, - nonSubscriptionProductIdentifiers: Set, -): Boolean = when (packageType) { - PackageType.ANNUAL, - PackageType.SIX_MONTH, - PackageType.THREE_MONTH, - PackageType.TWO_MONTH, - PackageType.MONTHLY, - PackageType.WEEKLY, - -> activelySubscribedProductIdentifiers.contains(product.id) - - PackageType.LIFETIME, - -> nonSubscriptionProductIdentifiers.contains(product.id) - - PackageType.CUSTOM, - PackageType.UNKNOWN, - -> false -} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration.kt index 367da9c91c..28aef1eac5 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfiguration.kt @@ -42,7 +42,6 @@ internal data class TemplateConfiguration( data class PackageInfo( val rcPackage: Package, val localization: ProcessedLocalizedConfiguration, - val currentlySubscribed: Boolean, val discountRelativeToMostExpensivePerMonth: Double?, ) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfigurationFactory.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfigurationFactory.kt index 24fbae444d..04df8290e6 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfigurationFactory.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfigurationFactory.kt @@ -12,8 +12,6 @@ internal object TemplateConfigurationFactory { mode: PaywallMode, paywallData: PaywallData, availablePackages: List, - activelySubscribedProductIdentifiers: Set, - nonSubscriptionProductIdentifiers: Set, template: PaywallTemplate, storefrontCountryCode: String?, ): Result { @@ -37,8 +35,6 @@ internal object TemplateConfigurationFactory { PackageConfigurationFactory.createPackageConfiguration( variableDataProvider = variableDataProvider, availablePackages = availablePackages, - activelySubscribedProductIdentifiers = activelySubscribedProductIdentifiers, - nonSubscriptionProductIdentifiers = nonSubscriptionProductIdentifiers, packageIdsInConfig = paywallData.config.packageIds, default = paywallData.config.defaultPackage, configurationType = template.configurationType, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/VariableProcessor.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/VariableProcessor.kt index 6a898d1208..dc3fbdaf7a 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/VariableProcessor.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/VariableProcessor.kt @@ -45,7 +45,7 @@ internal object VariableProcessor { executeAndReplaceWith: (String) -> String?, ): String { var resultString = string - REGEX.findAll(string).toList().reversed().forEach { matchResult -> + REGEX.findAll(string).toList().asReversed().forEach { matchResult -> val variableString = matchResult.value val variableWithoutBraces = variableString.substring(2, variableString.length - 2).trim() val replacement = executeAndReplaceWith(variableWithoutBraces) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/VariableProcessorV2.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/VariableProcessorV2.kt index 10ef4a1aa2..181b798615 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/VariableProcessorV2.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/VariableProcessorV2.kt @@ -100,7 +100,8 @@ internal object VariableProcessorV2 { // "Context": packageContext: PackageContext, rcPackage: Package, - locale: Locale, + currencyLocale: Locale, + dateLocale: Locale, date: Date, ): String = template.replaceVariablesWithValues { variable, functions -> getVariableValue( @@ -111,7 +112,8 @@ internal object VariableProcessorV2 { variableDataProvider = variableDataProvider, packageContext = packageContext, rcPackage = rcPackage, - locale = locale, + currencyLocale = currencyLocale, + dateLocale = dateLocale, date = date, ) } @@ -153,7 +155,8 @@ internal object VariableProcessorV2 { // "Context": packageContext: PackageContext, rcPackage: Package, - locale: Locale, + currencyLocale: Locale, + dateLocale: Locale, date: Date, ): String { val variable = findVariable(variableIdentifier, variableConfig.variableCompatibilityMap) @@ -167,11 +170,12 @@ internal object VariableProcessorV2 { variableDataProvider = variableDataProvider, packageContext = packageContext, rcPackage = rcPackage, - locale = locale, + currencyLocale = currencyLocale, + dateLocale = dateLocale, date = date, )?.let { processedVariable -> functions.fold(processedVariable) { accumulator, function -> - accumulator.processFunction(function, locale) + accumulator.processFunction(function, currencyLocale) } } @@ -262,26 +266,27 @@ internal object VariableProcessorV2 { // "Context": packageContext: PackageContext, rcPackage: Package, - locale: Locale, + currencyLocale: Locale, + dateLocale: Locale, date: Date, ): String? = when (this) { Variable.PRODUCT_CURRENCY_CODE -> rcPackage.product.price.currencyCode Variable.PRODUCT_CURRENCY_SYMBOL -> Currency .getInstance(rcPackage.product.price.currencyCode) - .getSymbol(locale) + .getSymbol(currencyLocale) Variable.PRODUCT_PERIODLY -> rcPackage.productPeriodly(localizedVariableKeys) Variable.PRODUCT_PRICE -> variableDataProvider.localizedPrice( rcPackage = rcPackage, - locale = locale, + locale = currencyLocale, showZeroDecimalPlacePrices = packageContext.showZeroDecimalPlacePrices, ) Variable.PRODUCT_PRICE_PER_PERIOD -> variableDataProvider.localizedPrice( rcPackage = rcPackage, - locale = locale, + locale = currencyLocale, showZeroDecimalPlacePrices = packageContext.showZeroDecimalPlacePrices, ).let { price -> val period = rcPackage.productPeriod(localizedVariableKeys) @@ -294,7 +299,7 @@ internal object VariableProcessorV2 { Variable.PRODUCT_PRICE_PER_PERIOD_ABBREVIATED -> variableDataProvider.localizedPrice( rcPackage = rcPackage, - locale = locale, + locale = currencyLocale, showZeroDecimalPlacePrices = packageContext.showZeroDecimalPlacePrices, ).let { price -> val period = rcPackage.productPeriodAbbreviated(localizedVariableKeys) @@ -308,13 +313,13 @@ internal object VariableProcessorV2 { Variable.PRODUCT_PRICE_PER_DAY -> when { rcPackage.isLifetime -> variableDataProvider.localizedPrice( rcPackage = rcPackage, - locale = locale, + locale = currencyLocale, showZeroDecimalPlacePrices = packageContext.showZeroDecimalPlacePrices, ) else -> variableDataProvider.localizedPricePerDay( rcPackage = rcPackage, - locale = locale, + locale = currencyLocale, showZeroDecimalPlacePrices = packageContext.showZeroDecimalPlacePrices, ) } @@ -322,13 +327,13 @@ internal object VariableProcessorV2 { Variable.PRODUCT_PRICE_PER_WEEK -> when { rcPackage.isLifetime -> variableDataProvider.localizedPrice( rcPackage = rcPackage, - locale = locale, + locale = currencyLocale, showZeroDecimalPlacePrices = packageContext.showZeroDecimalPlacePrices, ) else -> variableDataProvider.localizedPricePerWeek( rcPackage = rcPackage, - locale = locale, + locale = currencyLocale, showZeroDecimalPlacePrices = packageContext.showZeroDecimalPlacePrices, ) } @@ -336,13 +341,13 @@ internal object VariableProcessorV2 { Variable.PRODUCT_PRICE_PER_MONTH -> when { rcPackage.isLifetime -> variableDataProvider.localizedPrice( rcPackage = rcPackage, - locale = locale, + locale = currencyLocale, showZeroDecimalPlacePrices = packageContext.showZeroDecimalPlacePrices, ) else -> variableDataProvider.localizedPricePerMonth( rcPackage = rcPackage, - locale = locale, + locale = currencyLocale, showZeroDecimalPlacePrices = packageContext.showZeroDecimalPlacePrices, ) } @@ -350,13 +355,13 @@ internal object VariableProcessorV2 { Variable.PRODUCT_PRICE_PER_YEAR -> when { rcPackage.isLifetime -> variableDataProvider.localizedPrice( rcPackage = rcPackage, - locale = locale, + locale = currencyLocale, showZeroDecimalPlacePrices = packageContext.showZeroDecimalPlacePrices, ) else -> variableDataProvider.localizedPricePerYear( rcPackage = rcPackage, - locale = locale, + locale = currencyLocale, showZeroDecimalPlacePrices = packageContext.showZeroDecimalPlacePrices, ) } @@ -370,16 +375,16 @@ internal object VariableProcessorV2 { Variable.PRODUCT_PERIOD_WITH_UNIT -> rcPackage.productPeriodWithUnit(localizedVariableKeys) Variable.PRODUCT_OFFER_PRICE -> rcPackage.firstIntroOffer?.productOfferPrice(localizedVariableKeys) Variable.PRODUCT_OFFER_PRICE_PER_DAY -> - rcPackage.firstIntroOffer?.productOfferPricePerDay(locale, localizedVariableKeys) + rcPackage.firstIntroOffer?.productOfferPricePerDay(currencyLocale, localizedVariableKeys) Variable.PRODUCT_OFFER_PRICE_PER_WEEK -> - rcPackage.firstIntroOffer?.productOfferPricePerWeek(locale, localizedVariableKeys) + rcPackage.firstIntroOffer?.productOfferPricePerWeek(currencyLocale, localizedVariableKeys) Variable.PRODUCT_OFFER_PRICE_PER_MONTH -> - rcPackage.firstIntroOffer?.productOfferPricePerMonth(locale, localizedVariableKeys) + rcPackage.firstIntroOffer?.productOfferPricePerMonth(currencyLocale, localizedVariableKeys) Variable.PRODUCT_OFFER_PRICE_PER_YEAR -> - rcPackage.firstIntroOffer?.productOfferPricePerYear(locale, localizedVariableKeys) + rcPackage.firstIntroOffer?.productOfferPricePerYear(currencyLocale, localizedVariableKeys) Variable.PRODUCT_OFFER_PERIOD -> rcPackage.firstIntroOffer?.productOfferPeriod(localizedVariableKeys) Variable.PRODUCT_OFFER_PERIOD_ABBREVIATED -> @@ -392,7 +397,7 @@ internal object VariableProcessorV2 { Variable.PRODUCT_OFFER_PERIOD_WITH_UNIT -> rcPackage.firstIntroOffer?.productOfferPeriodWithUnit(localizedVariableKeys) - Variable.PRODUCT_OFFER_END_DATE -> rcPackage.firstIntroOffer?.productOfferEndDate(locale, date) + Variable.PRODUCT_OFFER_END_DATE -> rcPackage.firstIntroOffer?.productOfferEndDate(dateLocale, date) Variable.PRODUCT_SECONDARY_OFFER_PRICE -> rcPackage.secondIntroOffer?.productOfferPrice(localizedVariableKeys) Variable.PRODUCT_SECONDARY_OFFER_PERIOD -> rcPackage.secondIntroOffer?.productOfferPeriod(localizedVariableKeys) Variable.PRODUCT_SECONDARY_OFFER_PERIOD_ABBREVIATED -> diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/testdata/TestData.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/testdata/TestData.kt index 29f34d3cf4..dd0222b410 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/testdata/TestData.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/testdata/TestData.kt @@ -5,6 +5,7 @@ import androidx.compose.material3.ColorScheme import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.revenuecat.purchases.Offering @@ -21,6 +22,7 @@ import com.revenuecat.purchases.paywalls.components.PackageComponent import com.revenuecat.purchases.paywalls.components.StackComponent import com.revenuecat.purchases.ui.revenuecatui.PaywallMode import com.revenuecat.purchases.ui.revenuecatui.R +import com.revenuecat.purchases.ui.revenuecatui.data.MockPurchasesType import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState import com.revenuecat.purchases.ui.revenuecatui.data.PaywallViewModel import com.revenuecat.purchases.ui.revenuecatui.data.loadedLegacy @@ -417,6 +419,7 @@ internal class MockResourceProvider( private val resourceIds: Map> = emptyMap(), private val assetPaths: List = emptyList(), private val downloadedFilesByUrl: Map = emptyMap(), + private val fontFamiliesByXmlResourceId: Map = emptyMap(), ) : ResourceProvider { override fun getApplicationName(): String { return "Mock Paywall" @@ -450,6 +453,10 @@ internal class MockResourceProvider( override fun getResourceIdentifier(name: String, type: String): Int = resourceIds[type]?.get(name) ?: 0 + override fun getXmlFontFamily(resourceId: Int): FontFamily? { + return fontFamiliesByXmlResourceId[resourceId] + } + override fun getAssetFontPath(name: String): String? { val nameWithExtension = if (name.endsWith(".ttf")) name else "$name.ttf" val filePath = "${ResourceProvider.ASSETS_FONTS_DIR}/$nameWithExtension" @@ -488,8 +495,6 @@ internal class MockViewModel( when (val validated = offering.validatedPaywall(TestData.Constants.currentColorScheme, resourceProvider)) { is PaywallValidationResult.Legacy -> offering.toLegacyPaywallState( variableDataProvider = VariableDataProvider(resourceProvider), - activelySubscribedProductIdentifiers = setOf(), - nonSubscriptionProductIdentifiers = setOf(), mode = mode, validatedPaywallData = validated.displayablePaywall, template = validated.template, @@ -498,10 +503,9 @@ internal class MockViewModel( ) is PaywallValidationResult.Components -> offering.toComponentsPaywallState( validationResult = validated, - activelySubscribedProductIds = emptySet(), - purchasedNonSubscriptionProductIds = emptySet(), storefrontCountryCode = null, dateProvider = { Date(MILLIS_2025_01_25) }, + purchases = MockPurchasesType(), ) }, ) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/errors/PaywallValidationError.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/errors/PaywallValidationError.kt index 642d343005..437049f843 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/errors/PaywallValidationError.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/errors/PaywallValidationError.kt @@ -38,7 +38,6 @@ internal sealed class PaywallValidationError : Throwable() { is AllLocalizationsMissing -> message is AllVariableLocalizationsMissing -> message is MissingPackage -> message - is MissingAllPackages -> message is MissingColorAlias -> message is AliasedColorIsAlias -> message is MissingFontAlias -> message @@ -93,14 +92,6 @@ internal sealed class PaywallValidationError : Throwable() { PaywallValidationErrorStrings.MISSING_PACKAGE .format(missingPackageId, offeringId, allPackageIds.joinToString()) } - data class MissingAllPackages( - val offeringId: String, - val allPackageIds: Collection, - ) : PaywallValidationError() { - override val message: String = - PaywallValidationErrorStrings.MISSING_ALL_PACKAGES - .format(offeringId, allPackageIds.joinToString()) - } data class MissingColorAlias( val alias: ColorAlias, ) : PaywallValidationError() { diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/BlurTransformation.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/BlurTransformation.kt index 9dced7e51c..2341d5a4d5 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/BlurTransformation.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/BlurTransformation.kt @@ -5,6 +5,7 @@ import android.renderscript.Element import android.renderscript.RenderScript import android.renderscript.ScriptIntrinsicBlur import androidx.annotation.VisibleForTesting +import androidx.core.graphics.createBitmap import coil.size.Size import coil.transform.Transformation import kotlin.math.min @@ -56,7 +57,7 @@ internal fun Bitmap.blur(context: Context, radius: Float, scaleDown: Boolean = t script.setInput(input) script.forEach(output) - val blurredBitmap = Bitmap.createBitmap(bitmap.width, bitmap.height, config) + val blurredBitmap = config?.let { createBitmap(bitmap.width, bitmap.height, it) } output.copyTo(blurredBitmap) input.destroy() @@ -64,7 +65,7 @@ internal fun Bitmap.blur(context: Context, radius: Float, scaleDown: Boolean = t script.destroy() rs.destroy() - return blurredBitmap + return blurredBitmap ?: bitmap } private fun Bitmap.scaledDown(): Bitmap { diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/LocaleHelpers.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/LocaleHelpers.kt new file mode 100644 index 0000000000..735984dbaa --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/LocaleHelpers.kt @@ -0,0 +1,26 @@ +package com.revenuecat.purchases.ui.revenuecatui.helpers + +import java.util.Locale + +/** + * Creates a [Locale] from a string representation, supporting both "es-ES" and "es_ES" formats. + * + * @param localeString The locale string to parse (e.g., "en", "es-ES", "es_ES") + * @return A [Locale] instance parsed from the string + */ +internal fun createLocaleFromString(localeString: String): Locale { + return if (localeString.contains('-') || localeString.contains('_')) { + val parts = if (localeString.contains('-')) { + localeString.split('-', limit = 2) + } else { + localeString.split('_', limit = 2) + } + if (parts.size >= 2) { + Locale(parts[0], parts[1]) + } else { + Locale(parts[0]) + } + } else { + Locale(localeString) + } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt index b93a84c922..24448aea3e 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt @@ -21,6 +21,7 @@ import com.revenuecat.purchases.ui.revenuecatui.components.style.StackComponentS import com.revenuecat.purchases.ui.revenuecatui.components.style.StyleFactory import com.revenuecat.purchases.ui.revenuecatui.composables.PaywallIconName import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState +import com.revenuecat.purchases.ui.revenuecatui.data.PurchasesType import com.revenuecat.purchases.ui.revenuecatui.data.processed.PackageConfigurationType import com.revenuecat.purchases.ui.revenuecatui.data.processed.PaywallTemplate import com.revenuecat.purchases.ui.revenuecatui.data.processed.TemplateConfigurationFactory @@ -31,7 +32,6 @@ import com.revenuecat.purchases.ui.revenuecatui.extensions.createDefault import com.revenuecat.purchases.ui.revenuecatui.extensions.createDefaultForIdentifiers import com.revenuecat.purchases.ui.revenuecatui.extensions.defaultTemplate import java.util.Date -import kotlin.Result import com.revenuecat.purchases.ui.revenuecatui.helpers.Result as RcResult @Suppress("ReturnCount") @@ -168,17 +168,6 @@ internal fun Offering.validatePaywallComponentsDataOrNull( ) { backendRootComponentResult, stickyFooterResult, background -> val hasAnyPackages = backendRootComponentResult.availablePackages.hasAnyPackages || stickyFooterResult?.availablePackages?.hasAnyPackages ?: false - // Check if there are any packages available in the offering - if (!hasAnyPackages) { - return RcResult.Error( - nonEmptyListOf( - PaywallValidationError.MissingAllPackages( - identifier, - availablePackages.map { it.identifier }, - ), - ), - ) - } val backendRootComponent = backendRootComponentResult.componentStyle val stickyFooter = stickyFooterResult?.componentStyle @@ -285,8 +274,6 @@ private fun PaywallData.LocalizedConfiguration.validate(): PaywallValidationErro @Suppress("ReturnCount", "TooGenericExceptionCaught", "LongParameterList") internal fun Offering.toLegacyPaywallState( variableDataProvider: VariableDataProvider, - activelySubscribedProductIdentifiers: Set, - nonSubscriptionProductIdentifiers: Set, mode: PaywallMode, validatedPaywallData: PaywallData, template: PaywallTemplate, @@ -298,8 +285,6 @@ internal fun Offering.toLegacyPaywallState( mode = mode, paywallData = validatedPaywallData, availablePackages = availablePackages, - activelySubscribedProductIdentifiers = activelySubscribedProductIdentifiers, - nonSubscriptionProductIdentifiers = nonSubscriptionProductIdentifiers, template, storefrontCountryCode = storefrontCountryCode, ) @@ -318,10 +303,9 @@ internal fun Offering.toLegacyPaywallState( @Suppress("LongParameterList") internal fun Offering.toComponentsPaywallState( validationResult: PaywallValidationResult.Components, - activelySubscribedProductIds: Set, - purchasedNonSubscriptionProductIds: Set, storefrontCountryCode: String?, dateProvider: () -> Date, + purchases: PurchasesType, ): PaywallState.Loaded.Components { val showPricesWithDecimals = storefrontCountryCode?.let { !validationResult.zeroDecimalPlaceCountries.contains(it) @@ -336,11 +320,11 @@ internal fun Offering.toComponentsPaywallState( variableDataProvider = validationResult.variableDataProvider, offering = this, locales = validationResult.locales, - activelySubscribedProductIds = activelySubscribedProductIds, - purchasedNonSubscriptionProductIds = purchasedNonSubscriptionProductIds, + storefrontCountryCode = storefrontCountryCode, dateProvider = dateProvider, packages = validationResult.packages, initialSelectedTabIndex = validationResult.initialSelectedTabIndex, + purchases = purchases, ) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PaywallResourceProvider.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PaywallResourceProvider.kt index 4e53e44b41..253b8734ba 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PaywallResourceProvider.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PaywallResourceProvider.kt @@ -4,9 +4,11 @@ import android.annotation.SuppressLint import android.content.Context import android.content.res.Resources import androidx.annotation.StringRes +import androidx.compose.ui.text.font.FontFamily import com.revenuecat.purchases.Purchases import com.revenuecat.purchases.UiConfig import com.revenuecat.purchases.paywalls.DownloadedFontFamily +import com.revenuecat.purchases.ui.revenuecatui.utils.FontFamilyXmlParser import java.util.Locale /** @@ -21,6 +23,7 @@ internal interface ResourceProvider { fun getString(@StringRes resId: Int, vararg formatArgs: Any): String fun getLocale(): Locale fun getResourceIdentifier(name: String, type: String): Int + fun getXmlFontFamily(resourceId: Int): FontFamily? fun getAssetFontPath(name: String): String? fun getCachedFontFamilyOrStartDownload( fontInfo: UiConfig.AppConfig.FontsConfig.FontInfo.Name, @@ -56,6 +59,26 @@ internal class PaywallResourceProvider( override fun getResourceIdentifier(name: String, type: String): Int = resources.getIdentifier(name, type, packageName) + @Suppress("ReturnCount") + override fun getXmlFontFamily(resourceId: Int): FontFamily? { + val parser = try { + resources.getXml(resourceId) + } catch (_: Resources.NotFoundException) { + return null + } + return try { + FontFamilyXmlParser.parse(parser) + } catch (@Suppress("TooGenericExceptionCaught") e: Throwable) { + // This can happen if the XML is malformed or not a valid font family. + // We log the error and return null. + val resourceName = resources.getResourceEntryNameOrNull(resourceId) + Logger.e("Error parsing XML font family with resource ID ${resourceName ?: resourceId}", e) + null + } finally { + parser.close() + } + } + override fun getAssetFontPath(name: String): String? { val nameWithExtension = if (name.endsWith(".ttf")) name else "$name.ttf" @@ -83,3 +106,10 @@ internal fun Context.toResourceProvider(): ResourceProvider { private fun Context.applicationName(): String { return applicationInfo.loadLabel(packageManager).toString() } + +private fun Resources.getResourceEntryNameOrNull(resourceId: Int): String? = + try { + getResourceEntryName(resourceId) + } catch (_: Resources.NotFoundException) { + null + } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/strings/PaywallValidationErrorStrings.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/strings/PaywallValidationErrorStrings.kt index 6e44422887..ba3a5101d8 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/strings/PaywallValidationErrorStrings.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/strings/PaywallValidationErrorStrings.kt @@ -24,10 +24,6 @@ internal object PaywallValidationErrorStrings { "The Paywall references a package with id '%s', but Offering '%s' does not contain such a package. " + "It has these packages instead: [%s]. Either add the missing package to the Offering or remove it from " + "the Paywall." - const val MISSING_ALL_PACKAGES = - "Could not find any packages referenced by paywall in Offering '%s'. These packages were found: " + - "[%s]. Either add the correct packages to the Offering or remove them from " + - "the Paywall." const val MISSING_COLOR_ALIAS = "Aliased color '%s' does not exist." const val ALIASED_COLOR_IS_ALIAS = "Aliased color '%s' has an aliased value '%s', which is not allowed." const val MISSING_FONT_ALIAS = "Aliased font '%s' does not exist." diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/utils/FontFamilyXmlParser.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/utils/FontFamilyXmlParser.kt new file mode 100644 index 0000000000..03b572e138 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/utils/FontFamilyXmlParser.kt @@ -0,0 +1,137 @@ +package com.revenuecat.purchases.ui.revenuecatui.utils + +import android.content.res.XmlResourceParser +import androidx.annotation.VisibleForTesting +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import org.xmlpull.v1.XmlPullParser +import kotlin.jvm.Throws + +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal data class ParsedFont( + val resId: Int, + val weight: Int, + val style: FontStyle, +) + +@Suppress("NestedBlockDepth") +internal object FontFamilyXmlParser { + private const val UNRECOGNIZED_VALUE = -1 + private const val DEFAULT_FONT_WEIGHT = 400 + private const val ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android" + private const val APP_NAMESPACE = "http://schemas.android.com/apk/res-auto" + + @Throws + fun parse(parser: XmlResourceParser): FontFamily? { + val parsedFonts = parseXmlData(parser) + return if (parsedFonts.isNotEmpty()) { + FontFamily( + parsedFonts.map { (resId, weight, style) -> + Font( + resId = resId, + weight = FontWeight(weight), + style = style, + ) + }, + ) + } else { + null + } + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + @Throws + internal fun parseXmlData(parser: XmlResourceParser): List { + val parsedFonts = mutableListOf() + + var eventType = parser.eventType + while (eventType != XmlPullParser.END_DOCUMENT) { + when (eventType) { + XmlPullParser.START_TAG -> { + if (parser.name == "font") { + val parsedFont = parseFontData(parser) + if (parsedFont != null) { + parsedFonts.add(parsedFont) + } + } + } + } + eventType = parser.next() + } + + return parsedFonts + } + + private fun parseFontData(parser: XmlResourceParser): ParsedFont? { + val fontResId = getFontResourceId(parser) + if (fontResId == UNRECOGNIZED_VALUE) return null + + val fontWeight = getFontWeight(parser) + val fontStyle = getFontStyle(parser) + + return ParsedFont(fontResId, fontWeight, fontStyle) + } + + private fun getFontResourceId(parser: XmlResourceParser): Int { + // Try app namespace first (http://schemas.android.com/apk/res-auto) + var fontResId = parser.getAttributeResourceValue( + APP_NAMESPACE, + "font", + UNRECOGNIZED_VALUE, + ) + + // Fallback to android namespace + if (fontResId == UNRECOGNIZED_VALUE) { + fontResId = parser.getAttributeResourceValue( + ANDROID_NAMESPACE, + "font", + UNRECOGNIZED_VALUE, + ) + } + + return fontResId + } + + private fun getFontWeight(parser: XmlResourceParser): Int { + // Try app namespace first + var fontWeight = parser.getAttributeIntValue( + APP_NAMESPACE, + "fontWeight", + UNRECOGNIZED_VALUE, + ) + + // Fallback to android namespace + if (fontWeight == UNRECOGNIZED_VALUE) { + fontWeight = parser.getAttributeIntValue( + ANDROID_NAMESPACE, + "fontWeight", + DEFAULT_FONT_WEIGHT, + ) + } + + return if (fontWeight == UNRECOGNIZED_VALUE) DEFAULT_FONT_WEIGHT else fontWeight + } + + private fun getFontStyle(parser: XmlResourceParser): FontStyle { + // Try app namespace first + var fontStyleValue = parser.getAttributeValue( + APP_NAMESPACE, + "fontStyle", + ) + + // Fallback to android namespace + if (fontStyleValue == null) { + fontStyleValue = parser.getAttributeValue( + ANDROID_NAMESPACE, + "fontStyle", + ) + } + + return when (fontStyleValue) { + "italic" -> FontStyle.Italic + else -> FontStyle.Normal + } + } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/views/CompatComposeView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/views/CompatComposeView.kt new file mode 100644 index 0000000000..5483bcdf77 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/views/CompatComposeView.kt @@ -0,0 +1,183 @@ +package com.revenuecat.purchases.ui.revenuecatui.views + +import android.content.Context +import android.os.Bundle +import android.os.Parcelable +import android.util.AttributeSet +import android.view.KeyEvent +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.AbstractComposeView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.findViewTreeViewModelStoreOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.findViewTreeSavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.revenuecat.purchases.InternalRevenueCatAPI + +/** + * A ComposeView expects a few things to be set up in the view tree it is added to. This gets handled automatically by + * modern parents such as ComponentActivity and androidx Fragment. But this is not always the case, such as when the + * ComposeView is added to a plain Window, or in certain hybrid frameworks. A [CompatComposeView] can handle this + * scenario by acting as its own LifecycleOwner, SavedStateRegistryOwner and ViewModelStoreOwner if required. + * + * Note that, in this scenario, this does imply that the ComposeView's lifecycle is tied to its visibility. It ends + * when it is removed from the view tree. There is no concept of keeping state on a back stack. The ComposeView acts as + * a modal in this case. + */ +@Suppress("TooManyFunctions") +@InternalRevenueCatAPI +abstract class CompatComposeView @JvmOverloads internal constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : AbstractComposeView(context, attrs, defStyleAttr), + LifecycleOwner, + SavedStateRegistryOwner, + ViewModelStoreOwner { + + private companion object { + private const val KEY_SAVED_INSTANCE_STATE = "com.revenuecat.CompatComposeView.saved_instance_state" + } + + private var isManagingViewTree = false + private val lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this).apply { + currentState = Lifecycle.State.INITIALIZED + } + private val savedStateRegistryController: SavedStateRegistryController = + SavedStateRegistryController.create(this) + + override val lifecycle: Lifecycle = lifecycleRegistry + override val savedStateRegistry: SavedStateRegistry = savedStateRegistryController.savedStateRegistry + override val viewModelStore: ViewModelStore = ViewModelStore() + + open fun onBackPressed() { + (parent as? ViewGroup)?.removeView(this) + } + + override fun onSaveInstanceState(): Parcelable? { + val state = super.onSaveInstanceState() + if (isManagingViewTree) performSave(state) + return state + } + + override fun onRestoreInstanceState(state: Parcelable?) { + super.onRestoreInstanceState(state) + if (isManagingViewTree) performRestore(state) + } + + override fun onAttachedToWindow() { + initViewTreeOwners() + if (isManagingViewTree) { + savedStateRegistryController.performAttach() + performRestore(null) + } + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) + super.onAttachedToWindow() + } + + override fun onWindowVisibilityChanged(visibility: Int) { + super.onWindowVisibilityChanged(visibility) + if (visibility == VISIBLE) { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) + } else { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + } + } + + override fun onWindowFocusChanged(hasWindowFocus: Boolean) { + super.onWindowFocusChanged(hasWindowFocus) + if (hasWindowFocus) { + if (isManagingViewTree) { + // Make focusable and request focus, to be able to intercept back button presses. + isFocusableInTouchMode = true + isFocusable = true + requestFocus() + } + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + } else { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + } + } + + override fun onDetachedFromWindow() { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + if (isManagingViewTree) viewModelStore.clear() + deinitViewTreeOwners() + super.onDetachedFromWindow() + } + + @Suppress("ReturnCount") + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (!isManagingViewTree) return super.dispatchKeyEvent(event) + if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { + onBackPressed() + return true + } + return super.dispatchKeyEvent(event) + } + + private fun performSave(state: Parcelable?): Bundle { + val bundle = Bundle().apply { putParcelable(KEY_SAVED_INSTANCE_STATE, state) } + savedStateRegistryController.performSave(bundle) + return bundle + } + + private fun performRestore(state: Parcelable?) { + val bundle = Bundle().apply { putParcelable(KEY_SAVED_INSTANCE_STATE, state) } + savedStateRegistryController.performRestore(bundle) + } + + private fun initViewTreeOwners() { + val windowRoot = findWindowRoot() + if (windowRoot == null || windowRoot.findViewTreeLifecycleOwner() != null) return + + windowRoot.setViewTreeLifecycleOwner(this) + windowRoot.setViewTreeSavedStateRegistryOwner(this) + windowRoot.setViewTreeViewModelStoreOwner(this) + + isManagingViewTree = true + } + + private fun deinitViewTreeOwners() { + if (!isManagingViewTree) return + val windowRoot = findWindowRoot() ?: return + + if (windowRoot.findViewTreeLifecycleOwner() === this) { + windowRoot.setViewTreeLifecycleOwner(null) + } + if (windowRoot.findViewTreeSavedStateRegistryOwner() === this) { + windowRoot.setViewTreeSavedStateRegistryOwner(null) + } + if (windowRoot.findViewTreeViewModelStoreOwner() === this) { + windowRoot.setViewTreeViewModelStoreOwner(null) + } + } + + private fun View.findWindowRoot(): View? { + // The ultimate root of a window is android.view.ViewRootImpl, but that's a private type. This ViewRootImpl + // has 1 child, which is a regular ViewGroup we can work with. + var lastViewGroup: ViewGroup? = null + var currentParent = parent + + while (currentParent != null) { + if (currentParent !is ViewGroup) break + + lastViewGroup = currentParent + currentParent = currentParent.parent + } + + return lastViewGroup + } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/views/CustomerCenterView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/views/CustomerCenterView.kt index 2cd8bf1867..95a7250f64 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/views/CustomerCenterView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/views/CustomerCenterView.kt @@ -3,14 +3,13 @@ package com.revenuecat.purchases.ui.revenuecatui.views import android.content.Context import android.util.AttributeSet import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.AbstractComposeView import com.revenuecat.purchases.ui.revenuecatui.customercenter.CustomerCenter import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger /** * View that wraps the [CustomerCenter] Composable to display the Customer Center through the View system. */ -public class CustomerCenterView : AbstractComposeView { +public class CustomerCenterView : CompatComposeView { constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { init() @@ -41,6 +40,10 @@ public class CustomerCenterView : AbstractComposeView { this.dismissHandler = dismissHandler } + override fun onBackPressed() { + dismissHandler?.run { invoke() } ?: super.onBackPressed() + } + private fun init() { Logger.d("Initialized CustomerCenterView") } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/views/OriginalTemplatePaywallFooterView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/views/OriginalTemplatePaywallFooterView.kt index 46c69f2a7e..5f63c8b8d3 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/views/OriginalTemplatePaywallFooterView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/views/OriginalTemplatePaywallFooterView.kt @@ -3,13 +3,15 @@ package com.revenuecat.purchases.ui.revenuecatui.views import android.content.Context import android.util.AttributeSet import android.widget.FrameLayout +import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.platform.ComposeView import com.revenuecat.purchases.CustomerInfo +import com.revenuecat.purchases.InternalRevenueCatAPI import com.revenuecat.purchases.Offering import com.revenuecat.purchases.Package +import com.revenuecat.purchases.PresentedOfferingContext import com.revenuecat.purchases.PurchasesError import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.ui.revenuecatui.OfferingSelection @@ -86,7 +88,7 @@ open class OriginalTemplatePaywallFooterView : FrameLayout { dismissHandler?.invoke() }.build(), ) - private var initialOfferingId: String? = null + private var initialOfferingInfo: OfferingSelection.IdAndPresentedOfferingContext? = null private var initialFontProvider: FontProvider? = null private var initialCondensed: Boolean = PaywallViewAttributesReader.DEFAULT_CONDENSED private var dismissHandler: (() -> Unit)? = null @@ -130,15 +132,38 @@ open class OriginalTemplatePaywallFooterView : FrameLayout { /** * Sets the offering id to be used to display the Paywall. If not set, the default one will be used. */ + @Deprecated( + "You should set the offering on the constructor instead.", + ) fun setOfferingId(offeringId: String?) { val offeringSelection = if (offeringId == null) { OfferingSelection.None } else { - OfferingSelection.OfferingId(offeringId) + OfferingSelection.IdAndPresentedOfferingContext( + offeringId = offeringId, + presentedOfferingContext = null, + ) } paywallOptions = paywallOptions.copy(offeringSelection = offeringSelection) } + @InternalRevenueCatAPI + fun setOfferingIdAndPresentedOfferingContext( + offeringId: String?, + presentedOfferingContext: PresentedOfferingContext?, + ) { + if (offeringId == null) { + paywallOptions = paywallOptions.copy(offeringSelection = OfferingSelection.None) + } else { + paywallOptions = paywallOptions.copy( + offeringSelection = OfferingSelection.IdAndPresentedOfferingContext( + offeringId = offeringId, + presentedOfferingContext = presentedOfferingContext, + ), + ) + } + } + /** * Sets the font provider to be used for the Paywall. If not set, the default one will be used. * Only available for original template paywalls. Ignored for V2 Paywalls. @@ -152,11 +177,12 @@ open class OriginalTemplatePaywallFooterView : FrameLayout { paywallOptions = PaywallOptions.Builder { dismissHandler?.invoke() } .setListener(internalListener) .setFontProvider(initialFontProvider) - .setOfferingId(initialOfferingId) + .setOfferingIdAndPresentedOfferingContext(initialOfferingInfo) .build() addView( - ComposeView(context).apply { - setContent { + object : CompatComposeView(context) { + @Composable + override fun Content() { val paywallOptions by remember { paywallOptionsState } @@ -173,7 +199,13 @@ open class OriginalTemplatePaywallFooterView : FrameLayout { private fun parseAttributes(context: Context, attrs: AttributeSet?) { val (offeringId, fontProvider, _, condensed) = PaywallViewAttributesReader.parseAttributes(context, attrs, R.styleable.PaywallFooterView) ?: return - setOfferingId(offeringId) + this.initialOfferingInfo = offeringId?.let { + OfferingSelection.IdAndPresentedOfferingContext( + offeringId = offeringId, + // WIP: We do not support presentedOfferingContext when using the view in XML layouts. + presentedOfferingContext = null, + ) + } this.initialFontProvider = fontProvider condensed?.let { this.initialCondensed = it } } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/views/PaywallView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/views/PaywallView.kt index 98187d299c..f52887a5fa 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/views/PaywallView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/views/PaywallView.kt @@ -6,10 +6,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.platform.AbstractComposeView import com.revenuecat.purchases.CustomerInfo import com.revenuecat.purchases.Offering import com.revenuecat.purchases.Package +import com.revenuecat.purchases.PresentedOfferingContext import com.revenuecat.purchases.PurchasesError import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.ui.revenuecatui.OfferingSelection @@ -22,7 +22,7 @@ import com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider /** * View that wraps the [Paywall] Composable to display the Paywall through XML layouts and the View system. */ -class PaywallView : AbstractComposeView { +class PaywallView : CompatComposeView { constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { init(context, attrs) @@ -46,7 +46,12 @@ class PaywallView : AbstractComposeView { ) : super(context) { setPaywallListener(listener) setDismissHandler(dismissHandler) - setOfferingId(offering?.identifier) + offering?.let { + setOfferingId( + offeringId = it.identifier, + presentedOfferingContext = it.availablePackages.firstOrNull()?.presentedOfferingContext, + ) + } this.shouldDisplayDismissButton = shouldDisplayDismissButton this.initialFontProvider = fontProvider init(context, null) @@ -57,7 +62,7 @@ class PaywallView : AbstractComposeView { dismissHandler?.invoke() }.build(), ) - private var initialOfferingId: String? = null + private var initialOfferingInfo: OfferingSelection.IdAndPresentedOfferingContext? = null private var initialFontProvider: FontProvider? = null private var dismissHandler: (() -> Unit)? = null private var listener: PaywallListener? = null @@ -102,13 +107,18 @@ class PaywallView : AbstractComposeView { } /** - * Sets the offering id to be used to display the Paywall. If not set, the default one will be used. + * Sets the offering id and presented offering context to be used to display the Paywall. + * If not set, the default one will be used. */ - fun setOfferingId(offeringId: String?) { + @JvmOverloads + fun setOfferingId(offeringId: String?, presentedOfferingContext: PresentedOfferingContext? = null) { val offeringSelection = if (offeringId == null) { OfferingSelection.None } else { - OfferingSelection.OfferingId(offeringId) + OfferingSelection.IdAndPresentedOfferingContext( + offeringId = offeringId, + presentedOfferingContext = presentedOfferingContext, + ) } paywallOptions = paywallOptions.copy(offeringSelection = offeringSelection) } @@ -129,12 +139,16 @@ class PaywallView : AbstractComposeView { paywallOptions = paywallOptions.copy(shouldDisplayDismissButton = shouldDisplayDismissButton) } + override fun onBackPressed() { + dismissHandler?.run { invoke() } ?: super.onBackPressed() + } + private fun init(context: Context, attrs: AttributeSet?) { parseAttributes(context, attrs) paywallOptions = PaywallOptions.Builder { dismissHandler?.invoke() } .setListener(internalListener) .setFontProvider(initialFontProvider) - .setOfferingId(initialOfferingId) + .setOfferingIdAndPresentedOfferingContext(initialOfferingInfo) .setShouldDisplayDismissButton(shouldDisplayDismissButton ?: false) .build() } @@ -143,7 +157,13 @@ class PaywallView : AbstractComposeView { private fun parseAttributes(context: Context, attrs: AttributeSet?) { val (offeringId, fontProvider, shouldDisplayDismissButton, _) = PaywallViewAttributesReader.parseAttributes(context, attrs, R.styleable.PaywallView) ?: return - setOfferingId(offeringId) + this.initialOfferingInfo = offeringId?.let { + OfferingSelection.IdAndPresentedOfferingContext( + offeringId = offeringId, + // WIP: We do not support presentedOfferingContext when using the view in XML layouts. + presentedOfferingContext = null, + ) + } this.initialFontProvider = fontProvider this.shouldDisplayDismissButton = shouldDisplayDismissButton } diff --git a/ui/revenuecatui/src/main/res/values-az/strings.xml b/ui/revenuecatui/src/main/res/values-az/strings.xml new file mode 100644 index 0000000000..01293a407a --- /dev/null +++ b/ui/revenuecatui/src/main/res/values-az/strings.xml @@ -0,0 +1,23 @@ + + + OK + Satın almaları bərpa et + Bərpa et + Şərtlər və qaydalar + Şərtlər + Məxfilik siyasəti + Məxfilik + Davam et + {{ sub_offer_duration }} sınaq müddətinizi başladın, sonra {{ total_price_and_per_month }}. + %1$d%% endirim + Bütün planlar + İllik + 6 ay + 3 ay + 2 ay + Aylıq + Həftəlik + Ömürlük + Brauzer quraşdırılmayıb. Link açıla bilmədi. + Link səhvdir. Açıla bilmədi. + \ No newline at end of file diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallComponentDataValidationTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallComponentDataValidationTests.kt index 366c95af44..4ef03c11f7 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallComponentDataValidationTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallComponentDataValidationTests.kt @@ -2,6 +2,9 @@ package com.revenuecat.purchases.ui.revenuecatui import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle import androidx.test.ext.junit.runners.AndroidJUnit4 import com.revenuecat.purchases.FontAlias import com.revenuecat.purchases.Offering @@ -365,22 +368,43 @@ class PaywallComponentDataValidationTests { // Arrange val primaryFontAlias = FontAlias("primary") val secondaryFontAlias = FontAlias("secondary") - val robotoFont = FontSpec.Resource(1) - val robotoFontResourceName = FontInfo.Name("roboto") + val tertiaryFontAlias = FontAlias("tertiary") + val robotoFont = FontSpec.Resource( + fontFamily = FontFamily( + listOf( + Font(resId = 1, weight = androidx.compose.ui.text.font.FontWeight(400), style = FontStyle.Normal), + Font(resId = 2, weight = androidx.compose.ui.text.font.FontWeight(700), style = FontStyle.Italic), + ) + ) + ) + val robotoFontRegularResourceName = FontInfo.Name( + value = "roboto-regular", + family = "roboto", + weight = 400, + style = com.revenuecat.purchases.paywalls.components.properties.FontStyle.NORMAL, + ) + val robotoFontBoldItalicResourceName = FontInfo.Name( + value = "roboto-bold", + family = "roboto", + weight = 700, + style = com.revenuecat.purchases.paywalls.components.properties.FontStyle.ITALIC, + ) val openSansFont = FontSpec.Asset("fonts/open_sans.ttf") val openSansFontAssetName = FontInfo.Name("open_sans") val uiConfig = UiConfig( app = AppConfig( fonts = mapOf( - primaryFontAlias to FontsConfig(robotoFontResourceName), + primaryFontAlias to FontsConfig(robotoFontRegularResourceName), secondaryFontAlias to FontsConfig(openSansFontAssetName), + tertiaryFontAlias to FontsConfig(robotoFontBoldItalicResourceName), ), ), ) val resourceProvider = MockResourceProvider( resourceIds = mapOf( "font" to mapOf( - robotoFontResourceName.value to robotoFont.id, + robotoFontRegularResourceName.value to 1, + robotoFontBoldItalicResourceName.value to 2, ), ), assetPaths = listOf( @@ -406,6 +430,11 @@ class PaywallComponentDataValidationTests { color = textColor, fontName = secondaryFontAlias, ), + TextComponent( + text = LocalizationKey("key3"), + color = textColor, + fontName = tertiaryFontAlias, + ), TestData.Components.monthlyPackageComponent, ), ), @@ -417,6 +446,7 @@ class PaywallComponentDataValidationTests { defaultLocale to mapOf( LocalizationKey("key1") to LocalizationData.Text("value1"), LocalizationKey("key2") to LocalizationData.Text("value2"), + LocalizationKey("key3") to LocalizationData.Text("value3"), ), ), defaultLocaleIdentifier = defaultLocale, @@ -436,11 +466,110 @@ class PaywallComponentDataValidationTests { val validatedComponents = validated as PaywallValidationResult.Components assertNull(validatedComponents.errors) val stack = validatedComponents.stack as StackComponentStyle - assertEquals(3, stack.children.size) + assertEquals(4, stack.children.size) val text1 = stack.children[0] as TextComponentStyle val text2 = stack.children[1] as TextComponentStyle + val text3 = stack.children[2] as TextComponentStyle assertEquals(robotoFont, text1.fontSpec) assertEquals(openSansFont, text2.fontSpec) + assertEquals(robotoFont, text3.fontSpec) + } + + @Test + fun `Should successfully validate with XML font family`() { + // Arrange + val primaryFontAlias = FontAlias("primary") + val secondaryFontAlias = FontAlias("secondary") + val xmlFontFamily = FontFamily( + listOf( + Font(resId = 2, weight = androidx.compose.ui.text.font.FontWeight(400), style = FontStyle.Normal), + Font(resId = 3, weight = androidx.compose.ui.text.font.FontWeight(700), style = FontStyle.Italic), + ) + ) + val robotoFont = FontSpec.Resource(fontFamily = xmlFontFamily) + val robotoFontRegularResourceName = FontInfo.Name( + value = "roboto", + family = "roboto", + weight = 400, + style = com.revenuecat.purchases.paywalls.components.properties.FontStyle.NORMAL, + ) + val robotoFontBoldItalicResourceName = FontInfo.Name( + value = "roboto", + family = "roboto", + weight = 700, + style = com.revenuecat.purchases.paywalls.components.properties.FontStyle.ITALIC, + ) + val uiConfig = UiConfig( + app = AppConfig( + fonts = mapOf( + primaryFontAlias to FontsConfig(robotoFontRegularResourceName), + secondaryFontAlias to FontsConfig(robotoFontBoldItalicResourceName), + ), + ), + ) + val resourceProvider = MockResourceProvider( + resourceIds = mapOf( + "font" to mapOf( + robotoFontRegularResourceName.value to 1, + robotoFontBoldItalicResourceName.value to 1, + ), + ), + fontFamiliesByXmlResourceId = mapOf(1 to xmlFontFamily) + ) + val textColor = ColorScheme(light = ColorInfo.Hex(Color.Black.toArgb())) + val defaultLocale = LocaleId("en_US") + val data = PaywallComponentsData( + templateName = "template", + assetBaseURL = URL("https://assets.pawwalls.com"), + componentsConfig = ComponentsConfig( + base = PaywallComponentsConfig( + stack = StackComponent( + components = listOf( + TextComponent( + text = LocalizationKey("key1"), + color = textColor, + fontName = primaryFontAlias, + ), + TextComponent( + text = LocalizationKey("key2"), + color = textColor, + fontName = secondaryFontAlias, + ), + TestData.Components.monthlyPackageComponent, + ), + ), + background = Background.Color(ColorScheme(light = ColorInfo.Hex(Color.White.toArgb()))), + stickyFooter = null, + ), + ), + componentsLocalizations = mapOf( + defaultLocale to mapOf( + LocalizationKey("key1") to LocalizationData.Text("value1"), + LocalizationKey("key2") to LocalizationData.Text("value2"), + ), + ), + defaultLocaleIdentifier = defaultLocale, + ) + val offering = Offering( + identifier = "identifier", + serverDescription = "serverDescription", + metadata = emptyMap(), + availablePackages = listOf(TestData.Packages.monthly), + paywallComponents = Offering.PaywallComponents(uiConfig, data), + ) + + // Act + val validated = offering.validatedPaywall(TestData.Constants.currentColorScheme, resourceProvider) + + // Assert + val validatedComponents = validated as PaywallValidationResult.Components + assertNull(validatedComponents.errors) + val stack = validatedComponents.stack as StackComponentStyle + assertEquals(3, stack.children.size) + val text1 = stack.children[0] as TextComponentStyle + val text2 = stack.children[1] as TextComponentStyle + assertEquals(robotoFont, text1.fontSpec) + assertEquals(robotoFont, text2.fontSpec) } @Test @@ -450,7 +579,6 @@ class PaywallComponentDataValidationTests { val missingFontAlias2 = FontAlias("missing-font-2") val missingFontAlias3 = FontAlias("missing-font-3") val existingFontAlias = FontAlias("primary") - val existingFontResource = FontSpec.Resource(1) val existingFontResourceName = FontInfo.Name("roboto") val uiConfig = UiConfig( app = AppConfig( @@ -460,7 +588,7 @@ class PaywallComponentDataValidationTests { ), ) val resourceProvider = MockResourceProvider( - resourceIds = mapOf("font" to mapOf(existingFontResourceName.value to existingFontResource.id)), + resourceIds = mapOf("font" to mapOf(existingFontResourceName.value to 1)), ) val textColor = ColorScheme(light = ColorInfo.Hex(Color.Black.toArgb())) val defaultLocale = LocaleId("en_US") @@ -834,7 +962,7 @@ class PaywallComponentDataValidationTests { } @Test - fun `Should fail to validate if no package component is found`() { + fun `Should not fail to validate if no package component is found`() { // Arrange val defaultLocale = LocaleId("en_US") val data = PaywallComponentsData( @@ -871,12 +999,7 @@ class PaywallComponentDataValidationTests { val validated = offering.validatedPaywall(TestData.Constants.currentColorScheme, MockResourceProvider()) // Assert - assertNotNull(validated.errors) - assertEquals(validated.errors?.size, 1) - assertEquals(validated.errors?.first(), PaywallValidationError.MissingAllPackages( - offeringId = "identifier", - allPackageIds = listOf(TestData.Packages.annual.identifier, TestData.Packages.lifetime.identifier) - )) + assertNull(validated.errors) } @Test diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentViewTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentViewTests.kt index 952c3f9444..dc8e0c6366 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentViewTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentViewTests.kt @@ -38,7 +38,6 @@ import com.revenuecat.purchases.paywalls.components.common.PaywallComponentsConf import com.revenuecat.purchases.paywalls.components.common.PaywallComponentsData import com.revenuecat.purchases.paywalls.components.properties.ColorInfo import com.revenuecat.purchases.paywalls.components.properties.ColorScheme -import com.revenuecat.purchases.paywalls.components.properties.FontWeight as RCFontWeight import com.revenuecat.purchases.paywalls.components.properties.Size import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fit import com.revenuecat.purchases.ui.revenuecatui.assertions.assertPixelColorEquals @@ -66,6 +65,7 @@ import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode import org.robolectric.shadows.ShadowPixelCopy import java.net.URL +import com.revenuecat.purchases.paywalls.components.properties.FontWeight as RCFontWeight @RunWith(AndroidJUnit4::class) class TextComponentViewTests { @@ -831,8 +831,8 @@ class TextComponentViewTests { val countryWithoutDecimals = "MX" val textKey = LocalizationKey("key_selected") val textWithPriceVariable = LocalizationData.Text("Price: {{ product.price }}") - val expectedTextWithDecimals = "Price: \$ 2.00" - val expectedTextWithoutDecimals = "Price: MX\$1" + val expectedTextWithDecimals = "Price: $ 2.00" + val expectedTextWithoutDecimals = "Price: $1" val localizations = nonEmptyMapOf( defaultLocaleIdentifier to nonEmptyMapOf( textKey to textWithPriceVariable, diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentViewVariablesTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentViewVariablesTests.kt index e05072ffb7..518aa7e966 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentViewVariablesTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentViewVariablesTests.kt @@ -155,6 +155,19 @@ internal class TextComponentViewVariablesTests( period = Period(value = 1, unit = Period.Unit.YEAR, iso8601 = "P1Y"), freeTrialPeriod = Period(value = 1, unit = Period.Unit.MONTH, iso8601 = "P1M"), ) + private val productYearlySekOneOffer = TestStoreProduct( + id = "com.revenuecat.annual_product", + name = "Annual", + title = "Annual (App name)", + price = Price( + amountMicros = 20000_000_000, + currencyCode = "SEK", + formatted = "20.000,00 kr", + ), + description = "Annual", + period = Period(value = 1, unit = Period.Unit.YEAR, iso8601 = "P1Y"), + freeTrialPeriod = Period(value = 1, unit = Period.Unit.MONTH, iso8601 = "P1M"), + ) private val productLifetimeUsd = TestStoreProduct( id = "com.revenuecat.lifetime_product", name = "Lifetime", @@ -222,6 +235,14 @@ internal class TextComponentViewVariablesTests( product = productYearlyJpyOneOffer, ) + @Suppress("DEPRECIATION") + private val packageYearlySekOneOffer = Package( + packageType = PackageType.ANNUAL, + identifier = "package_yearly", + offering = OFFERING_ID, + product = productYearlySekOneOffer, + ) + @Suppress("DEPRECATION") private val packageLifetimeUsd = Package( packageType = PackageType.LIFETIME, @@ -1003,7 +1024,7 @@ internal class TextComponentViewVariablesTests( storefrontCountryCode = "JP", variableLocalizations = variableLocalizationKeysForEnUs(), ), - "¥55" + "¥55" ), arrayOf( "{{ ${Variable.PRODUCT_PRICE_PER_WEEK.identifier} }}", @@ -1013,7 +1034,7 @@ internal class TextComponentViewVariablesTests( storefrontCountryCode = "JP", variableLocalizations = variableLocalizationKeysForEnUs(), ), - "¥384" + "¥384" ), arrayOf( "{{ ${Variable.PRODUCT_PRICE_PER_MONTH.identifier} }}", @@ -1023,7 +1044,7 @@ internal class TextComponentViewVariablesTests( storefrontCountryCode = "JP", variableLocalizations = variableLocalizationKeysForEnUs(), ), - "¥1,667" + "¥1,667" ), arrayOf( "{{ ${Variable.PRODUCT_PRICE_PER_YEAR.identifier} }}", @@ -1033,7 +1054,7 @@ internal class TextComponentViewVariablesTests( storefrontCountryCode = "JP", variableLocalizations = variableLocalizationKeysForEnUs(), ), - "¥20,000" + "¥20,000" ), arrayOf( "{{ ${Variable.PRODUCT_PRICE_PER_PERIOD.identifier} }}", @@ -1055,6 +1076,48 @@ internal class TextComponentViewVariablesTests( ), "¥20,000/yr" ), + // storefrontCountryCode different from device locale. We should always prefer the storefront country when + // formatting calculated prices, to avoid discrepancies with prices coming from the store directly. + arrayOf( + "{{ ${Variable.PRODUCT_PRICE_PER_DAY.identifier} }}", + Args( + packages = listOf(packageYearlySekOneOffer), + locale = "en_US", + storefrontCountryCode = "SE", + variableLocalizations = variableLocalizationKeysForEnUs(), + ), + "54,79 kr" + ), + arrayOf( + "{{ ${Variable.PRODUCT_PRICE_PER_WEEK.identifier} }}", + Args( + packages = listOf(packageYearlySekOneOffer), + locale = "en_US", + storefrontCountryCode = "SE", + variableLocalizations = variableLocalizationKeysForEnUs(), + ), + "383,56 kr" + ), + arrayOf( + "{{ ${Variable.PRODUCT_PRICE_PER_MONTH.identifier} }}", + Args( + packages = listOf(packageYearlySekOneOffer), + locale = "en_US", + storefrontCountryCode = "SE", + variableLocalizations = variableLocalizationKeysForEnUs(), + ), + "1 666,67 kr" + ), + arrayOf( + "{{ ${Variable.PRODUCT_PRICE_PER_YEAR.identifier} }}", + Args( + packages = listOf(packageYearlySekOneOffer), + locale = "en_US", + storefrontCountryCode = "SE", + variableLocalizations = variableLocalizationKeysForEnUs(), + ), + "20 000,00 kr" + ), // Functions: arrayOf( "{{ product.store_product_name | lowercase }}", diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/CustomActionDataTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/CustomActionDataTest.kt new file mode 100644 index 0000000000..921d85788a --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/CustomActionDataTest.kt @@ -0,0 +1,45 @@ +package com.revenuecat.purchases.ui.revenuecatui.customercenter + +import com.revenuecat.purchases.customercenter.CustomActionData +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class CustomActionDataTest { + + @Test + fun `CustomActionData creates correctly with both parameters`() { + val actionIdentifier = "delete_user" + val purchaseIdentifier = "monthly_subscription" + + val customActionData = CustomActionData( + actionIdentifier = actionIdentifier, + purchaseIdentifier = purchaseIdentifier + ) + + assertThat(customActionData.actionIdentifier).isEqualTo(actionIdentifier) + assertThat(customActionData.purchaseIdentifier).isEqualTo(purchaseIdentifier) + } + + @Test + fun `CustomActionData creates correctly with null purchase identifier`() { + val actionIdentifier = "rate_app" + + val customActionData = CustomActionData( + actionIdentifier = actionIdentifier, + purchaseIdentifier = null + ) + + assertThat(customActionData.actionIdentifier).isEqualTo(actionIdentifier) + assertThat(customActionData.purchaseIdentifier).isNull() + } + + @Test + fun `CustomActionData equality works correctly`() { + val customActionData1 = CustomActionData("action1", "purchase1") + val customActionData2 = CustomActionData("action1", "purchase1") + val customActionData3 = CustomActionData("action2", "purchase1") + + assertThat(customActionData1).isEqualTo(customActionData2) + assertThat(customActionData1).isNotEqualTo(customActionData3) + } +} \ No newline at end of file diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/ScreenOfferingExtensionsTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/ScreenOfferingExtensionsTest.kt new file mode 100644 index 0000000000..630be1d1c0 --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/ScreenOfferingExtensionsTest.kt @@ -0,0 +1,134 @@ +package com.revenuecat.purchases.ui.revenuecatui.customercenter + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.customercenter.CustomerCenterConfigData +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE) +class ScreenOfferingExtensionsTest { + + private val localization = CustomerCenterConfigData.Localization( + locale = "en_US", + localizedStrings = mapOf("buy_subscription" to "Custom Buy Subscription Text") + ) + + @Test + fun `resolveButtonText returns custom buttonText when available`() { + val screenOffering = CustomerCenterConfigData.ScreenOffering( + type = CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.CURRENT, + buttonText = "Get Premium Now" + ) + + val screen = CustomerCenterConfigData.Screen( + type = CustomerCenterConfigData.Screen.ScreenType.NO_ACTIVE, + title = "Test Screen", + paths = emptyList(), + offering = screenOffering + ) + + val result = screen.resolveButtonText(localization) + + assertThat(result).isEqualTo("Get Premium Now") + } + + @Test + fun `resolveButtonText falls back to BUY_SUBSCRIPTION when buttonText is null`() { + val screenOffering = CustomerCenterConfigData.ScreenOffering( + type = CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.CURRENT, + buttonText = null + ) + + val screen = CustomerCenterConfigData.Screen( + type = CustomerCenterConfigData.Screen.ScreenType.NO_ACTIVE, + title = "Test Screen", + paths = emptyList(), + offering = screenOffering + ) + + val result = screen.resolveButtonText(localization) + + assertThat(result).isEqualTo("Custom Buy Subscription Text") + } + + @Test + fun `resolveButtonText falls back to BUY_SUBSCRIPTION when offering is null`() { + val screen = CustomerCenterConfigData.Screen( + type = CustomerCenterConfigData.Screen.ScreenType.NO_ACTIVE, + title = "Test Screen", + paths = emptyList(), + offering = null + ) + + val result = screen.resolveButtonText(localization) + + assertThat(result).isEqualTo("Custom Buy Subscription Text") + } + + @Test + fun `resolveButtonText uses default BUY_SUBSCRIPTION text when not in localization`() { + val emptyLocalization = CustomerCenterConfigData.Localization( + locale = "en_US", + localizedStrings = emptyMap() + ) + + val screenOffering = CustomerCenterConfigData.ScreenOffering( + type = CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.CURRENT, + buttonText = null + ) + + val screen = CustomerCenterConfigData.Screen( + type = CustomerCenterConfigData.Screen.ScreenType.NO_ACTIVE, + title = "Test Screen", + paths = emptyList(), + offering = screenOffering + ) + + val result = screen.resolveButtonText(emptyLocalization) + + // Should fall back to the default value defined in CommonLocalizedString.BUY_SUBSCRIPTION + assertThat(result).isEqualTo("Buy Subscription") + } + + @Test + fun `resolveButtonText works with empty buttonText string`() { + val screenOffering = CustomerCenterConfigData.ScreenOffering( + type = CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.CURRENT, + buttonText = "" + ) + + val screen = CustomerCenterConfigData.Screen( + type = CustomerCenterConfigData.Screen.ScreenType.NO_ACTIVE, + title = "Test Screen", + paths = emptyList(), + offering = screenOffering + ) + + val result = screen.resolveButtonText(localization) + + assertThat(result).isEqualTo("") + } + + @Test + fun `resolveButtonText works with different screen types`() { + val screenOffering = CustomerCenterConfigData.ScreenOffering( + type = CustomerCenterConfigData.ScreenOffering.ScreenOfferingType.SPECIFIC, + offeringId = "premium_monthly", + buttonText = "Upgrade to Monthly" + ) + + val managementScreen = CustomerCenterConfigData.Screen( + type = CustomerCenterConfigData.Screen.ScreenType.MANAGEMENT, + title = "Management Screen", + paths = emptyList(), + offering = screenOffering + ) + + val result = managementScreen.resolveButtonText(localization) + + assertThat(result).isEqualTo("Upgrade to Monthly") + } +} \ No newline at end of file diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterViewModelTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterViewModelTests.kt index 667c8f2878..7d5126e151 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterViewModelTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterViewModelTests.kt @@ -38,6 +38,7 @@ import com.revenuecat.purchases.ui.revenuecatui.data.testdata.TestData import com.revenuecat.purchases.ui.revenuecatui.helpers.createGoogleStoreProduct import com.revenuecat.purchases.ui.revenuecatui.helpers.stubGoogleSubscriptionOption import com.revenuecat.purchases.ui.revenuecatui.helpers.stubPricingPhase +import com.revenuecat.purchases.ui.revenuecatui.helpers.subtract import io.mockk.Runs import io.mockk.clearAllMocks import io.mockk.clearMocks @@ -49,11 +50,10 @@ import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat import org.junit.After import org.junit.Before @@ -61,6 +61,7 @@ import org.junit.Test import org.junit.runner.RunWith import java.util.Date import java.util.Locale +import kotlin.time.Duration @RunWith(AndroidJUnit4::class) class CustomerCenterViewModelTests { @@ -112,6 +113,24 @@ class CustomerCenterViewModelTests { type = HelpPath.PathType.REFUND_REQUEST ) ) + ), + Screen.ScreenType.NO_ACTIVE to CustomerCenterConfigData.Screen( + type = CustomerCenterConfigData.Screen.ScreenType.NO_ACTIVE, + title = "No Active Subscription", + subtitle = "You don't have an active subscription.", + paths = listOf( + HelpPath( + id = "restore_id", + title = "Restore Purchases", + type = HelpPath.PathType.MISSING_PURCHASE + ), + HelpPath( + id = "support_id", + title = "Contact Support", + type = HelpPath.PathType.CUSTOM_URL, + url = "https://support.example.com" + ) + ) ) ) } @@ -122,7 +141,7 @@ class CustomerCenterViewModelTests { } @Test - fun `loadAndDisplayPromotionalOffer returns false when offer is not eligible`() = runTest { + fun `loadAndDisplayPromotionalOffer returns false when offer is not eligible`(): Unit = runBlocking { setupPurchasesMock() val model = setupViewModel() @@ -160,7 +179,7 @@ class CustomerCenterViewModelTests { } @Test - fun `loadAndDisplayPromotionalOffer works if legacy product mapping`() = runTest { + fun `loadAndDisplayPromotionalOffer works if legacy product mapping`(): Unit = runBlocking { setupPurchasesMock() val model = setupViewModel() @@ -206,7 +225,7 @@ class CustomerCenterViewModelTests { } @Test - fun `loadAndDisplayPromotionalOffer works if legacy product mapping trial`() = runTest { + fun `loadAndDisplayPromotionalOffer works if legacy product mapping trial`(): Unit = runBlocking { setupPurchasesMock() val model = setupViewModel() @@ -251,7 +270,7 @@ class CustomerCenterViewModelTests { } @Test - fun `loadAndDisplayPromotionalOffer returns false when no matching offer found in legacy product mapping`() = runTest { + fun `loadAndDisplayPromotionalOffer returns false when no matching offer found in legacy product mapping`(): Unit = runBlocking { setupPurchasesMock() val model = setupViewModel() @@ -293,7 +312,7 @@ class CustomerCenterViewModelTests { } @Test - fun `loadAndDisplayPromotionalOffer returns true for cross-product promotion`() = runTest { + fun `loadAndDisplayPromotionalOffer returns true for cross-product promotion`(): Unit = runBlocking { setupPurchasesMock() val model = setupViewModel() @@ -356,7 +375,7 @@ class CustomerCenterViewModelTests { } @Test - fun `loadAndDisplayPromotionalOffer returns false when target product not found in cross-product promotion`() = runTest { + fun `loadAndDisplayPromotionalOffer returns false when target product not found in cross-product promotion`(): Unit = runBlocking { setupPurchasesMock() val model = setupViewModel() @@ -404,7 +423,7 @@ class CustomerCenterViewModelTests { } @Test - fun `loadAndDisplayPromotionalOffer returns false when no matching cross-product promotion found`() = runTest { + fun `loadAndDisplayPromotionalOffer returns false when no matching cross-product promotion found`(): Unit = runBlocking { setupPurchasesMock() val model = setupViewModel() @@ -463,7 +482,7 @@ class CustomerCenterViewModelTests { } @Test - fun `loadAndDisplayPromotionalOffer loads target product with same base plan if not specified`() = runTest { + fun `loadAndDisplayPromotionalOffer loads target product with same base plan if not specified`(): Unit = runBlocking { setupPurchasesMock() val model = setupViewModel() @@ -532,7 +551,7 @@ class CustomerCenterViewModelTests { } @Test - fun `loadAndDisplayPromotionalOffer handles source product without base plan`() = runTest { + fun `loadAndDisplayPromotionalOffer handles source product without base plan`(): Unit = runBlocking { setupPurchasesMock() val model = setupViewModel() @@ -602,7 +621,7 @@ class CustomerCenterViewModelTests { } @Test - fun `onNavigationButtonPressed handles CLOSE and BACK buttons correctly`() = runTest { + fun `onNavigationButtonPressed handles CLOSE and BACK buttons correctly`(): Unit = runBlocking { setupPurchasesMock() every { customerInfo.activeSubscriptions } returns setOf("product-id") @@ -713,7 +732,7 @@ class CustomerCenterViewModelTests { } @Test - fun `dismissRestoreDialog reloads customer center`() = runTest { + fun `dismissRestoreDialog reloads customer center`(): Unit = runBlocking { setupPurchasesMock() every { customerInfo.activeSubscriptions } returns setOf("product-id") @@ -810,7 +829,7 @@ class CustomerCenterViewModelTests { @Test - fun `notifyListenersForRestoreStarted calls both listeners`() = runTest { + fun `notifyListenersForRestoreStarted calls both listeners`(): Unit = runBlocking { setupPurchasesMock() // Create two separate listeners to verify they're both called @@ -836,7 +855,7 @@ class CustomerCenterViewModelTests { } @Test - fun `notifyListenersForRestoreCompleted calls both listeners with correct customer info`() = runTest { + fun `notifyListenersForRestoreCompleted calls both listeners with correct customer info`(): Unit = runBlocking { setupPurchasesMock() val directListener = mockk(relaxed = true) @@ -861,7 +880,7 @@ class CustomerCenterViewModelTests { } @Test - fun `notifyListenersForRestoreFailed calls both listeners with correct error`() = runTest { + fun `notifyListenersForRestoreFailed calls both listeners with correct error`(): Unit = runBlocking { setupPurchasesMock() val directListener = mockk(relaxed = true) @@ -889,7 +908,7 @@ class CustomerCenterViewModelTests { } @Test - fun `notifyListenersForManageSubscription calls both listeners`() = runTest { + fun `notifyListenersForManageSubscription calls both listeners`(): Unit = runBlocking { setupPurchasesMock() val directListener = mockk(relaxed = true) @@ -974,7 +993,7 @@ class CustomerCenterViewModelTests { } @Test - fun `feedback survey completion notifies listeners with correct ID`() = runTest { + fun `feedback survey completion notifies listeners with correct ID`(): Unit = runBlocking { setupPurchasesMock() val directListener = mockk(relaxed = true) @@ -1073,7 +1092,7 @@ class CustomerCenterViewModelTests { } @Test - fun `notifyListenersForManagementOptionSelected converts paths to actions and notifies listeners`() = runTest { + fun `notifyListenersForManagementOptionSelected converts paths to actions and notifies listeners`(): Unit = runBlocking { setupPurchasesMock() val context = mockk(relaxed = true) @@ -1152,7 +1171,7 @@ class CustomerCenterViewModelTests { } @Test - fun `loadCustomerCenter is called after successful promotional offer purchase`() = runTest { + fun `loadCustomerCenter is called after successful promotional offer purchase`(): Unit = runBlocking { setupPurchasesMock() val model = CustomerCenterViewModelImpl( @@ -1178,7 +1197,7 @@ class CustomerCenterViewModelTests { } @Test - fun `loadCustomerCenter uses base plan from active subscription when entitlements are empty`() = runTest { + fun `loadCustomerCenter uses base plan from active subscription when entitlements are empty`(): Unit = runBlocking { setupPurchasesMock() val subscription = SubscriptionInfo( @@ -1227,7 +1246,7 @@ class CustomerCenterViewModelTests { } @Test - fun `isSupportedPaths allows CANCEL when purchase is not lifetime`() = runTest { + fun `isSupportedPaths allows CANCEL when purchase is not lifetime`(): Unit = runBlocking { setupPurchasesMock() every { customerInfo.activeSubscriptions } returns setOf(TestData.Packages.monthly.product.id) every { customerInfo.subscriptionsByProductIdentifier } returns mapOf( @@ -1287,7 +1306,7 @@ class CustomerCenterViewModelTests { } @Test - fun `isSupportedPaths filters CANCEL when management URL is not present`() = runTest { + fun `isSupportedPaths filters CANCEL when management URL is not present`(): Unit = runBlocking { setupPurchasesMock() every { customerInfo.activeSubscriptions } returns setOf(TestData.Packages.monthly.product.id) every { customerInfo.subscriptionsByProductIdentifier } returns mapOf( @@ -1313,7 +1332,6 @@ class CustomerCenterViewModelTests { managementURL = null, ) ) - every { customerInfo.nonSubscriptionTransactions } returns listOf() val model = CustomerCenterViewModelImpl( @@ -1323,24 +1341,15 @@ class CustomerCenterViewModelTests { isDarkMode = false ) - val job = launch { - model.state.collect { state -> - if (state is CustomerCenterState.Success) { - val paths = state.mainScreenPaths - assertThat(paths) - .withFailMessage( - "Expected CANCEL path to not be present when there are no management URL. Paths: $paths") - .noneMatch { it.type == HelpPath.PathType.CANCEL } - cancel() - } - } - } - - job.join() + val state = model.state.filterIsInstance().first() + val paths = state.mainScreenPaths + assertThat(paths) + .withFailMessage("Expected CANCEL path to not be present when there are no management URL. Paths: $paths") + .noneMatch { it.type == HelpPath.PathType.CANCEL } } @Test - fun `isSupportedPaths filters CANCEL when purchase is lifetime`() = runTest { + fun `isSupportedPaths filters CANCEL when purchase is lifetime`(): Unit = runBlocking { setupPurchasesMock() every { customerInfo.activeSubscriptions } returns setOf(TestData.Packages.monthly.product.id) every { customerInfo.nonSubscriptionTransactions } returns listOf( @@ -1362,23 +1371,15 @@ class CustomerCenterViewModelTests { isDarkMode = false ) - val job = launch { - model.state.collect { state -> - if (state is CustomerCenterState.Success) { - val paths = state.mainScreenPaths - assertThat(paths) - .withFailMessage("Expected CANCEL path to not be present for lifetime purchases. Paths: $paths") - .noneMatch { it.type == HelpPath.PathType.CANCEL } - cancel() - } - } - } - - job.join() + val state = model.state.filterIsInstance().first() + val paths = state.mainScreenPaths + assertThat(paths) + .withFailMessage("Expected CANCEL path to not be present for lifetime purchases. Paths: $paths") + .noneMatch { it.type == HelpPath.PathType.CANCEL } } @Test - fun `isSupportedPaths filters CANCEL when purchase is non Play Store lifetime and management URL`() = runTest { + fun `isSupportedPaths filters CANCEL when purchase is non Play Store lifetime and management URL`(): Unit = runBlocking { setupPurchasesMock() every { customerInfo.activeSubscriptions } returns setOf(TestData.Packages.monthly.product.id) every { customerInfo.managementURL } returns Uri.parse("https://apple.com/") @@ -1401,23 +1402,15 @@ class CustomerCenterViewModelTests { isDarkMode = false ) - val job = launch { - model.state.collect { state -> - if (state is CustomerCenterState.Success) { - val paths = state.mainScreenPaths - assertThat(paths) - .withFailMessage("Expected CANCEL path to not be present for lifetime purchases. Paths: $paths") - .noneMatch { it.type == HelpPath.PathType.CANCEL } - cancel() - } - } - } - - job.join() + val state = model.state.filterIsInstance().first() + val paths = state.mainScreenPaths + assertThat(paths) + .withFailMessage("Expected CANCEL path to not be present for lifetime purchases. Paths: $paths") + .noneMatch { it.type == HelpPath.PathType.CANCEL } } @Test - fun `isSupportedPaths allows CUSTOM_URL for Play store`() = runTest { + fun `isSupportedPaths allows CUSTOM_URL for Play store`(): Unit = runBlocking { setupPurchasesMock() every { customerInfo.activeSubscriptions } returns setOf(TestData.Packages.monthly.product.id) every { customerInfo.subscriptionsByProductIdentifier } returns mapOf( @@ -1451,23 +1444,15 @@ class CustomerCenterViewModelTests { isDarkMode = false ) - val job = launch { - model.state.collect { state -> - if (state is CustomerCenterState.Success) { - val paths = state.mainScreenPaths - assertThat(paths) - .withFailMessage("Expected CUSTOM_URL path to be present. Paths: $paths") - .anyMatch { it.type == HelpPath.PathType.CUSTOM_URL } - cancel() - } - } - } - - job.join() + val state = model.state.filterIsInstance().first() + val paths = state.mainScreenPaths + assertThat(paths) + .withFailMessage("Expected CUSTOM_URL path to be present. Paths: $paths") + .anyMatch { it.type == HelpPath.PathType.CUSTOM_URL } } @Test - fun `isSupportedPaths allows CUSTOM_URL for App store`() = runTest { + fun `isSupportedPaths allows CUSTOM_URL for App store`(): Unit = runBlocking { setupPurchasesMock() every { customerInfo.activeSubscriptions } returns setOf(TestData.Packages.monthly.product.id) every { customerInfo.subscriptionsByProductIdentifier } returns mapOf( @@ -1501,23 +1486,15 @@ class CustomerCenterViewModelTests { isDarkMode = false ) - val job = launch { - model.state.collect { state -> - if (state is CustomerCenterState.Success) { - val paths = state.mainScreenPaths - assertThat(paths) - .withFailMessage("Expected CUSTOM_URL path to be present. Paths: $paths") - .anyMatch { it.type == HelpPath.PathType.CUSTOM_URL } - cancel() - } - } - } - - job.join() + val state = model.state.filterIsInstance().first() + val paths = state.mainScreenPaths + assertThat(paths) + .withFailMessage("Expected CUSTOM_URL path to be present. Paths: $paths") + .anyMatch { it.type == HelpPath.PathType.CUSTOM_URL } } @Test - fun `isSupportedPaths allows MISSING_PURCHASE for App store`() = runTest { + fun `isSupportedPaths allows MISSING_PURCHASE for App store`(): Unit = runBlocking { setupPurchasesMock() every { customerInfo.activeSubscriptions } returns setOf(TestData.Packages.monthly.product.id) every { customerInfo.subscriptionsByProductIdentifier } returns mapOf( @@ -1551,23 +1528,15 @@ class CustomerCenterViewModelTests { isDarkMode = false ) - val job = launch { - model.state.collect { state -> - if (state is CustomerCenterState.Success) { - val paths = state.mainScreenPaths - assertThat(paths) - .withFailMessage("Expected MISSING_PURCHASE path for APP_STORE. Paths: $paths") - .anyMatch { it.type == HelpPath.PathType.MISSING_PURCHASE } - cancel() - } - } - } - - job.join() + val state = model.state.filterIsInstance().first() + val paths = state.mainScreenPaths + assertThat(paths) + .withFailMessage("Expected MISSING_PURCHASE path for APP_STORE. Paths: $paths") + .anyMatch { it.type == HelpPath.PathType.MISSING_PURCHASE } } @Test - fun `isSupportedPaths allows MISSING_PURCHASE for Play store`() = runTest { + fun `isSupportedPaths allows MISSING_PURCHASE for Play store`(): Unit = runBlocking { setupPurchasesMock() every { customerInfo.activeSubscriptions } returns setOf(TestData.Packages.monthly.product.id) every { customerInfo.subscriptionsByProductIdentifier } returns mapOf( @@ -1601,24 +1570,17 @@ class CustomerCenterViewModelTests { isDarkMode = false ) - val job = launch { - model.state.collect { state -> - if (state is CustomerCenterState.Success) { - val paths = state.mainScreenPaths - assertThat(paths) - .withFailMessage("Expected MISSING_PURCHASE path for PLAY_STORE. Paths: $paths") - .anyMatch { it.type == HelpPath.PathType.MISSING_PURCHASE } - cancel() - } - } - } - - job.join() + val state = model.state.filterIsInstance().first() + val paths = state.mainScreenPaths + assertThat(paths) + .withFailMessage("Expected MISSING_PURCHASE path for PLAY_STORE. Paths: $paths") + .anyMatch { it.type == HelpPath.PathType.MISSING_PURCHASE } } @Test - fun `isSupportedPaths filters non compatible paths for App store`() = runTest { + fun `isSupportedPaths filters non compatible paths for App store`(): Unit = runBlocking { setupPurchasesMock() + every { customerInfo.activeSubscriptions } returns setOf(TestData.Packages.monthly.product.id) every { customerInfo.subscriptionsByProductIdentifier } returns mapOf( "productIdentifier" to SubscriptionInfo( @@ -1651,26 +1613,250 @@ class CustomerCenterViewModelTests { isDarkMode = false ) - val job = launch { - model.state.collect { state -> - if (state is CustomerCenterState.Success) { - val paths = state.mainScreenPaths - assertThat(paths) - .withFailMessage("Not expected REFUND_REQUEST path for APP_STORE. Paths: $paths") - .noneMatch { it.type == HelpPath.PathType.REFUND_REQUEST } - assertThat(paths) - .withFailMessage("Not expected CHANGE_PLANS path for APP_STORE. Paths: $paths") - .noneMatch { it.type == HelpPath.PathType.CHANGE_PLANS } - cancel() - } - } - } + val state = model.state.filterIsInstance().first() + val paths = state.mainScreenPaths + assertThat(paths) + .withFailMessage("Not expected REFUND_REQUEST path for APP_STORE. Paths: $paths") + .noneMatch { it.type == HelpPath.PathType.REFUND_REQUEST } + assertThat(paths) + .withFailMessage("Not expected CHANGE_PLANS path for APP_STORE. Paths: $paths") + .noneMatch { it.type == HelpPath.PathType.CHANGE_PLANS } + } - job.join() + @Test + fun `transformPathsOnSubscriptionState converts CANCEL to RESUBSCRIBE for cancelled subs`(): Unit = runBlocking { + setupPurchasesMock() + + val cancelPath = HelpPath( + id = "cancel_id", + title = "Cancel", + type = HelpPath.PathType.CANCEL, + feedbackSurvey = HelpPath.PathDetail.FeedbackSurvey( + title = "Why are you cancelling?", + options = listOf( + HelpPath.PathDetail.FeedbackSurvey.Option( + id = "1", + title = "Too expensive", + promotionalOffer = null + ) + ) + ), + promotionalOffer = examplePromotionalOffer() + ) + + val managementScreen = Screen( + type = Screen.ScreenType.MANAGEMENT, + title = "Management", + subtitle = null, + paths = listOf(cancelPath) + ) + + every { configData.getManagementScreen() } returns managementScreen + every { configData.localization } returns CustomerCenterConfigData.Localization( + locale = "en_US", + localizedStrings = mapOf( + "cancel" to "Cancel", + "resubscribe" to "Resubscribe" + ) + ) + + val model = CustomerCenterViewModelImpl( + purchases = purchases, + locale = Locale.US, + colorScheme = TestData.Constants.currentColorScheme, + isDarkMode = false + ) + + // Wait for the initial load to complete + model.state.filterIsInstance().first() + + // Select the cancelled purchase + val cancelledPurchase = CustomerCenterConfigTestData.purchaseInformationYearlyExpiring + model.selectPurchase(cancelledPurchase) + + val state = model.state.filterIsInstance() + .first { it.currentDestination is CustomerCenterDestination.SelectedPurchaseDetail } + val paths = state.detailScreenPaths + + val transformedPath = paths.find { it.id == "cancel_id" } + assertThat(transformedPath).isNotNull + assertThat(transformedPath?.title).isEqualTo("Resubscribe") + assertThat(transformedPath?.type).isEqualTo(HelpPath.PathType.CANCEL) + assertThat(transformedPath?.feedbackSurvey).isNull() // Should be removed + assertThat(transformedPath?.promotionalOffer).isNull() // Should be removed } @Test - fun `isSupportedPaths filters non compatible paths for Play store`() = runTest { + fun `transformPathsOnSubscriptionState keeps CANCEL unchanged for active subscriptions`(): Unit = runBlocking { + setupPurchasesMock() + + val originalFeedbackSurvey = HelpPath.PathDetail.FeedbackSurvey( + title = "Why are you cancelling?", + options = listOf( + HelpPath.PathDetail.FeedbackSurvey.Option( + id = "1", + title = "Too expensive", + promotionalOffer = null + ) + ) + ) + + val originalPromotionalOffer = examplePromotionalOffer() + + val cancelPath = HelpPath( + id = "cancel_id", + title = "Cancel", + type = HelpPath.PathType.CANCEL, + feedbackSurvey = originalFeedbackSurvey, + promotionalOffer = originalPromotionalOffer + ) + + val managementScreen = Screen( + type = Screen.ScreenType.MANAGEMENT, + title = "Management", + subtitle = null, + paths = listOf(cancelPath) + ) + + every { configData.getManagementScreen() } returns managementScreen + every { configData.localization } returns CustomerCenterConfigData.Localization( + locale = "en_US", + localizedStrings = mapOf( + "cancel" to "Cancel", + "resubscribe" to "Resubscribe" + ) + ) + + val model = CustomerCenterViewModelImpl( + purchases = purchases, + locale = Locale.US, + colorScheme = TestData.Constants.currentColorScheme, + isDarkMode = false + ) + + // Wait for the initial load to complete + model.state.filterIsInstance().first() + + val activePurchase = CustomerCenterConfigTestData.purchaseInformationMonthlyRenewing + model.selectPurchase(activePurchase) + + val state = model.state.filterIsInstance() + .first { it.currentDestination is CustomerCenterDestination.SelectedPurchaseDetail } + val paths = state.detailScreenPaths + + // Find the path that should remain unchanged + val unchangedPath = paths.find { it.id == "cancel_id" } + assertThat(unchangedPath).isNotNull + assertThat(unchangedPath?.title).isEqualTo("Cancel") // Title should remain Cancel + assertThat(unchangedPath?.type).isEqualTo(HelpPath.PathType.CANCEL) + assertThat(unchangedPath?.feedbackSurvey).isEqualTo(originalFeedbackSurvey) // Should be preserved + assertThat(unchangedPath?.promotionalOffer).isEqualTo(originalPromotionalOffer) // Should be preserved + } + + @Test + fun `transformPathsOnSubscriptionState uses localized RESUBSCRIBE string`(): Unit = runBlocking { + setupPurchasesMock() + every { customerInfo.activeSubscriptions } returns setOf("product_id") + + val cancelPath = HelpPath( + id = "cancel_id", + title = "Cancel", + type = HelpPath.PathType.CANCEL + ) + + val managementScreen = Screen( + type = Screen.ScreenType.MANAGEMENT, + title = "Management", + subtitle = null, + paths = listOf(cancelPath) + ) + + every { configData.getManagementScreen() } returns managementScreen + every { configData.localization } returns CustomerCenterConfigData.Localization( + locale = "es_ES", + localizedStrings = mapOf( + "cancel" to "Cancelar", + "resubscribe" to "Resuscribirse" + ) + ) + + val model = CustomerCenterViewModelImpl( + purchases = purchases, + locale = Locale.US, + colorScheme = TestData.Constants.currentColorScheme, + isDarkMode = false + ) + + // Wait for the initial load to complete + model.state.filterIsInstance().first() + + // Select the cancelled purchase + val cancelledPurchase = CustomerCenterConfigTestData.purchaseInformationYearlyExpiring + model.selectPurchase(cancelledPurchase) + + val state = model.state.filterIsInstance() + .first { it.currentDestination is CustomerCenterDestination.SelectedPurchaseDetail } + val paths = state.detailScreenPaths + + // Find the transformed path + val transformedPath = paths.find { it.id == "cancel_id" } + assertThat(transformedPath).isNotNull + assertThat(transformedPath?.title).isEqualTo("Resuscribirse") // Should use localized string + } + + @Test + fun `transformPathsOnSubscriptionState falls back to default when localization missing`(): Unit = runBlocking { + setupPurchasesMock() + every { customerInfo.activeSubscriptions } returns setOf("product_id") + + val cancelPath = HelpPath( + id = "cancel_id", + title = "Cancel", + type = HelpPath.PathType.CANCEL + ) + + val managementScreen = Screen( + type = Screen.ScreenType.MANAGEMENT, + title = "Management", + subtitle = null, + paths = listOf(cancelPath) + ) + + every { configData.getManagementScreen() } returns managementScreen + every { configData.localization } returns CustomerCenterConfigData.Localization( + locale = "en_US", + localizedStrings = mapOf( + "cancel" to "Cancel" + // No "resubscribe" string provided + ) + ) + + val model = CustomerCenterViewModelImpl( + purchases = purchases, + locale = Locale.US, + colorScheme = TestData.Constants.currentColorScheme, + isDarkMode = false + ) + + // Wait for the initial load to complete + model.state.filterIsInstance().first() + + // Select the cancelled purchase + val cancelledPurchase = CustomerCenterConfigTestData.purchaseInformationYearlyExpiring + model.selectPurchase(cancelledPurchase) + + + val state = model.state.filterIsInstance() + .first { it.currentDestination is CustomerCenterDestination.SelectedPurchaseDetail } + val paths = state.detailScreenPaths + + val transformedPath = paths.find { it.id == "cancel_id" } + assertThat(transformedPath).isNotNull + assertThat(transformedPath?.title).isEqualTo("Resubscribe") + } + + @Test + fun `isSupportedPaths filters non compatible paths for Play store`(): Unit = runBlocking { setupPurchasesMock() every { customerInfo.activeSubscriptions } returns setOf(TestData.Packages.monthly.product.id) every { customerInfo.subscriptionsByProductIdentifier } returns mapOf( @@ -1704,6 +1890,9 @@ class CustomerCenterViewModelTests { isDarkMode = false ) + // Wait for the initial state to load + model.state.first { it is CustomerCenterState.Success } + val job = launch { model.state.collect { state -> if (state is CustomerCenterState.Success) { @@ -1722,7 +1911,162 @@ class CustomerCenterViewModelTests { job.join() } - // Helper method to setup common mocks + @Test + fun `loadCustomerCenter shows latest expired subscription when no active purchases`(): Unit = runBlocking { + setupPurchasesMock() + every { customerInfo.activeSubscriptions } returns setOf() + every { customerInfo.nonSubscriptionTransactions } returns emptyList() + + val expiredDate1 = Date(System.currentTimeMillis() - 7 * 24 * 60 * 60 * 1000) // 7 days ago + val purchaseDate1 = expiredDate1.subtract(Duration.parse("7d")) + + val expiredDate2 = Date(System.currentTimeMillis() - 3 * 24 * 60 * 60 * 1000) // 3 days ago (latest) + val purchaseDate2 = expiredDate2.subtract(Duration.parse("7d")) + + val olderExpiredSubscription = SubscriptionInfo( + productIdentifier = "old_product", + purchaseDate = purchaseDate1, + originalPurchaseDate = purchaseDate1, + expiresDate = expiredDate1, + store = Store.PLAY_STORE, + unsubscribeDetectedAt = Date(), + isSandbox = false, + billingIssuesDetectedAt = null, + gracePeriodExpiresDate = null, + ownershipType = OwnershipType.PURCHASED, + periodType = PeriodType.NORMAL, + refundedAt = null, + storeTransactionId = null, + requestDate = Date(), + autoResumeDate = null, + displayName = null, + price = null, + productPlanIdentifier = null, + managementURL = null, + ) + + val latestExpiredSubscription = SubscriptionInfo( + productIdentifier = "latest_product", + purchaseDate = purchaseDate2, + originalPurchaseDate = purchaseDate2, + expiresDate = expiredDate2, + store = Store.PLAY_STORE, + unsubscribeDetectedAt = Date(), + isSandbox = false, + billingIssuesDetectedAt = null, + gracePeriodExpiresDate = null, + ownershipType = OwnershipType.PURCHASED, + periodType = PeriodType.NORMAL, + refundedAt = null, + storeTransactionId = null, + requestDate = Date(), + autoResumeDate = null, + displayName = null, + price = null, + productPlanIdentifier = null, + managementURL = null, + ) + + every { customerInfo.subscriptionsByProductIdentifier } returns mapOf( + "old_product" to olderExpiredSubscription, + "latest_product" to latestExpiredSubscription + ) + + val mockProduct = createGoogleStoreProduct( + productId = "latest_product", + basePlanId = "monthly", + name = "Latest Product" + ) + coEvery { purchases.awaitGetProduct("latest_product", null) } returns mockProduct + + val model = setupViewModel() + + val successState = model.state.filterIsInstance().first() + + assertThat(successState.purchases).hasSize(1) + val purchase = successState.purchases.first() + assertThat(purchase.product?.id).isEqualTo("latest_product:monthly") + assertThat(purchase.isExpired).isTrue() + } + + @Test + fun `loadCustomerCenter returns empty when no purchases at all`(): Unit = runBlocking { + setupPurchasesMock() + every { customerInfo.activeSubscriptions } returns setOf() + every { customerInfo.nonSubscriptionTransactions } returns emptyList() + every { customerInfo.subscriptionsByProductIdentifier } returns emptyMap() + + val model = setupViewModel() + + val successState = model.state.filterIsInstance().first() + + assertThat(successState.purchases).isEmpty() + } + + @Test + fun `expired subscription should not show CANCEL path`(): Unit = runBlocking { + setupPurchasesMock() + every { customerInfo.activeSubscriptions } returns setOf() + every { customerInfo.nonSubscriptionTransactions } returns emptyList() + + val expiredDate = Date(System.currentTimeMillis() - 3 * 24 * 60 * 60 * 1000) // 3 days ago + val purchaseDate = expiredDate.subtract(Duration.parse("7d")) + + val expiredSubscription = SubscriptionInfo( + productIdentifier = "expired_product", + purchaseDate = purchaseDate, + originalPurchaseDate = purchaseDate, + expiresDate = expiredDate, + store = Store.PLAY_STORE, + unsubscribeDetectedAt = Date(), + isSandbox = false, + billingIssuesDetectedAt = null, + gracePeriodExpiresDate = null, + ownershipType = OwnershipType.PURCHASED, + periodType = PeriodType.NORMAL, + refundedAt = null, + storeTransactionId = null, + requestDate = Date(), + autoResumeDate = null, + displayName = null, + price = null, + productPlanIdentifier = null, + managementURL = Uri.parse("https://play.google.com/store/account/subscriptions"), + ) + + every { customerInfo.subscriptionsByProductIdentifier } returns mapOf( + "expired_product" to expiredSubscription + ) + + val mockProduct = createGoogleStoreProduct( + productId = "expired_product", + basePlanId = "monthly", + name = "Expired Product" + ) + coEvery { purchases.awaitGetProduct("expired_product", null) } returns mockProduct + + val model = setupViewModel() + + val successState = model.state.filterIsInstance().first() + + assertThat(successState.purchases).hasSize(1) + val purchase = successState.purchases.first() + assertThat(purchase.isExpired).isTrue() + + val paths = successState.mainScreenPaths + assertThat(paths) + .withFailMessage("Expected CANCEL path to not be present for expired subscription. Paths: $paths") + .noneMatch { it.type == CustomerCenterConfigData.HelpPath.PathType.CANCEL } + + // Should have paths from NO_ACTIVE screen (MISSING_PURCHASE and CUSTOM_URL) + assertThat(paths) + .withFailMessage("Expected MISSING_PURCHASE path from NO_ACTIVE screen. Paths: $paths") + .anyMatch { it.type == CustomerCenterConfigData.HelpPath.PathType.MISSING_PURCHASE && it.id == "restore_id" } + assertThat(paths) + .withFailMessage("Expected CUSTOM_URL path from NO_ACTIVE screen. Paths: $paths") + .anyMatch { it.type == CustomerCenterConfigData.HelpPath.PathType.CUSTOM_URL && it.id == "support_id" } + } + private fun setupPurchasesMock() { every { purchases.customerCenterListener } returns null coEvery { purchases.awaitGetProduct(any(), any()) } returns null @@ -1734,9 +2078,16 @@ class CustomerCenterViewModelTests { every { purchases.storefrontCountryCode } returns "US" every { purchases.track(any()) } just Runs every { purchases.syncPurchases() } just Runs + every { purchases.preferredUILocaleOverride } returns null every { configData.getManagementScreen() } returns screens[Screen.ScreenType.MANAGEMENT] every { configData.getNoActiveScreen() } returns screens[Screen.ScreenType.NO_ACTIVE] + every { configData.localization } returns CustomerCenterConfigData.Localization( + locale = "en_US", + localizedStrings = mapOf( + "cancel" to "Cancel", + ) + ) every { customerInfo.managementURL } returns null every { customerInfo.activeSubscriptions } returns setOf() @@ -1744,10 +2095,11 @@ class CustomerCenterViewModelTests { every { customerInfo.subscriptionsByProductIdentifier } returns emptyMap() every { customerInfo.nonSubscriptionTransactions } returns emptyList() } - private suspend fun TestScope.setupSuccessLoadScreen( + + private suspend fun setupSuccessLoadScreen( originalPath: HelpPath, model: CustomerCenterViewModelImpl, - ) { + ): CustomerCenterState.Success { // Set up the state as Success val managementScreen = Screen( type = Screen.ScreenType.MANAGEMENT, @@ -1779,14 +2131,8 @@ class CustomerCenterViewModelTests { // Wait for initial state to load model.loadCustomerCenter() - val job = launch { - model.state.collect { state -> - if (state is CustomerCenterState.Success) { - cancel() - } - } - } - job.join() + val state = model.state.filterIsInstance().first() + return state } private fun createSubscriptionOption( @@ -1889,4 +2235,158 @@ class CustomerCenterViewModelTests { ) } + private fun examplePromotionalOffer() = CustomerCenterConfigTestData.customerCenterData() + .screens[Screen.ScreenType.MANAGEMENT]!!.paths[1].promotionalOffer + + @Test + fun `onCustomActionSelected calls listener with correct action identifier from actionIdentifier field`(): Unit = runBlocking { + setupPurchasesMock() + val directListener = mockk(relaxed = true) + val purchasesListener = mockk(relaxed = true) + every { purchases.customerCenterListener } returns purchasesListener + + val model = CustomerCenterViewModelImpl( + purchases = purchases, + locale = Locale.US, + colorScheme = TestData.Constants.currentColorScheme, + isDarkMode = false, + listener = directListener + ) + + val customActionData = com.revenuecat.purchases.customercenter.CustomActionData( + actionIdentifier = "delete_user", + purchaseIdentifier = "monthly_sub" + ) + + model.onCustomActionSelected(customActionData) + + verify { directListener.onCustomActionSelected("delete_user", "monthly_sub") } + verify { purchasesListener.onCustomActionSelected("delete_user", "monthly_sub") } + } + + @Test + fun `pathButtonPressed with CUSTOM_ACTION uses actionIdentifier when available`(): Unit = runBlocking { + setupPurchasesMock() + val directListener = mockk(relaxed = true) + val purchasesListener = mockk(relaxed = true) + every { purchases.customerCenterListener } returns purchasesListener + + val model = CustomerCenterViewModelImpl( + purchases = purchases, + locale = Locale.US, + colorScheme = TestData.Constants.currentColorScheme, + isDarkMode = false, + listener = directListener + ) + + val customActionPath = HelpPath( + id = "path_123", + title = "Delete Account", + type = HelpPath.PathType.CUSTOM_ACTION, + actionIdentifier = "delete_user" + ) + + val purchaseInfo = createMockPurchaseInformation("monthly_sub") + + model.pathButtonPressed(mockk(relaxed = true), customActionPath, purchaseInfo) + + verify { directListener.onCustomActionSelected("delete_user", "monthly_sub") } + verify { purchasesListener.onCustomActionSelected("delete_user", "monthly_sub") } + } + + @Test + fun `pathButtonPressed with CUSTOM_ACTION ignores action when actionIdentifier is null`(): Unit = runBlocking { + setupPurchasesMock() + val directListener = mockk(relaxed = true) + val purchasesListener = mockk(relaxed = true) + every { purchases.customerCenterListener } returns purchasesListener + + val model = CustomerCenterViewModelImpl( + purchases = purchases, + locale = Locale.US, + colorScheme = TestData.Constants.currentColorScheme, + isDarkMode = false, + listener = directListener + ) + + val customActionPath = HelpPath( + id = "legacy_action_id", + title = "Rate App", + type = HelpPath.PathType.CUSTOM_ACTION, + actionIdentifier = null + ) + + model.pathButtonPressed(mockk(relaxed = true), customActionPath, null) + + verify(exactly = 0) { directListener.onCustomActionSelected(any(), any()) } + verify(exactly = 0) { purchasesListener.onCustomActionSelected(any(), any()) } + } + + @Test + fun `pathButtonPressed with CUSTOM_ACTION includes purchase identifier when available`(): Unit = runBlocking { + setupPurchasesMock() + val directListener = mockk(relaxed = true) + val purchasesListener = mockk(relaxed = true) + every { purchases.customerCenterListener } returns purchasesListener + + val model = CustomerCenterViewModelImpl( + purchases = purchases, + locale = Locale.US, + colorScheme = TestData.Constants.currentColorScheme, + isDarkMode = false, + listener = directListener + ) + + val customActionPath = HelpPath( + id = "path_id", + title = "Contact Support", + type = HelpPath.PathType.CUSTOM_ACTION, + actionIdentifier = "contact_support" + ) + + val purchaseInfo = createMockPurchaseInformation("annual_plan") + + model.pathButtonPressed(mockk(relaxed = true), customActionPath, purchaseInfo) + + verify { directListener.onCustomActionSelected("contact_support", "annual_plan") } + verify { purchasesListener.onCustomActionSelected("contact_support", "annual_plan") } + } + + @Test + fun `pathButtonPressed with CUSTOM_ACTION passes null purchase identifier when no purchase info`(): Unit = runBlocking { + setupPurchasesMock() + val directListener = mockk(relaxed = true) + val purchasesListener = mockk(relaxed = true) + every { purchases.customerCenterListener } returns purchasesListener + + val model = CustomerCenterViewModelImpl( + purchases = purchases, + locale = Locale.US, + colorScheme = TestData.Constants.currentColorScheme, + isDarkMode = false, + listener = directListener + ) + + val customActionPath = HelpPath( + id = "path_id", + title = "General Action", + type = HelpPath.PathType.CUSTOM_ACTION, + actionIdentifier = "general_action" + ) + + model.pathButtonPressed(mockk(relaxed = true), customActionPath, null) + + verify { directListener.onCustomActionSelected("general_action", null) } + verify { purchasesListener.onCustomActionSelected("general_action", null) } + } + + private fun createMockPurchaseInformation(productId: String): PurchaseInformation { + val mockProduct = mockk() + every { mockProduct.id } returns productId + + return mockk().apply { + every { product } returns mockProduct + } + } + } diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/PurchaseInformationTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/PurchaseInformationTest.kt index 2e09804875..354f520381 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/PurchaseInformationTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/PurchaseInformationTest.kt @@ -35,6 +35,7 @@ class PurchaseInformationTest { private val dateFormatter = mockk() private val locale = Locale.US + private val localization = CustomerCenterConfigTestData.customerCenterData().localization @Test fun `test PurchaseInformation with active Google subscription and entitlement`() { @@ -71,6 +72,7 @@ class PurchaseInformationTest { transaction = transaction, dateFormatter = dateFormatter, locale = locale, + localization = localization, ) assertPurchaseInformation( @@ -121,7 +123,8 @@ class PurchaseInformationTest { subscribedProduct = storeProduct, transaction = transaction, dateFormatter = dateFormatter, - locale = locale + locale = locale, + localization = localization ) assertPurchaseInformation( @@ -172,7 +175,8 @@ class PurchaseInformationTest { subscribedProduct = storeProduct, transaction = transaction, dateFormatter = dateFormatter, - locale = locale + locale = locale, + localization = localization ) assertPurchaseInformation( @@ -214,12 +218,13 @@ class PurchaseInformationTest { subscribedProduct = null, transaction = transaction, dateFormatter = dateFormatter, - locale = locale + locale = locale, + localization = localization ) assertPurchaseInformation( purchaseInformation, - title = "test_product", + title = "Subscription", price = PriceDetails.Unknown, store = Store.APP_STORE, product = null, @@ -256,12 +261,13 @@ class PurchaseInformationTest { subscribedProduct = null, transaction = transaction, dateFormatter = dateFormatter, - locale = locale + locale = locale, + localization = localization ) assertPurchaseInformation( purchaseInformation, - title = "test_product", + title = "Subscription", price = PriceDetails.Unknown, store = Store.APP_STORE, product = null, @@ -298,12 +304,13 @@ class PurchaseInformationTest { subscribedProduct = null, transaction = transaction, dateFormatter = dateFormatter, - locale = locale + locale = locale, + localization = localization ) assertPurchaseInformation( purchaseInformation, - title = "test_product", + title = "Subscription", price = PriceDetails.Unknown, store = Store.APP_STORE, product = null, @@ -343,6 +350,7 @@ class PurchaseInformationTest { transaction = transaction, dateFormatter = dateFormatter, locale = locale, + localization = localization, ) assertPurchaseInformation( @@ -387,7 +395,8 @@ class PurchaseInformationTest { subscribedProduct = null, transaction = transaction, dateFormatter = dateFormatter, - locale = locale + locale = locale, + localization = localization ) assertPurchaseInformation( @@ -430,12 +439,13 @@ class PurchaseInformationTest { subscribedProduct = null, transaction = transaction, dateFormatter = dateFormatter, - locale = locale + locale = locale, + localization = localization ) assertPurchaseInformation( purchaseInformation, - title = "com.revenuecat.product", + title = "Subscription", price = PriceDetails.Unknown, store = Store.STRIPE, product = null, @@ -473,12 +483,13 @@ class PurchaseInformationTest { subscribedProduct = null, transaction = transaction, dateFormatter = dateFormatter, - locale = locale + locale = locale, + localization = localization ) assertPurchaseInformation( purchaseInformation, - title = "com.revenuecat.product", + title = "Subscription", price = PriceDetails.Unknown, store = Store.STRIPE, product = null, @@ -516,12 +527,13 @@ class PurchaseInformationTest { subscribedProduct = null, transaction = transaction, dateFormatter = dateFormatter, - locale = locale + locale = locale, + localization = localization ) assertPurchaseInformation( purchaseInformation, - title = "com.revenuecat.product", + title = "Subscription", price = PriceDetails.Unknown, store = Store.STRIPE, product = null, @@ -559,12 +571,13 @@ class PurchaseInformationTest { subscribedProduct = null, transaction = transaction, dateFormatter = dateFormatter, - locale = locale + locale = locale, + localization = localization ) assertPurchaseInformation( purchaseInformation, - title = "com.revenuecat.product", + title = "Subscription", price = PriceDetails.Unknown, store = Store.PADDLE, product = null, @@ -602,12 +615,13 @@ class PurchaseInformationTest { subscribedProduct = null, transaction = transaction, dateFormatter = dateFormatter, - locale = locale + locale = locale, + localization = localization ) assertPurchaseInformation( purchaseInformation, - title = "com.revenuecat.product", + title = "Subscription", price = PriceDetails.Unknown, store = Store.PADDLE, product = null, @@ -645,12 +659,13 @@ class PurchaseInformationTest { subscribedProduct = null, transaction = transaction, dateFormatter = dateFormatter, - locale = locale + locale = locale, + localization = localization ) assertPurchaseInformation( purchaseInformation, - title = "com.revenuecat.product", + title = "Subscription", price = PriceDetails.Unknown, store = Store.PADDLE, product = null, @@ -699,7 +714,8 @@ class PurchaseInformationTest { subscribedProduct = storeProduct, transaction = transaction, dateFormatter = dateFormatter, - locale = locale + locale = locale, + localization = localization ) assertPurchaseInformation( @@ -717,6 +733,388 @@ class PurchaseInformationTest { ) } + @Test + fun `test PurchaseInformation with no entitlement and no subscribed product shows user-friendly fallback`() { + val subscriptionTransaction = createTransactionDetails( + isActive = true, + willRenew = true, + store = Store.STRIPE, + productIdentifier = "com.revenuecat.technical.id", + expiresDate = null + ) + val subscriptionPurchaseInfo = PurchaseInformation( + entitlementInfo = null, + subscribedProduct = null, + transaction = subscriptionTransaction, + dateFormatter = dateFormatter, + locale = locale, + localization = localization + ) + + assertThat(subscriptionPurchaseInfo.title).isEqualTo("Subscription") + + val nonSubscriptionTransaction = createNonSubscriptionTransactionDetails( + store = Store.STRIPE, + productIdentifier = "com.revenuecat.technical.id" + ) + val nonSubscriptionPurchaseInfo = PurchaseInformation( + entitlementInfo = null, + subscribedProduct = null, + transaction = nonSubscriptionTransaction, + dateFormatter = dateFormatter, + locale = locale, + localization = localization + ) + + assertThat(nonSubscriptionPurchaseInfo.title).isEqualTo("One time purchase") + } + + @Test + fun `test sandbox transaction with zero price falls back to product price`() { + val expiresDate = oneDayFromNow + setupDateFormatter(expiresDate, "3 Oct 2063") + + val transaction = createTransactionDetails( + isActive = true, + willRenew = true, + store = Store.PLAY_STORE, + productIdentifier = "test_product", + expiresDate = expiresDate, + price = Price("$0.00", 0L, "USD"), + isSandbox = true + ) + + val storeProduct = TestStoreProduct( + "test_product", + "name", + "Monthly Product", + "description", + Price("$9.99", 9_990_000, "USD"), + Period(1, Period.Unit.MONTH, "P1M") + ) + + val purchaseInformation = PurchaseInformation( + entitlementInfo = null, + subscribedProduct = storeProduct, + transaction = transaction, + dateFormatter = dateFormatter, + locale = locale, + localization = localization + ) + + assertThat(purchaseInformation.pricePaid).isEqualTo(PriceDetails.Paid("$9.99")) + } + + @Test + fun `test non-sandbox transaction with zero price shows as free`() { + val expiresDate = oneDayFromNow + setupDateFormatter(expiresDate, "3 Oct 2063") + + val transaction = createTransactionDetails( + isActive = true, + willRenew = true, + store = Store.PLAY_STORE, + productIdentifier = "test_product", + expiresDate = expiresDate, + price = Price("$0.00", 0L, "USD"), + isSandbox = false + ) + + val storeProduct = TestStoreProduct( + "test_product", + "name", + "Monthly Product", + "description", + Price("$9.99", 9_990_000, "USD"), + Period(1, Period.Unit.MONTH, "P1M") + ) + + val purchaseInformation = PurchaseInformation( + entitlementInfo = null, + subscribedProduct = storeProduct, + transaction = transaction, + dateFormatter = dateFormatter, + locale = locale, + localization = localization + ) + + assertThat(purchaseInformation.pricePaid).isEqualTo(PriceDetails.Free) + } + + @Test + fun `test non-sandbox transaction with price shows transaction price`() { + val expiresDate = oneDayFromNow + setupDateFormatter(expiresDate, "3 Oct 2063") + + val transaction = createTransactionDetails( + isActive = true, + willRenew = true, + store = Store.PLAY_STORE, + productIdentifier = "test_product", + expiresDate = expiresDate, + price = Price("$4.99", 4_990_000, "USD"), + isSandbox = false + ) + + val storeProduct = TestStoreProduct( + "test_product", + "name", + "Monthly Product", + "description", + Price("$9.99", 9_990_000, "USD"), + Period(1, Period.Unit.MONTH, "P1M") + ) + + val purchaseInformation = PurchaseInformation( + entitlementInfo = null, + subscribedProduct = storeProduct, + transaction = transaction, + dateFormatter = dateFormatter, + locale = locale, + localization = localization + ) + + assertThat(purchaseInformation.pricePaid).isEqualTo(PriceDetails.Paid("$4.99")) + } + + @Test + fun `test sandbox transaction with non-zero price shows transaction price`() { + val expiresDate = oneDayFromNow + setupDateFormatter(expiresDate, "3 Oct 2063") + + val transaction = createTransactionDetails( + isActive = true, + willRenew = true, + store = Store.PLAY_STORE, + productIdentifier = "test_product", + expiresDate = expiresDate, + price = Price("$4.99", 4_990_000, "USD"), + isSandbox = true + ) + + val storeProduct = TestStoreProduct( + "test_product", + "name", + "Monthly Product", + "description", + Price("$9.99", 9_990_000, "USD"), + Period(1, Period.Unit.MONTH, "P1M") + ) + + val purchaseInformation = PurchaseInformation( + entitlementInfo = null, + subscribedProduct = storeProduct, + transaction = transaction, + dateFormatter = dateFormatter, + locale = locale, + localization = localization + ) + + assertThat(purchaseInformation.pricePaid).isEqualTo(PriceDetails.Paid("$4.99")) + } + + @Test + fun `test one-time purchase with product shows product information`() { + val transaction = createNonSubscriptionTransactionDetails( + store = Store.PLAY_STORE, + productIdentifier = "test_product", + price = Price("$9.99", 9_990_000, "USD"), + isSandbox = false + ) + + val storeProduct = TestStoreProduct( + "test_product", + "name", + "One-time Product", + "description", + Price("$9.99", 9_990_000, "USD"), + null + ) + + val purchaseInformation = PurchaseInformation( + entitlementInfo = null, + subscribedProduct = storeProduct, + transaction = transaction, + dateFormatter = dateFormatter, + locale = locale, + localization = localization + ) + + assertPurchaseInformation( + purchaseInformation, + title = "One-time Product", + price = PriceDetails.Paid("$9.99"), + store = Store.PLAY_STORE, + product = storeProduct, + isSubscription = false, + isExpired = false, + isTrial = false, + isCancelled = false, + expirationOrRenewal = null, + managementURL = null + ) + } + + @Test + fun `test one-time purchase without product shows fallback title`() { + val transaction = createNonSubscriptionTransactionDetails( + store = Store.APP_STORE, + productIdentifier = "test_product", + price = Price("$4.99", 4_990_000, "USD"), + isSandbox = false + ) + + val purchaseInformation = PurchaseInformation( + entitlementInfo = null, + subscribedProduct = null, + transaction = transaction, + dateFormatter = dateFormatter, + locale = locale, + localization = localization + ) + + assertPurchaseInformation( + purchaseInformation, + title = "One time purchase", + price = PriceDetails.Paid("$4.99"), + store = Store.APP_STORE, + product = null, + isSubscription = false, + isExpired = false, + isTrial = false, + isCancelled = false, + expirationOrRenewal = null, + managementURL = null + ) + } + + @Test + fun `test one-time purchase with zero price shows as free`() { + val transaction = createNonSubscriptionTransactionDetails( + store = Store.PLAY_STORE, + productIdentifier = "test_product", + price = Price("$0.00", 0L, "USD"), + isSandbox = false + ) + + val storeProduct = TestStoreProduct( + "test_product", + "name", + "Free Product", + "description", + Price("$0.00", 0L, "USD"), + null + ) + + val purchaseInformation = PurchaseInformation( + entitlementInfo = null, + subscribedProduct = storeProduct, + transaction = transaction, + dateFormatter = dateFormatter, + locale = locale, + localization = localization + ) + + assertPurchaseInformation( + purchaseInformation, + title = "Free Product", + price = PriceDetails.Free, + store = Store.PLAY_STORE, + product = storeProduct, + isSubscription = false, + isExpired = false, + isTrial = false, + isCancelled = false, + expirationOrRenewal = null, + managementURL = null + ) + } + + @Test + fun `test one-time purchase sandbox with zero price falls back to product price`() { + val transaction = createNonSubscriptionTransactionDetails( + store = Store.PLAY_STORE, + productIdentifier = "test_product", + price = Price("$0.00", 0L, "USD"), + isSandbox = true + ) + + val storeProduct = TestStoreProduct( + "test_product", + "name", + "Premium Feature", + "description", + Price("$2.99", 2_990_000, "USD"), + null + ) + + val purchaseInformation = PurchaseInformation( + entitlementInfo = null, + subscribedProduct = storeProduct, + transaction = transaction, + dateFormatter = dateFormatter, + locale = locale, + localization = localization + ) + + assertPurchaseInformation( + purchaseInformation, + title = "Premium Feature", + price = PriceDetails.Paid("$2.99"), + store = Store.PLAY_STORE, + product = storeProduct, + isSubscription = false, + isExpired = false, + isTrial = false, + isCancelled = false, + expirationOrRenewal = null, + managementURL = null + ) + } + + @Test + fun `test one-time purchase without price shows unknown`() { + val transaction = createNonSubscriptionTransactionDetails( + store = Store.PLAY_STORE, + productIdentifier = "test_product", + price = null, + isSandbox = false + ) + + val purchaseInformation = PurchaseInformation( + entitlementInfo = null, + subscribedProduct = null, + transaction = transaction, + dateFormatter = dateFormatter, + locale = locale, + localization = localization + ) + + assertPurchaseInformation( + purchaseInformation, + title = "One time purchase", + price = PriceDetails.Unknown, + store = Store.PLAY_STORE, + product = null, + isSubscription = false, + isExpired = false, + isTrial = false, + isCancelled = false, + expirationOrRenewal = null, + managementURL = null + ) + } + + @Test + fun `test lifetime purchase shows as lifetime`() { + assertThat(CustomerCenterConfigTestData.purchaseInformationLifetime.isLifetime).isTrue() + } + + @Test + fun `test subscription purchase shows as not lifetime`() { + assertThat(CustomerCenterConfigTestData.purchaseInformationMonthlyRenewing.isLifetime).isFalse() + } + private fun assertPurchaseInformation( purchaseInformation: PurchaseInformation, title: String?, @@ -778,7 +1176,9 @@ class PurchaseInformationTest { expiresDate: Date?, productPlanIdentifier: String? = null, isTrial: Boolean = false, - managementURL: Uri? = Uri.parse(MANAGEMENT_URL) + managementURL: Uri? = Uri.parse(MANAGEMENT_URL), + price: Price? = null, + isSandbox: Boolean = false ): TransactionDetails.Subscription { return TransactionDetails.Subscription( productIdentifier = productIdentifier, @@ -788,7 +1188,23 @@ class PurchaseInformationTest { expiresDate = expiresDate, productPlanIdentifier = productPlanIdentifier, isTrial = isTrial, - managementURL = managementURL + managementURL = managementURL, + price = price, + isSandbox = isSandbox + ) + } + + private fun createNonSubscriptionTransactionDetails( + store: Store, + productIdentifier: String, + price: Price? = null, + isSandbox: Boolean = false + ): TransactionDetails.NonSubscription { + return TransactionDetails.NonSubscription( + productIdentifier = productIdentifier, + store = store, + price = price, + isSandbox = isSandbox ) } diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/PurchaseInformationCardViewTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/PurchaseInformationCardViewTest.kt new file mode 100644 index 0000000000..5728cbf1fa --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/views/PurchaseInformationCardViewTest.kt @@ -0,0 +1,52 @@ +package com.revenuecat.purchases.ui.revenuecatui.customercenter.views + +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.ui.revenuecatui.customercenter.data.CustomerCenterConfigTestData +import io.mockk.mockk +import io.mockk.verify +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PurchaseInformationCardViewTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val mockLocalization = CustomerCenterConfigTestData.customerCenterData().localization + + @Test + fun `clickable card triggers callback when clicked`() { + val onCardClick = mockk<() -> Unit>(relaxed = true) + + composeTestRule.setContent { + PurchaseInformationCardView( + purchaseInformation = CustomerCenterConfigTestData.purchaseInformationLifetime, + localization = mockLocalization, + isDetailedView = false, + onCardClick = onCardClick + ) + } + + composeTestRule.onNode(hasText("Lifetime")).performClick() + + verify { onCardClick() } + } + + @Test + fun `non-clickable card does not crash when rendered`() { + composeTestRule.setContent { + PurchaseInformationCardView( + purchaseInformation = CustomerCenterConfigTestData.purchaseInformationLifetime, + localization = mockLocalization, + isDetailedView = true, + onCardClick = null + ) + } + } + +} \ No newline at end of file diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt new file mode 100644 index 0000000000..f7d219bdc9 --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt @@ -0,0 +1,47 @@ +package com.revenuecat.purchases.ui.revenuecatui.data + +import com.revenuecat.purchases.CacheFetchPolicy +import com.revenuecat.purchases.CustomerInfo +import com.revenuecat.purchases.Offerings +import com.revenuecat.purchases.PurchaseParams +import com.revenuecat.purchases.PurchaseResult +import com.revenuecat.purchases.PurchasesAreCompletedBy +import com.revenuecat.purchases.common.events.FeatureEvent +import com.revenuecat.purchases.customercenter.CustomerCenterConfigData +import com.revenuecat.purchases.customercenter.CustomerCenterListener +import com.revenuecat.purchases.models.StoreProduct + +/** + * Mock implementation of [PurchasesType] for tests and previews + */ +internal class MockPurchasesType( + override val preferredUILocaleOverride: String? = null, + override val purchasesAreCompletedBy: PurchasesAreCompletedBy = PurchasesAreCompletedBy.REVENUECAT, + override val storefrontCountryCode: String? = null, + override val customerCenterListener: CustomerCenterListener? = null, +) : PurchasesType { + override suspend fun awaitPurchase(purchaseParams: PurchaseParams.Builder): PurchaseResult { + throw NotImplementedError("Mock implementation") + } + override suspend fun awaitRestore(): CustomerInfo { + throw NotImplementedError("Mock implementation") + } + override suspend fun awaitOfferings(): Offerings { + throw NotImplementedError("Mock implementation") + } + override suspend fun awaitCustomerInfo(fetchPolicy: CacheFetchPolicy): CustomerInfo { + throw NotImplementedError("Mock implementation") + } + override suspend fun awaitCustomerCenterConfigData(): CustomerCenterConfigData { + throw NotImplementedError("Mock implementation") + } + override suspend fun awaitGetProduct(productId: String, basePlan: String?): StoreProduct? { + throw NotImplementedError("Mock implementation") + } + override fun track(event: FeatureEvent) { + // No-op for mock + } + override fun syncPurchases() { + // No-op for mock + } +} \ No newline at end of file diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallStateLoadedComponentsLocaleTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallStateLoadedComponentsLocaleTests.kt index 0bbbb52b63..b4adfaf8af 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallStateLoadedComponentsLocaleTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallStateLoadedComponentsLocaleTests.kt @@ -10,6 +10,7 @@ import com.revenuecat.purchases.ui.revenuecatui.components.previewStackComponent import com.revenuecat.purchases.ui.revenuecatui.components.properties.BackgroundStyles import com.revenuecat.purchases.ui.revenuecatui.components.properties.ColorStyle import com.revenuecat.purchases.ui.revenuecatui.components.properties.ColorStyles +import com.revenuecat.purchases.ui.revenuecatui.data.MockPurchasesType import com.revenuecat.purchases.ui.revenuecatui.data.processed.VariableDataProvider import com.revenuecat.purchases.ui.revenuecatui.data.testdata.MockResourceProvider import com.revenuecat.purchases.ui.revenuecatui.helpers.NonEmptyList @@ -95,7 +96,7 @@ internal class PaywallStateLoadedComponentsLocaleTests( "device locale only matches language, not region, es-AR -> es-ES", Args( paywallLocales = nonEmptyListOf("en_US", "es_ES"), - deviceLocales = nonEmptyListOf("es-AR",), + deviceLocales = nonEmptyListOf("es-AR"), expected = "es-ES", ), ), @@ -365,8 +366,7 @@ internal class PaywallStateLoadedComponentsLocaleTests( paywallComponents = null, ), locales = paywallLocales.map { LocaleId(it) }.toNonEmptySetOrNull()!!, - activelySubscribedProductIds = emptySet(), - purchasedNonSubscriptionProductIds = emptySet(), + storefrontCountryCode = "US", dateProvider = { Date() }, packages = PaywallState.Loaded.Components.AvailablePackages( packagesOutsideTabs = emptyList(), @@ -374,6 +374,7 @@ internal class PaywallStateLoadedComponentsLocaleTests( ), initialLocaleList = LocaleList(deviceLocales.map { Locale(it) }), initialSelectedTabIndex = 0, + purchases = MockPurchasesType(), ) } diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt index 647bd89916..2688454250 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt @@ -10,6 +10,7 @@ import com.revenuecat.purchases.CustomerInfo import com.revenuecat.purchases.Offering import com.revenuecat.purchases.Offerings import com.revenuecat.purchases.Package +import com.revenuecat.purchases.PresentedOfferingContext import com.revenuecat.purchases.PurchaseResult import com.revenuecat.purchases.PurchasesAreCompletedBy import com.revenuecat.purchases.PurchasesError @@ -32,13 +33,13 @@ import com.revenuecat.purchases.paywalls.components.properties.ColorInfo import com.revenuecat.purchases.paywalls.components.properties.ColorScheme import com.revenuecat.purchases.paywalls.events.PaywallEvent import com.revenuecat.purchases.paywalls.events.PaywallEventType +import com.revenuecat.purchases.ui.revenuecatui.OfferingSelection import com.revenuecat.purchases.ui.revenuecatui.PaywallListener import com.revenuecat.purchases.ui.revenuecatui.PaywallMode import com.revenuecat.purchases.ui.revenuecatui.PaywallOptions import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogic import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogicResult import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogicWithCallback -import com.revenuecat.purchases.ui.revenuecatui.data.processed.TemplateConfiguration import com.revenuecat.purchases.ui.revenuecatui.data.testdata.MockResourceProvider import com.revenuecat.purchases.ui.revenuecatui.data.testdata.TestData import com.revenuecat.purchases.ui.revenuecatui.helpers.UiConfig @@ -135,6 +136,7 @@ class PaywallViewModelTest { every { purchases.storefrontCountryCode } returns "US" every { purchases.track(any()) } just Runs every { purchases.syncPurchases() } just Runs + every { purchases.preferredUILocaleOverride } returns null every { listener.onPurchaseStarted(any()) } just runs every { listener.onPurchaseCompleted(any(), any()) } just runs @@ -511,10 +513,7 @@ class PaywallViewModelTest { @Test fun `Should load default offering`() { - val model = create( - activeSubscriptions = setOf(TestData.Packages.monthly.product.id), - nonSubscriptionTransactionProductIdentifiers = setOf(TestData.Packages.lifetime.product.id) - ) + val model = create() coVerify { purchases.awaitOfferings() } @@ -527,12 +526,13 @@ class PaywallViewModelTest { val expectedPaywall = defaultOffering.paywall!! verifyPaywall(state, expectedPaywall) - assertThat(state.templateConfiguration.packages.packageIsCurrentlySubscribed(TestData.Packages.monthly)) - .isTrue - assertThat(state.templateConfiguration.packages.packageIsCurrentlySubscribed(TestData.Packages.annual)) - .isFalse - assertThat(state.templateConfiguration.packages.packageIsCurrentlySubscribed(TestData.Packages.lifetime)) - .isTrue + assertThat(state.templateConfiguration.packages.all.firstOrNull { it.rcPackage == TestData.Packages.monthly }) + .isNotNull + assertThat(state.templateConfiguration.packages.all.firstOrNull { it.rcPackage == TestData.Packages.annual }) + .isNotNull + assertThat(state.templateConfiguration.packages.all.firstOrNull { it.rcPackage == TestData.Packages.lifetime }) + .isNotNull + assertThat(state.templateConfiguration.packages.all.size).isEqualTo(3) } @Test @@ -541,10 +541,7 @@ class PaywallViewModelTest { PurchasesError(PurchasesErrorCode.NetworkError )) - val model = create( - activeSubscriptions = setOf(TestData.Packages.monthly.product.id), - nonSubscriptionTransactionProductIdentifiers = setOf(TestData.Packages.lifetime.product.id) - ) + val model = create() coVerify { purchases.awaitOfferings() } @@ -564,10 +561,7 @@ class PaywallViewModelTest { mapOf(), ) - val model = create( - activeSubscriptions = setOf(TestData.Packages.monthly.product.id), - nonSubscriptionTransactionProductIdentifiers = setOf(TestData.Packages.lifetime.product.id) - ) + val model = create() coVerify { purchases.awaitOfferings() } @@ -598,6 +592,52 @@ class PaywallViewModelTest { verifyPaywall(state, expectedPaywall) } + @Test + fun `Should load selected offering with presented offering context`() { + val offering = TestData.template1Offering + val expectedPresentedOfferingContext = PresentedOfferingContext( + offeringIdentifier = offering.identifier, + placementIdentifier = "test-placement-id", + targetingContext = PresentedOfferingContext.TargetingContext( + revision = 1, + ruleId = "test-rule-id" + ) + ) + val model = PaywallViewModelImpl( + MockResourceProvider(), + purchases, + PaywallOptions.Builder(dismissRequest = { dismissInvoked = true }) + .setListener(listener) + .setOfferingIdAndPresentedOfferingContext(OfferingSelection.IdAndPresentedOfferingContext( + offeringId = offering.identifier, + presentedOfferingContext = expectedPresentedOfferingContext, + )) + .setPurchaseLogic(null) + .setMode(PaywallMode.default) + .build(), + TestData.Constants.currentColorScheme, + isDarkMode = false, + shouldDisplayBlock = null, + ) + + coVerify(exactly = 1) { purchases.awaitOfferings() } + + val state = model.state.value + if (state !is PaywallState.Loaded.Legacy) { + fail("Invalid state") + return + } + + assertThat(state.offering.availablePackages).allMatch { + it.presentedOfferingContext == expectedPresentedOfferingContext && + it.product.presentedOfferingContext == expectedPresentedOfferingContext + } + + val expectedPaywall = offering.paywall!! + + verifyPaywall(state, expectedPaywall) + } + @Test fun `Should load paywall components if using components paywall in full screen mode`(): Unit = runBlocking { // Arrange @@ -1250,15 +1290,10 @@ class PaywallViewModelTest { private fun create( offering: Offering? = null, - activeSubscriptions: Set = setOf(), - nonSubscriptionTransactionProductIdentifiers: Set = setOf(), customPurchaseLogic: PurchaseLogic? = null, mode: PaywallMode = PaywallMode.default, shouldDisplayBlock: ((CustomerInfo) -> Boolean)? = null, ): PaywallViewModelImpl { - mockActiveSubscriptions(activeSubscriptions) - mockNonSubscriptionTransactions(nonSubscriptionTransactionProductIdentifiers) - return PaywallViewModelImpl( MockResourceProvider(), purchases, @@ -1274,40 +1309,6 @@ class PaywallViewModelTest { ) } - private fun mockActiveSubscriptions(subscriptions: Set) { - every { customerInfo.activeSubscriptions } returns subscriptions - } - - private fun mockNonSubscriptionTransactions(productIdentifiers: Set) { - every { customerInfo.nonSubscriptionTransactions } returns productIdentifiers - .map { productIdentifier -> - Transaction( - transactionIdentifier = UUID.randomUUID().toString(), - revenuecatId = UUID.randomUUID().toString(), - productIdentifier = productIdentifier, - productId = productIdentifier, - purchaseDate = Date(), - storeTransactionId = UUID.randomUUID().toString(), - store = Store.PLAY_STORE, - displayName = "Product $productIdentifier", - isSandbox = false, - originalPurchaseDate = Date(), - price = (1..100).random().toDouble().let { - Price("$it", it.toLong() * 1_000_000, "USD") - }, - ) - } - } - - /** - * Note: this is O(n), for testing only - */ - private fun TemplateConfiguration.PackageConfiguration.packageIsCurrentlySubscribed( - rcPackage: Package, - ): Boolean { - return all.first { it.rcPackage.identifier == rcPackage.identifier }.currentlySubscribed - } - private fun verifyPaywall( state: PaywallState.Loaded.Legacy, expectedPaywall: PaywallData, diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/MultiTierTemplateConfigurationFactoryTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/MultiTierTemplateConfigurationFactoryTest.kt index 85400545d8..983fac6850 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/MultiTierTemplateConfigurationFactoryTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/MultiTierTemplateConfigurationFactoryTest.kt @@ -40,12 +40,6 @@ internal class MultiTierTemplateConfigurationFactoryTest { TestData.Packages.annual, TestData.Packages.lifetime, ), - activelySubscribedProductIdentifiers = setOf( - TestData.Packages.monthly.product.id, - ), - nonSubscriptionProductIdentifiers = setOf( - TestData.Packages.lifetime.product.id - ), template = PaywallTemplate.TEMPLATE_7, storefrontCountryCode = "US", ) @@ -118,37 +112,31 @@ internal class MultiTierTemplateConfigurationFactoryTest { ) val monthlyPackage = TestData.Packages.monthly.getPackageInfoForTest( - currentlySubscribed = true, paywallData = TestData.template7, features = basicFeatures, tierId = "basic" ) val bimonthlyPackage = TestData.Packages.bimonthly.getPackageInfoForTest( - currentlySubscribed = false, paywallData = TestData.template7, features = standardFeatures, tierId = "standard" ) val quarterlyPackage = TestData.Packages.quarterly.getPackageInfoForTest( - currentlySubscribed = false, paywallData = TestData.template7, features = premiumFeatures, tierId = "premium" ) val semesterPackage = TestData.Packages.semester.getPackageInfoForTest( - currentlySubscribed = false, paywallData = TestData.template7, features = standardFeatures, tierId = "standard" ) val annualPackage = TestData.Packages.annual.getPackageInfoForTest( - currentlySubscribed = false, paywallData = TestData.template7, features = basicFeatures, tierId = "basic" ) val lifetime = TestData.Packages.lifetime.getPackageInfoForTest( - currentlySubscribed = true, paywallData = TestData.template7, features = premiumFeatures, tierId = "premium" @@ -248,8 +236,6 @@ internal class MultiTierTemplateConfigurationFactoryTest { TestData.Packages.weekly, TestData.Packages.lifetime, ), - activelySubscribedProductIdentifiers = emptySet(), - nonSubscriptionProductIdentifiers = emptySet(), template = PaywallTemplate.TEMPLATE_7, storefrontCountryCode = "US", ) diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfigurationFactoryTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfigurationFactoryTest.kt index 2d4bae2142..7ea5e6d12e 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfigurationFactoryTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/processed/TemplateConfigurationFactoryTest.kt @@ -34,12 +34,6 @@ internal class TemplateConfigurationFactoryTest { TestData.Packages.annual, TestData.Packages.lifetime, ), - activelySubscribedProductIdentifiers = setOf( - TestData.Packages.monthly.product.id, - ), - nonSubscriptionProductIdentifiers = setOf( - TestData.Packages.lifetime.product.id - ), template = PaywallTemplate.TEMPLATE_2, storefrontCountryCode = "US", ) @@ -68,9 +62,9 @@ internal class TemplateConfigurationFactoryTest { ) val packageConfiguration = template2Configuration.packages as TemplateConfiguration.PackageConfiguration.Multiple - val annualPackage = TestData.Packages.annual.getPackageInfoForTest(currentlySubscribed = false) - val monthlyPackage = TestData.Packages.monthly.getPackageInfoForTest(currentlySubscribed = true) - val lifetime = TestData.Packages.lifetime.getPackageInfoForTest(currentlySubscribed = true) + val annualPackage = TestData.Packages.annual.getPackageInfoForTest() + val monthlyPackage = TestData.Packages.monthly.getPackageInfoForTest() + val lifetime = TestData.Packages.lifetime.getPackageInfoForTest() val expectedConfiguration = TemplateConfiguration.PackageConfiguration.Multiple( TemplateConfiguration.PackageConfiguration.MultiPackage( diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/extensions/PaywallState.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/extensions/PaywallState.kt index 06132db71d..518ad7485a 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/extensions/PaywallState.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/extensions/PaywallState.kt @@ -1,7 +1,9 @@ package com.revenuecat.purchases.ui.revenuecatui.extensions import com.revenuecat.purchases.Offering +import com.revenuecat.purchases.ui.revenuecatui.data.MockPurchasesType import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState +import com.revenuecat.purchases.ui.revenuecatui.data.PurchasesType import com.revenuecat.purchases.ui.revenuecatui.data.testdata.MockResourceProvider import com.revenuecat.purchases.ui.revenuecatui.errors.PaywallValidationError import com.revenuecat.purchases.ui.revenuecatui.helpers.NonEmptyList @@ -17,17 +19,15 @@ import com.revenuecat.purchases.ui.revenuecatui.helpers.validatePaywallComponent */ internal fun Offering.toComponentsPaywallState( validationResult: PaywallValidationResult.Components, - activelySubscribedProductIds: Set = emptySet(), - purchasedNonSubscriptionProductIds: Set = emptySet(), storefrontCountryCode: String? = null, - dateProvider: () -> Date = { Date() } + dateProvider: () -> Date = { Date() }, + purchases: PurchasesType = MockPurchasesType(), ): PaywallState.Loaded.Components = actualToComponentsPaywallState( validationResult = validationResult, - activelySubscribedProductIds = activelySubscribedProductIds, - purchasedNonSubscriptionProductIds = purchasedNonSubscriptionProductIds, storefrontCountryCode = storefrontCountryCode, dateProvider = dateProvider, + purchases = purchases, ) /** diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PackageInfoForTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PackageInfoForTest.kt index 0c5f45453a..be71b0e46d 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PackageInfoForTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PackageInfoForTest.kt @@ -10,7 +10,6 @@ import com.revenuecat.purchases.ui.revenuecatui.data.testdata.templates.template import java.util.Locale internal fun Package.getPackageInfoForTest( - currentlySubscribed: Boolean = false, paywallData: PaywallData = TestData.template2, features: List = emptyList(), tierId: String? = null, @@ -106,7 +105,6 @@ internal fun Package.getPackageInfoForTest( return TemplateConfiguration.PackageInfo( rcPackage = this, localization = processedLocalization, - currentlySubscribed = currentlySubscribed, discountRelativeToMostExpensivePerMonth = discountRelativeToMostExpensivePerMonth, ) } diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/utils/FontFamilyXmlParserTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/utils/FontFamilyXmlParserTest.kt new file mode 100644 index 0000000000..9e3a65d9d5 --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/utils/FontFamilyXmlParserTest.kt @@ -0,0 +1,239 @@ +package com.revenuecat.purchases.ui.revenuecatui.utils + +import android.content.res.XmlResourceParser +import androidx.compose.ui.text.font.FontStyle +import org.assertj.core.api.Assertions.assertThat +import org.intellij.lang.annotations.Language +import org.junit.Test +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserFactory +import java.io.StringReader + +class FontFamilyXmlParserTest { + + @Test + fun `parseXmlData returns empty list for empty XML`() { + val xmlContent = """ + + """ + + val parser = TestXmlResourceParser(xmlContent) + val result = FontFamilyXmlParser.parseXmlData(parser) + + assertThat(result).isEmpty() + } + + @Test + fun `parseXmlData returns font data with app namespace attributes`() { + val xmlContent = """ + + + """ + + val parser = TestXmlResourceParser(xmlContent, mapOf("@font/regular" to 123)) + val result = FontFamilyXmlParser.parseXmlData(parser) + + assertThat(result).hasSize(1) + assertThat(result[0].resId).isEqualTo(123) // resId + assertThat(result[0].weight).isEqualTo(400) // weight + assertThat(result[0].style).isEqualTo(FontStyle.Normal) // style + } + + @Test + fun `parseXmlData returns font data with android namespace fallback`() { + val xmlContent = """ + + + """ + + val parser = TestXmlResourceParser(xmlContent, mapOf("@font/bold_italic" to 456)) + val result = FontFamilyXmlParser.parseXmlData(parser) + + assertThat(result).hasSize(1) + assertThat(result[0].resId).isEqualTo(456) + assertThat(result[0].weight).isEqualTo(700) + assertThat(result[0].style).isEqualTo(FontStyle.Italic) + } + + @Test + fun `parseXmlData returns multiple font data entries`() { + val xmlContent = """ + + + + + """ + + val parser = TestXmlResourceParser( + xmlContent, + mapOf( + "@font/regular" to 100, + "@font/italic" to 200, + "@font/semibold" to 300 + ) + ) + val result = FontFamilyXmlParser.parseXmlData(parser) + + assertThat(result).hasSize(3) + assertThat(result[0]).isEqualTo(ParsedFont(100, 400, FontStyle.Normal)) + assertThat(result[1]).isEqualTo(ParsedFont(200, 700, FontStyle.Italic)) + assertThat(result[2]).isEqualTo(ParsedFont(300, 600, FontStyle.Normal)) + } + + @Test + fun `parseXmlData uses default values when attributes are missing`() { + val xmlContent = """ + + + """ + + val parser = TestXmlResourceParser(xmlContent, mapOf("@font/regular" to 789)) + val result = FontFamilyXmlParser.parseXmlData(parser) + + assertThat(result).hasSize(1) + assertThat(result[0].resId).isEqualTo(789) + assertThat(result[0].weight).isEqualTo(400) // default weight + assertThat(result[0].style).isEqualTo(FontStyle.Normal) // default style + } + + @Test + fun `parseXmlData returns empty list when font resource ID is invalid`() { + val xmlContent = """ + + + """ + + val parser = TestXmlResourceParser(xmlContent) // no resource mapping + val result = FontFamilyXmlParser.parseXmlData(parser) + + assertThat(result).isEmpty() + } + + @Test + fun `parseXmlData falls back from app namespace to android namespace`() { + val xmlContent = """ + + + """ + + val parser = TestXmlResourceParser(xmlContent, mapOf("@font/regular" to 999)) + val result = FontFamilyXmlParser.parseXmlData(parser) + + assertThat(result).hasSize(1) + assertThat(result[0].resId).isEqualTo(999) + assertThat(result[0].weight).isEqualTo(400) + assertThat(result[0].style).isEqualTo(FontStyle.Normal) + } + + @Test + fun `parse returns null for empty XML`() { + val xmlContent = """ + + """ + + val parser = TestXmlResourceParser(xmlContent) + val result = FontFamilyXmlParser.parse(parser) + + assertThat(result).isNull() + } + + @Test + fun `parse returns FontFamily when fonts are parsed successfully`() { + val xmlContent = """ + + + """ + + val parser = TestXmlResourceParser(xmlContent, mapOf("@font/regular" to 123)) + val result = FontFamilyXmlParser.parse(parser) + + assertThat(result).isNotNull() + } + + // Test implementation of XmlResourceParser that uses real XML parsing + private class TestXmlResourceParser( + @Language("xml") xmlContent: String, + private val resourceMap: Map = emptyMap() + ) : XmlResourceParser { + + private val realParser: XmlPullParser = XmlPullParserFactory.newInstance().apply { + isNamespaceAware = true + }.newPullParser().apply { + setInput(StringReader(xmlContent)) + } + + override fun getAttributeResourceValue(namespace: String?, attribute: String?, defaultValue: Int): Int { + if (attribute == "font") { + val fontValue = getAttributeValue(namespace, attribute) + return resourceMap[fontValue] ?: defaultValue + } + return defaultValue + } + + override fun getAttributeIntValue(namespace: String?, attribute: String?, defaultValue: Int): Int { + val value = getAttributeValue(namespace, attribute) + return value?.toIntOrNull() ?: defaultValue + } + + // Delegate all XmlPullParser methods to the real parser + override fun getEventType(): Int = realParser.eventType + override fun next(): Int = realParser.next() + override fun getName(): String? = realParser.name + override fun getAttributeCount(): Int = realParser.attributeCount + override fun getAttributeName(index: Int): String? = realParser.getAttributeName(index) + override fun getAttributeValue(index: Int): String? = realParser.getAttributeValue(index) + override fun getAttributeValue(namespace: String?, name: String?): String? = realParser.getAttributeValue(namespace, name) + + // Unused XmlResourceParser methods - minimal implementations + override fun close() {} + override fun getAttributeUnsignedIntValue(namespace: String?, attribute: String?, defaultValue: Int): Int = defaultValue + override fun getAttributeBooleanValue(namespace: String?, attribute: String?, defaultValue: Boolean): Boolean = defaultValue + override fun getAttributeFloatValue(namespace: String?, attribute: String?, defaultValue: Float): Float = defaultValue + override fun getAttributeListValue(namespace: String?, attribute: String?, options: Array?, defaultValue: Int): Int = defaultValue + override fun getAttributeNameResource(index: Int): Int = 0 + override fun getAttributeResourceValue(index: Int, defaultValue: Int): Int = defaultValue + override fun getAttributeIntValue(index: Int, defaultValue: Int): Int = defaultValue + override fun getAttributeUnsignedIntValue(index: Int, defaultValue: Int): Int = defaultValue + override fun getAttributeBooleanValue(index: Int, defaultValue: Boolean): Boolean = defaultValue + override fun getAttributeFloatValue(index: Int, defaultValue: Float): Float = defaultValue + override fun getAttributeListValue(index: Int, options: Array?, defaultValue: Int): Int = defaultValue + override fun getIdAttribute(): String? = null + override fun getClassAttribute(): String? = null + override fun getIdAttributeResourceValue(defaultValue: Int): Int = defaultValue + override fun getStyleAttribute(): Int = 0 + + // Minimal XmlPullParser delegate implementations for unused methods + override fun setFeature(name: String?, state: Boolean) {} + override fun getFeature(name: String?): Boolean = false + override fun setProperty(name: String?, value: Any?) {} + override fun getProperty(name: String?): Any? = null + override fun setInput(reader: java.io.Reader?) {} + override fun setInput(inputStream: java.io.InputStream?, inputEncoding: String?) {} + override fun getInputEncoding(): String? = null + override fun defineEntityReplacementText(entityName: String?, replacementText: String?) {} + override fun getNamespaceCount(depth: Int): Int = 0 + override fun getNamespacePrefix(pos: Int): String? = null + override fun getNamespaceUri(pos: Int): String? = null + override fun getNamespace(prefix: String?): String? = null + override fun getDepth(): Int = 0 + override fun getPositionDescription(): String? = null + override fun getLineNumber(): Int = 0 + override fun getColumnNumber(): Int = 0 + override fun isWhitespace(): Boolean = false + override fun getText(): String? = null + override fun getTextCharacters(holderForStartAndLength: IntArray?): CharArray? = null + override fun getNamespace(): String? = null + override fun getPrefix(): String? = null + override fun isEmptyElementTag(): Boolean = false + override fun getAttributeNamespace(index: Int): String? = null + override fun getAttributePrefix(index: Int): String? = null + override fun getAttributeType(index: Int): String? = null + override fun isAttributeDefault(index: Int): Boolean = false + override fun nextToken(): Int = 0 + override fun require(type: Int, namespace: String?, name: String?) {} + override fun nextText(): String? = null + override fun nextTag(): Int = 0 + } +}