diff --git a/.github/bump_versions_in_release_branch.sh b/.github/bump_versions_in_release_branch.sh index 68ed1d1f6..af27bc8ec 100755 --- a/.github/bump_versions_in_release_branch.sh +++ b/.github/bump_versions_in_release_branch.sh @@ -1,4 +1,5 @@ -#!/bin/bash +#!/usr/bin/env bash +set -euo pipefail # Check if the parameter is provided if [ $# -eq 0 ]; then @@ -31,19 +32,22 @@ ls -l # Add changelog to the index and create a commit properties_file="gradle.properties" current_version=$(grep -E '^SDK_VERSION_NAME=' gradle.properties | cut -d'=' -f2) -sed -i "s/^SDK_VERSION_NAME=.*/SDK_VERSION_NAME=$version/" $properties_file -sed -i "s/^SDK_VERSION_NAME=.*/SDK_VERSION_NAME=$version/" $properties_file +sed -i "s/^SDK_VERSION_NAME=.*/SDK_VERSION_NAME=$version/" "$properties_file" build_gradle_example_path="example/app/build.gradle" -sed -i -E "s/cloud.mindbox:mobile-sdk:[0-9]+\.[0-9]+\.[0-9]+(-rc)?/cloud.mindbox:mobile-sdk:$version/" $build_gradle_example_path +sed -i -E "s/cloud.mindbox:mobile-sdk:[0-9]+\.[0-9]+\.[0-9]+(-rc)?/cloud.mindbox:mobile-sdk:$version/" "$build_gradle_example_path" echo "Bump SDK version from $current_version to $version." -git add $properties_file -git add -f $build_gradle_example_path -git commit -m "Bump SDK version to $version" +git add "$properties_file" +git add -f "$build_gradle_example_path" +if git diff --cached --quiet; then + echo "Version is already $version — nothing to commit." +else + git commit -m "Bump SDK version to $version" +fi echo "Pushing changes to branch: $current_branch" -if ! git push origin $current_branch; then +if ! git push origin "$current_branch"; then echo "Failed to push changes to the origin $current_branch" exit 1 fi diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..e8b6f5edf --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "UTC" + open-pull-requests-limit: 1 + groups: + github-actions: + patterns: ["*"] + commit-message: + prefix: "deps" + include: "scope" diff --git a/.github/git-release-ci.sh b/.github/git-release-ci.sh index 8c004fdfa..6c42dbd12 100755 --- a/.github/git-release-ci.sh +++ b/.github/git-release-ci.sh @@ -1,5 +1,5 @@ -#!/bin/bash -set -e +#!/usr/bin/env bash +set -euo pipefail version=$(grep '^SDK_VERSION_NAME=' gradle.properties | cut -d '=' -f2) @@ -8,7 +8,8 @@ if [[ $version == *"rc"* ]]; then is_beta=true fi -branch=${GITHUB_REF#refs/heads/} +branch=${RELEASE_BRANCH:-${GITHUB_REF:-}} +branch=${branch#refs/heads/} echo "Version: $version" echo "Is Beta: $is_beta" @@ -17,29 +18,32 @@ echo "Branch: $branch" echo "Fetching tags from remote repository." git fetch --tags -if git rev-parse "$version" >/dev/null 2>&1; then +if git rev-parse -q --verify "refs/tags/$version" >/dev/null; then echo "Local tag $version already exists." else echo "Creating local tag $version." - git tag $version + git tag "$version" fi -if git ls-remote --tags origin | grep -q "refs/tags/$version"; then +if [ -n "$(git ls-remote --tags origin "refs/tags/$version")" ]; then echo "Remote tag $version already exists." else echo "Pushing local tag $version to remote." - git push origin $version + git push origin "$version" fi # Create release release_title="Release $version" -text="Autogenerated release. Check more details at [Mindbox SDK Documentation](https://developers.mindbox.ru/docs/androidhuawei-native-sdk)" +text="Autogenerated release. Check more details at [Mindbox SDK Documentation](https://developers.mindbox.ru/docs/android-sdk)" echo "Release title: $release_title" echo "Text: $text" -prerelease_flag=$([ "$is_beta" = true ] && echo "--prerelease" || echo "") +release_args=("$version" --target "$branch" --title "$release_title" --notes "$text") +if [ "$is_beta" = true ]; then + release_args+=(--prerelease) +fi -gh release create "$version" --target "$branch" --title "$release_title" --notes "$text" $prerelease_flag +gh release create "${release_args[@]}" echo "Release $version created for repo on branch $branch" diff --git a/.github/workflows/distribute-develop-mission.yml b/.github/workflows/distribute-develop-mission.yml index d8fb904a2..05e36c56a 100644 --- a/.github/workflows/distribute-develop-mission.yml +++ b/.github/workflows/distribute-develop-mission.yml @@ -8,10 +8,14 @@ on: types: - closed +permissions: + contents: read + jobs: call-reusable: if: ${{ github.event.pull_request.merged == true }} uses: ./.github/workflows/distribute-reusable.yml with: branch: ${{ github.base_ref }} - secrets: inherit + secrets: + GITLAB_TRIGGER_TOKEN: ${{ secrets.GITLAB_TRIGGER_TOKEN }} diff --git a/.github/workflows/distribute-manual.yml b/.github/workflows/distribute-manual.yml index b0341fbb0..f4593cd01 100644 --- a/.github/workflows/distribute-manual.yml +++ b/.github/workflows/distribute-manual.yml @@ -8,10 +8,14 @@ on: required: false default: "" +permissions: + contents: read + jobs: call-reusable: uses: ./.github/workflows/distribute-reusable.yml with: branch: ${{ github.ref_name }} app_ref: ${{ inputs.app_ref }} - secrets: inherit + secrets: + GITLAB_TRIGGER_TOKEN: ${{ secrets.GITLAB_TRIGGER_TOKEN }} diff --git a/.github/workflows/distribute-release-support-mission.yml b/.github/workflows/distribute-release-support-mission.yml index e3fa92654..bcca01cfb 100644 --- a/.github/workflows/distribute-release-support-mission.yml +++ b/.github/workflows/distribute-release-support-mission.yml @@ -10,10 +10,14 @@ on: - opened - synchronize +permissions: + contents: read + jobs: call-reusable: if: ${{ startsWith(github.event.pull_request.head.ref, 'release/') }} uses: ./.github/workflows/distribute-reusable.yml - secrets: inherit + secrets: + GITLAB_TRIGGER_TOKEN: ${{ secrets.GITLAB_TRIGGER_TOKEN }} with: branch: ${{ github.event.pull_request.head.ref }} diff --git a/.github/workflows/distribute-reusable.yml b/.github/workflows/distribute-reusable.yml index 229fd15f2..41289c90e 100644 --- a/.github/workflows/distribute-reusable.yml +++ b/.github/workflows/distribute-reusable.yml @@ -14,12 +14,15 @@ on: GITLAB_TRIGGER_TOKEN: required: true +permissions: + contents: read + jobs: distribution: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.branch }} submodules: recursive @@ -30,9 +33,12 @@ jobs: run: | set -euo pipefail commits="$(git log -3 --pretty=format:"%s")" - echo "commits<> "$GITHUB_ENV" - echo "$commits" >> "$GITHUB_ENV" - echo "EOF" >> "$GITHUB_ENV" + delim="EOF_$(openssl rand -hex 8)" + { + echo "commits<<$delim" + echo "$commits" + echo "$delim" + } >> "$GITHUB_ENV" - name: Debug payload that will be sent to GitLab shell: bash diff --git a/.github/workflows/gitleaks-secrets-validate.yml b/.github/workflows/gitleaks-secrets-validate.yml new file mode 100644 index 000000000..b77c864c5 --- /dev/null +++ b/.github/workflows/gitleaks-secrets-validate.yml @@ -0,0 +1,23 @@ +name: Gitleaks Secrets Validate +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + scan: + name: gitleaks + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + + - name: Run Gitleaks + uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_LICENSE: ${{ secrets.MINDBOX_GITLEAKS_LICENSE }} diff --git a/.github/workflows/lint_unitTests_build.yml b/.github/workflows/lint_unitTests_build.yml index 73064f50d..94e849bf8 100644 --- a/.github/workflows/lint_unitTests_build.yml +++ b/.github/workflows/lint_unitTests_build.yml @@ -18,64 +18,86 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - fetch-depth: 2 + fetch-depth: 0 submodules: true - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'temurin' java-version: '17' cache: 'gradle' - name: Setup Android SDK - uses: android-actions/setup-android@v2 + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 - name: Get changed Kotlin files id: get_changed_files + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.sha }} run: | - CHANGED_FILES=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} | grep -E '\.kt$|\.kts$' || true) + set -euo pipefail + if [ -z "$BASE_SHA" ] || ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then + echo "Base commit '$BASE_SHA' unavailable; falling back to ${HEAD_SHA}^" + BASE_SHA=$(git rev-parse "${HEAD_SHA}^") + fi + echo "Diff range: $BASE_SHA..$HEAD_SHA" + CHANGED_FILES=$(git diff --name-only --diff-filter=d "$BASE_SHA" "$HEAD_SHA" | { grep -E '\.kt$|\.kts$' || true; }) echo "Changed Kotlin files:" echo "$CHANGED_FILES" - echo "CHANGED_FILES<> $GITHUB_OUTPUT - echo "$CHANGED_FILES" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT + DELIM="EOF_$(openssl rand -hex 8)" + { + echo "CHANGED_FILES<<$DELIM" + echo "$CHANGED_FILES" + echo "$DELIM" + } >> "$GITHUB_OUTPUT" - name: Run ktlintCheck on changed files if: steps.get_changed_files.outputs.CHANGED_FILES != '' - run: ./gradlew ktlintCheck -PinternalKtlintGitFilter="${{ steps.get_changed_files.outputs.CHANGED_FILES }}" + env: + CHANGED_FILES: ${{ steps.get_changed_files.outputs.CHANGED_FILES }} + run: ./gradlew ktlintCheck -PinternalKtlintGitFilter="$CHANGED_FILES" - name: lint check run: ./gradlew --no-daemon lintDebug + - name: detekt check + run: ./gradlew --no-daemon detektDebug + unit: - runs-on: ubuntu-latest + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: true - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'temurin' java-version: '17' cache: 'gradle' - name: Setup Android SDK - uses: android-actions/setup-android@v2 + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 - name: unit tests with coverage run: ./gradlew --no-daemon --stacktrace testDebugUnitTest koverHtmlReport - name: test report - uses: asadmansr/android-test-report-action@v1.2.0 + uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6.4.2 if: ${{ always() }} + with: + report_paths: '**/build/test-results/testDebugUnitTest/TEST-*.xml' + fail_on_failure: true + detailed_summary: true + annotate_only: true - name: upload coverage report - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 if: github.ref == 'refs/heads/develop' with: path: build/reports/kover/html @@ -93,17 +115,17 @@ jobs: steps: - name: deploy to GitHub Pages id: deploy - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: true - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/manual-prepare_release_branch.yml b/.github/workflows/manual-prepare_release_branch.yml index e0a180732..6bb8c6619 100644 --- a/.github/workflows/manual-prepare_release_branch.yml +++ b/.github/workflows/manual-prepare_release_branch.yml @@ -15,14 +15,19 @@ on: required: true default: 'master' +permissions: + contents: read + jobs: validate-input: name: Validate release_version format runs-on: ubuntu-latest steps: - name: Check version matches semver or semver-rc + env: + RELEASE_VERSION: ${{ github.event.inputs.release_version }} run: | - VER="${{ github.event.inputs.release_version }}" + VER="$RELEASE_VERSION" echo "→ release_version = $VER" if ! [[ "$VER" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc)?$ ]]; then echo "❌ release_version must be X.Y.Z or X.Y.Z-rc" @@ -35,22 +40,26 @@ jobs: needs: validate-input steps: - name: Checkout minimal repo - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 submodules: recursive - name: Validate source branch exists + env: + SOURCE_BRANCH: ${{ github.event.inputs.source_branch }} run: | - SRC="${{ github.event.inputs.source_branch }}" + SRC="$SOURCE_BRANCH" if ! git ls-remote --heads origin "$SRC" | grep -q "$SRC"; then echo "❌ source_branch '$SRC' does not exist on origin" exit 1 fi - name: Validate target branch exists + env: + TARGET_BRANCH: ${{ github.event.inputs.target_branch }} run: | - DST="${{ github.event.inputs.target_branch }}" + DST="$TARGET_BRANCH" if ! git ls-remote --heads origin "$DST" | grep -q "$DST"; then echo "❌ target_branch '$DST' does not exist on origin" exit 1 @@ -60,11 +69,13 @@ jobs: name: Create release branch & bump version runs-on: ubuntu-latest needs: validate-branches + permissions: + contents: write outputs: release_branch: ${{ steps.bump.outputs.release_branch }} steps: - name: Checkout source branch - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.inputs.source_branch }} fetch-depth: 0 @@ -77,9 +88,12 @@ jobs: - name: Create release branch & bump version id: bump + env: + RELEASE_VERSION: ${{ github.event.inputs.release_version }} + SOURCE_BRANCH: ${{ github.event.inputs.source_branch }} run: | - VERSION="${{ github.event.inputs.release_version }}" - SRC="${{ github.event.inputs.source_branch }}" + VERSION="$RELEASE_VERSION" + SRC="$SOURCE_BRANCH" REL="release/$VERSION" echo "→ Branching from $SRC into $REL" @@ -88,11 +102,13 @@ jobs: echo "→ Running bump script on $REL" ./.github/bump_versions_in_release_branch.sh "$VERSION" - echo "release_branch=$REL" >> $GITHUB_OUTPUT + echo "release_branch=$REL" >> "$GITHUB_OUTPUT" - name: Push release branch + env: + RELEASE_BRANCH: ${{ steps.bump.outputs.release_branch }} run: | - git push origin "${{ steps.bump.outputs.release_branch }}" + git push origin "$RELEASE_BRANCH" check_sdk_version: name: Check SDK Version @@ -100,7 +116,7 @@ jobs: needs: bump_and_branch steps: - name: Checkout the release branch - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ needs.bump_and_branch.outputs.release_branch }} fetch-depth: 0 @@ -110,8 +126,10 @@ jobs: run: git pull - name: Validate sdkVersion + env: + RELEASE_VERSION: ${{ github.event.inputs.release_version }} run: | - EXPECT="${{ github.event.inputs.release_version }}" + EXPECT="$RELEASE_VERSION" SDK_VERSION=$(grep -E '^SDK_VERSION_NAME=' gradle.properties | cut -d'=' -f2) echo "→ Found in code: $SDK_VERSION, expected: $EXPECT" if [ "$SDK_VERSION" != "$EXPECT" ]; then @@ -130,11 +148,12 @@ jobs: SRC: ${{ needs.bump_and_branch.outputs.release_branch }} DST: ${{ github.event.inputs.target_branch }} REPO: ${{ github.repository }} + RELEASE_VERSION: ${{ github.event.inputs.release_version }} run: | echo "→ Creating PR from $SRC into $DST" gh pr create \ --repo "$REPO" \ --base "$DST" \ --head "$SRC" \ - --title "Release ${{ github.event.inputs.release_version }}" \ - --body "Updates the release version to ${{ github.event.inputs.release_version }}. Automated PR: merge $SRC into $DST" \ No newline at end of file + --title "Release $RELEASE_VERSION" \ + --body "Updates the release version to $RELEASE_VERSION. Automated PR: merge $SRC into $DST" \ No newline at end of file diff --git a/.github/workflows/prepare_release_branch.yml b/.github/workflows/prepare_release_branch.yml index 4cbf2f832..07eea0dad 100644 --- a/.github/workflows/prepare_release_branch.yml +++ b/.github/workflows/prepare_release_branch.yml @@ -6,6 +6,9 @@ on: - 'release/*.*.*' - 'support/*.*.*' +permissions: + contents: read + jobs: extract_version: if: github.event.created @@ -16,23 +19,27 @@ jobs: steps: - name: Extract version from branch name id: extract + env: + REF_NAME: ${{ github.ref_name }} run: | - BRANCH_NAME="${{ github.ref_name }}" + BRANCH_NAME="$REF_NAME" echo "BRANCH_NAME: $BRANCH_NAME" VERSION="${BRANCH_NAME#release/}" VERSION="${VERSION#support/}" echo "VERSION: $VERSION" - echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" bump_version: name: Bump Version runs-on: ubuntu-latest needs: extract_version + permissions: + contents: write outputs: version2: ${{ steps.bump.outputs.version2 }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: recursive @@ -42,12 +49,16 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Bump version - run: ./.github/bump_versions_in_release_branch.sh "${{ needs.extract_version.outputs.version }}" + env: + VERSION: ${{ needs.extract_version.outputs.version }} + run: ./.github/bump_versions_in_release_branch.sh "$VERSION" - - name: Ouput version + - name: Output version id: bump + env: + VERSION: ${{ needs.extract_version.outputs.version }} run: | - echo "version2=${{ needs.extract_version.outputs.version }}" >> $GITHUB_OUTPUT + echo "version2=$VERSION" >> "$GITHUB_OUTPUT" check_sdk_version: name: Check SDK Version @@ -55,7 +66,7 @@ jobs: needs: bump_version steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 submodules: recursive @@ -64,12 +75,14 @@ jobs: run: git pull - name: Check if SDK_VERSION_NAME matches VERSION + env: + VERSION2: ${{ needs.bump_version.outputs.version2 }} run: | SDK_VERSION=$(grep '^SDK_VERSION_NAME=' gradle.properties | cut -d '=' -f2) - EXPECTED_VERSION=${{ needs.bump_version.outputs.version2 }} + EXPECTED_VERSION="$VERSION2" if [ "$SDK_VERSION" != "$EXPECTED_VERSION" ]; then - echo "SDK_VERSION_NAME ($ACTUAL_VERSION) does not match the expected version ($EXPECTED_VERSION)." + echo "SDK_VERSION_NAME ($SDK_VERSION) does not match the expected version ($EXPECTED_VERSION)." exit 1 fi shell: bash @@ -80,19 +93,20 @@ jobs: needs: check_sdk_version steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: recursive - name: Create Pull Request + env: + GH_TOKEN: ${{ secrets.PAT_FOR_TRIGGERING_BRANCH_PROTECTION }} + REF_NAME: ${{ github.ref_name }} run: | gh pr create \ --base master \ - --head ${{ github.ref_name }} \ - --title "${{ github.ref_name }}" \ - --body "Updates the release version to ${{ github.ref_name }}" + --head "$REF_NAME" \ + --title "$REF_NAME" \ + --body "Updates the release version to $REF_NAME" PR_URL=$(gh pr view --json url --jq '.url') - echo "PR_URL=$PR_URL" >> $GITHUB_ENV - env: - GH_TOKEN: ${{ secrets.PAT_FOR_TRIGGERING_BRANCH_PROTECTION }} + echo "PR_URL=$PR_URL" >> "$GITHUB_ENV" diff --git a/.github/workflows/publish-from-master-or-support.yml b/.github/workflows/publish-from-master-or-support.yml index a536cec3e..f60e6957a 100644 --- a/.github/workflows/publish-from-master-or-support.yml +++ b/.github/workflows/publish-from-master-or-support.yml @@ -7,10 +7,22 @@ on: - 'master' - 'support/*' +permissions: + contents: read + jobs: call-reusable: if: ${{ github.event.pull_request.merged == true }} + permissions: + contents: write uses: ./.github/workflows/publish-reusable.yml with: branch: ${{ github.base_ref }} - secrets: inherit \ No newline at end of file + secrets: + GPGKEYCONTENTS: ${{ secrets.GPGKEYCONTENTS }} + SECRETPASSPHRASE: ${{ secrets.SECRETPASSPHRASE }} + SIGNINGPASSWORD: ${{ secrets.SIGNINGPASSWORD }} + SIGNINGKEYID: ${{ secrets.SIGNINGKEYID }} + CENTER_PORTAL_USERNAME: ${{ secrets.CENTER_PORTAL_USERNAME }} + CENTER_PORTAL_PASSWORD: ${{ secrets.CENTER_PORTAL_PASSWORD }} + PAT_FOR_TRIGGERING_BRANCH_PROTECTION: ${{ secrets.PAT_FOR_TRIGGERING_BRANCH_PROTECTION }} \ No newline at end of file diff --git a/.github/workflows/publish-manual.yml b/.github/workflows/publish-manual.yml index 7277c7758..6c15a81d6 100644 --- a/.github/workflows/publish-manual.yml +++ b/.github/workflows/publish-manual.yml @@ -4,20 +4,34 @@ name: SDK publish RC manual on: workflow_dispatch: +permissions: + contents: read + jobs: check-branch: runs-on: ubuntu-latest steps: - name: Check if branch matches pattern + env: + REF_NAME: ${{ github.ref_name }} run: | - if ! echo "${{ github.ref_name }}" | grep -q "release/.*-rc"; then + if ! echo "$REF_NAME" | grep -q "release/.*-rc"; then echo "Branch name must match pattern 'release/*-rc' (e.g. release/2.13.2-rc)" exit 1 fi call-publish-reusable: needs: check-branch + permissions: + contents: write uses: ./.github/workflows/publish-reusable.yml with: branch: ${{ github.ref_name }} - secrets: inherit + secrets: + GPGKEYCONTENTS: ${{ secrets.GPGKEYCONTENTS }} + SECRETPASSPHRASE: ${{ secrets.SECRETPASSPHRASE }} + SIGNINGPASSWORD: ${{ secrets.SIGNINGPASSWORD }} + SIGNINGKEYID: ${{ secrets.SIGNINGKEYID }} + CENTER_PORTAL_USERNAME: ${{ secrets.CENTER_PORTAL_USERNAME }} + CENTER_PORTAL_PASSWORD: ${{ secrets.CENTER_PORTAL_PASSWORD }} + PAT_FOR_TRIGGERING_BRANCH_PROTECTION: ${{ secrets.PAT_FOR_TRIGGERING_BRANCH_PROTECTION }} diff --git a/.github/workflows/publish-reusable.yml b/.github/workflows/publish-reusable.yml index ef7cd19c9..bab01df55 100644 --- a/.github/workflows/publish-reusable.yml +++ b/.github/workflows/publish-reusable.yml @@ -6,43 +6,61 @@ on: branch: required: true type: string - + secrets: + GPGKEYCONTENTS: + required: true + SECRETPASSPHRASE: + required: true + SIGNINGPASSWORD: + required: true + SIGNINGKEYID: + required: true + CENTER_PORTAL_USERNAME: + required: true + CENTER_PORTAL_PASSWORD: + required: true + PAT_FOR_TRIGGERING_BRANCH_PROTECTION: + required: true + +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.branch }} submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'temurin' java-version: '17' cache: 'gradle' - name: Setup Android SDK - uses: android-actions/setup-android@v2 + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 - name: lint check run: ./gradlew --no-daemon lintDebug unit-tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.branch }} submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'temurin' java-version: '17' cache: 'gradle' - name: Setup Android SDK - uses: android-actions/setup-android@v2 + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 - name: unit tests run: ./gradlew --no-daemon testDebugUnitTest @@ -50,13 +68,13 @@ jobs: needs: [lint, unit-tests] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.branch }} submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'temurin' java-version: '17' @@ -67,58 +85,73 @@ jobs: set-tag: needs: [build] runs-on: ubuntu-latest + permissions: + contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.branch }} submodules: recursive - name: Extract SDK version run: | SDK_VERSION=$(grep '^SDK_VERSION_NAME=' gradle.properties | cut -d '=' -f2) - echo "SDK_VERSION=$SDK_VERSION" >> $GITHUB_ENV + echo "SDK_VERSION=$SDK_VERSION" >> "$GITHUB_ENV" echo "Extracted SDK version: $SDK_VERSION" - name: Create tag run: | - git tag ${{ env.SDK_VERSION }} - git push origin ${{ env.SDK_VERSION }} + if git rev-parse -q --verify "refs/tags/$SDK_VERSION" >/dev/null; then + echo "Local tag $SDK_VERSION already exists." + else + git tag "$SDK_VERSION" + fi + if [ -n "$(git ls-remote --tags origin "refs/tags/$SDK_VERSION")" ]; then + echo "Remote tag $SDK_VERSION already exists." + else + git push origin "$SDK_VERSION" + fi publish: needs: [set-tag] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.branch }} submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'temurin' java-version: '17' cache: 'gradle' - name: Prepare to publish + env: + GPGKEYCONTENTS: ${{ secrets.GPGKEYCONTENTS }} + SECRETPASSPHRASE: ${{ secrets.SECRETPASSPHRASE }} + SIGNINGPASSWORD: ${{ secrets.SIGNINGPASSWORD }} + SIGNINGKEYID: ${{ secrets.SIGNINGKEYID }} + CENTER_PORTAL_USERNAME: ${{ secrets.CENTER_PORTAL_USERNAME }} + CENTER_PORTAL_PASSWORD: ${{ secrets.CENTER_PORTAL_PASSWORD }} run: | - echo '${{secrets.GPGKEYCONTENTS}}' | base64 -d > /tmp/publish_key.gpg - gpg --quiet --batch --yes --decrypt --passphrase="${{secrets.SECRETPASSPHRASE}}" \ + echo "$GPGKEYCONTENTS" | base64 -d > /tmp/publish_key.gpg + gpg --quiet --batch --yes --decrypt --passphrase="$SECRETPASSPHRASE" \ --output /tmp/secret.gpg /tmp/publish_key.gpg - echo "" >> gradle.properties - echo "signing.password=${{secrets.SIGNINGPASSWORD}}" >> gradle.properties - echo "signing.keyId=${{secrets.SIGNINGKEYID}}" >> gradle.properties - echo "signing.secretKeyRingFile=/tmp/secret.gpg" >> gradle.properties - echo "mavenCentralUsername=${{secrets.CENTER_PORTAL_USERNAME}}" >> gradle.properties - echo "mavenCentralPassword=${{secrets.CENTER_PORTAL_PASSWORD}}" >> gradle.properties + { + echo "" + echo "signing.password=$SIGNINGPASSWORD" + echo "signing.keyId=$SIGNINGKEYID" + echo "signing.secretKeyRingFile=/tmp/secret.gpg" + echo "mavenCentralUsername=$CENTER_PORTAL_USERNAME" + echo "mavenCentralPassword=$CENTER_PORTAL_PASSWORD" + } >> gradle.properties + - name: Publish to Central Portal + # Change github variable CENTER_PORTAL_AUTO_RELEASE to set up auto release Maven Central env: - signingpassword: ${{secrets.SIGNINGPASSWORD}} - signingkeyId: ${{secrets.SIGNINGKEYID}} - SECRETPASSPHRASE: ${{secrets.SECRETPASSPHRASE}} - GPGKEYCONTENTS: ${{secrets.GPGKEYCONTENTS}} + CENTER_PORTAL_AUTO_RELEASE: ${{ vars.CENTER_PORTAL_AUTO_RELEASE }} SONATYPE_CONNECT_TIMEOUT_SECONDS: 60 SONATYPE_CLOSE_TIMEOUT_SECONDS: 900 - ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true' - - name: Publish to Central Portal - # Change github variable CENTER_PORTAL_AUTO_RELEASE to set up auto release Maven Central run: | - if [ "${{ vars.CENTER_PORTAL_AUTO_RELEASE }}" = "true" ]; then + if [ "$CENTER_PORTAL_AUTO_RELEASE" = "true" ]; then ./gradlew publishAndReleaseToMavenCentral --no-configuration-cache else ./gradlew publishToMavenCentral --no-configuration-cache @@ -127,14 +160,17 @@ jobs: release-github: needs: [publish] runs-on: ubuntu-latest + permissions: + contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.branch }} - - name: Github Release generation + - name: GitHub Release generation run: ./.github/git-release-ci.sh env: GH_TOKEN: ${{ github.token }} + RELEASE_BRANCH: ${{ inputs.branch }} merge: needs: [publish] @@ -146,7 +182,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.PAT_FOR_TRIGGERING_BRANCH_PROTECTION }} steps: - name: Checkout develop branch - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: develop - name: Create Pull Request @@ -154,22 +190,4 @@ jobs: - name: Merge Pull Request run: | pr_number=$(gh pr list --base develop --head master --json number --jq '.[0].number') - gh pr merge $pr_number --merge --auto - - message-to-loop-if-success: - needs: [release-github] - runs-on: ubuntu-latest - steps: - - name: Send message to LOOP - env: - LOOP_NOTIFICATION_WEBHOOK_URL: ${{ secrets.LOOP_NOTIFICATION_WEBHOOK_URL }} - VERSION: ${{ github.ref_name }} - run: | - MESSAGE=$(cat <> $GITHUB_ENV - echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_ENV + echo "MASTER_VERSION=$MASTER_VERSION" >> "$GITHUB_ENV" + echo "RELEASE_VERSION=$RELEASE_VERSION" >> "$GITHUB_ENV" - name: Compare versions - uses: jackbilestech/semver-compare@1.0.4 + uses: jackbilestech/semver-compare@b6b063c569b77bea4a0ab627192cbdabf75de3f5 # 1.0.4 with: head: ${{ env.RELEASE_VERSION }} base: ${{ env.MASTER_VERSION }} diff --git a/.gitignore b/.gitignore index 4ee9d130f..3cfad2fe7 100644 --- a/.gitignore +++ b/.gitignore @@ -357,3 +357,4 @@ hs_err_pid* # End of https://www.toptal.com/developers/gitignore/api/android,androidstudio,macos,linux,intellij /keys +/example/example.properties diff --git a/.gitmodules b/.gitmodules index 8112ba12c..972e08741 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "kmp-common-sdk"] path = kmp-common-sdk url = https://github.com/mindbox-cloud/kmp-common-sdk.git - branch = develop + branch = develop \ No newline at end of file diff --git a/detekt-rules/build.gradle b/detekt-rules/build.gradle new file mode 100644 index 000000000..9bd9ef6bc --- /dev/null +++ b/detekt-rules/build.gradle @@ -0,0 +1,27 @@ +apply plugin: 'java-library' +apply plugin: 'kotlin' + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +compileKotlin { + kotlinOptions { + jvmTarget = '11' + } +} + +compileTestKotlin { + kotlinOptions { + jvmTarget = '11' + } +} + +dependencies { + implementation libs.detekt.api + implementation libs.kotlin.stdlib + + testImplementation libs.detekt.test + testImplementation libs.junit +} diff --git a/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/GsonSerializedNameRule.kt b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/GsonSerializedNameRule.kt new file mode 100644 index 000000000..3d5cc9ac7 --- /dev/null +++ b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/GsonSerializedNameRule.kt @@ -0,0 +1,173 @@ +package cloud.mindbox.detekt + +import io.gitlab.arturbosch.detekt.api.CodeSmell +import io.gitlab.arturbosch.detekt.api.Config +import io.gitlab.arturbosch.detekt.api.Debt +import io.gitlab.arturbosch.detekt.api.Entity +import io.gitlab.arturbosch.detekt.api.Issue +import io.gitlab.arturbosch.detekt.api.Rule +import io.gitlab.arturbosch.detekt.api.Severity +import org.jetbrains.kotlin.descriptors.ClassDescriptor +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtClassLiteralExpression +import org.jetbrains.kotlin.psi.KtDotQualifiedExpression +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtObjectDeclaration +import org.jetbrains.kotlin.psi.KtParameter +import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType +import org.jetbrains.kotlin.resolve.BindingContext +import org.jetbrains.kotlin.resolve.DescriptorToSourceUtils +import org.jetbrains.kotlin.resolve.calls.util.getResolvedCall +import org.jetbrains.kotlin.resolve.calls.util.getType +import org.jetbrains.kotlin.types.KotlinType + +class GsonSerializedNameRule(config: Config) : Rule(config) { + + override val issue: Issue = Issue( + id = "GsonMissingSerializedName", + severity = Severity.Defect, + description = "Gson-serialized Kotlin data class constructor properties must declare @SerializedName.", + debt = Debt.FIVE_MINS + ) + + private val reportedParameterKeys: MutableSet = mutableSetOf() + private val checkedClasses: MutableSet = mutableSetOf() + + override fun preVisit(root: KtFile) { + reportedParameterKeys.clear() + } + + override fun visitClass(klass: KtClass) { + super.visitClass(klass) + if (!klass.isData()) return + if (!klass.hasSerializedNameContract()) return + reportMissingSerializedNameParameters(klass) + } + + override fun visitCallExpression(expression: KtCallExpression) { + super.visitCallExpression(expression) + val calleeName = expression.calleeExpression?.text ?: return + if (calleeName in DIRECT_GSON_FUNCTION_NAMES) { + checkFirstArgumentType(expression) + checkTypeArguments(expression) + checkClassLiteralArguments(expression) + } else { + checkIfIndirectGsonCall(expression) + } + } + + override fun visitObjectDeclaration(declaration: KtObjectDeclaration) { + super.visitObjectDeclaration(declaration) + declaration.superTypeListEntries + .filter { entry -> entry.typeReference?.text?.contains(TYPE_TOKEN) == true } + .forEach { entry -> + val type = bindingContext[BindingContext.TYPE, entry.typeReference] ?: return@forEach + type.arguments + .filterNot { projection -> projection.isStarProjection } + .forEach { projection -> checkKotlinType(projection.type) } + } + } + + private fun checkIfIndirectGsonCall(expression: KtCallExpression) { + val resolvedCall = expression.getResolvedCall(bindingContext) ?: return + val sourceFunction = DescriptorToSourceUtils + .descriptorToDeclaration(resolvedCall.resultingDescriptor) as? KtNamedFunction ?: return + if (sourceFunction.typeParameters.isEmpty()) return + if (!sourceFunction.containsDirectGsonCall()) return + checkFirstArgumentType(expression) + checkTypeArguments(expression) + checkClassLiteralArguments(expression) + } + + private fun KtNamedFunction.containsDirectGsonCall(): Boolean = + collectDescendantsOfType() + .any { call -> call.calleeExpression?.text in DIRECT_GSON_FUNCTION_NAMES } + + private fun checkFirstArgumentType(expression: KtCallExpression) { + val argument = expression.valueArguments.firstOrNull()?.getArgumentExpression() ?: return + checkKotlinType(argument.getType(bindingContext) ?: return) + } + + private fun checkTypeArguments(expression: KtCallExpression) { + expression.typeArguments + .mapNotNull { typeProjection -> typeProjection.typeReference } + .mapNotNull { typeRef -> bindingContext[BindingContext.TYPE, typeRef] } + .forEach { type -> checkKotlinType(type) } + } + + private fun checkClassLiteralArguments(expression: KtCallExpression) { + expression.valueArguments + .mapNotNull { argument -> argument.getArgumentExpression() } + .forEach { argument -> + val classLiteral = when (argument) { + is KtDotQualifiedExpression -> argument.receiverExpression as? KtClassLiteralExpression + is KtClassLiteralExpression -> argument + else -> null + } ?: return@forEach + val type = classLiteral.getType(bindingContext) ?: return@forEach + type.arguments.firstOrNull() + ?.takeIf { projection -> !projection.isStarProjection } + ?.type + ?.let(::checkKotlinType) + } + } + + private fun checkKotlinType(type: KotlinType) { + val descriptor = type.constructor.declarationDescriptor as? ClassDescriptor ?: return + val sourceClass = DescriptorToSourceUtils.descriptorToDeclaration(descriptor) as? KtClass + sourceClass?.let(::reportMissingSerializedNameParameters) + type.arguments + .filterNot { projection -> projection.isStarProjection } + .forEach { projection -> checkKotlinType(projection.type) } + } + + private fun reportMissingSerializedNameParameters(klass: KtClass) { + if (!klass.isData()) return + val qualifiedName = klass.fqName?.asString() ?: return + if (!checkedClasses.add(qualifiedName)) return + klass.primaryConstructorParameters + .filter { parameter -> parameter.valOrVarKeyword != null } + .forEach { parameter -> + if (!parameter.hasSerializedNameAnnotation()) reportParameter(parameter, klass) + val typeRef = parameter.typeReference ?: return@forEach + val type = bindingContext[BindingContext.TYPE, typeRef] ?: return@forEach + checkKotlinType(type) + } + } + + private fun reportParameter(parameter: KtParameter, klass: KtClass) { + val key = "${parameter.containingFile.name}:${parameter.textOffset}" + if (!reportedParameterKeys.add(key)) return + report( + CodeSmell( + issue = issue, + entity = Entity.from(parameter), + message = "${klass.name}.${parameter.name} must declare @SerializedName." + ) + ) + } + + private fun KtClass.hasSerializedNameContract(): Boolean { + return primaryConstructorParameters.any { parameter -> parameter.hasSerializedNameAnnotation() } + } + + private fun KtParameter.hasSerializedNameAnnotation(): Boolean { + return annotationEntries.any { annotationEntry -> + annotationEntry.shortName?.asString() == SERIALIZED_NAME + } + } + + private companion object { + private const val SERIALIZED_NAME = "SerializedName" + private const val TYPE_TOKEN = "TypeToken" + + private val DIRECT_GSON_FUNCTION_NAMES: Set = setOf( + "fromJson", + "fromJsonTree", + "toJson", + "toJsonTree", + ) + } +} diff --git a/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/MindboxRuleSetProvider.kt b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/MindboxRuleSetProvider.kt new file mode 100644 index 000000000..1978f0bd9 --- /dev/null +++ b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/MindboxRuleSetProvider.kt @@ -0,0 +1,19 @@ +package cloud.mindbox.detekt + +import io.gitlab.arturbosch.detekt.api.Config +import io.gitlab.arturbosch.detekt.api.RuleSet +import io.gitlab.arturbosch.detekt.api.RuleSetProvider + +class MindboxRuleSetProvider : RuleSetProvider { + + override val ruleSetId: String = "mindbox" + + override fun instance(config: Config): RuleSet { + return RuleSet( + id = ruleSetId, + rules = listOf( + GsonSerializedNameRule(config = config), + ) + ) + } +} diff --git a/detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider b/detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider new file mode 100644 index 000000000..9f5c9241b --- /dev/null +++ b/detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider @@ -0,0 +1 @@ +cloud.mindbox.detekt.MindboxRuleSetProvider diff --git a/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/GsonSerializedNameRuleTest.kt b/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/GsonSerializedNameRuleTest.kt new file mode 100644 index 000000000..395fbdc30 --- /dev/null +++ b/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/GsonSerializedNameRuleTest.kt @@ -0,0 +1,276 @@ +package cloud.mindbox.detekt + +import io.github.detekt.test.utils.createEnvironment +import io.gitlab.arturbosch.detekt.api.Finding +import io.gitlab.arturbosch.detekt.test.TestConfig +import io.gitlab.arturbosch.detekt.test.compileAndLintWithContext +import org.junit.AfterClass +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GsonSerializedNameRuleTest { + + private val stubs = """ + annotation class SerializedName(val value: String) + abstract class TypeToken + fun fromJson(json: String, clazz: Class<*>): Any = error("stub") + fun fromJson(json: String, clazz: Class): T = error("stub") + fun toJson(obj: Any?): String = error("stub") + fun fromJsonTyped(json: String, clazz: Class): T = fromJson(json, clazz) + fun toJsonTyped(obj: T): String = toJson(obj) + fun operationBodyJson(obj: T): String = toJson(obj) + fun convertJsonToBody(json: String, clazz: Class): T = fromJson(json, clazz) + """.trimIndent() + + private fun compileAndLint(code: String): List = + GsonSerializedNameRule(TestConfig()).compileAndLintWithContext(env, code) + + @Test + fun `reports class passed via class literal to fromJson`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto(val name: String) + fun test(json: String): Any = fromJson(json, MyDto::class.java) + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("MyDto.name")) + } + + @Test + fun `reports class passed as explicit type argument to fromJson`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto(val name: String) + fun test(json: String): MyDto = fromJson(json, MyDto::class.java) + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("MyDto.name")) + } + + @Test + fun `reports class passed as first argument to toJson`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto(val name: String) + fun test() = toJson(MyDto("x")) + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("MyDto.name")) + } + + @Test + fun `reports class when toJson called with this inside the class`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto(val name: String) { + fun serialize() = toJson(this) + } + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("MyDto.name")) + } + + @Test + fun `reports class passed via operationBodyJson`() { + val findings = compileAndLint( + """ + $stubs + data class RequestBody(val action: String) + fun test() = operationBodyJson(RequestBody("click")) + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("RequestBody.action")) + } + + @Test + fun `reports class passed via fromJsonTyped class literal`() { + val findings = compileAndLint( + """ + $stubs + data class ResponseBody(val status: String) + fun test(json: String): ResponseBody = fromJsonTyped(json, ResponseBody::class.java) + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("ResponseBody.status")) + } + + @Test + fun `reports class passed via convertJsonToBody`() { + val findings = compileAndLint( + """ + $stubs + data class EventBody(val type: String) + fun test(json: String): Any = convertJsonToBody(json, EventBody::class.java) + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("EventBody.type")) + } + + @Test + fun `reports class via custom auto-detected generic wrapper`() { + val findings = compileAndLint( + """ + $stubs + data class Payload(val id: String) + fun myDeserializer(json: String, clazz: Class): T = fromJson(json, clazz) + fun test(json: String) = myDeserializer(json, Payload::class.java) + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("Payload.id")) + } + + @Test + fun `does not report class passed to a function that does not wrap Gson`() { + val findings = compileAndLint( + """ + $stubs + data class Config(val key: String) + fun jacksonDeserialize(json: String, clazz: Class): T = error("stub") + fun test(json: String) = jacksonDeserialize(json, Config::class.java) + """.trimIndent() + ) + assertTrue(findings.isEmpty()) + } + + @Test + fun `reports class used as direct TypeToken generic argument`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto(val name: String) + val token = object : TypeToken() {} + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("MyDto.name")) + } + + @Test + fun `reports class nested inside List in TypeToken`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto(val name: String) + val token = object : TypeToken>() {} + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("MyDto.name")) + } + + @Test + fun `reports transitive field type missing annotation`() { + val findings = compileAndLint( + """ + $stubs + data class Inner(val value: Int) + data class Outer(val inner: Inner) + fun test() = toJson(Outer(Inner(1))) + """.trimIndent() + ) + assertEquals(2, findings.size) + assertTrue(findings.any { it.message.contains("Outer.inner") }) + assertTrue(findings.any { it.message.contains("Inner.value") }) + } + + @Test + fun `reports class nested inside generic List field transitively`() { + val findings = compileAndLint( + """ + $stubs + data class Item(val id: Int) + data class Page(val items: List) + fun test() = toJson(Page(emptyList())) + """.trimIndent() + ) + assertTrue(findings.any { it.message.contains("Page.items") }) + assertTrue(findings.any { it.message.contains("Item.id") }) + } + + @Test + fun `reports missing annotation when class has partial SerializedName contract`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto( + @SerializedName("name") val name: String, + val age: Int + ) + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("MyDto.age")) + } + + @Test + fun `does not report data class not involved in any Gson call`() { + val findings = compileAndLint( + """ + $stubs + data class Config(val key: String, val value: Int) + """.trimIndent() + ) + assertTrue(findings.isEmpty()) + } + + @Test + fun `does not report when all fields have SerializedName`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto( + @SerializedName("name") val name: String, + @SerializedName("age") val age: Int + ) + fun test() = toJson(MyDto("x", 1)) + """.trimIndent() + ) + assertTrue(findings.isEmpty()) + } + + @Test + fun `does not report non-data class passed to toJson`() { + val findings = compileAndLint( + """ + $stubs + class NotADataClass(val name: String) + fun test() = toJson(NotADataClass("x")) + """.trimIndent() + ) + assertTrue(findings.isEmpty()) + } + + @Test + fun `does not report stdlib types like String or List`() { + val findings = compileAndLint( + """ + $stubs + fun test() = toJson(listOf("a", "b")) + """.trimIndent() + ) + assertTrue(findings.isEmpty()) + } + + companion object { + private val environmentWrapper = createEnvironment() + private val env get() = environmentWrapper.env + + @AfterClass @JvmStatic + fun tearDown() { + environmentWrapper.dispose() + } + } +} diff --git a/detekt.yml b/detekt.yml new file mode 100644 index 000000000..81724dec1 --- /dev/null +++ b/detekt.yml @@ -0,0 +1,35 @@ +config: + validation: true + excludes: "mindbox.*,mindbox>.*>.*" + +comments: + active: false + +complexity: + active: false + +coroutines: + active: false + +empty-blocks: + active: false + +exceptions: + active: false + +naming: + active: false + +performance: + active: false + +potential-bugs: + active: false + +style: + active: false + +mindbox: + active: true + GsonMissingSerializedName: + active: true diff --git a/example/.gitignore b/example/.gitignore index 0e96b7e16..06055cc26 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -28,4 +28,5 @@ local.properties *.zip *.keystore keystore.properties +example.properties *.jks \ No newline at end of file diff --git a/example/README.md b/example/README.md index 2828f2555..27c717f31 100644 --- a/example/README.md +++ b/example/README.md @@ -1,25 +1,31 @@ To run the example application with functioning mobile push notifications (complete only step 4 for in-app functionality to work), follow these steps: -1) Change the package identifier in the **app/build.gradle** file +1) Change the package identifier in the **app/build.gradle** file if needed 2) Add your application to either Firebase, Huawei, RuStore project, following the instructions provided at: -[Firebase Key Generation](https://developers.mindbox.ru/docs/firebase-get-keys) / +[Firebase Key Generation](https://developers.mindbox.ru/docs/firebase-get-keys) / [Huawei Key Generation](https://developers.mindbox.ru/docs/huawei-get-keys) / [RuStore Key Generation](https://developers.mindbox.ru/docs/rustore-get-keys) / or add app in your existing project 3) Configure Push Notification Services: * For Firebase: -Copy the **google-services.json** file into the app folder of your project. +Copy the **google-services.json** file into the **app/** folder of your project. * For Huawei: -Copy the **agcconnect-services.json** file into the app folder of your project. - -* For RuStore: -Change RU_STORE_PROJECT_ID in **app/src/main/AndroidManifest.xml**. Replacing RU_STORE_PROJECT_ID with your actual RuStore project ID. - -4) Set your domain and endpoint in the [ExampleApplication](https://github.com/mindbox-cloud/android-sdk/blob/develop/example/app/src/main/java/com/mindbox/example/ExampleApplication.kt) class within the configuration builder +Copy the **agconnect-services.json** file into the **app/** folder of your project. + +4) Create **example.properties** in the root of the example project (next to `settings.gradle`). +This file is gitignored — fill in your own values: +``` +mindbox.domain=your.domain.here +mindbox.endpointId=your-endpoint-id +mindbox.ruStoreProjectId=your-rustore-project-id +``` +These values are injected into `BuildConfig` at build time and read by +[ExampleApplication](https://github.com/mindbox-cloud/android-sdk/blob/develop/example/app/src/main/java/com/mindbox/example/ExampleApplication.kt). +Alternatively, you can set the values directly in that file as string literals. 5) Run the application diff --git a/example/app/build.gradle b/example/app/build.gradle index 18dd0238c..357f6310c 100644 --- a/example/app/build.gradle +++ b/example/app/build.gradle @@ -8,7 +8,17 @@ plugins { def keystorePropertiesFile = rootProject.file("keystore.properties") def keystoreProperties = new Properties() if (keystorePropertiesFile.exists()) { - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) + keystorePropertiesFile.withInputStream { stream -> + keystoreProperties.load(stream) + } +} + +def examplePropertiesFile = rootProject.file("example.properties") +def exampleProperties = new Properties() +if (examplePropertiesFile.exists()) { + examplePropertiesFile.withInputStream { stream -> + exampleProperties.load(stream) + } } android { @@ -19,9 +29,14 @@ android { applicationId "com.mindbox.example" minSdk 21 targetSdk 35 - versionCode 7 - versionName "2.5" + versionCode 8 + versionName "2.15.2" multiDexEnabled true + + buildConfigField "String", "MINDBOX_DOMAIN", "\"${exampleProperties.getProperty('mindbox.domain', '')}\"" + buildConfigField "String", "MINDBOX_ENDPOINT_ID", "\"${exampleProperties.getProperty('mindbox.endpointId', '')}\"" + buildConfigField "String", "MINDBOX_RUSTORE_PROJECT_ID", "\"${exampleProperties.getProperty('mindbox.ruStoreProjectId', '')}\"" + manifestPlaceholders = [ruStoreProjectId: exampleProperties.getProperty('mindbox.ruStoreProjectId', '')] } compileOptions { @@ -82,7 +97,7 @@ dependencies { implementation 'com.google.android.material:material:1.11.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - //Push services + // Push services implementation platform('com.google.firebase:firebase-bom:32.7.1') implementation 'com.google.firebase:firebase-analytics-ktx' implementation 'com.google.firebase:firebase-messaging-ktx' @@ -91,13 +106,13 @@ dependencies { implementation 'com.google.code.gson:gson:2.11.0' - //Mindbox - implementation 'cloud.mindbox:mobile-sdk:2.15.2' + // Mindbox + implementation 'cloud.mindbox:mobile-sdk:2.15.3' implementation 'cloud.mindbox:mindbox-firebase' implementation 'cloud.mindbox:mindbox-huawei' implementation 'cloud.mindbox:mindbox-rustore' - //Glade for custom loader + // Glide for custom loader implementation 'com.github.bumptech.glide:glide:4.15.1' implementation 'androidx.activity:activity-ktx:1.9.0' annotationProcessor 'com.github.bumptech.glide:compiler:4.15.1' diff --git a/example/app/proguard-rules.pro b/example/app/proguard-rules.pro index d0e2a4320..7303885a8 100644 --- a/example/app/proguard-rules.pro +++ b/example/app/proguard-rules.pro @@ -32,5 +32,6 @@ -dontwarn com.huawei.libcore.io.ExternalStorageFileInputStream -dontwarn com.huawei.libcore.io.ExternalStorageFileOutputStream -dontwarn com.huawei.libcore.io.ExternalStorageRandomAccessFile +-dontwarn com.huawei.hms.availableupdate.UpdateAdapterMgr -keep class com.mindbox.example.PushPayload { *; } \ No newline at end of file diff --git a/example/app/src/main/AndroidManifest.xml b/example/app/src/main/AndroidManifest.xml index 5d2c43ecb..9c3eecb2f 100644 --- a/example/app/src/main/AndroidManifest.xml +++ b/example/app/src/main/AndroidManifest.xml @@ -64,7 +64,7 @@ + android:value="${ruStoreProjectId}" /> \ No newline at end of file diff --git a/example/app/src/main/java/com/mindbox/example/ExampleApplication.kt b/example/app/src/main/java/com/mindbox/example/ExampleApplication.kt index a267d82cd..30ab39c90 100644 --- a/example/app/src/main/java/com/mindbox/example/ExampleApplication.kt +++ b/example/app/src/main/java/com/mindbox/example/ExampleApplication.kt @@ -12,7 +12,9 @@ import cloud.mindbox.mobile_sdk.logger.Level class ExampleApp : Application() { companion object { - const val RU_STORE_PROJECT_ID = "" //paste your RuStore project id + // Value is injected from example.properties via BuildConfig. + // To set it manually, replace BuildConfig.MINDBOX_RUSTORE_PROJECT_ID with a string literal. + val RU_STORE_PROJECT_ID: String = BuildConfig.MINDBOX_RUSTORE_PROJECT_ID //paste your RuStore project id private var privateApplication: Application? = null val application: Application get() = privateApplication!! @@ -23,10 +25,15 @@ class ExampleApp : Application() { privateApplication = this //https://developers.mindbox.ru/docs/android-sdk-initialization + // Values are injected from example.properties via BuildConfig. + // To set them manually, replace BuildConfig.MINDBOX_DOMAIN / MINDBOX_ENDPOINT_ID with string literals. + val domain = BuildConfig.MINDBOX_DOMAIN.ifEmpty { "" } //paste your domain address + val endpointId = BuildConfig.MINDBOX_ENDPOINT_ID.ifEmpty { "" } //paste your endpointId + val configuration = MindboxConfiguration.Builder( context = applicationContext, - domain = "",//paste your domain address - endpointId = ""//paste your domain address + domain = domain, + endpointId = endpointId ) .shouldCreateCustomer(true) .subscribeCustomerIfCreated(true) diff --git a/example/app/src/main/res/layout/activity_main.xml b/example/app/src/main/res/layout/activity_main.xml index 175b3333d..67eaa34f2 100644 --- a/example/app/src/main/res/layout/activity_main.xml +++ b/example/app/src/main/res/layout/activity_main.xml @@ -4,6 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:fitsSystemWindows="true" tools:context=".MainActivity"> + logger.e(this, "Failed to create named FirebaseApp '$appName'", error) + }, + ) } override suspend fun getToken( context: Context ): String? = suspendCancellableCoroutine { continuation -> - FirebaseMessaging.getInstance().token + resolveFirebaseMessaging(context).token .addOnCanceledListener { continuation.resumeWithException(CancellationException()) } @@ -38,6 +66,29 @@ internal class FirebaseServiceHandler( .addOnFailureListener(continuation::resumeWithException) } + private fun resolveFirebaseMessaging(context: Context): FirebaseMessaging { + val appName = namedFirebaseAppName(context) + if (appName.isBlank()) { + return FirebaseMessaging.getInstance() + } + return runCatching { + FirebaseApp.getInstance(appName).get(FirebaseMessaging::class.java) + }.getOrElse { error -> + logger.e( + this, + "Could not resolve named FirebaseApp '$appName', " + + "falling back to [DEFAULT] FirebaseApp", + error, + ) + FirebaseMessaging.getInstance() + } + } + + private fun namedFirebaseAppName(context: Context): String = + cachedNamedAppName ?: exceptionHandler.runCatching(defaultValue = "") { + context.getString(R.string.mindbox_firebase_app_name).trim() + }.also { cachedNamedAppName = it } + override fun getAdsId(context: Context): Pair { val advertisingIdInfo = AdvertisingIdClient.getAdvertisingIdInfo(context) val id = advertisingIdInfo.id diff --git a/mindbox-firebase/src/main/res/values/strings.xml b/mindbox-firebase/src/main/res/values/strings.xml new file mode 100644 index 000000000..f79e12f07 --- /dev/null +++ b/mindbox-firebase/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/mindbox-firebase/src/test/kotlin/cloud/mindbox/mindbox_firebase/FirebaseServiceHandlerTest.kt b/mindbox-firebase/src/test/kotlin/cloud/mindbox/mindbox_firebase/FirebaseServiceHandlerTest.kt new file mode 100644 index 000000000..bdb61e65a --- /dev/null +++ b/mindbox-firebase/src/test/kotlin/cloud/mindbox/mindbox_firebase/FirebaseServiceHandlerTest.kt @@ -0,0 +1,272 @@ +package cloud.mindbox.mindbox_firebase + +import android.content.Context +import cloud.mindbox.mobile_sdk.logger.MindboxLogger +import cloud.mindbox.mobile_sdk.utils.ExceptionHandler +import com.google.android.gms.tasks.OnCanceledListener +import com.google.android.gms.tasks.OnFailureListener +import com.google.android.gms.tasks.OnSuccessListener +import com.google.android.gms.tasks.Task +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.messaging.FirebaseMessaging +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test + +class FirebaseServiceHandlerTest { + + private val context = mockk() + private val logger = mockk(relaxed = true) + + // Real handler (not a mock): runCatching must execute the block so the + // resource lookup actually runs. + private val exceptionHandler = object : ExceptionHandler() { + override fun handle(exception: Throwable) = Unit + } + private val handler = FirebaseServiceHandler( + logger = logger, + exceptionHandler = exceptionHandler, + ) + + @After + fun tearDown() = unmockkAll() + + /** + * Stubs the optional `mindbox_firebase_app_name` resource. The empty string is the + * shipped default (host app didn't override it). + */ + private fun stubAppName(value: String) { + every { context.getString(R.string.mindbox_firebase_app_name) } returns value + } + + private fun stubDefaultMessaging(token: String) { + val defaultMessaging = mockk { + every { this@mockk.token } returns successTask(token) + } + mockkStatic(FirebaseMessaging::class) + every { FirebaseMessaging.getInstance() } returns defaultMessaging + } + + private fun successTask(token: String): Task { + val task = mockk>() + every { task.addOnCanceledListener(any()) } returns task + every { task.addOnSuccessListener(any>()) } answers { + firstArg>().onSuccess(token) + task + } + every { task.addOnFailureListener(any()) } returns task + return task + } + + // --- Token resolution (resolveFirebaseMessaging via registerToken) --- + + @Test + fun `resource blank (default) - token read from DEFAULT FirebaseApp (backward compatible)`() = runTest { + stubAppName("") + stubDefaultMessaging("default-token") + + val token = handler.registerToken(context, previousToken = null) + + assertEquals("default-token", token) + verify(exactly = 1) { FirebaseMessaging.getInstance() } + } + + @Test + fun `resource whitespace-only - treated as blank, uses DEFAULT FirebaseApp`() = runTest { + stubAppName(" ") + stubDefaultMessaging("default-token") + + val token = handler.registerToken(context, previousToken = null) + + assertEquals("default-token", token) + verify(exactly = 1) { FirebaseMessaging.getInstance() } + } + + @Test + fun `resource set and named app present - token read from named FirebaseApp`() = runTest { + stubAppName("mindbox-named") + val namedMessaging = mockk { + every { token } returns successTask("named-token") + } + val namedApp = mockk { + every { get(FirebaseMessaging::class.java) } returns namedMessaging + } + mockkStatic(FirebaseApp::class) + every { FirebaseApp.getInstance("mindbox-named") } returns namedApp + mockkStatic(FirebaseMessaging::class) + every { FirebaseMessaging.getInstance() } returns mockk() + + val token = handler.registerToken(context, previousToken = null) + + assertEquals("named-token", token) + verify(exactly = 1) { FirebaseApp.getInstance("mindbox-named") } + // Isolation: the named path must never touch the [DEFAULT] instance. + verify(exactly = 0) { FirebaseMessaging.getInstance() } + } + + @Test + fun `resource with surrounding whitespace - trimmed before use as named app`() = runTest { + stubAppName(" mindbox-named ") + val namedMessaging = mockk { + every { token } returns successTask("named-token") + } + val namedApp = mockk { + every { get(FirebaseMessaging::class.java) } returns namedMessaging + } + mockkStatic(FirebaseApp::class) + every { FirebaseApp.getInstance("mindbox-named") } returns namedApp + mockkStatic(FirebaseMessaging::class) + every { FirebaseMessaging.getInstance() } returns mockk() + + val token = handler.registerToken(context, previousToken = null) + + assertEquals("named-token", token) + // Trimmed name is used, not the raw " mindbox-named ". + verify(exactly = 1) { FirebaseApp.getInstance("mindbox-named") } + } + + @Test + fun `resource set but named app unresolvable - falls back to DEFAULT FirebaseApp`() = runTest { + stubAppName("does-not-exist") + mockkStatic(FirebaseApp::class) + every { FirebaseApp.getInstance("does-not-exist") } throws + IllegalStateException("FirebaseApp with name does-not-exist doesn't exist.") + stubDefaultMessaging("default-token") + + val token = handler.registerToken(context, previousToken = null) + + assertEquals("default-token", token) + verify(exactly = 1) { FirebaseMessaging.getInstance() } + // A clear error naming the missing app is logged before the fallback. + verify { logger.e(any(), match { it.contains("does-not-exist") }, any()) } + } + + // --- initService: SDK creates the isolated named app --- + + @Test + fun `initService creates named app from DEFAULT options when resource set and app absent`() = runTest { + stubAppName("mindbox-named") + val defaultOptions = mockk() + mockkStatic(FirebaseApp::class) + every { FirebaseApp.initializeApp(context) } returns mockk() + every { FirebaseApp.getApps(context) } returns emptyList() + every { FirebaseApp.getInstance() } returns mockk { every { options } returns defaultOptions } + every { FirebaseApp.initializeApp(context, defaultOptions, "mindbox-named") } returns mockk() + + handler.initService(context) + + verify(exactly = 1) { FirebaseApp.initializeApp(context, defaultOptions, "mindbox-named") } + // AC2: the configured app name is visible in the logs (logged once, at creation). + verify { logger.i(any(), match { it.contains("mindbox-named") }) } + } + + @Test + fun `initService does not recreate named app when client already created it`() = runTest { + stubAppName("mindbox-named") + val existing = mockk { every { name } returns "mindbox-named" } + mockkStatic(FirebaseApp::class) + every { FirebaseApp.initializeApp(context) } returns mockk() + every { FirebaseApp.getApps(context) } returns listOf(existing) + + handler.initService(context) + + verify(exactly = 0) { FirebaseApp.initializeApp(context, any(), any()) } + } + + @Test + fun `initService logs error and does not crash when named app creation fails`() = runTest { + stubAppName("mindbox-named") + val defaultOptions = mockk() + mockkStatic(FirebaseApp::class) + every { FirebaseApp.initializeApp(context) } returns mockk() + every { FirebaseApp.getApps(context) } returns emptyList() + every { FirebaseApp.getInstance() } returns mockk { every { options } returns defaultOptions } + every { FirebaseApp.initializeApp(context, defaultOptions, "mindbox-named") } throws + IllegalStateException("boom") + + handler.initService(context) // must not throw + + verify { logger.e(any(), match { it.contains("Failed to create") }, any()) } + } + + @Test + fun `initService does not create named app when resource is blank`() = runTest { + stubAppName("") + mockkStatic(FirebaseApp::class) + every { FirebaseApp.initializeApp(context) } returns mockk() + + handler.initService(context) + + verify(exactly = 0) { FirebaseApp.initializeApp(context, any(), any()) } + } + + // --- End-to-end: initService creates the app, getToken then reads from it --- + + @Test + fun `initService creates named app and a subsequent getToken reads its token`() = runTest { + stubAppName("mindbox-named") + val defaultOptions = mockk() + val namedMessaging = mockk { + every { token } returns successTask("named-token") + } + val namedApp = mockk { + every { get(FirebaseMessaging::class.java) } returns namedMessaging + } + mockkStatic(FirebaseApp::class) + every { FirebaseApp.initializeApp(context) } returns mockk() + every { FirebaseApp.getApps(context) } returns emptyList() + every { FirebaseApp.getInstance() } returns mockk { every { options } returns defaultOptions } + every { FirebaseApp.initializeApp(context, defaultOptions, "mindbox-named") } returns namedApp + every { FirebaseApp.getInstance("mindbox-named") } returns namedApp + mockkStatic(FirebaseMessaging::class) + every { FirebaseMessaging.getInstance() } returns mockk() + + handler.initService(context) + val token = handler.registerToken(context, previousToken = null) + + assertEquals("named-token", token) + verify(exactly = 1) { FirebaseApp.initializeApp(context, defaultOptions, "mindbox-named") } + verify(exactly = 0) { FirebaseMessaging.getInstance() } + // Resource read once across the whole init -> token sequence (memoization). + verify(exactly = 1) { context.getString(R.string.mindbox_firebase_app_name) } + } + + // --- Memoization: resource read once --- + + @Test + fun `resource is read only once across multiple token requests - default path`() = runTest { + stubAppName("") + stubDefaultMessaging("default-token") + + handler.registerToken(context, previousToken = null) + handler.registerToken(context, previousToken = null) + + verify(exactly = 1) { context.getString(R.string.mindbox_firebase_app_name) } + } + + @Test + fun `resource is read only once across multiple token requests - named path`() = runTest { + stubAppName("mindbox-named") + val namedApp = mockk { + every { get(FirebaseMessaging::class.java) } returns mockk { + every { token } returns successTask("named-token") + } + } + mockkStatic(FirebaseApp::class) + every { FirebaseApp.getInstance("mindbox-named") } returns namedApp + mockkStatic(FirebaseMessaging::class) + every { FirebaseMessaging.getInstance() } returns mockk() + + handler.registerToken(context, previousToken = null) + handler.registerToken(context, previousToken = null) + + verify(exactly = 1) { context.getString(R.string.mindbox_firebase_app_name) } + } +} diff --git a/modulesCommon.gradle b/modulesCommon.gradle index e1b436a48..9e621e5e7 100644 --- a/modulesCommon.gradle +++ b/modulesCommon.gradle @@ -4,6 +4,7 @@ apply plugin: 'signing' apply plugin: 'org.jlleitschuh.gradle.ktlint' apply plugin: 'com.vanniktech.maven.publish' apply plugin: 'org.jetbrains.kotlinx.kover' +apply plugin: 'io.gitlab.arturbosch.detekt' group = 'com.github.mindbox-cloud' @@ -34,6 +35,10 @@ android { } } + testOptions { + unitTests.returnDefaultValues = true + } + compileOptions { sourceCompatibility JavaVersion.VERSION_11 targetCompatibility JavaVersion.VERSION_11 @@ -48,6 +53,16 @@ android { } } +detekt { + buildUponDefaultConfig = true + config.setFrom(files("${rootDir}/detekt.yml")) + source.setFrom(files("src/main/java", "src/main/kotlin")) +} + +dependencies { + detektPlugins project(path: ':detekt-rules', configuration: 'runtimeElements') +} + mavenPublishing { publishToMavenCentral("CENTRAL_PORTAL") diff --git a/sdk/build.gradle b/sdk/build.gradle index 9d7d78b71..326c66632 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -36,7 +36,6 @@ android { testOptions { unitTests.includeAndroidResources = true - unitTests.returnDefaultValues = true } tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { kotlinOptions { @@ -87,6 +86,7 @@ dependencies { // Handle app lifecycle implementation libs.androidx.lifecycle + implementation libs.androidx.startup implementation libs.threetenabp // Glide diff --git a/sdk/src/main/AndroidManifest.xml b/sdk/src/main/AndroidManifest.xml index e50bafe2f..92ba798a8 100644 --- a/sdk/src/main/AndroidManifest.xml +++ b/sdk/src/main/AndroidManifest.xml @@ -9,6 +9,16 @@ + + + + .sortByPriority(): List { return this.sortedByDescending { it.isPriority } } +/** + * Tags to attach to operations emitted by this in-app: [InApp.tags] when non-empty and the tags + * feature is enabled, otherwise `null` (so Gson omits the `tags` key downstream). The feature-flag + * decision is taken by the caller and passed in as [isTagsFeatureEnabled]. + */ +internal fun InApp.gatedTags(isTagsFeatureEnabled: Boolean): Map? = + tags?.takeIf { it.isNotEmpty() && isTagsFeatureEnabled } + internal inline fun Queue.pollIf(predicate: (T) -> Boolean): T? { return peek()?.takeIf(predicate)?.let { poll() } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt index 25a12a5d6..6662d32d7 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt @@ -10,8 +10,6 @@ import androidx.annotation.DrawableRes import androidx.annotation.MainThread import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread -import androidx.lifecycle.Lifecycle.State.RESUMED -import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.WorkerFactory import cloud.mindbox.common.MindboxCommon import cloud.mindbox.mobile_sdk.di.MindboxDI @@ -88,7 +86,7 @@ public object Mindbox : MindboxLog { private val tokenCallbacks = ConcurrentHashMap Unit>() private val deviceUuidCallbacks = ConcurrentHashMap Unit>() - private lateinit var lifecycleManager: LifecycleManager + private val lifecycleManager: LifecycleManager? get() = LifecycleManager.instance private val userVisitManager: UserVisitManager by mindboxInject { userVisitManager } private val timeProvider by mindboxInject { timeProvider } @@ -554,152 +552,143 @@ public object Mindbox : MindboxLog { context: Context, configuration: MindboxConfiguration, pushServices: List, - ) { - LoggingExceptionHandler.runCatching { - verifyThreadExecution(methodName = "init") - val currentProcessName = context.getCurrentProcessName() - if (!context.isMainProcess(currentProcessName)) { - logW("Skip Mindbox init not in main process! Current process $currentProcessName") - return@runCatching - } - Stopwatch.start(Stopwatch.INIT_SDK) + ): Unit = loggingRunCatching { + verifyThreadExecution(methodName = "init") + val currentProcessName = context.getCurrentProcessName() + if (!context.isMainProcess(currentProcessName)) { + logW("Skip Mindbox init not in main process! Current process $currentProcessName") + return@loggingRunCatching + } + Stopwatch.start(Stopwatch.INIT_SDK) - initComponents(context.applicationContext) - pushConverters = selectPushServiceHandler(pushServices) - logI("init in $currentProcessName. firstInitCall: ${firstInitCall.get()}, " + + initComponents(context.applicationContext) + // Prewarm stage 1: head start for webview in-apps from the cached config + // (mirrors the iOS SDK's init-time prewarm; a real show always preempts it). + MindboxDI.appModule.inAppWebViewPrewarmManager.prewarmOnInit() + pushConverters = selectPushServiceHandler(pushServices) + logI( + "init in $currentProcessName. firstInitCall: ${firstInitCall.get()}, " + "configuration: $configuration, pushServices: " + pushServices.joinToString(", ") { it.javaClass.simpleName } + - ", SdkVersion:${getSdkVersion()}, CommonSdkVersion:${MindboxCommon.VERSION_NAME}") + ", SdkVersion:${getSdkVersion()}, CommonSdkVersion:${MindboxCommon.VERSION_NAME}", + ) - if (!firstInitCall.get()) { - InitializeLock.reset(InitializeLock.State.SAVE_MINDBOX_CONFIG) - } else { - userVisitManager.saveUserVisit() - } + if (!firstInitCall.get()) { + InitializeLock.reset(InitializeLock.State.SAVE_MINDBOX_CONFIG) + } else { + userVisitManager.saveUserVisit() + } - initScope.launch { - InitializeLock.await(InitializeLock.State.MIGRATION) - val checkResult = checkConfig(configuration) - val validatedConfiguration = validateConfiguration(configuration) - DbManager.saveConfigurations(Configuration(configuration)) - logI("init. checkResult: $checkResult") - if (checkResult != ConfigUpdate.NOT_UPDATED && !MindboxPreferences.isFirstInitialize) { - logI("init. softReinitialization") - softReinitialization(context.applicationContext) - } + launchInitJob(context, configuration, pushServices) + setupLifecycleManager(context) + attachLifecycleCallbacks() + } - if (checkResult == ConfigUpdate.UPDATED) { - setPushServiceHandler(context, pushServices) - firstInitialization(context.applicationContext, validatedConfiguration) + private fun launchInitJob( + context: Context, + configuration: MindboxConfiguration, + pushServices: List, + ) { + initScope.launch { + InitializeLock.await(InitializeLock.State.MIGRATION) + val checkResult = checkConfig(configuration) + val validatedConfiguration = validateConfiguration(configuration) + DbManager.saveConfigurations(Configuration(configuration)) + logI("init. checkResult: $checkResult") + if (checkResult != ConfigUpdate.NOT_UPDATED && !MindboxPreferences.isFirstInitialize) { + logI("init. softReinitialization") + softReinitialization(context.applicationContext) + } - val isTrackVisitNotSent = Mindbox::lifecycleManager.isInitialized && - !lifecycleManager.isTrackVisitSent() - if (isTrackVisitNotSent) { - MindboxLoggerImpl.d(this, "Track visit event with source $DIRECT") - sendTrackVisitEvent(context.applicationContext, DIRECT) - } - } else { - mindboxScope.launch { - setPushServiceHandler(context, pushServices) - } - MindboxEventManager.sendEventsIfExist(context.applicationContext) + if (checkResult == ConfigUpdate.UPDATED) { + setPushServiceHandler(context, pushServices) + firstInitialization(context.applicationContext, validatedConfiguration) + } else { + mindboxScope.launch { + setPushServiceHandler(context, pushServices) } - MindboxPreferences.uuidDebugEnabled = configuration.uuidDebugEnabled - }.initState(InitializeLock.State.SAVE_MINDBOX_CONFIG) - .invokeOnCompletion { throwable -> - if (throwable == null) { - if (firstInitCall.get()) { - val activity = context as? Activity - if (activity != null && lifecycleManager.isCurrentActivityResumed) { - inAppMessageManager.registerCurrentActivity(activity) - mindboxScope.launch { - inAppMutex.withLock { - logI("Start inapp manager after init. firstInitCall: ${firstInitCall.get()}") - if (!firstInitCall.getAndSet(false)) return@launch - inAppMessageManager.listenEventAndInApp() - inAppMessageManager.initLogs() - MindboxEventManager.eventFlow.emit(MindboxEventManager.appStarted()) - inAppMessageManager.requestConfig().join() - } - } + MindboxEventManager.sendEventsIfExist(context.applicationContext) + } + MindboxPreferences.uuidDebugEnabled = configuration.uuidDebugEnabled + }.initState(InitializeLock.State.SAVE_MINDBOX_CONFIG) + .invokeOnCompletion { throwable -> + if (throwable == null && firstInitCall.get()) { + val activity = context as? Activity + if (activity != null && lifecycleManager?.isCurrentActivityResumed == true) { + inAppMessageManager.registerCurrentActivity(activity) + mindboxScope.launch { + inAppMutex.withLock { + logI("Start inapp manager after init. firstInitCall: ${firstInitCall.get()}") + if (!firstInitCall.getAndSet(false)) return@launch + inAppMessageManager.listenEventAndInApp() + inAppMessageManager.initLogs() + MindboxEventManager.eventFlow.emit(MindboxEventManager.appStarted()) + inAppMessageManager.requestConfig().join() } } } } - // Handle back app in foreground - (context.applicationContext as? Application)?.apply { - val applicationLifecycle = ProcessLifecycleOwner.get().lifecycle + } + } - if (!Mindbox::lifecycleManager.isInitialized) { - val activity = context as? Activity - val isApplicationResumed = applicationLifecycle.currentState == RESUMED - if (isApplicationResumed && activity == null) { - logE("Incorrect context type for calling init in this place") - } - if (isApplicationResumed || context !is Application) { - logW( - "We recommend to call Mindbox.init() synchronously from " + - "Application.onCreate. If you can't do so, don't forget to " + - "call Mindbox.initPushServices from Application.onCreate", - ) + private fun setupLifecycleManager(context: Context) { + if (LifecycleManager.isRegister) { + if (!firstInitCall.get()) { + lifecycleManager?.scheduleReinitTrackVisit() + } + return + } + + logW("Register LifecycleManager (startup initializer not found)") + LifecycleManager.register(context) + } + + private fun attachLifecycleCallbacks() { + lifecycleManager?.callbacks = object : LifecycleManager.Callbacks { + override fun onActivityStarted(activity: Activity) { + UuidCopyManager.onAppMovedToForeground(activity) + mindboxScope.launch { + if (!MindboxPreferences.isFirstInitialize) { + updateAppInfo(activity.applicationContext) } + } + } - logI("init. init lifecycleManager") - lifecycleManager = LifecycleManager( - currentActivityName = activity?.javaClass?.name, - currentIntent = activity?.intent, - isAppInBackground = !isApplicationResumed, - onActivityStarted = { startedActivity -> - UuidCopyManager.onAppMovedToForeground(startedActivity) - mindboxScope.launch { - if (!MindboxPreferences.isFirstInitialize) { - updateAppInfo(startedActivity.applicationContext) - } - } - }, - onActivityPaused = { pausedActivity -> - inAppMessageManager.onPauseCurrentActivity(pausedActivity) - }, - onActivityResumed = { resumedActivity -> - inAppMessageManager.onResumeCurrentActivity( - resumedActivity - ) - if (firstInitCall.get()) { - mindboxScope.launch { - InitializeLock.await(InitializeLock.State.SAVE_MINDBOX_CONFIG) - inAppMutex.withLock { - logI("Start inapp manager after resume activity. firstInitCall: ${firstInitCall.get()}") - if (!firstInitCall.getAndSet(false)) return@launch - inAppMessageManager.listenEventAndInApp() - inAppMessageManager.initLogs() - MindboxEventManager.eventFlow.emit(MindboxEventManager.appStarted()) - inAppMessageManager.requestConfig().join() - } - } - } - }, - onActivityStopped = { resumedActivity -> - inAppMessageManager.onStopCurrentActivity(resumedActivity) - }, - onTrackVisitReady = { source, requestUrl -> - sessionStorageManager.hasSessionExpired() - eventScope.launch { - sendTrackVisitEvent( - MindboxDI.appModule.appContext, - source, - requestUrl - ) - } + override fun onActivityPaused(activity: Activity) { + inAppMessageManager.onPauseCurrentActivity(activity) + } + + override fun onActivityResumed(activity: Activity) { + inAppMessageManager.onResumeCurrentActivity(activity) + if (firstInitCall.get()) { + mindboxScope.launch { + InitializeLock.await(InitializeLock.State.SAVE_MINDBOX_CONFIG) + inAppMutex.withLock { + logI("Start in-app manager after resume activity. firstInitCall: ${firstInitCall.get()}") + if (!firstInitCall.getAndSet(false)) return@launch + inAppMessageManager.listenEventAndInApp() + inAppMessageManager.initLogs() + MindboxEventManager.eventFlow.emit(MindboxEventManager.appStarted()) + inAppMessageManager.requestConfig().join() } - ) - } else { - unregisterActivityLifecycleCallbacks(lifecycleManager) - applicationLifecycle.removeObserver(lifecycleManager) - lifecycleManager.wasReinitialized() + } } + } - registerActivityLifecycleCallbacks(lifecycleManager) - applicationLifecycle.addObserver(lifecycleManager) + override fun onActivityStopped(activity: Activity) { + inAppMessageManager.onStopCurrentActivity(activity) + } + + override fun onTrackVisitReady(source: String?, requestUrl: String?) { + sessionStorageManager.hasSessionExpired() + eventScope.launch { + InitializeLock.await(InitializeLock.State.SAVE_MINDBOX_CONFIG) + sendTrackVisitEvent( + MindboxDI.appModule.appContext, + source, + requestUrl, + ) + } } } } @@ -888,12 +877,9 @@ public object Mindbox : MindboxLog { * @param intent new intent for activity, which was received in [Activity.onNewIntent] method */ public fun onNewIntent(intent: Intent?): Unit = LoggingExceptionHandler.runCatching { - MindboxLoggerImpl.d(this, "onNewIntent. intent: $intent") - if (Mindbox::lifecycleManager.isInitialized) { - lifecycleManager.onNewIntent(intent) - } else { - MindboxLoggerImpl.d(this, "onNewIntent. LifecycleManager is not initialized. Skipping.") - } + mindboxLogI("onNewIntent. intent: $intent") + lifecycleManager?.onNewIntent(intent) + ?: mindboxLogI("onNewIntent. LifecycleManager is not initialized. Skipping.") } /** @@ -1331,7 +1317,7 @@ public object Mindbox : MindboxLog { mindboxLogI( "updateAppInfo. pushToken: $pushTokens, isNotificationEnabled: $isNotificationEnabled, " + - "old isNotificationEnabled: $savedPushTokens" + "old isNotificationEnabled: $savedIsNotificationEnabled" ) val initData = UpdateData( isNotificationsEnabled = isNotificationEnabled, @@ -1394,6 +1380,7 @@ public object Mindbox : MindboxLog { private fun softReinitialization( context: Context, ) { + MindboxDI.appModule.inAppWebViewPrewarmManager.terminate() mindboxScope.cancel() DbManager.removeAllEventsFromQueue() BackgroundWorkManager.cancelAllWork(context) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt index 6695ffa47..4f3ec5350 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt @@ -26,9 +26,14 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSer import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.* import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidator import cloud.mindbox.mobile_sdk.inapp.presentation.InAppMessageDelayedManager +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewCachePolicy +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmManager +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmManagerImpl import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManagerImpl import cloud.mindbox.mobile_sdk.inapp.presentation.view.BridgeMessage +import cloud.mindbox.mobile_sdk.inapp.webview.InAppWebViewPrewarmEngine +import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.managers.* import cloud.mindbox.mobile_sdk.managers.MobileConfigSettingsManagerImpl import cloud.mindbox.mobile_sdk.managers.RequestPermissionManager @@ -145,6 +150,30 @@ internal fun DataModule( ) } + override val webViewCachePolicy: InAppWebViewCachePolicy by lazy { + InAppWebViewCachePolicy(mobileConfigSerializationManager) + } + + override val inAppWebViewPrewarmManager: InAppWebViewPrewarmManager by lazy { + InAppWebViewPrewarmManagerImpl( + engine = InAppWebViewPrewarmEngine( + appContext = appContext, + log = { message -> mindboxLogI("[WebView] Prewarm: $message") }, + isCacheEnabled = { webViewCachePolicy.isCacheEnabled } + ), + mobileConfigSerializationManager = mobileConfigSerializationManager, + // Lazy on purpose: this service is resolved on the main thread during SDK init + // (prewarm stage 1), and constructing GatewayManager spins up Volley (thread + // pool + cache-dir I/O) — defer that to the background fetch that needs it. + gatewayManager = lazy { gatewayManager }, + inAppValidator = inAppValidator, + webViewLayerValidator = webViewLayerValidator, + learnedHostsStore = InAppWebViewLearnedHostsStore(), + featureToggleManager = featureToggleManager, + webViewCachePolicy = webViewCachePolicy + ) + } + override val mobileConfigRepository: MobileConfigRepository by lazy { MobileConfigRepositoryImpl( inAppMapper = inAppMapper, @@ -163,7 +192,8 @@ internal fun DataModule( mobileConfigSettingsManager = mobileConfigSettingsManager, integerPositiveValidator = integerPositiveValidator, inappSettingsManager = inappSettingsManager, - featureToggleManager = featureToggleManager + featureToggleManager = featureToggleManager, + inAppWebViewPrewarmManager = inAppWebViewPrewarmManager ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt index a2cf4ba4f..367531343 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt @@ -47,7 +47,8 @@ internal fun DomainModule( inAppTargetingErrorRepository = inAppTargetingErrorRepository, inAppContentFetcher = inAppContentFetcher, inAppRepository = inAppRepository, - inAppFailureTracker = inAppFailureTracker + inAppFailureTracker = inAppFailureTracker, + featureToggleManager = featureToggleManager ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt index 4d5f84995..696a863d8 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt @@ -64,6 +64,8 @@ internal interface DataModule : MindboxModule { val sessionStorageManager: SessionStorageManager val mobileConfigRepository: MobileConfigRepository val mobileConfigSerializationManager: MobileConfigSerializationManager + val inAppWebViewPrewarmManager: InAppWebViewPrewarmManager + val webViewCachePolicy: InAppWebViewCachePolicy val inAppGeoRepository: InAppGeoRepository val inAppRepository: InAppRepository val callbackRepository: CallbackRepository diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt index de83c9b68..f05beac23 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt @@ -29,7 +29,8 @@ internal fun PresentationModule( sessionStorageManager = sessionStorageManager, userVisitManager = userVisitManager, inAppMessageDelayedManager = inAppMessageDelayedManager, - timeProvider = timeProvider + timeProvider = timeProvider, + featureToggleManager = featureToggleManager ) } override val clipboardManager: ClipboardManager by lazy { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt index 3407125cb..2dc1a8013 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt @@ -5,6 +5,12 @@ import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponse import java.util.concurrent.ConcurrentHashMap internal const val SEND_INAPP_SHOW_ERROR_FEATURE = "MobileSdkShouldSendInAppShowError" +internal const val SEND_INAPP_TAGS_FEATURE = "MobileSdkShouldSendInAppTags" +internal const val PREWARM_INAPP_WEBVIEW_FEATURE = "MobileSdkShouldPrewarmInAppWebView" +internal const val CACHE_INAPP_WEBVIEW_FEATURE = "MobileSdkShouldCacheInAppWebView" + +/** Every toggle is a kill switch: an absent/unknown key defaults to enabled. */ +internal const val FEATURE_TOGGLE_DEFAULT: Boolean = true internal class FeatureToggleManagerImpl : FeatureToggleManager { @@ -20,6 +26,6 @@ internal class FeatureToggleManagerImpl : FeatureToggleManager { } override fun isEnabled(key: String): Boolean { - return toggles[key] ?: true + return toggles[key] ?: FEATURE_TOGGLE_DEFAULT } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt index 8b9fd6a3b..580338fd4 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt @@ -44,7 +44,12 @@ internal class InAppFailureTrackerImpl( inAppRepository.sendInAppShowFailure(listOf(failure)) } - override fun sendFailure(inAppId: String, failureReason: FailureReason, errorDetails: String?) { + override fun sendFailure( + inAppId: String, + failureReason: FailureReason, + errorDetails: String?, + tags: Map? + ) { val timestamp = Instant.ofEpochMilli(timeProvider.currentTimeMillis()) .convertToZonedDateTimeAtUTC() .convertToString() @@ -54,12 +59,18 @@ internal class InAppFailureTrackerImpl( inAppId = inAppId, failureReason = failureReason, errorDetails = errorDetails?.take(COUNT_OF_CHARS_IN_ERROR_DETAILS), - dateTimeUtc = timestamp + dateTimeUtc = timestamp, + tags = tags ) ) } - override fun collectFailure(inAppId: String, failureReason: FailureReason, errorDetails: String?) { + override fun collectFailure( + inAppId: String, + failureReason: FailureReason, + errorDetails: String?, + tags: Map? + ) { val timestamp = Instant.ofEpochMilli(timeProvider.currentTimeMillis()) .convertToZonedDateTimeAtUTC() .convertToString() @@ -68,7 +79,8 @@ internal class InAppFailureTrackerImpl( inAppId = inAppId, failureReason = failureReason, errorDetails = errorDetails?.take(COUNT_OF_CHARS_IN_ERROR_DETAILS), - dateTimeUtc = timestamp + dateTimeUtc = timestamp, + tags = tags ) ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt index 8efdca88a..aa3a650a6 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt @@ -7,72 +7,101 @@ import cloud.mindbox.mobile_sdk.R import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppImageLoader import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppImageSizeStorage import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppContentFetchingError -import cloud.mindbox.mobile_sdk.logger.mindboxLogD import cloud.mindbox.mobile_sdk.logger.mindboxLogE +import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.maxScreenDimension import com.bumptech.glide.Glide import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeout import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException +import kotlin.time.Duration.Companion.milliseconds internal class InAppGlideImageLoaderImpl( private val context: Context, private val inAppImageSizeStorage: InAppImageSizeStorage ) : InAppImageLoader { - private val requests = HashMap>() + private val requests = ConcurrentHashMap>() override suspend fun loadImage(inAppId: String, url: String): Boolean { - mindboxLogD("loading image for inapp with id $inAppId started") - return suspendCancellableCoroutine { cancellableContinuation -> - val target = Glide.with(context).load(url) - .timeout(context.getString(R.string.mindbox_inapp_fetching_timeout).toInt()) - .listener(object : - RequestListener { - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target?, - isFirstResource: Boolean - ): Boolean { - return runCatching { - mindboxLogD("loading image with url = $url for inapp with id $inAppId failed") - cancellableContinuation.resumeWithException(InAppContentFetchingError(e)) - true - }.getOrElse { - mindboxLogE( - "Unknown error when loading image from network failed", - exception = it - ) - true - } - } + mindboxLogI("Loading image for inapp with id $inAppId started") + val timeoutMs = context.getString(R.string.mindbox_inapp_fetching_timeout).toLong() + val maxDim = context.maxScreenDimension() + return try { + withTimeout(timeoutMs.milliseconds) { + suspendCancellableCoroutine { continuation -> + requests[inAppId] = startPreload(inAppId, url, maxDim, timeoutMs.toInt(), continuation) + continuation.invokeOnCancellation { cancelLoading(inAppId) } + } + } + } catch (e: TimeoutCancellationException) { + mindboxLogE("Image loading timed out after ${timeoutMs}ms for inapp $inAppId", e) + throw InAppContentFetchingError(e) + } + } + + private fun startPreload( + inAppId: String, + url: String, + maxDim: Int, + timeoutMs: Int, + continuation: CancellableContinuation, + ): Target = Glide.with(context) + .load(url) + .timeout(timeoutMs) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .override(maxDim, maxDim) + .centerInside() + .listener(buildRequestListener(inAppId, url, continuation)) + .preload(maxDim, maxDim) + + private fun buildRequestListener( + inAppId: String, + url: String, + continuation: CancellableContinuation, + ): RequestListener = object : RequestListener { + + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean, + ): Boolean { + mindboxLogI("Image loading failed for inapp $inAppId, url = $url") + requests.remove(inAppId) + if (continuation.isActive) { + continuation.resumeWithException(InAppContentFetchingError(e)) + } + return true + } - override fun onResourceReady( - resource: Drawable, - model: Any?, - target: Target?, - dataSource: DataSource?, - isFirstResource: Boolean - ): Boolean { - return runCatching { - mindboxLogD("loading image with url = $url for inapp with id $inAppId succeeded") - inAppImageSizeStorage.addSize(inAppId, url, resource.toBitmap().width, resource.toBitmap().height) - cancellableContinuation.resume(true) - true - }.getOrElse { - mindboxLogE( - "Unknown error when loading image from network failed", - exception = it - ) - true - } - } - }).preload() - requests[inAppId] = target + override fun onResourceReady( + resource: Drawable, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean, + ): Boolean { + mindboxLogI("Image loading succeeded for inapp $inAppId, url = $url") + if (!continuation.isActive) return true + requests.remove(inAppId) + return runCatching { + val bitmap = resource.toBitmap() + inAppImageSizeStorage.addSize(inAppId, url, bitmap.width, bitmap.height) + continuation.resume(true) + }.onFailure { e -> + mindboxLogE("Failed to process loaded image for inapp $inAppId", e) + continuation.resumeWithException(InAppContentFetchingError(null)) + }.isSuccess } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt index d80cff2c1..675e549e1 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt @@ -3,9 +3,10 @@ package cloud.mindbox.mobile_sdk.inapp.data.managers import cloud.mindbox.mobile_sdk.fromJsonTyped import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppFailuresWrapper -import cloud.mindbox.mobile_sdk.models.operation.request.InAppHandleRequest +import cloud.mindbox.mobile_sdk.models.operation.request.InAppClickRequest import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowRequest import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure +import cloud.mindbox.mobile_sdk.models.operation.request.InAppTargetingRequest import cloud.mindbox.mobile_sdk.toJsonTyped import cloud.mindbox.mobile_sdk.utils.LoggingExceptionHandler import cloud.mindbox.mobile_sdk.utils.loggingRunCatching @@ -30,9 +31,20 @@ internal class InAppSerializationManagerImpl(private val gson: Gson) : InAppSeri } } - override fun serializeToInAppActionString(inAppId: String): String { - return loggingRunCatching("") { - gson.toJsonTyped(InAppHandleRequest(inAppId = inAppId)) + override fun serializeToInAppTargetingString(inAppId: String, tags: Map?): String { + return loggingRunCatching(defaultValue = "") { + gson.toJsonTyped(InAppTargetingRequest(inAppId = inAppId, tags = tags)) + } + } + + override fun serializeToInAppClickActionString(inAppId: String, tags: Map?): String { + return loggingRunCatching(defaultValue = "") { + gson.toJsonTyped( + InAppClickRequest( + inAppId = inAppId, + tags = tags, + ) + ) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStore.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStore.kt new file mode 100644 index 000000000..b4f8fd58f --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStore.kt @@ -0,0 +1,44 @@ +package cloud.mindbox.mobile_sdk.inapp.data.managers + +import cloud.mindbox.mobile_sdk.managers.SharedPreferencesManager +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching +import org.json.JSONArray + +/** + * Persists https hosts actually observed during webview in-app shows (per endpoint). + * The mobile config only reveals the bootstrap hosts; the heavy ones (image CDNs) + * are discovered at show time and fed to the next launch's preconnect prewarm. + */ +internal class InAppWebViewLearnedHostsStore { + + companion object { + private const val KEY_PREFIX = "MBInAppWebViewLearnedHosts." + private const val MAX_HOSTS = 12 + } + + fun hosts(endpointId: String): List = loggingRunCatching(defaultValue = emptyList()) { + val raw = SharedPreferencesManager.getString(key(endpointId)) + ?.takeIf { it.isNotBlank() } + ?: return@loggingRunCatching emptyList() + val array = JSONArray(raw) + (0 until array.length()).mapNotNull { index -> + array.optString(index).takeIf { host -> host.isNotBlank() } + } + } + + /** + * Newest-first merge capped at [MAX_HOSTS] so one weird show can't flood the list. + * Synchronized: merges arrive from evaluate callbacks/coroutines of concurrent closes, + * and an unsynchronized read-modify-write would silently drop one close's hosts. + */ + @Synchronized + fun merge(endpointId: String, observedHosts: List): Unit = loggingRunCatching { + if (endpointId.isBlank()) return@loggingRunCatching + val incoming = observedHosts.map(String::trim).filter(String::isNotBlank) + if (incoming.isEmpty()) return@loggingRunCatching + val merged = (incoming + hosts(endpointId)).distinct().take(MAX_HOSTS) + SharedPreferencesManager.put(key(endpointId), JSONArray(merged).toString()) + } + + private fun key(endpointId: String): String = KEY_PREFIX + endpointId +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt index 1e0ad72a3..19f31bf4a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt @@ -109,8 +109,8 @@ internal class InAppRepositoryImpl( } } - override fun sendInAppClicked(inAppId: String) { - inAppSerializationManager.serializeToInAppActionString(inAppId).apply { + override fun sendInAppClicked(inAppId: String, tags: Map?) { + inAppSerializationManager.serializeToInAppClickActionString(inAppId, tags).apply { if (isNotBlank()) { MindboxEventManager.inAppClicked( context, @@ -120,8 +120,8 @@ internal class InAppRepositoryImpl( } } - override fun sendUserTargeted(inAppId: String) { - inAppSerializationManager.serializeToInAppActionString(inAppId).apply { + override fun sendUserTargeted(inAppId: String, tags: Map?) { + inAppSerializationManager.serializeToInAppTargetingString(inAppId, tags).apply { if (isNotBlank()) { MindboxEventManager.sendUserTargeted( context, diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt index 42abb68db..770335e67 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt @@ -13,6 +13,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.MobileConfi import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidator import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppConfig import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTtlData +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmManager import cloud.mindbox.mobile_sdk.logger.mindboxLogD import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.logger.mindboxLogI @@ -51,7 +52,8 @@ internal class MobileConfigRepositoryImpl( private val mobileConfigSettingsManager: MobileConfigSettingsManager, private val integerPositiveValidator: IntegerPositiveValidator, private val inappSettingsManager: InappSettingsManager, - private val featureToggleManager: FeatureToggleManager + private val featureToggleManager: FeatureToggleManager, + private val inAppWebViewPrewarmManager: InAppWebViewPrewarmManager ) : MobileConfigRepository { private val mutex = Mutex() @@ -105,6 +107,9 @@ internal class MobileConfigRepositoryImpl( featureToggleManager.applyToggles(config = filteredConfig) persistOperationsDomain(filteredConfig) configState.value = updatedInAppConfig + // Prewarm stage 2: warm what the config's webview in-apps will need + // (or release the warm instance when the config proves there are none). + inAppWebViewPrewarmManager.prewarmResources(updatedInAppConfig) mindboxLogI(message = "Providing config: $updatedInAppConfig") } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt index 758e8613b..0cb0ca776 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt @@ -118,8 +118,8 @@ internal class InAppInteractorImpl( inAppRepository.saveInAppStateChangeTime(timeStamp.toTimestamp()) } - override fun sendInAppClicked(inAppId: String) { - inAppRepository.sendInAppClicked(inAppId) + override fun sendInAppClicked(inAppId: String, tags: Map?) { + inAppRepository.sendInAppClicked(inAppId, tags) } override suspend fun listenToTargetingEvents() { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt index 421e25a2b..6294f0d11 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt @@ -1,6 +1,7 @@ package cloud.mindbox.mobile_sdk.inapp.domain import cloud.mindbox.mobile_sdk.Mindbox.logI +import cloud.mindbox.mobile_sdk.gatedTags import cloud.mindbox.mobile_sdk.getErrorResponseBodyData import cloud.mindbox.mobile_sdk.getImageUrl import cloud.mindbox.mobile_sdk.inapp.domain.extensions.asVolleyError @@ -8,7 +9,9 @@ import cloud.mindbox.mobile_sdk.inapp.domain.extensions.getProductFromTargetingD import cloud.mindbox.mobile_sdk.inapp.domain.extensions.getVolleyErrorDetails import cloud.mindbox.mobile_sdk.inapp.domain.extensions.shouldTrackImageDownloadError import cloud.mindbox.mobile_sdk.inapp.domain.extensions.shouldTrackTargetingError +import cloud.mindbox.mobile_sdk.inapp.data.managers.SEND_INAPP_TAGS_FEATURE import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppContentFetcher +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppProcessingManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppGeoRepository @@ -27,7 +30,8 @@ internal class InAppProcessingManagerImpl( private val inAppTargetingErrorRepository: InAppTargetingErrorRepository, private val inAppContentFetcher: InAppContentFetcher, private val inAppRepository: InAppRepository, - private val inAppFailureTracker: InAppFailureTracker + private val inAppFailureTracker: InAppFailureTracker, + private val featureToggleManager: FeatureToggleManager ) : InAppProcessingManager { companion object { @@ -35,12 +39,16 @@ internal class InAppProcessingManagerImpl( "CheckCustomerSegments requires customer" } + private fun isTagsFeatureEnabled(): Boolean = + featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) + override suspend fun chooseInAppToShow( inApps: List, triggerEvent: InAppEventType, ): InApp? { for (inApp in inApps) { val data = getTargetingData(triggerEvent) + val tags = inApp.gatedTags(isTagsFeatureEnabled()) var isTargetingErrorOccurred = false var isInAppContentFetched: Boolean? = null var targetingCheck = false @@ -105,7 +113,8 @@ internal class InAppProcessingManagerImpl( inAppFailureTracker.sendFailure( inAppId = inApp.id, failureReason = FailureReason.UNKNOWN_ERROR, - errorDetails = "Unknown exception when checking target ${throwable.message}. ${throwable.cause?.getVolleyErrorDetails() ?: "volleyError=null"}" + errorDetails = "Unknown exception when checking target ${throwable.message}. ${throwable.cause?.getVolleyErrorDetails() ?: "volleyError=null"}", + tags = tags ) throw throwable } @@ -125,13 +134,14 @@ internal class InAppProcessingManagerImpl( } mindboxLogD("loading and targeting fetching finished") if (isTargetingErrorOccurred) return chooseInAppToShow(inApps, triggerEvent) - trackTargetingErrorIfAny(inApp, data) + trackTargetingErrorIfAny(inApp, data, tags) if (isInAppContentFetched == false && targetingCheck) { imageFetchError?.takeIf { it.shouldTrackImageDownloadError() }?.let { error -> inAppFailureTracker.collectFailure( inAppId = inApp.id, failureReason = FailureReason.IMAGE_DOWNLOAD_FAILED, - errorDetails = error.message + "\n Url is ${inApp.form.variants.first().getImageUrl()}" + errorDetails = (error.message ?: "Image loading error") + "\n Url is ${inApp.form.variants.first().getImageUrl()}", + tags = tags ) } } @@ -184,7 +194,10 @@ internal class InAppProcessingManagerImpl( if (isTargetingErrorOccurred) return sendTargetedInApp(inApp, triggerEvent) if (inApp.targeting.checkTargeting(data)) { logI("InApp with id = ${inApp.id} sends targeting by event $triggerEvent") - inAppRepository.sendUserTargeted(inAppId = inApp.id) + inAppRepository.sendUserTargeted( + inAppId = inApp.id, + tags = inApp.gatedTags(isTagsFeatureEnabled()), + ) } } @@ -210,7 +223,7 @@ internal class InAppProcessingManagerImpl( mindboxLogW("Error fetching customer segmentations", error) } - private fun trackTargetingErrorIfAny(inApp: InApp, data: TargetingData) { + private fun trackTargetingErrorIfAny(inApp: InApp, data: TargetingData, tags: Map?) { when { inApp.targeting.hasSegmentationNode() && inAppSegmentationRepository.getCustomerSegmentationFetched() == CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR -> { @@ -219,7 +232,8 @@ internal class InAppProcessingManagerImpl( inAppFailureTracker.collectFailure( inAppId = inApp.id, failureReason = FailureReason.CUSTOMER_SEGMENT_REQUEST_FAILED, - errorDetails = errorDetails + errorDetails = errorDetails, + tags = tags ) } return @@ -232,7 +246,8 @@ internal class InAppProcessingManagerImpl( inAppFailureTracker.collectFailure( inAppId = inApp.id, failureReason = FailureReason.GEO_TARGETING_FAILED, - errorDetails = errorDetails + errorDetails = errorDetails, + tags = tags ) } return @@ -246,7 +261,8 @@ internal class InAppProcessingManagerImpl( inAppFailureTracker.collectFailure( inAppId = inApp.id, failureReason = FailureReason.PRODUCT_SEGMENT_REQUEST_FAILED, - errorDetails = errorDetails + errorDetails = errorDetails, + tags = tags ) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt index 663c70bea..e2f5453eb 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt @@ -16,6 +16,7 @@ import java.net.SocketTimeoutException import java.net.UnknownHostException import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason +import kotlinx.coroutines.TimeoutCancellationException internal fun VolleyError.isTimeoutError(): Boolean { return this is TimeoutError || cause is SocketTimeoutException @@ -36,6 +37,7 @@ internal fun Throwable.shouldTrackTargetingError(): Boolean { } internal fun Throwable.shouldTrackImageDownloadError(): Boolean { + if (cause is TimeoutCancellationException) return false val glideException = cause as? GlideException ?: return true return glideException.rootCauses.none { rootCause -> when { @@ -80,7 +82,8 @@ private fun parseOperationBody(operationBody: String?): Pair? = internal fun InAppFailureTracker.sendPresentationFailure( inAppId: String, errorDescription: String, - throwable: Throwable? = null + throwable: Throwable? = null, + tags: Map? = null ) { val errorDetails = when { throwable != null -> "$errorDescription: ${throwable.message ?: "Unknown error"}" @@ -90,7 +93,8 @@ internal fun InAppFailureTracker.sendPresentationFailure( sendFailure( inAppId = inAppId, failureReason = FailureReason.PRESENTATION_FAILED, - errorDetails = errorDetails + errorDetails = errorDetails, + tags = tags ) } @@ -98,7 +102,8 @@ internal fun InAppFailureTracker.sendFailureWithContext( inAppId: String, failureReason: FailureReason, errorDescription: String, - throwable: Throwable? = null + throwable: Throwable? = null, + tags: Map? = null ) { val errorDetails = when { throwable != null -> "$errorDescription: ${throwable.message ?: "Unknown error"}" @@ -108,7 +113,8 @@ internal fun InAppFailureTracker.sendFailureWithContext( sendFailure( inAppId = inAppId, failureReason = failureReason, - errorDetails = errorDetails + errorDetails = errorDetails, + tags = tags ) } @@ -116,6 +122,7 @@ internal inline fun InAppFailureTracker.executeWithFailureTracking( inAppId: String, failureReason: FailureReason, errorDescription: String, + tags: Map? = null, crossinline onFailure: () -> Unit = {}, block: () -> T ): Result { @@ -124,7 +131,8 @@ internal inline fun InAppFailureTracker.executeWithFailureTracking( inAppId = inAppId, failureReason = failureReason, errorDescription = errorDescription, - throwable = throwable + throwable = throwable, + tags = tags ) onFailure() } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt index 44d940eb6..c755790af 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt @@ -19,7 +19,7 @@ internal interface InAppInteractor { tags: Map? ) - fun sendInAppClicked(inAppId: String) + fun sendInAppClicked(inAppId: String, tags: Map?) suspend fun fetchMobileConfig() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt index 9c4f4e268..7d53d8dab 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt @@ -7,13 +7,15 @@ internal interface InAppFailureTracker { fun sendFailure( inAppId: String, failureReason: FailureReason, - errorDetails: String? + errorDetails: String?, + tags: Map? = null ) fun collectFailure( inAppId: String, failureReason: FailureReason, - errorDetails: String? + errorDetails: String?, + tags: Map? = null ) fun sendCollectedFailures() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt index e01acce06..d10e48bcf 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt @@ -10,7 +10,9 @@ internal interface InAppSerializationManager { fun serializeToInAppShownActionString(inAppId: String, timeToDisplay: String, tags: Map?): String - fun serializeToInAppActionString(inAppId: String): String + fun serializeToInAppTargetingString(inAppId: String, tags: Map?): String + + fun serializeToInAppClickActionString(inAppId: String, tags: Map?): String fun serializeToInAppShowFailuresString(inAppShowFailures: List): String diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt index eba668ef2..b40c0101d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt @@ -31,9 +31,9 @@ internal interface InAppRepository { fun sendInAppShown(inAppId: String, timeToDisplay: String, tags: Map?) - fun sendInAppClicked(inAppId: String) + fun sendInAppClicked(inAppId: String, tags: Map?) - fun sendUserTargeted(inAppId: String) + fun sendUserTargeted(inAppId: String, tags: Map?) fun sendInAppShowFailure(failures: List) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/GeoTargeting.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/GeoTargeting.kt index 1bb81a18a..f58fca729 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/GeoTargeting.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/GeoTargeting.kt @@ -1,7 +1,9 @@ package cloud.mindbox.mobile_sdk.inapp.domain.models +import com.google.gson.annotations.SerializedName + internal data class GeoTargeting( - val cityId: String, - val regionId: String, - val countryId: String, + @SerializedName("cityId") val cityId: String, + @SerializedName("regionId") val regionId: String, + @SerializedName("countryId") val countryId: String, ) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppDataError.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppDataError.kt index 2f25843db..bbae479a0 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppDataError.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppDataError.kt @@ -1,7 +1,6 @@ package cloud.mindbox.mobile_sdk.inapp.domain.models import com.android.volley.VolleyError -import com.bumptech.glide.load.engine.GlideException internal class CustomerSegmentationError(volleyError: VolleyError) : Exception(volleyError) @@ -11,4 +10,7 @@ internal class GeoError(volleyError: VolleyError) : Exception(volleyError) internal class ProductSegmentationError(volleyError: VolleyError) : Exception(volleyError) -internal class InAppContentFetchingError(error: GlideException?) : Exception(error) +internal class InAppContentFetchingError( + cause: Throwable?, + message: String? = cause?.message ?: "Image loading error", +) : Exception(message, cause) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt index 2596787a6..c14263295 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt @@ -1,7 +1,8 @@ package cloud.mindbox.mobile_sdk.inapp.domain.models import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure +import com.google.gson.annotations.SerializedName internal data class InAppFailuresWrapper( - val failures: List + @SerializedName("failures") val failures: List ) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppTypeWrapper.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppTypeWrapper.kt index f520c7ab9..8ad560b21 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppTypeWrapper.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppTypeWrapper.kt @@ -6,6 +6,7 @@ internal data class InAppTypeWrapper( val inAppType: T, val inAppActionCallbacks: InAppActionCallbacks, val onRenderStart: () -> Unit, + val tags: Map? = null, ) internal fun interface OnInAppClick { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/ActivityManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/ActivityManagerImpl.kt index 8d362d694..26e7316ba 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/ActivityManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/ActivityManagerImpl.kt @@ -16,7 +16,7 @@ internal class ActivityManagerImpl( try { Intent(Intent.ACTION_VIEW, Uri.parse(url)).also { intent -> intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - return if (callbackInteractor.isValidUrl(url) && intent.resolveActivity(context.packageManager) == null) { + return if (callbackInteractor.isValidUrl(url) && intent.resolveActivity(context.packageManager) != null) { context.startActivity(intent) true } else { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt index f8ee03ee6..9d5698870 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt @@ -3,8 +3,11 @@ package cloud.mindbox.mobile_sdk.inapp.presentation import android.app.Activity import cloud.mindbox.mobile_sdk.InitializeLock import cloud.mindbox.mobile_sdk.Mindbox +import cloud.mindbox.mobile_sdk.gatedTags +import cloud.mindbox.mobile_sdk.inapp.data.managers.SEND_INAPP_TAGS_FEATURE import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppActionCallbacks +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.OnInAppClick @@ -34,7 +37,8 @@ internal class InAppMessageManagerImpl( private val sessionStorageManager: SessionStorageManager, private val userVisitManager: UserVisitManager, private val inAppMessageDelayedManager: InAppMessageDelayedManager, - private val timeProvider: TimeProvider + private val timeProvider: TimeProvider, + private val featureToggleManager: FeatureToggleManager, ) : InAppMessageManager { init { @@ -93,14 +97,15 @@ internal class InAppMessageManagerImpl( } var renderStartTime = Timestamp(0L) - val tags = inApp.tags?.takeIf { it.isNotEmpty() } + val tags = inApp.gatedTags(featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE)) inAppMessageViewDisplayer.tryShowInAppMessage( inAppType = inAppMessage, onRenderStart = { renderStartTime = timeProvider.currentTimestamp() }, + tags = tags, inAppActionCallbacks = object : InAppActionCallbacks { override val onInAppClick = OnInAppClick { - inAppInteractor.sendInAppClicked(inAppMessage.inAppId) + inAppInteractor.sendInAppClicked(inAppMessage.inAppId, tags) } override val onInAppShown = OnInAppShown { handleInAppShown(renderStartTime, preparedTimeMs, inAppMessage, tags) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt index 6caf48ff8..52b089e5c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt @@ -16,6 +16,7 @@ internal interface InAppMessageViewDisplayer { inAppType: InAppType, inAppActionCallbacks: InAppActionCallbacks, onRenderStart: () -> Unit = {}, + tags: Map? = null, ) fun registerCurrentActivity(activity: Activity) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt index 93af840a7..5d61e57ec 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt @@ -48,7 +48,6 @@ internal class InAppMessageViewDisplayerImpl( private var currentActivity: Activity? = null private val defaultCallback: InAppCallback = ComposableInAppCallback( - UrlInAppCallback(), DeepLinkInAppCallback(), CopyPayloadInAppCallback(), LoggingInAppCallback() @@ -138,8 +137,9 @@ internal class InAppMessageViewDisplayerImpl( inAppType: InAppType, inAppActionCallbacks: InAppActionCallbacks, onRenderStart: () -> Unit, + tags: Map?, ) { - val wrapper = InAppTypeWrapper(inAppType, inAppActionCallbacks, onRenderStart) + val wrapper = InAppTypeWrapper(inAppType, inAppActionCallbacks, onRenderStart, tags) if (isUiPresent() && currentHolder == null && pausedHolder == null) { val duration = Stopwatch.track(Stopwatch.INIT_SDK) @@ -209,6 +209,7 @@ internal class InAppMessageViewDisplayerImpl( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.PRESENTATION_FAILED, errorDescription = "Error when trying draw inapp", + tags = wrapper.tags, onFailure = ::closeInApp ) { currentHolder?.show(createMindboxView(root)) @@ -216,7 +217,8 @@ internal class InAppMessageViewDisplayerImpl( } ?: run { inAppFailureTracker.sendPresentationFailure( inAppId = wrapper.inAppType.inAppId, - errorDescription = "currentRoot is null" + errorDescription = "currentRoot is null", + tags = wrapper.tags ) } } @@ -227,10 +229,12 @@ internal class InAppMessageViewDisplayerImpl( ?: return false currentHolder = restoredHolder pausedHolder = null + val restoredTags = restoredHolder.wrapper.tags val root: ViewGroup = currentActivity?.root ?: run { inAppFailureTracker.sendPresentationFailure( inAppId = inAppId, - errorDescription = "failed to reattach inApp: currentRoot is null" + errorDescription = "failed to reattach inApp: currentRoot is null", + tags = restoredTags ) return true } @@ -238,6 +242,7 @@ internal class InAppMessageViewDisplayerImpl( inAppId = inAppId, failureReason = FailureReason.PRESENTATION_FAILED, errorDescription = "Error when trying reattach InApp", + tags = restoredTags, onFailure = ::closeInApp, ) { restoredHolder.reattach(createMindboxView(root)) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicy.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicy.kt new file mode 100644 index 000000000..c543471a7 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicy.kt @@ -0,0 +1,55 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation + +import cloud.mindbox.mobile_sdk.inapp.data.managers.CACHE_INAPP_WEBVIEW_FEATURE +import cloud.mindbox.mobile_sdk.inapp.data.managers.FEATURE_TOGGLE_DEFAULT +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSerializationManager +import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponseBlank +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences + +/** + * The cache half of the WebView feature toggles (`MobileSdkShouldCacheInAppWebView`), + * latched once per process from the cached config on disk — mirrors iOS's + * `InAppWebViewDataStore.isCacheFeatureEnabled`. + * + * Read from the cache, not [cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager]: + * the prewarm's first WebView is created at SDK init, before any fresh config can populate + * the manager — reading the live toggle there always sees the default (enabled), even when + * the cached config already says otherwise. The decision is latched for the whole launch by + * design: prewarm and every later show must agree, or the cache would be split between two + * behaviors for the same session. This is also why the value can't just live in + * [cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager]'s toggle + * map: that map is cleared and repopulated from the fresh config on every fetch, which would + * silently un-latch this decision the first time a fresh config disagreed with the cache. + */ +internal class InAppWebViewCachePolicy( + private val mobileConfigSerializationManager: MobileConfigSerializationManager +) { + + @Volatile + private var latched: Boolean? = null + + val isCacheEnabled: Boolean + @Synchronized get() = latched ?: extract(parseCachedConfigBlank()).also { latched = it } + + /** + * Lets a caller that already parsed the cached config blank for its own purposes (the + * prewarm manager, which needs the same blank for its layers and prewarm-toggle checks) + * hand it over instead of this class deserializing the same JSON a second time. + * + * First value wins: a call after the toggle has already latched — from here or from + * [isCacheEnabled] itself — is a no-op. + */ + @Synchronized + fun prime(configBlank: InAppConfigResponseBlank?) { + if (latched == null) latched = extract(configBlank) + } + + private fun extract(configBlank: InAppConfigResponseBlank?): Boolean = + configBlank?.settings?.featureToggles?.toggles?.get(CACHE_INAPP_WEBVIEW_FEATURE) ?: FEATURE_TOGGLE_DEFAULT + + private fun parseCachedConfigBlank(): InAppConfigResponseBlank? { + val configString = MindboxPreferences.inAppConfig + if (configString.isBlank()) return null + return mobileConfigSerializationManager.deserializeToConfigDtoBlank(configString) + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt new file mode 100644 index 000000000..e6ad0036c --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt @@ -0,0 +1,423 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation + +import cloud.mindbox.mobile_sdk.InitializeLock +import cloud.mindbox.mobile_sdk.Mindbox +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi +import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto +import cloud.mindbox.mobile_sdk.inapp.data.managers.FEATURE_TOGGLE_DEFAULT +import cloud.mindbox.mobile_sdk.inapp.data.managers.InAppWebViewLearnedHostsStore +import cloud.mindbox.mobile_sdk.inapp.data.managers.PREWARM_INAPP_WEBVIEW_FEATURE +import cloud.mindbox.mobile_sdk.inapp.data.validators.WebViewLayerValidator +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSerializationManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidator +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppConfig +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType +import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer +import cloud.mindbox.mobile_sdk.inapp.webview.InAppWebViewPrewarmEngine +import cloud.mindbox.mobile_sdk.inapp.webview.InAppWebViewPrewarmLayer +import cloud.mindbox.mobile_sdk.inapp.webview.InAppWebViewPrewarmPlanner +import cloud.mindbox.mobile_sdk.inapp.webview.WebViewController +import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.logger.mindboxLogW +import cloud.mindbox.mobile_sdk.managers.DbManager +import cloud.mindbox.mobile_sdk.managers.GatewayManager +import cloud.mindbox.mobile_sdk.models.Configuration +import cloud.mindbox.mobile_sdk.models.getShortUserAgent +import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponseBlank +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching +import cloud.mindbox.mobile_sdk.utils.loggingRunCatchingSuspending +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONArray +import org.json.JSONTokener +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlin.time.Duration.Companion.milliseconds + +/** + * Production prewarm for webview in-apps. Two stages, both driven by the mobile + * config (no hardcoded hosts or URLs): + * + * 1. SDK init — head start from the previous launch's cached config; + * 2. config downloaded/parsed — [prewarmResources] (releases the warm instance + * when the config proves there are no webview in-apps). + * + * The resource prewarm loads a preconnect page (origins from the config layers + + * API domain + hosts learned from previous shows) and then the layer's real content + * page with the official prewarm params on its URL, so the shared HTTP cache and + * connection pool are warm before the first show. A real show always preempts the prewarm + * ([onRealShowWillStart]): the hidden WebView is destroyed and the network is + * handed over. Unlike iOS there is no instance reuse — Android shares the renderer + * process, so a warm instance buys nothing (measured). + */ +internal interface InAppWebViewPrewarmManager { + + /** Prewarm stage 1: head start from the cached config. Call once at SDK init. */ + fun prewarmOnInit() + + /** Prewarm stage 2: warm what [config]'s webview in-apps need (or release when none). */ + fun prewarmResources(config: InAppConfig) + + /** + * A real WEBVIEW show is starting: abort the prewarm and free its WebView. + * + * Deliberately narrow — image/snackbar shows do not preempt: their downloads are small + * next to the settle window (network-idle release frees the WebView within seconds), + * while aborting here is terminal and would forfeit the whole launch's byendpoint warm + * because an unrelated banner happened to show first. + */ + fun onRealShowWillStart() + + /** Records the https hosts the shown page actually used (learned-hosts store). */ + @OptIn(InternalMindboxApi::class) + fun captureObservedHosts(controller: WebViewController) + + /** + * Terminates the prewarm subsystem for good. Call before cancelling the coroutine scope + * this manager runs on (SDK teardown, soft reinitialization): a settle-poll job killed by + * scope cancellation never reaches its own tail-end `engine.release()`, so without this + * the warm WebView would leak until process death. + */ + fun terminate() +} + +@OptIn(InternalMindboxApi::class) +internal class InAppWebViewPrewarmManagerImpl( + private val engine: InAppWebViewPrewarmEngine, + private val mobileConfigSerializationManager: MobileConfigSerializationManager, + private val gatewayManager: Lazy, + private val inAppValidator: InAppValidator, + private val webViewLayerValidator: WebViewLayerValidator, + private val learnedHostsStore: InAppWebViewLearnedHostsStore, + private val featureToggleManager: FeatureToggleManager, + private val webViewCachePolicy: InAppWebViewCachePolicy +) : InAppWebViewPrewarmManager { + + companion object { + // Hard cap on how long the hidden WebView may live after the content page was + // handed to it; normally the network-idle poll below releases it much earlier + // (cache/sockets survive at the profile level, so keeping it alive buys nothing). + private const val SETTLE_RELEASE_MS = 30_000L + + // Network-idle release: the page is considered settled when the Resource Timing + // entry count is stable across consecutive polls AND the last completed resource + // finished at least IDLE_QUIET_MS ago. Entries appear only on completion, so the + // quiet window (not the stable count alone) is what guards against an in-flight + // download; a transfer slower than the window can still be cut — same worst case + // as the hard cap, the show just re-fetches that file. + private const val IDLE_POLL_MS = 1_000L + private const val IDLE_QUIET_MS = 2_000L + private const val IDLE_STABLE_POLLS = 2 + + // Returns ":" (or "" on any error). + private const val IDLE_PROBE_JS = + "(function(){try{var e=performance.getEntriesByType('resource');var l=0;" + + "for(var i=0;il)l=r}" + + "return e.length+':'+Math.round(performance.now()-l)}catch(t){return''}})()" + } + + private val hasStartedResourcePrewarm = AtomicBoolean(false) + private val hasAborted = AtomicBoolean(false) + + // Set when the freshest config proved there is nothing to warm: an init prewarm still + // suspended in the content fetch must not resurrect a WebView the config just retired. + private val latestConfigHasNoLayers = AtomicBoolean(false) + private val settleJob = AtomicReference(null) + + override fun prewarmOnInit() { + // Cheap short-circuit before paying for the config read/parse below: a repeat + // initialize() call must not redo work a prior attempt already finished. + if (hasStartedResourcePrewarm.get()) return + Mindbox.mindboxScope.launch { + loggingRunCatchingSuspending { + // Other init-time readers wait for this; without it, a migration that fails + // and triggers a softReset could erase the cached config out from under a + // prewarm that already read it. + InitializeLock.await(InitializeLock.State.MIGRATION) + val cachedConfig = MindboxPreferences.inAppConfig + if (cachedConfig.isBlank()) { + // Nothing to prewarm, but the cache toggle still needs a decision for + // the first real show — latch it to the default now, off the main + // thread, instead of parsing lazily (nothing to parse anyway) the + // moment isCacheEnabled is first read during that show. + webViewCachePolicy.prime(null) + return@loggingRunCatchingSuspending + } + val layers = webViewLayers(cachedConfig) + if (layers.isEmpty()) return@loggingRunCatchingSuspending + mindboxLogI("[WebView] Prewarm: head start from cached config (${layers.size} webview layer(s))") + startResourcePrewarm(layers) + } + } + } + + override fun prewarmResources(config: InAppConfig) { + // A fresh config that turns the toggle off also kills a stage-1 instance started + // under the previous launch's config. + if (!featureToggleManager.isEnabled(PREWARM_INAPP_WEBVIEW_FEATURE)) { + mindboxLogI("[WebView] Prewarm: feature toggle is off, releasing warm WebView") + releaseWarmWebView() + return + } + val layers = config.inApps + .flatMap { inApp -> inApp.form.variants } + .filterIsInstance() + .flatMap { webView -> webView.layers } + .filterIsInstance() + .map { layer -> InAppWebViewPrewarmLayer(baseUrl = layer.baseUrl, contentUrl = layer.contentUrl) } + if (layers.isEmpty()) { + mindboxLogI("[WebView] Prewarm: config has no webview in-apps, releasing warm WebView") + releaseWarmWebView() + return + } + latestConfigHasNoLayers.set(false) + Mindbox.mindboxScope.launch { + loggingRunCatchingSuspending { + startResourcePrewarm(layers) + } + } + } + + private fun releaseWarmWebView() { + latestConfigHasNoLayers.set(true) + settleJob.getAndSet(null)?.cancel() + engine.release() + } + + override fun onRealShowWillStart() = abortPermanently() + + override fun terminate() = abortPermanently() + + private fun abortPermanently() { + hasAborted.set(true) + settleJob.getAndSet(null)?.cancel() + // Terminal: the engine latches synchronously, so a prewarm load already posted from + // a background thread cannot resurrect the WebView afterward. + engine.abort() + } + + override fun captureObservedHosts(controller: WebViewController) { + controller.evaluateJavaScript(InAppWebViewPrewarmPlanner.observedResourceHostsScript) { result -> + val observedHosts = parseObservedHosts(result) + if (observedHosts.isEmpty()) return@evaluateJavaScript + Mindbox.mindboxScope.launch { + loggingRunCatchingSuspending { + val endpointId = currentConfiguration()?.endpointId ?: return@loggingRunCatchingSuspending + learnedHostsStore.merge(endpointId, observedHosts) + mindboxLogI("[WebView] Prewarm: learned hosts for $endpointId: $observedHosts") + } + } + } + } + + /** + * Runs at most once per process — but only an attempt that actually reaches the engine + * consumes the one-shot: a transient configuration read failure or an unplannable + * cached config must not block a later attempt from a valid fresh config. + * + * By design the one-shot also means stage 2 does NOT re-warm when stage 1 already ran + * from a cached config whose URLs have since changed — the head start beats freshness + * for this launch, and the next launch heals with the new cached config. + */ + private suspend fun startResourcePrewarm(layers: List) { + if (hasAborted.get()) return + + val configuration = currentConfiguration() ?: run { + mindboxLogW("[WebView] Prewarm: no saved configuration, skipping") + return + } + val plan = InAppWebViewPrewarmPlanner.buildPlan( + layers = layers, + extraOrigins = listOf(configuration.domain) + learnedHostsStore.hosts(configuration.endpointId) + ) ?: run { + mindboxLogW("[WebView] Prewarm: no valid webview layer urls in config, skipping") + return + } + // Re-check after the configuration read suspension BEFORE the one-shot CAS: an + // attempt that stumbles here must not consume it, or a fresh no-layers config + // landing mid-suspension would burn the one-shot on a prewarm that never reaches + // the engine, per this function's own doc comment. + if (hasAborted.get() || latestConfigHasNoLayers.get()) return + if (!hasStartedResourcePrewarm.compareAndSet(false, true)) return + val userAgentSuffix = configuration.getShortUserAgent() + + mindboxLogI("[WebView] Prewarm: preconnect to ${plan.preconnectOrigins.joinToString(",")} under ${plan.baseUrl}") + engine.loadPreconnectPage(plan.preconnectHtml, plan.baseUrl, userAgentSuffix) + + val html = runCatching { gatewayManager.value.fetchWebViewContent(plan.contentUrl) } + .getOrElse { error -> + mindboxLogW("[WebView] Prewarm: content page fetch failed: $error") + scheduleSettleRelease() + return + } + // Re-check both verdicts after the suspension point: a real show may have taken the + // network over, or a fresh config may have proven there is nothing to warm — either + // way the fetched content must not resurrect a WebView. The no-layers path must + // also RELEASE: the preconnect load above may have already created the WebView, and + // with no settle poll scheduled on this path nothing else would ever free it. + if (hasAborted.get()) return + if (latestConfigHasNoLayers.get()) { + engine.release() + return + } + // Official prewarm contract on the document URL: a runtime that knows it boots + // tracker-only; an older runtime ignores it (plain page warm, no byendpoint). + val prewarmBaseUrl = InAppWebViewPrewarmPlanner.prewarmContentBaseUrl( + baseUrl = plan.baseUrl, + endpointId = configuration.endpointId, + deviceUuid = MindboxPreferences.deviceUuid + ) + mindboxLogI("[WebView] Prewarm: content page under $prewarmBaseUrl, endpoint ${configuration.endpointId}") + engine.loadContentPage( + html = html, + baseUrl = prewarmBaseUrl, + userAgentSuffix = userAgentSuffix + ) + scheduleSettleRelease() + } + + private fun scheduleSettleRelease() { + // A terminal preempt may have landed while the caller was suspended — never store a + // poll job into the slot onRealShowWillStart() just cleared (it would probe the main + // looper for 30s during the live show). + if (hasAborted.get()) return + val job = Mindbox.mindboxScope.launch { + // Belt-and-suspenders with the per-tick check below: an abort landing between + // the guard above and this coroutine's first resumption must not run even one + // poll tick. + if (hasAborted.get()) return@launch + // Budget in poll units instead of a wall clock read: the whole loop runs on + // virtual time in tests. A null probe is charged the full probe timeout so the + // cap stays a real wall-clock bound even when the evaluate callback never fires + // (blocked main thread, renderer stall — the pathological cases the cap exists + // for). A fast-but-garbage probe gets overcharged and releases early, which is + // the safe direction: garbage means the page or WebView is not answering. + val budgetPolls = (SETTLE_RELEASE_MS / IDLE_POLL_MS).toInt() + val timeoutCharge = (IDLE_QUIET_MS / IDLE_POLL_MS).toInt() + var polls = 0 + var lastCount = -1 + var stablePolls = 0 + var idle = false + while (polls < budgetPolls) { + delay(IDLE_POLL_MS.milliseconds) + polls++ + if (hasAborted.get()) return@launch + val probe = probeResourceState() + if (probe == null) { + polls += timeoutCharge + continue + } + if (probe.entryCount == lastCount) { + stablePolls++ + } else { + stablePolls = 0 + lastCount = probe.entryCount + } + if (stablePolls >= IDLE_STABLE_POLLS && probe.msSinceLastResponseEnd > IDLE_QUIET_MS) { + idle = true + break + } + } + mindboxLogI( + "[WebView] Prewarm: settle release after ~${polls * IDLE_POLL_MS}ms of budget " + + if (idle) "(network idle, $lastCount resources)" else "(hard cap)" + ) + engine.release() + } + settleJob.getAndSet(job)?.cancel() + if (hasAborted.get()) { + settleJob.getAndSet(null)?.cancel() + } + } + + private data class ResourceProbe(val entryCount: Int, val msSinceLastResponseEnd: Long) + + /** + * One Resource Timing probe on the prewarm page. Null when the probe cannot run or + * returns garbage (WebView gone/aborted, page not ready) — callers just keep polling + * until the hard cap. The 2s timeout guards against a callback that never fires. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private suspend fun probeResourceState(): ResourceProbe? = + withTimeoutOrNull(IDLE_QUIET_MS.milliseconds) { + suspendCancellableCoroutine { continuation -> + engine.evaluateJavaScript(IDLE_PROBE_JS) { rawResult -> + // evaluateJavascript JSON-quotes string results: "\"6:3456\"". + val parts = rawResult?.trim('"')?.split(':') + val probe = if (parts?.size == 2) { + val count = parts[0].toIntOrNull() + val sinceLast = parts[1].toLongOrNull() + if (count != null && sinceLast != null) ResourceProbe(count, sinceLast) else null + } else { + null + } + if (continuation.isActive) continuation.resume(probe) {} + } + } + } + + private suspend fun currentConfiguration(): Configuration? = + runCatching { DbManager.listenConfigurations().first() }.getOrNull() + + /** + * Light parse of the cached config: only webview layer urls, no targeting checks. + * + * The toggle is read from THIS cached config, not [featureToggleManager]: stage 1 races + * the fresh config download, so the manager may still hold last launch's (or no) state + * when this runs. + */ + private fun webViewLayers(configString: String): List { + val configBlank = mobileConfigSerializationManager.deserializeToConfigDtoBlank(configString) + ?: return emptyList() + // Shares this parse with the cache toggle instead of it deserializing the same + // cached config a second time; a no-op once the toggle has already latched. + webViewCachePolicy.prime(configBlank) + if (!isPrewarmEnabled(configBlank)) { + mindboxLogI("[WebView] Prewarm: feature toggle is off in the cached config, skipping head start") + return emptyList() + } + return configBlank.inApps.orEmpty() + // Same version gate as the real pipeline: in-apps for other SDK versions may + // carry form formats this version cannot even deserialize. + .asSequence() + .filter { inAppBlank -> inAppValidator.validateInAppVersion(inAppBlank) } + .flatMap { inAppBlank -> + mobileConfigSerializationManager.deserializeToInAppFormDto(inAppBlank.form) + ?.variants.orEmpty() + .filterIsInstance() + // Same gate as the real pipeline (InAppMapper): a modal only becomes a + // WebView in-app when webview is its FIRST layer — collecting every + // webview layer regardless of position would prewarm modals that will + // never actually show as a webview, burning the one-shot on them. + .mapNotNull { modal -> modal.content?.background?.layers?.firstOrNull() } + } + .filterIsInstance() + .filter { layerDto -> webViewLayerValidator.isValid(layerDto) } + .map { layerDto -> InAppWebViewPrewarmLayer(baseUrl = layerDto.baseUrl, contentUrl = layerDto.contentUrl) } + .toList() + } + + private fun isPrewarmEnabled(configBlank: InAppConfigResponseBlank): Boolean = + configBlank.settings?.featureToggles?.toggles?.get(PREWARM_INAPP_WEBVIEW_FEATURE) ?: FEATURE_TOGGLE_DEFAULT + + /** + * `evaluateJavascript` returns the JS value JSON-encoded; the probe returns a string + * containing a JSON array, so unwrap the outer string and then parse the array. + */ + private fun parseObservedHosts(result: String?): List = loggingRunCatching(defaultValue = emptyList()) { + if (result.isNullOrBlank() || result == "null") return@loggingRunCatching emptyList() + val unwrapped = JSONTokener(result).nextValue() as? String ?: return@loggingRunCatching emptyList() + val array = JSONArray(unwrapped) + (0 until array.length()).mapNotNull { index -> + array.optString(index).takeIf { host -> host.isNotBlank() } + } + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/callbacks/DeepLinkInAppCallback.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/callbacks/DeepLinkInAppCallback.kt index 036e08ea4..7959f08a5 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/callbacks/DeepLinkInAppCallback.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/callbacks/DeepLinkInAppCallback.kt @@ -5,7 +5,16 @@ import cloud.mindbox.mobile_sdk.inapp.presentation.ActivityManager import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback /** - * Ready-to-use implementation of InAppCallback that handles opening deeplink if it's possible + * Ready-to-use implementation of [InAppCallback] that opens a link of any scheme if there is an + * activity able to handle it. + * + * The link is opened via an `ACTION_VIEW` intent and the system decides where it goes: a custom + * deep link or a verified Android App Link is opened inside the app, while a regular http/https + * link is opened in the browser. In other words, this callback covers both deep links and plain + * web urls, so it is the only link-opening callback needed in the default chain. + * + * Do NOT combine it with [UrlInAppCallback] in the same chain: both would issue `startActivity` + * for the same http/https link, opening it twice. **/ public open class DeepLinkInAppCallback : InAppCallback { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/callbacks/UrlInAppCallback.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/callbacks/UrlInAppCallback.kt index a366de337..8a5f4e54d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/callbacks/UrlInAppCallback.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/callbacks/UrlInAppCallback.kt @@ -5,7 +5,15 @@ import cloud.mindbox.mobile_sdk.inapp.presentation.ActivityManager import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback /** - * Ready-to-use implementation of InAppCallback that handles opening url in browser + * Ready-to-use implementation of [InAppCallback] that opens only http/https links. + * + * The link is opened via an `ACTION_VIEW` intent and the system chooses the handler: a regular + * web link goes to the browser, while a verified Android App Link may be opened inside the + * corresponding app. Non-http/https schemes (e.g. custom deep links) are ignored — use + * [DeepLinkInAppCallback] for those. + * + * Do NOT combine this callback with [DeepLinkInAppCallback] in the same chain: both would issue + * `startActivity` for the same http/https link, opening it twice. **/ public open class UrlInAppCallback : InAppCallback { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt index b3a94c480..f4565da49 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt @@ -10,6 +10,7 @@ import android.widget.FrameLayout import android.widget.ImageView import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible import cloud.mindbox.mobile_sdk.R import cloud.mindbox.mobile_sdk.di.mindboxInject import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendPresentationFailure @@ -22,6 +23,7 @@ import cloud.mindbox.mobile_sdk.inapp.presentation.InAppMessageViewDisplayerImpl import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView import cloud.mindbox.mobile_sdk.inapp.presentation.actions.InAppActionHandler import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.maxScreenDimension import cloud.mindbox.mobile_sdk.removeChildById import cloud.mindbox.mobile_sdk.safeAs import cloud.mindbox.mobile_sdk.setSingleClickListener @@ -122,64 +124,74 @@ internal abstract class AbstractInAppViewHolder( } protected fun getImageFromCache(url: String, imageView: InAppImageView) { + val maxDim = currentDialog.context.maxScreenDimension() + val timeout = currentDialog.context.getString(R.string.mindbox_inapp_fetching_timeout).toInt() Glide .with(currentDialog.context) .load(url) - .diskCacheStrategy(DiskCacheStrategy.ALL) - .listener(object : RequestListener { - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target?, - isFirstResource: Boolean - ): Boolean { - return runCatching { - inAppFailureTracker.sendPresentationFailure( - inAppId = wrapper.inAppType.inAppId, - errorDescription = "Failed to load in-app image with url = $url", - throwable = e - ) - inAppController.close() - false - }.getOrElse { throwable -> - inAppFailureTracker.sendPresentationFailure( - inAppId = wrapper.inAppType.inAppId, - errorDescription = "Unknown error after loading image from cache succeeded", - throwable = throwable - ) - false - } - } + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .override(maxDim, maxDim) + .timeout(timeout) + .centerInside() + .listener(buildCacheRequestListener(url, imageView)) + .into(imageView) + } - override fun onResourceReady( - resource: Drawable?, - model: Any?, - target: Target?, - dataSource: DataSource?, - isFirstResource: Boolean - ): Boolean { - return runCatching { - bind() - preparedImages[imageView] = true - if (!preparedImages.values.contains(false)) { - this@AbstractInAppViewHolder.mindboxLogI("In-app shown") - wrapper.inAppActionCallbacks.onInAppShown.onShown() - for (image in preparedImages.keys) { - image.visibility = View.VISIBLE - } - } - false - }.getOrElse { throwable -> - inAppFailureTracker.sendPresentationFailure( - inAppId = wrapper.inAppType.inAppId, - errorDescription = "Unknown error in onResourceReady callback", - throwable = throwable - ) - false - } + private fun buildCacheRequestListener( + url: String, + imageView: InAppImageView, + ): RequestListener = object : RequestListener { + + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean, + ): Boolean { + runCatching { + inAppFailureTracker.sendPresentationFailure( + inAppId = wrapper.inAppType.inAppId, + errorDescription = "Failed to load in-app image with url = $url", + throwable = e, + tags = wrapper.tags + ) + inAppController.close() + }.onFailure { throwable -> + inAppFailureTracker.sendPresentationFailure( + inAppId = wrapper.inAppType.inAppId, + errorDescription = "Unknown error in onLoadFailed callback for url = $url", + throwable = throwable, + tags = wrapper.tags + ) + } + return false + } + + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean, + ): Boolean { + runCatching { + bind() + preparedImages[imageView] = true + if (!preparedImages.values.contains(false)) { + this@AbstractInAppViewHolder.mindboxLogI("In-app ${wrapper.inAppType.inAppId} shown") + wrapper.inAppActionCallbacks.onInAppShown.onShown() + preparedImages.keys.forEach { it.isVisible = true } } - }) - .into(imageView) + }.onFailure { throwable -> + inAppFailureTracker.sendPresentationFailure( + inAppId = wrapper.inAppType.inAppId, + errorDescription = "Unknown error in onResourceReady callback for url = $url", + throwable = throwable, + tags = wrapper.tags + ) + } + return false + } } protected open fun initView(currentRoot: ViewGroup) { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt index aa4367465..6fa850699 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt @@ -7,6 +7,7 @@ import android.view.* import android.view.animation.AccelerateDecelerateInterpolator import android.widget.FrameLayout import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet import androidx.core.view.* import cloud.mindbox.mobile_sdk.SnackbarPosition import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType @@ -292,3 +293,15 @@ internal data class InAppInsets( internal fun interface BackButtonLayout { fun setBackListener(listener: (() -> Unit)?) } + +/** + * Clones the current constraint state, applies [block] to it, then commits the result back. + * Removes the clone/applyTo boilerplate from call sites. + */ +internal fun InAppConstraintLayout.updateConstraints(block: ConstraintSet.() -> Unit) { + ConstraintSet().apply { + clone(this@updateConstraints) + block() + applyTo(this@updateConstraints) + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppCrossView.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppCrossView.kt index e2c505d43..f0fce31e5 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppCrossView.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppCrossView.kt @@ -2,18 +2,19 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.content.Context import android.graphics.Canvas -import android.graphics.Color import android.graphics.Paint import android.view.View import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.view.doOnLayout import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import cloud.mindbox.mobile_sdk.inapp.domain.models.Element import cloud.mindbox.mobile_sdk.inapp.domain.models.Element.CloseButton.Position.Kind.PROPORTION import cloud.mindbox.mobile_sdk.inapp.domain.models.Element.CloseButton.Size.Kind.DP -import cloud.mindbox.mobile_sdk.logger.mindboxLogD import cloud.mindbox.mobile_sdk.px import kotlin.math.roundToInt +import androidx.core.graphics.toColorInt +import cloud.mindbox.mobile_sdk.logger.mindboxLogI internal class InAppCrossView : View { @@ -76,32 +77,21 @@ internal class InAppCrossView : View { updateLayoutParams { width = crossWidth height = crossHeight - paint.color = Color.parseColor(closeButtonElement.color) + paint.color = closeButtonElement.color.toColorInt() } - val constraintSet = ConstraintSet() - val marginTop = - ((currentDialog.height - crossHeight) * closeButtonElement.position.top).roundToInt() - val marginEnd = - ((currentDialog.width - crossWidth) * closeButtonElement.position.right).roundToInt() + currentDialog.doOnLayout { + val marginTop = + ((currentDialog.height - crossHeight) * closeButtonElement.position.top).roundToInt() + val marginEnd = + ((currentDialog.width - crossWidth) * closeButtonElement.position.right).roundToInt() - constraintSet.clone(currentDialog) - constraintSet.connect( - id, - ConstraintSet.TOP, - currentDialog.id, - ConstraintSet.TOP, - marginTop - ) - constraintSet.connect( - id, - ConstraintSet.END, - currentDialog.id, - ConstraintSet.END, - marginEnd - ) - constraintSet.applyTo(currentDialog) - mindboxLogD("InApp cross is shown with params:color = ${closeButtonElement.color}, lineWidth = ${closeButtonElement.lineWidth}, width = ${closeButtonElement.size.width}, height = ${closeButtonElement.size.height} with kind ${closeButtonElement.size.kind.name}. Margins: top = ${closeButtonElement.position.top}, bottom = ${closeButtonElement.position.bottom}, left = ${closeButtonElement.position.left}, right = ${closeButtonElement.position.right} and kind ${closeButtonElement.position.kind.name}") - if (paint.strokeWidth == 0f || crossWidth == 0 || crossHeight == 0) isVisible = false + currentDialog.updateConstraints { + connect(id, ConstraintSet.TOP, currentDialog.id, ConstraintSet.TOP, marginTop) + connect(id, ConstraintSet.END, currentDialog.id, ConstraintSet.END, marginEnd) + } + mindboxLogI("InApp cross is shown with params:color = ${closeButtonElement.color}, lineWidth = ${closeButtonElement.lineWidth}, width = ${closeButtonElement.size.width}, height = ${closeButtonElement.size.height} with kind ${closeButtonElement.size.kind.name}. Margins: top = ${closeButtonElement.position.top}, bottom = ${closeButtonElement.position.bottom}, left = ${closeButtonElement.position.left}, right = ${closeButtonElement.position.right} and kind ${closeButtonElement.position.kind.name}") + if (paint.strokeWidth == 0f || crossWidth == 0 || crossHeight == 0) isVisible = false + } } fun prepareViewForModalWindow(currentDialog: InAppConstraintLayout) { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppImageView.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppImageView.kt index 588ad4773..b851a391a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppImageView.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppImageView.kt @@ -1,7 +1,6 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.content.Context -import android.widget.FrameLayout import androidx.appcompat.widget.AppCompatImageView import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet @@ -24,7 +23,7 @@ internal class InAppImageView(context: Context) : AppCompatImageView(context) { val oneThirdScreenHeight = resources.displayMetrics.heightPixels / 3 val desiredHeight = (((resources.displayMetrics.widthPixels.toDouble() - marginStart.toDouble() - marginEnd.toDouble()) / (size.width.toDouble())) * size.height).roundToInt() - layoutParams = FrameLayout.LayoutParams( + layoutParams = ConstraintLayout.LayoutParams( ConstraintLayout.LayoutParams.MATCH_PARENT, if (desiredHeight > oneThirdScreenHeight) oneThirdScreenHeight else desiredHeight ) @@ -36,38 +35,13 @@ internal class InAppImageView(context: Context) : AppCompatImageView(context) { width = 0.dp height = 0.dp } - val constraintSet = ConstraintSet() - constraintSet.clone(currentDialog) - constraintSet.setDimensionRatio(id, MODAL_WINDOW_ASPECT_RATIO) scaleType = ScaleType.CENTER_CROP - constraintSet.connect( - id, - ConstraintSet.TOP, - currentDialog.id, - ConstraintSet.TOP, - 0 - ) - constraintSet.connect( - id, - ConstraintSet.END, - currentDialog.id, - ConstraintSet.END, - 0 - ) - constraintSet.connect( - id, - ConstraintSet.START, - currentDialog.id, - ConstraintSet.START, - 0 - ) - constraintSet.connect( - id, - ConstraintSet.BOTTOM, - currentDialog.id, - ConstraintSet.BOTTOM, - 0 - ) - constraintSet.applyTo(currentDialog) + currentDialog.updateConstraints { + setDimensionRatio(id, MODAL_WINDOW_ASPECT_RATIO) + connect(id, ConstraintSet.TOP, currentDialog.id, ConstraintSet.TOP, 0) + connect(id, ConstraintSet.END, currentDialog.id, ConstraintSet.END, 0) + connect(id, ConstraintSet.START, currentDialog.id, ConstraintSet.START, 0) + connect(id, ConstraintSet.BOTTOM, currentDialog.id, ConstraintSet.BOTTOM, 0) + } } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppPositionController.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppPositionController.kt index 7381f7392..0bd70b689 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppPositionController.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppPositionController.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.ContextWrapper import android.view.View import android.view.ViewGroup +import androidx.annotation.VisibleForTesting import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity @@ -99,9 +100,21 @@ internal class InAppPositionController { } } - private fun findTopDialogFragment(fragmentManager: FragmentManager): DialogFragment? { - return fragmentManager.fragments.filterIsInstance().lastOrNull { it.isAdded } - } + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun findTopDialogFragment(fragmentManager: FragmentManager): DialogFragment? = + fragmentManager.allDialogFragments() + .lastOrNull { it.isAdded && it.dialog?.window != null } + + private fun FragmentManager.allDialogFragments(): List = + fragments.flatMap { fragment -> + val self = if (fragment is DialogFragment) listOf(fragment) else emptyList() + val nested = if (fragment.isAdded) { + fragment.childFragmentManager.allDialogFragments() + } else { + emptyList() + } + self + nested + } private fun View.findActivity(): Activity? { var context = this.context diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/SnackbarInAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/SnackbarInAppViewHolder.kt index 49ce7af4e..1f4c1a124 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/SnackbarInAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/SnackbarInAppViewHolder.kt @@ -1,6 +1,7 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.view.ViewGroup +import androidx.core.view.doOnLayout import androidx.core.view.isInvisible import cloud.mindbox.mobile_sdk.SnackbarPosition import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppImageSizeStorage @@ -92,9 +93,11 @@ internal class SnackbarInAppViewHolder( } } if (isFirstShow) { - when (wrapper.inAppType.position.gravity.vertical) { - SnackbarPosition.TOP -> inAppLayout.slideDown() - SnackbarPosition.BOTTOM -> inAppLayout.slideUp() + currentDialog.doOnLayout { + when (wrapper.inAppType.position.gravity.vertical) { + SnackbarPosition.TOP -> inAppLayout.slideDown() + SnackbarPosition.BOTTOM -> inAppLayout.slideUp() + } } } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 76c5ccca6..7ef1db802 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -3,6 +3,8 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.app.Activity import android.app.Application import android.net.Uri +import android.os.Handler +import android.os.Looper import android.view.ViewGroup import android.widget.RelativeLayout import android.widget.Toast @@ -22,6 +24,8 @@ import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewCachePolicy +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView import androidx.lifecycle.ProcessLifecycleOwner @@ -77,6 +81,11 @@ internal class WebViewInAppViewHolder( private var closeInappTimer: Timer? = null private var webViewController: WebViewController? = null private var currentWebViewOrigin: String? = null + private var readyChecker: WebViewReadyChecker? = null + private val mainHandler: Handler = Handler(Looper.getMainLooper()) + + private var hasInitialized = false + private var pendingReadyCheckFailure: String? = null private var motionService: MotionServiceProtocol? = null @@ -89,6 +98,8 @@ internal class WebViewInAppViewHolder( private val gson: Gson by mindboxInject { this.gson } private val timeProvider: TimeProvider by mindboxInject { timeProvider } + private val webViewPrewarmManager: InAppWebViewPrewarmManager by mindboxInject { inAppWebViewPrewarmManager } + private val webViewCachePolicy: InAppWebViewCachePolicy by mindboxInject { webViewCachePolicy } private val messageValidator: BridgeMessageValidator by lazy { BridgeMessageValidator() } private val hapticRequestValidator: HapticRequestValidator by lazy { HapticRequestValidator() } private val gatewayManager: GatewayManager by mindboxInject { gatewayManager } @@ -97,7 +108,7 @@ internal class WebViewInAppViewHolder( private val mindboxNotificationManager: MindboxNotificationManager by mindboxInject { mindboxNotificationManager } private val appContext: Application by mindboxInject { appContext } private val operationExecutor: WebViewOperationExecutor by lazy { - MindboxWebViewOperationExecutor() + MindboxWebViewOperationExecutor(gson) } private val linkRouter: WebViewLinkRouter by lazy { MindboxWebViewLinkRouter(appContext) @@ -278,6 +289,7 @@ internal class WebViewInAppViewHolder( } private fun handleInitAction(controller: WebViewController): String { + hasInitialized = true stopTimer() wrapper.inAppActionCallbacks.onInAppShown.onShown() val mindboxView = currentMindboxView ?: run { @@ -351,7 +363,7 @@ internal class WebViewInAppViewHolder( } private fun handleAsyncOperationAction(message: BridgeMessage.Request): String { - operationExecutor.executeAsyncOperation(appContext, message.payload) + operationExecutor.executeAsyncOperation(appContext, message.payload, wrapper.tags) return BridgeMessage.EMPTY_PAYLOAD } @@ -364,7 +376,7 @@ internal class WebViewInAppViewHolder( } private suspend fun handleSyncOperationAction(message: BridgeMessage.Request): String { - return operationExecutor.executeSyncOperation(message.payload) + return operationExecutor.executeSyncOperation(message.payload, wrapper.tags) } private fun handleLocalStateGetAction(message: BridgeMessage.Request): String { @@ -416,7 +428,12 @@ internal class WebViewInAppViewHolder( private fun createWebViewController(layer: Layer.WebViewLayer): WebViewController { mindboxLogI("Creating WebView for In-App: ${wrapper.inAppType.inAppId} with layer ${layer.type}") - val controller: WebViewController = WebViewController.create(currentDialog.context, BuildConfig.DEBUG) + val controller: WebViewController = WebViewController.create( + context = currentDialog.context, + isDebugEnabled = BuildConfig.DEBUG, + isCacheEnabled = webViewCachePolicy.isCacheEnabled, + log = { message -> mindboxLogI("[WebView] $message") } + ) val view: WebViewPlatformView = controller.view view.layoutParams = RelativeLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, @@ -426,7 +443,7 @@ internal class WebViewInAppViewHolder( override fun onPageFinished(url: String?) { mindboxLogD("onPageFinished: $url") currentWebViewOrigin = resolveOrigin(url) ?: currentWebViewOrigin - webViewController?.evaluateJavaScript(JS_CHECK_BRIDGE, ::checkEvaluateJavaScript) + startReadyCheck(url) } override fun onShouldOverrideUrlLoading(url: String?, isForMainFrame: Boolean?): Boolean { @@ -439,7 +456,8 @@ internal class WebViewInAppViewHolder( inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, - errorDescription = "WebView error: code=${error.code}, description=${error.description}, url=${error.url}" + errorDescription = "WebView error: code=${error.code}, description=${error.description}, url=${error.url}", + tags = wrapper.tags ) inAppController.close() } @@ -514,16 +532,79 @@ internal class WebViewInAppViewHolder( } } + /** + * Readiness probe for a freshly finished page. Module scripts can evaluate a beat after + * `onPageFinished` (slow device, cold cache), so a single early `false` must not close a + * healthy in-app — the checker polls before giving up. Every new `onPageFinished` (redirect, + * re-load) restarts the poll; teardown cancels it. The outgoing-message verification in + * [sendActionInternal] intentionally stays single-shot ([checkEvaluateJavaScript]) — that + * path talks to a page that already proved itself ready. + * + * Give-up BEFORE `init` only records the failure ([pendingReadyCheckFailure]) — the + * init timer is the closing authority there, since the checker's ~1.2s budget can + * expire while an allowed same-origin navigation is still in flight or a slow page is + * still booting its bridge (cases the timer would have accepted; the window stays + * invisible until `init` anyway). Give-up AFTER `init` closes immediately instead: a + * same-origin navigation broke a bridge that had already proven itself alive, and + * there is no init timer left to catch it. + */ + private fun startReadyCheck(url: String?) { + // A late onPageFinished can be queued on the main looper when the in-app closes; + // a checker started against the torn-down holder would only produce a spurious + // failure event. + if (webViewController == null) return + readyChecker?.cancel() + val checker = WebViewReadyChecker( + evaluate = { script, resultCallback -> + webViewController?.evaluateJavaScript(script, resultCallback) + ?: resultCallback(null) + }, + schedule = { delayMillis, action -> mainHandler.postDelayed(action, delayMillis) } + ) + readyChecker = checker + checker.run( + script = JS_CHECK_BRIDGE, + expectedResult = JS_RETURN, + onReady = { mindboxLogD("JS ready check passed for $url") }, + onGiveUp = { lastFailure -> + if (hasInitialized) { + inAppFailureTracker.sendFailureWithContext( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, + errorDescription = "JS ready check gave up after init for $url: $lastFailure", + tags = wrapper.tags + ) + inAppController.close() + } else { + pendingReadyCheckFailure = lastFailure + } + } + ) + } + + /** + * Verifies an outgoing bridge call's JS result. Tracks the failure but does NOT close + * the in-app: whether one undelivered message is fatal is the caller's policy (a failed + * `back` action closes via its own onError, a failed motion event just stops monitoring) + * — force-closing here used to tear down a healthy show over a single transient miss. + * Page readiness has its own retrying probe ([startReadyCheck]). + */ internal fun checkEvaluateJavaScript(response: String?): Boolean { return when (response) { JS_RETURN -> true else -> { - inAppFailureTracker.sendFailureWithContext( - inAppId = wrapper.inAppType.inAppId, - failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, - errorDescription = "evaluateJavaScript return unexpected response: $response" - ) - inAppController.close() + // A miss during teardown (holder already closed, controller gone) is an + // expected race, not a presentation failure — don't feed it to telemetry. + if (webViewController != null) { + inAppFailureTracker.sendFailureWithContext( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, + errorDescription = "evaluateJavaScript returned unexpected response: $response", + tags = wrapper.tags + ) + } else { + mindboxLogW("evaluateJavaScript miss after teardown (ignored): $response") + } false } } @@ -563,10 +644,13 @@ internal class WebViewInAppViewHolder( error: Throwable, controller: WebViewController, ) { - val json: String = runCatching { - val payload = ErrorPayload(error = requireNotNull(error.message)) - gson.toJson(payload) - }.getOrDefault(BridgeMessage.UNKNOWN_ERROR_PAYLOAD) + val json: String = when (error) { + is WebViewSyncOperationException -> error.payloadJson + else -> runCatching { + val payload = ErrorPayload(error = requireNotNull(error.message)) + gson.toJson(payload) + }.getOrDefault(BridgeMessage.UNKNOWN_ERROR_PAYLOAD) + } val errorMessage: BridgeMessage.Error = BridgeMessage.createErrorAction(message, json) mindboxLogE("WebView send error response for ${message.action} with payload ${errorMessage.payload}") @@ -603,6 +687,8 @@ internal class WebViewInAppViewHolder( private fun renderLayer(layer: Layer.WebViewLayer) { if (webViewController == null) { + // A real show takes priority: kill the prewarm so it can't compete for bandwidth. + webViewPrewarmManager.onRealShowWillStart() val controller: WebViewController = createWebViewController(layer) webViewController = controller @@ -646,17 +732,19 @@ internal class WebViewInAppViewHolder( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.WEBVIEW_LOAD_FAILED, errorDescription = "Failed to fetch HTML content for In-App", - throwable = e + throwable = e, + tags = wrapper.tags ) - inAppController.close() + controller.executeOnViewThread { inAppController.close() } } } ?: run { inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.WEBVIEW_LOAD_FAILED, - errorDescription = "WebView content URL is null" + errorDescription = "WebView content URL is null", + tags = wrapper.tags ) - inAppController.close() + controller.executeOnViewThread { inAppController.close() } } } } @@ -666,6 +754,7 @@ internal class WebViewInAppViewHolder( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.PRESENTATION_FAILED, errorDescription = "Error when trying WebView layout", + tags = wrapper.tags, ) { val view: WebViewPlatformView = controller.view if (view.parent !== inAppLayout) { @@ -677,7 +766,8 @@ internal class WebViewInAppViewHolder( inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, - errorDescription = "WebView controller is null when trying show inapp" + errorDescription = "WebView controller is null when trying show inapp", + tags = wrapper.tags ) inAppController.close() } @@ -685,14 +775,27 @@ internal class WebViewInAppViewHolder( private fun onContentPageLoaded(content: WebViewHtmlContent) { webViewController?.let { controller -> - controller.executeOnViewThread { - controller.loadContent(content) - } + hasInitialized = false + pendingReadyCheckFailure = null + controller.loadContent(content) startTimer { + // A ready-check give-up that already fired before init is the more specific + // reason this timed out — report it instead of the generic load-timeout so + // the "ready check never passed" category isn't lost. + val readyCheckFailure = pendingReadyCheckFailure inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, - failureReason = FailureReason.WEBVIEW_LOAD_FAILED, - errorDescription = "WebView initialization timed out after ${Stopwatch.stop(TIMER)}." + failureReason = if (readyCheckFailure != null) { + FailureReason.WEBVIEW_PRESENTATION_FAILED + } else { + FailureReason.WEBVIEW_LOAD_FAILED + }, + errorDescription = if (readyCheckFailure != null) { + "JS ready check never passed before init timeout: $readyCheckFailure" + } else { + "WebView initialization timed out after ${Stopwatch.stop(TIMER)}." + }, + tags = wrapper.tags ) controller.executeOnViewThread { inAppController.close() @@ -756,14 +859,20 @@ internal class WebViewInAppViewHolder( hapticFeedbackExecutor.cancel() motionService?.stopMonitoring() stopTimer() + readyChecker?.cancel() + readyChecker = null cancelPendingResponses("WebView In-App is closed") webViewController?.let { controller -> + // Remember which https hosts this show actually used — feeds the next launch's preconnect. + webViewPrewarmManager.captureObservedHosts(controller) + // Detach first: a page event already queued on the main looper must not reach + // this torn-down holder (e.g. a late onPageFinished spawning a ready checker). + controller.setEventListener(null) val view: WebViewPlatformView = controller.view view.parent.safeAs()?.removeView(view) controller.destroy() } currentWebViewOrigin = null - webViewController?.destroy() webViewController = null currentMindboxView = null super.onClose() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt index 80812e9f1..24ca375a3 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt @@ -1,8 +1,11 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.app.Application +import cloud.mindbox.mobile_sdk.logger.mindboxLogW import cloud.mindbox.mobile_sdk.managers.MindboxEventManager import cloud.mindbox.mobile_sdk.models.MindboxError +import com.google.gson.Gson +import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.JsonParser import kotlinx.coroutines.suspendCancellableCoroutine @@ -11,20 +14,23 @@ import kotlin.coroutines.resumeWithException internal interface WebViewOperationExecutor { - fun executeAsyncOperation(context: Application, payload: String?) + fun executeAsyncOperation(context: Application, payload: String?, tags: Map?) - suspend fun executeSyncOperation(payload: String?): String + suspend fun executeSyncOperation(payload: String?, tags: Map?): String } -internal class MindboxWebViewOperationExecutor : WebViewOperationExecutor { +internal class MindboxWebViewOperationExecutor( + private val gson: Gson, +) : WebViewOperationExecutor { companion object { private const val OPERATION_FIELD = "operation" private const val BODY_FIELD = "body" + private const val TAGS_FIELD = "tags" } - override fun executeAsyncOperation(context: Application, payload: String?) { - val (operation, body) = parseOperationRequest(payload) + override fun executeAsyncOperation(context: Application, payload: String?, tags: Map?) { + val (operation, body) = parseOperationRequest(payload, tags) MindboxEventManager.asyncOperation( context = context, name = operation, @@ -32,8 +38,8 @@ internal class MindboxWebViewOperationExecutor : WebViewOperationExecutor { ) } - override suspend fun executeSyncOperation(payload: String?): String { - val (operation, body) = parseOperationRequest(payload) + override suspend fun executeSyncOperation(payload: String?, tags: Map?): String { + val (operation, body) = parseOperationRequest(payload, tags) return suspendCancellableCoroutine { continuation -> MindboxEventManager.syncOperation( name = operation, @@ -46,7 +52,7 @@ internal class MindboxWebViewOperationExecutor : WebViewOperationExecutor { onError = { error: MindboxError -> if (continuation.isActive) { continuation.resumeWithException( - IllegalStateException(error.toJson()) + WebViewSyncOperationException(error.toWebViewDataJson(gson)) ) } }, @@ -54,12 +60,51 @@ internal class MindboxWebViewOperationExecutor : WebViewOperationExecutor { } } - private fun parseOperationRequest(payload: String?): Pair { - val jsonObject: JsonObject = JsonParser.parseString(payload).asJsonObject + private fun parseOperationRequest(payload: String?, tags: Map?): Pair { + payload ?: throw IllegalArgumentException("Payload is not provided") + val jsonObject: JsonObject = runCatching { JsonParser.parseString(payload).asJsonObject } + .getOrElse { throw IllegalArgumentException("Payload is not a valid JSON object", it) } val operation: String = jsonObject.getAsJsonPrimitive(OPERATION_FIELD)?.asString ?: throw IllegalArgumentException("Operation is not provided") - val body: String = jsonObject.getAsJsonObject(BODY_FIELD)?.toString() + val bodyObject: JsonObject = jsonObject.getAsJsonObject(BODY_FIELD) ?: throw IllegalArgumentException("Body is not provided") - return operation to body + return operation to buildOperationBody(bodyObject, tags) + } + + private fun buildOperationBody(bodyObject: JsonObject, tags: Map?): String { + if (!tags.isNullOrEmpty()) { + mergeTags(bodyObject, tags) + } + return bodyObject.toString() + } + + private fun mergeTags(bodyObject: JsonObject, tags: Map) { + val existingTags: JsonElement? = bodyObject.get(TAGS_FIELD) + when { + existingTags == null || existingTags.isJsonNull -> { + bodyObject.add(TAGS_FIELD, gson.toJsonTree(tags)) + } + + existingTags.isJsonObject -> { + val tagsObject: JsonObject = existingTags.asJsonObject + tags.forEach { (key: String, value: String) -> + if (tagsObject.has(key)) { + mindboxLogW( + "WebView operation body `tags` already contains key `$key`; " + + "keeping client value, skipping in-app value" + ) + } else { + tagsObject.addProperty(key, value) + } + } + } + + else -> { + mindboxLogW( + "WebView operation body `tags` is not a JSON object; " + + "keeping client value, skipping in-app tags" + ) + } + } } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyChecker.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyChecker.kt new file mode 100644 index 000000000..7ddf6230c --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyChecker.kt @@ -0,0 +1,66 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +/** + * Polls the page for the JS bridge instead of deciding on a single onPageFinished-time probe. + * + * Module scripts can finish evaluating a beat after the page's load event on slow devices or + * with a cold cache, so one early `false` must not close a healthy in-app. The check re-runs + * on a short cadence and gives up only after the full budget; a new navigation (or teardown) + * cancels the previous checker outright. Mirrors the iOS SDK's WebViewReadyChecker. + * + * Pure logic: evaluation and scheduling are injected, so the class is JVM-testable. + */ +internal class WebViewReadyChecker( + private val evaluate: (script: String, resultCallback: (String?) -> Unit) -> Unit, + private val schedule: (delayMillis: Long, action: () -> Unit) -> Unit, +) { + + companion object { + const val MAX_ATTEMPTS: Int = 8 + const val RETRY_DELAY_MS: Long = 150L + } + + @Volatile + private var isCancelled = false + + fun run( + script: String, + expectedResult: String, + onReady: () -> Unit, + onGiveUp: (lastFailure: String) -> Unit + ) { + attempt(1, script, expectedResult, onReady, onGiveUp) + } + + /** + * Abandons the poll without calling either completion — the caller's new navigation + * (or teardown) owns readiness from here. + */ + fun cancel() { + isCancelled = true + } + + private fun attempt( + number: Int, + script: String, + expectedResult: String, + onReady: () -> Unit, + onGiveUp: (String) -> Unit + ) { + if (isCancelled) return + evaluate(script) { result -> + if (isCancelled) return@evaluate + if (result == expectedResult) { + onReady() + return@evaluate + } + if (number >= MAX_ATTEMPTS) { + onGiveUp("evaluateJavaScript returned unexpected response: $result") + return@evaluate + } + schedule(RETRY_DELAY_MS) { + attempt(number + 1, script, expectedResult, onReady, onGiveUp) + } + } + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewSyncOperationError.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewSyncOperationError.kt new file mode 100644 index 000000000..e277b856f --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewSyncOperationError.kt @@ -0,0 +1,57 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import cloud.mindbox.mobile_sdk.models.MindboxError +import com.google.gson.Gson +import com.google.gson.JsonObject + +internal class WebViewSyncOperationException(val payloadJson: String) : Exception(payloadJson) + +/** + * Data-only JSON without the `{type, data}` envelope — the WebView JS-bridge `onError` + * contract shared with iOS: string `httpStatusCode`, no transport `statusCode`. + * `toJson()` must keep the envelope: RN/Flutter wrappers dispatch on it. + * + * Serialized via `JsonElement.toString()`, not `gson.toJson`: the SDK gson has + * htmlSafe enabled and would escape `<`/`&`/`'`, while iOS `JSONEncoder` does not. + */ +internal fun MindboxError.toWebViewDataJson(gson: Gson): String { + val data = JsonObject() + when (this) { + is MindboxError.Validation -> { + data.addProperty("status", status) + data.add("validationMessages", gson.toJsonTree(validationMessages)) + } + + is MindboxError.Protocol -> + data.addServerErrorFields(status, errorMessage, errorId, httpStatusCode) + + is MindboxError.InternalServer -> + data.addServerErrorFields(status, errorMessage, errorId, httpStatusCode) + + is MindboxError.UnknownServer -> { + status?.let { data.addProperty("status", it) } + errorMessage?.let { data.addProperty("errorMessage", it) } + errorId?.let { data.addProperty("errorId", it) } + data.addProperty("httpStatusCode", httpStatusCode?.toString() ?: "null") + } + + is MindboxError.Unknown -> { + data.addProperty("errorKey", "unknown") + data.addProperty("errorName", throwable?.javaClass?.canonicalName ?: "") + data.addProperty("errorMessage", throwable?.localizedMessage ?: "") + } + } + return data.toString() +} + +private fun JsonObject.addServerErrorFields( + status: String, + errorMessage: String?, + errorId: String?, + httpStatusCode: Int?, +) { + addProperty("status", status) + errorMessage?.let { addProperty("errorMessage", it) } + addProperty("errorId", errorId ?: "") + addProperty("httpStatusCode", httpStatusCode?.toString() ?: "null") +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/Level.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/Level.kt index c03e3f412..d4585c3c0 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/Level.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/Level.kt @@ -1,6 +1,6 @@ package cloud.mindbox.mobile_sdk.logger -public enum class Level(public var value: Int) { +public enum class Level(public val value: Int) { VERBOSE(0), DEBUG(1), @@ -9,3 +9,8 @@ public enum class Level(public var value: Int) { ERROR(4), NONE(5) } + +internal infix fun Level.isAtMost(other: Level): Boolean = value <= other.value + +@Suppress("unused") +internal infix fun Level.isAtLeast(other: Level): Boolean = value >= other.value diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/MindboxLoggerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/MindboxLoggerImpl.kt index a51335187..bfc825ff7 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/MindboxLoggerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/MindboxLoggerImpl.kt @@ -50,12 +50,32 @@ internal object MindboxLoggerImpl : MindboxLogger { internal var level: Level = DEFAULT_LOG_LEVEL /** - * All the methods below should be used only after Mindbox.initComponents method was called + * Returns [Level.DEBUG] if `adb shell setprop log.tag.Mindbox DEBUG` (or VERBOSE) is active, + * [Level.NONE] otherwise. Any other setprop value is treated as "not set". + * + * When [Level.DEBUG] is returned it overrides the programmatic [level], enabling all log + * output regardless of what [level] is currently configured to. + * + * `Log.isLoggable(TAG, Log.DEBUG)` returns `true` for both VERBOSE and DEBUG setprop values + * because VERBOSE has a lower priority than DEBUG in Android's priority scale. + * The property is consulted when execution reaches the setprop override check, so changes + * take effect immediately without app restart for calls that evaluate this branch. + * + * By design, only DEBUG and VERBOSE are supported as override values. WARN and ERROR could + * technically be detected (they make `isLoggable(INFO)` return `false`, unlike the default), + * but using setprop to make logging *more restrictive* than the SDK default serves no practical + * debugging purpose. INFO is indistinguishable from "no setprop" and is also intentionally + * ignored. Use [Mindbox.setLogLevel] for fine-grained programmatic control. */ + private fun setPropLevel(): Level = + if (Log.isLoggable(TAG, Log.DEBUG)) Level.DEBUG else Level.NONE + /** + * All the methods below should be used only after Mindbox.initComponents method was called + */ override fun i(parent: Any, message: String) { val logMessage = buildMessage(parent, message) - if (level.value <= Level.INFO.value) { + if (level isAtMost Level.INFO || setPropLevel() isAtMost Level.INFO) { Log.i(TAG, logMessage) } saveLog(logMessage) @@ -63,7 +83,7 @@ internal object MindboxLoggerImpl : MindboxLogger { override fun d(parent: Any, message: String) { val logMessage = buildMessage(parent, message) - if (level.value <= Level.DEBUG.value) { + if (level isAtMost Level.DEBUG || setPropLevel() isAtMost Level.DEBUG) { Log.d(TAG, logMessage) } saveLog(logMessage) @@ -71,7 +91,7 @@ internal object MindboxLoggerImpl : MindboxLogger { override fun e(parent: Any, message: String) { val logMessage = buildMessage(parent, message) - if (level.value <= Level.ERROR.value) { + if (level isAtMost Level.ERROR || setPropLevel() isAtMost Level.ERROR) { Log.e(TAG, logMessage) } saveLog(logMessage) @@ -79,7 +99,7 @@ internal object MindboxLoggerImpl : MindboxLogger { override fun e(parent: Any, message: String, exception: Throwable) { val logMessage = buildMessage(parent, message) - if (level.value <= Level.ERROR.value) { + if (level isAtMost Level.ERROR || setPropLevel() isAtMost Level.ERROR) { Log.e(TAG, logMessage, exception) } saveLog(logMessage + exception.stackTraceToString()) @@ -87,7 +107,7 @@ internal object MindboxLoggerImpl : MindboxLogger { override fun w(parent: Any, message: String) { val logMessage = buildMessage(parent, message) - if (level.value <= Level.WARN.value) { + if (level isAtMost Level.WARN || setPropLevel() isAtMost Level.WARN) { Log.w(TAG, logMessage) } saveLog(logMessage) @@ -95,7 +115,7 @@ internal object MindboxLoggerImpl : MindboxLogger { override fun w(parent: Any, message: String, exception: Throwable) { val logMessage = buildMessage(parent, message) - if (level.value <= Level.WARN.value) { + if (level isAtMost Level.WARN || setPropLevel() isAtMost Level.WARN) { Log.w(TAG, logMessage, exception) } saveLog(logMessage + exception.stackTraceToString()) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt index 858cb1e4d..d8ab6c67f 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt @@ -2,11 +2,16 @@ package cloud.mindbox.mobile_sdk.managers import android.app.Activity import android.app.Application +import android.content.Context import android.content.Intent import android.os.Bundle import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import cloud.mindbox.mobile_sdk.Mindbox.logE +import cloud.mindbox.mobile_sdk.Mindbox.logW +import cloud.mindbox.mobile_sdk.logger.MindboxLog import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.models.DIRECT import cloud.mindbox.mobile_sdk.models.LINK @@ -16,186 +21,293 @@ import cloud.mindbox.mobile_sdk.utils.loggingRunCatching import java.util.Timer import kotlin.concurrent.timer -internal class LifecycleManager( +internal class LifecycleManager internal constructor( private var currentActivityName: String?, private var currentIntent: Intent?, private var isAppInBackground: Boolean, - private var onActivityResumed: (resumedActivity: Activity) -> Unit, - private var onActivityPaused: (pausedActivity: Activity) -> Unit, - private var onActivityStarted: (activity: Activity) -> Unit, - private var onActivityStopped: (activity: Activity) -> Unit, - private var onTrackVisitReady: (source: String?, requestUrl: String?) -> Unit, -) : Application.ActivityLifecycleCallbacks, LifecycleEventObserver { +) : Application.ActivityLifecycleCallbacks, LifecycleEventObserver, MindboxLog { + + internal interface Callbacks { + fun onActivityStarted(activity: Activity) {} + + fun onActivityPaused(activity: Activity) {} + + fun onActivityResumed(activity: Activity) {} + + fun onActivityStopped(activity: Activity) {} + + fun onTrackVisitReady(source: String?, requestUrl: String?) {} + } companion object { - private const val SCHEMA_HTTP = "http" - private const val SCHEMA_HTTPS = "https" + private const val TIMER_PERIOD = 1_200_000L + private const val MAX_INTENT_HASHES = 50 + + @Volatile + internal var instance: LifecycleManager? = null + + internal val isRegister: Boolean get() = instance != null - private const val TIMER_PERIOD = 1200000L - private const val MAX_INTENT_HASHES_SIZE = 50 + internal fun register(context: Context) { + if (instance != null) return + + val lifecycle = ProcessLifecycleOwner.get().lifecycle + val activity = context as? Activity + val application = context.applicationContext as? Application + val isForegrounded = lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + + if (isForegrounded && activity == null) { + logE("Incorrect context type for calling init in this place") + } + if (isForegrounded || context !is Application) { + logW( + "We recommend to call Mindbox.init() synchronously from " + + "Application.onCreate. If you can't do so, don't forget to " + + "call Mindbox.initPushServices from Application.onCreate", + ) + } + + // Double-checked locking: the fast path above filters the common case cheaply; + // the synchronized block below prevents two racing threads from both creating + // a manager and registering it twice as an observer. + synchronized(LifecycleManager::class.java) { + if (instance != null) return + + LifecycleManager( + currentActivityName = activity?.javaClass?.name, + currentIntent = activity?.intent, + isAppInBackground = !isForegrounded, + ).also { manager -> + application?.registerActivityLifecycleCallbacks(manager) + lifecycle.addObserver(manager) + instance = manager + } + } + } } - private var isIntentChanged = true - private var timer: Timer? = null + /** + * True when a foreground transition happened before [callbacks] was set — + * i.e. before [cloud.mindbox.mobile_sdk.Mindbox.init] was called. + */ + @Volatile + private var pendingVisit: Boolean = false + + @Volatile + var callbacks: Callbacks? = null + set(value) { + field = value + if (value != null && pendingVisit) { + pendingVisit = false + dispatchCurrentVisit(value) + } + } + + /** + * True by default — Activity.onResume() fires before the manager is registered + * when Mindbox.init() is called from Activity.onCreate(). + */ + var isCurrentActivityResumed: Boolean = true + private set + + private var intentChanged = true + private var keepaliveTimer: Timer? = null private val intentHashes = mutableListOf() + private var skipNextTrackVisit = false /** - * True by default. - * Has to be true because Activity.onResume() triggers before Lifecycle Manager is registered - * when Mindbox.init() was called in Activity.onCreate() - **/ - var isCurrentActivityResumed = true - private var skipSendingTrackVisit = false + * True when [onMovedToForeground] was called while [currentIntent] was still null — + * i.e. the app foregrounded before the first [onActivityStarted] callback arrived. + * + * This happens in Case 3: no [MindboxLifecycleInitializer], [Mindbox.init] called from + * [Application.onCreate]. [ProcessLifecycleOwnerInitializer] registers [LifecycleDispatcher] + * first, so the process-level ON_START fires *before* [LifecycleManager.onActivityStarted] + * updates [currentIntent]. The flag is cleared and the visit is dispatched inside + * [onActivityStarted] once the intent becomes available. + */ + @Volatile + private var foregroundedWithoutIntent = false - override fun onActivityCreated(activity: Activity, p1: Bundle?) { - } + override fun onActivityCreated(activity: Activity, p1: Bundle?) = Unit + + override fun onActivitySaveInstanceState(activity: Activity, p1: Bundle) = Unit + + override fun onActivityDestroyed(activity: Activity) = Unit override fun onActivityStarted(activity: Activity): Unit = loggingRunCatching { mindboxLogI("onActivityStarted. activity: ${activity.javaClass.simpleName}") - onActivityStarted.invoke(activity) - val areActivitiesEqual = currentActivityName == activity.javaClass.name + callbacks?.onActivityStarted(activity) + + val sameActivity = currentActivityName == activity.javaClass.name val intent = activity.intent - isIntentChanged = if (currentIntent != intent) { - updateActivityParameters(activity) - intent?.hashCode()?.let(::updateHashesList) ?: true + intentChanged = if (currentIntent != intent) { + updateActivityState(activity) + intent?.hashCode()?.let(::isNewHash) ?: true } else { false } - if (isAppInBackground || !isIntentChanged) { + if (isAppInBackground || !intentChanged) { isAppInBackground = false + if (foregroundedWithoutIntent && intentChanged) { + foregroundedWithoutIntent = false + sendTrackVisit(intent) + } return@loggingRunCatching } - sendTrackVisit(activity.intent, areActivitiesEqual) + sendTrackVisit(intent, sameActivity) } override fun onActivityResumed(activity: Activity) { mindboxLogI("onActivityResumed. activity: ${activity.javaClass.simpleName}") isCurrentActivityResumed = true - onActivityResumed.invoke(activity) - isCurrentActivityResumed = true + callbacks?.onActivityResumed(activity) } override fun onActivityPaused(activity: Activity) { mindboxLogI("onActivityPaused. activity: ${activity.javaClass.simpleName}") isCurrentActivityResumed = false - onActivityPaused.invoke(activity) - isCurrentActivityResumed = false + callbacks?.onActivityPaused(activity) } override fun onActivityStopped(activity: Activity) { mindboxLogI("onActivityStopped. activity: ${activity.javaClass.simpleName}") if (currentIntent == null || currentActivityName == null) { - updateActivityParameters(activity) + updateActivityState(activity) } - onActivityStopped.invoke(activity) - } - - override fun onActivitySaveInstanceState(activity: Activity, p1: Bundle) { + callbacks?.onActivityStopped(activity) } - override fun onActivityDestroyed(activity: Activity) { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + when (event) { + Lifecycle.Event.ON_STOP -> onMovedToBackground() + Lifecycle.Event.ON_START -> onMovedToForeground() + else -> Unit + } } fun isTrackVisitSent(): Boolean { currentIntent?.let { intent -> - if (updateHashesList(intent.hashCode())) { + if (isNewHash(intent.hashCode())) { sendTrackVisit(intent) } } return currentIntent != null } - fun wasReinitialized() { - skipSendingTrackVisit = true + /** + * Schedules a track-visit to be dispatched the next time [callbacks] is assigned. + * + * Call this before replacing [callbacks] via [cloud.mindbox.mobile_sdk.Mindbox.init] + * so the new endpoint receives a track-visit immediately upon reinitialisation. + * The backend uses this signal to learn the device is now active in the new environment. + */ + fun scheduleReinitTrackVisit() { + pendingVisit = true + mindboxLogI("Track visit scheduled for reinit") } - fun onNewIntent(newIntent: Intent?): Unit? = newIntent?.let { intent -> - if (intent.data != null || intent.extras?.getBoolean(IS_OPENED_FROM_PUSH_BUNDLE_KEY) == true) { - isIntentChanged = updateHashesList(intent.hashCode()) - sendTrackVisit(intent) - skipSendingTrackVisit = isAppInBackground - } + fun onNewIntent(newIntent: Intent?) { + val intent = newIntent ?: return + val hasDeepLink = intent.data != null + val isFromPush = intent.extras?.getBoolean(IS_OPENED_FROM_PUSH_BUNDLE_KEY) == true + if (!hasDeepLink && !isFromPush) return + + intentChanged = isNewHash(intent.hashCode()) + sendTrackVisit(intent) + skipNextTrackVisit = isAppInBackground } - private fun onAppMovedToBackground(): Unit = loggingRunCatching { + private fun onMovedToBackground(): Unit = loggingRunCatching { mindboxLogI("onAppMovedToBackground") isAppInBackground = true + pendingVisit = false + foregroundedWithoutIntent = false cancelKeepaliveTimer() } - private fun onAppMovedToForeground(): Unit = loggingRunCatching { + private fun onMovedToForeground(): Unit = loggingRunCatching { mindboxLogI("onAppMovedToForeground") - if (!skipSendingTrackVisit) { - currentIntent?.let(::sendTrackVisit) + if (skipNextTrackVisit) { + skipNextTrackVisit = false + return@loggingRunCatching + } + val intent = currentIntent + if (intent != null) { + sendTrackVisit(intent) } else { - skipSendingTrackVisit = false + foregroundedWithoutIntent = true + mindboxLogI("Track visit deferred — foregrounded before first activity") } } - private fun updateActivityParameters(activity: Activity): Unit = loggingRunCatching { + private fun updateActivityState(activity: Activity): Unit = loggingRunCatching { currentActivityName = activity.javaClass.name currentIntent = activity.intent } private fun sendTrackVisit( - intent: Intent, - areActivitiesEqual: Boolean = true, + intent: Intent?, + sameActivity: Boolean = true, ): Unit = loggingRunCatching { - val source = if (isIntentChanged) source(intent) else DIRECT - - if (areActivitiesEqual || source != DIRECT) { - val requestUrl = if (source == LINK) intent.data?.toString() else null - onTrackVisitReady.invoke(source, requestUrl) - startKeepaliveTimer() + val source = if (intentChanged) intentSource(intent) else DIRECT + if (!sameActivity && source == DIRECT) return@loggingRunCatching - mindboxLogI("Track visit event with source $source and url $requestUrl") + val cb = callbacks + if (cb == null) { + pendingVisit = true + mindboxLogI("Track visit pending (no callbacks yet)") + return@loggingRunCatching } + pendingVisit = false + val requestUrl = if (source == LINK) intent?.data?.toString() else null + cb.onTrackVisitReady(source, requestUrl) + startKeepaliveTimer() + mindboxLogI("Track visit event with source $source and url $requestUrl") } - private fun source(intent: Intent?): String? = loggingRunCatching(defaultValue = null) { - when { - intent?.scheme == SCHEMA_HTTP || intent?.scheme == SCHEMA_HTTPS -> LINK - intent?.extras?.getBoolean(IS_OPENED_FROM_PUSH_BUNDLE_KEY) == true -> PUSH - else -> DIRECT - } + /** + * Derives source and URL from the already-stored [currentIntent]/[intentChanged] and + * dispatches the track-visit through [cb]. + * + * Called from the [callbacks] setter when [pendingVisit] is raised — the same pattern + * iOS uses in `MBSessionManager` when `initializationCompleted` fires while `isActive` is true. + */ + private fun dispatchCurrentVisit(cb: Callbacks): Unit = loggingRunCatching { + val intent = currentIntent ?: return@loggingRunCatching + val source = if (intentChanged) intentSource(intent) else DIRECT + val requestUrl = if (source == LINK) intent.data?.toString() else null + cb.onTrackVisitReady(source, requestUrl) + startKeepaliveTimer() + mindboxLogI("Track visit dispatched from pending state: source=$source url=$requestUrl") } - private fun updateHashesList(code: Int): Boolean = loggingRunCatching(defaultValue = true) { - if (!intentHashes.contains(code)) { - if (intentHashes.size >= MAX_INTENT_HASHES_SIZE) { - intentHashes.removeAt(0) - } - intentHashes.add(code) - true - } else { - false - } + private fun intentSource(intent: Intent?): String = when { + intent?.scheme == "http" || intent?.scheme == "https" -> LINK + intent?.extras?.getBoolean(IS_OPENED_FROM_PUSH_BUNDLE_KEY) == true -> PUSH + else -> DIRECT + } + + private fun isNewHash(hash: Int): Boolean = loggingRunCatching(defaultValue = true) { + if (intentHashes.contains(hash)) return@loggingRunCatching false + if (intentHashes.size >= MAX_INTENT_HASHES) intentHashes.removeAt(0) + intentHashes.add(hash) + true } private fun startKeepaliveTimer(): Unit = loggingRunCatching { cancelKeepaliveTimer() - timer = timer( + keepaliveTimer = timer( initialDelay = TIMER_PERIOD, period = TIMER_PERIOD, - action = { onTrackVisitReady.invoke(null, null) }, + action = { callbacks?.onTrackVisitReady(null, null) }, ) } private fun cancelKeepaliveTimer(): Unit = loggingRunCatching { - timer?.cancel() - timer = null - } - - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - when (event) { - Lifecycle.Event.ON_STOP -> onAppMovedToBackground() - Lifecycle.Event.ON_START -> onAppMovedToForeground() - else -> { - // do nothing - } - } + keepaliveTimer?.cancel() + keepaliveTimer = null } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializer.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializer.kt new file mode 100644 index 000000000..61ddcca1f --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializer.kt @@ -0,0 +1,29 @@ +package cloud.mindbox.mobile_sdk.managers + +import android.content.Context +import androidx.annotation.RestrictTo +import androidx.startup.Initializer +import cloud.mindbox.mobile_sdk.getCurrentProcessName +import cloud.mindbox.mobile_sdk.isMainProcess +import cloud.mindbox.mobile_sdk.logger.mindboxLogI + +/** + * Registers [LifecycleManager] at application startup via androidx.startup so that lifecycle + * tracking begins before [cloud.mindbox.mobile_sdk.Mindbox.init] is called. + * + * Track-visit events are only dispatched after [cloud.mindbox.mobile_sdk.Mindbox.init] wires + * the [LifecycleManager.onTrackVisitReady] callback. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY) +public class MindboxLifecycleInitializer : Initializer { + + override fun create(context: Context) { + val currentProcessName = context.getCurrentProcessName() + if (!context.isMainProcess(currentProcessName)) return + + mindboxLogI("LifecycleInitializer: Register LifecycleManager in startup initializer") + LifecycleManager.register(context) + } + + override fun dependencies(): List>> = emptyList() +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/CustomFields.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/CustomFields.kt index 8439d6758..9cdafecb6 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/CustomFields.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/CustomFields.kt @@ -13,6 +13,10 @@ public class CustomFields(public val fields: Map? = null) { * Convert [CustomFields] value to [T] typed object. * * @param classOfT Class type for result [CustomFields] object. + * + * **Important:** [T] is a caller-supplied type deserialized via Gson. All constructor + * properties of [T] must declare [@SerializedName][com.google.gson.annotations.SerializedName] + * to ensure correct serialization after code shrinking (ProGuard/R8). */ public fun convertTo(classOfT: Class): T? = LoggingExceptionHandler.runCatching(defaultValue = null) { val gson = Gson() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt deleted file mode 100644 index b8256c07a..000000000 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package cloud.mindbox.mobile_sdk.models.operation.request - -import com.google.gson.annotations.SerializedName - -internal data class InAppHandleRequest( - @SerializedName("inappId") - val inAppId: String -) - -internal data class InAppShowRequest( - @SerializedName("inappId") - val inAppId: String, - @SerializedName("timeToDisplay") - val timeToDisplay: String, - @SerializedName("tags") - val tags: Map? -) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppOperationRequests.kt similarity index 65% rename from sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt rename to sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppOperationRequests.kt index c3a0b4fe4..b1ea0ea8c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppOperationRequests.kt @@ -2,6 +2,29 @@ package cloud.mindbox.mobile_sdk.models.operation.request import com.google.gson.annotations.SerializedName +internal data class InAppShowRequest( + @SerializedName("inappId") + val inAppId: String, + @SerializedName("timeToDisplay") + val timeToDisplay: String, + @SerializedName("tags") + val tags: Map? +) + +internal data class InAppClickRequest( + @SerializedName("inappId") + val inAppId: String, + @SerializedName("tags") + val tags: Map? +) + +internal data class InAppTargetingRequest( + @SerializedName("inappId") + val inAppId: String, + @SerializedName("tags") + val tags: Map? = null +) + internal data class InAppShowFailure( @SerializedName("inappId") val inAppId: String, @@ -10,7 +33,9 @@ internal data class InAppShowFailure( @SerializedName("errorDetails") val errorDetails: String?, @SerializedName("dateTimeUtc") - val dateTimeUtc: String + val dateTimeUtc: String, + @SerializedName("tags") + val tags: Map? = null ) internal enum class FailureReason(val value: String) { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt index de99b37d1..0a1ad6f6c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt @@ -75,7 +75,7 @@ internal data class SettingsDtoBlank( @JsonAdapter(FeatureTogglesDtoBlankDeserializer::class) internal data class FeatureTogglesDtoBlank( - val toggles: Map + @SerializedName("toggles") val toggles: Map ) } @@ -110,14 +110,14 @@ internal data class TtlDto( ) internal data class SlidingExpirationDto( - val config: Milliseconds?, - val pushTokenKeepalive: Milliseconds?, + @SerializedName("config") val config: Milliseconds?, + @SerializedName("pushTokenKeepalive") val pushTokenKeepalive: Milliseconds?, ) internal data class InappSettingsDto( - val maxInappsPerSession: Int?, - val maxInappsPerDay: Int?, - val minIntervalBetweenShows: Milliseconds?, + @SerializedName("maxInappsPerSession") val maxInappsPerSession: Int?, + @SerializedName("maxInappsPerDay") val maxInappsPerDay: Int?, + @SerializedName("minIntervalBetweenShows") val minIntervalBetweenShows: Milliseconds?, ) internal data class LogRequestDto( diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/MindboxRemoteMessage.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/MindboxRemoteMessage.kt index b85d0d477..9083fad81 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/MindboxRemoteMessage.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/MindboxRemoteMessage.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.app.PendingIntent import android.content.Context import android.os.Bundle +import com.google.gson.annotations.SerializedName /** * A class representing mindbox remote message @@ -11,13 +12,13 @@ import android.os.Bundle * with your custom push notification implementation. * */ public data class MindboxRemoteMessage( - val uniqueKey: String, - val title: String, - val description: String, - val pushActions: List, - val pushLink: String?, - val imageUrl: String?, - val payload: String?, + @SerializedName("uniqueKey") val uniqueKey: String, + @SerializedName("title") val title: String, + @SerializedName("description") val description: String, + @SerializedName("pushActions") val pushActions: List, + @SerializedName("pushLink") val pushLink: String?, + @SerializedName("imageUrl") val imageUrl: String?, + @SerializedName("payload") val payload: String?, ) { public companion object { public const val DATA_UNIQUE_KEY: String = "uniqueKey" diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/PushToken.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/PushToken.kt index d339a096e..0ce645111 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/PushToken.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/PushToken.kt @@ -6,6 +6,7 @@ import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.utils.MindboxUtils.Stopwatch import cloud.mindbox.mobile_sdk.utils.awaitAllWithTimeout import com.google.gson.Gson +import com.google.gson.annotations.SerializedName import com.google.gson.reflect.TypeToken import kotlinx.coroutines.async import kotlinx.coroutines.withContext @@ -18,8 +19,8 @@ internal data class PushToken( ) internal data class PrefPushToken( - val token: String, - val updateDate: Long, + @SerializedName("token") val token: String, + @SerializedName("updateDate") val updateDate: Long, ) internal typealias PushTokenMap = Map diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/ExtensionsKtTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/ExtensionsKtTest.kt index ddded4e97..63432761d 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/ExtensionsKtTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/ExtensionsKtTest.kt @@ -1,5 +1,6 @@ package cloud.mindbox.mobile_sdk +import cloud.mindbox.mobile_sdk.models.InAppStub import org.junit.Assert.* import org.junit.Test @@ -45,4 +46,29 @@ class ExtensionsKtTest { assertFalse((null as String?).equalsAny("")) assertFalse((null as String?).equalsAny("null")) } + + @Test + fun `gatedTags returns tags when present and feature enabled`() { + val tags = mapOf("templateType" to "Popup") + val inApp = InAppStub.getInApp().copy(tags = tags) + assertEquals(tags, inApp.gatedTags(isTagsFeatureEnabled = true)) + } + + @Test + fun `gatedTags returns null when feature disabled`() { + val inApp = InAppStub.getInApp().copy(tags = mapOf("templateType" to "Popup")) + assertNull(inApp.gatedTags(isTagsFeatureEnabled = false)) + } + + @Test + fun `gatedTags returns null when tags empty`() { + val inApp = InAppStub.getInApp().copy(tags = emptyMap()) + assertNull(inApp.gatedTags(isTagsFeatureEnabled = true)) + } + + @Test + fun `gatedTags returns null when tags null`() { + val inApp = InAppStub.getInApp().copy(tags = null) + assertNull(inApp.gatedTags(isTagsFeatureEnabled = true)) + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt index 3638f2e16..6a062ed24 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt @@ -204,6 +204,48 @@ internal class InAppFailureTrackerImplTest { verify(exactly = 0) { inAppRepository.sendInAppShowFailure(any()) } } + @Test + fun `sendFailure forwards tags to the failure`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + val tags = mapOf("templateType" to "Popup") + val slot = slot>() + + inAppFailureTracker.sendFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = "error", + tags = tags + ) + + verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } + assertEquals(tags, slot.captured[0].tags) + } + + @Test + fun `collectFailure keeps each failures own tags`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + val slot = slot>() + + inAppFailureTracker.collectFailure( + inAppId = "inApp1", + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = null, + tags = mapOf("templateType" to "Popup") + ) + inAppFailureTracker.collectFailure( + inAppId = "inApp2", + failureReason = FailureReason.IMAGE_DOWNLOAD_FAILED, + errorDetails = null, + tags = null + ) + inAppFailureTracker.sendCollectedFailures() + + verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } + val captured = slot.captured + assertEquals(mapOf("templateType" to "Popup"), captured.first { it.inAppId == "inApp1" }.tags) + assertEquals(null, captured.first { it.inAppId == "inApp2" }.tags) + } + @Test fun `sendFailure with null errorDetails`() { every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt new file mode 100644 index 000000000..2200df2ac --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt @@ -0,0 +1,238 @@ +package cloud.mindbox.mobile_sdk.inapp.data.managers + +import android.content.Context +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.util.DisplayMetrics +import androidx.core.graphics.drawable.toBitmap +import cloud.mindbox.mobile_sdk.R +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppImageSizeStorage +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppContentFetchingError +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.RequestManager +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.junit4.MockKRule +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class InAppGlideImageLoaderImplTest { + + @get:Rule + val mockkRule = MockKRule(this) + + @MockK + private lateinit var context: Context + + @MockK + private lateinit var resources: Resources + + @RelaxedMockK + private lateinit var inAppImageSizeStorage: InAppImageSizeStorage + + @RelaxedMockK + private lateinit var requestManager: RequestManager + + @RelaxedMockK + private lateinit var requestBuilder: RequestBuilder + + private val testDispatcher = StandardTestDispatcher() + private val listenerSlot = slot>() + private lateinit var loader: InAppGlideImageLoaderImpl + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + + val displayMetrics = DisplayMetrics().apply { + widthPixels = 1080 + heightPixels = 1920 + } + every { context.applicationContext } returns context + every { context.resources } returns resources + every { resources.displayMetrics } returns displayMetrics + every { context.getString(R.string.mindbox_inapp_fetching_timeout) } returns "3000" + + mockkStatic(Glide::class) + every { Glide.with(any()) } returns requestManager + every { requestManager.load(any()) } returns requestBuilder + every { requestBuilder.timeout(any()) } returns requestBuilder + every { requestBuilder.diskCacheStrategy(any()) } returns requestBuilder + every { requestBuilder.override(any(), any()) } returns requestBuilder + every { requestBuilder.centerInside() } returns requestBuilder + every { requestBuilder.listener(capture(listenerSlot)) } returns requestBuilder + every { requestBuilder.preload(any(), any()) } returns mockk() + + loader = InAppGlideImageLoaderImpl(context, inAppImageSizeStorage) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + // region loadImage — success + + @Test + fun `loadImage returns true when image loaded successfully`() = runTest { + val bitmap = mockk { + every { width } returns 500 + every { height } returns 300 + } + val drawable = mockk(relaxed = true) + mockkStatic("androidx.core.graphics.drawable.DrawableKt") + every { drawable.toBitmap(any(), any(), any()) } returns bitmap + + var result: Boolean? = null + val job = launch { result = loader.loadImage("id1", URL) } + + runCurrent() + listenerSlot.captured.onResourceReady(drawable, null, null, null, false) + runCurrent() + job.join() + + assertTrue(result == true) + } + + @Test + fun `loadImage stores image dimensions on success`() = runTest { + val bitmap = mockk { + every { width } returns 500 + every { height } returns 300 + } + val drawable = mockk(relaxed = true) + mockkStatic("androidx.core.graphics.drawable.DrawableKt") + every { drawable.toBitmap(any(), any(), any()) } returns bitmap + + val job = launch { runCatching { loader.loadImage("id1", URL) } } + + runCurrent() + listenerSlot.captured.onResourceReady(drawable, null, null, null, false) + runCurrent() + job.join() + + verify(exactly = 1) { inAppImageSizeStorage.addSize("id1", URL, 500, 300) } + } + + // endregion + + // region loadImage — failure + + @Test + fun `loadImage throws InAppContentFetchingError when Glide reports failure`() = runTest { + var thrownException: Throwable? = null + val job = launch { + runCatching { loader.loadImage("id1", URL) }.onFailure { thrownException = it } + } + + runCurrent() + listenerSlot.captured.onLoadFailed(mockk(relaxed = true), null, null, false) + runCurrent() + job.join() + + assertTrue(thrownException is InAppContentFetchingError) + } + + @Test + fun `loadImage throws InAppContentFetchingError when onResourceReady callback throws`() = runTest { + val drawable = mockk(relaxed = true) + mockkStatic("androidx.core.graphics.drawable.DrawableKt") + every { drawable.toBitmap(any(), any(), any()) } throws RuntimeException("decode error") + + var thrownException: Throwable? = null + val job = launch { + runCatching { loader.loadImage("id1", URL) }.onFailure { thrownException = it } + } + + runCurrent() + listenerSlot.captured.onResourceReady(drawable, null, null, null, false) + runCurrent() + job.join() + + assertTrue(thrownException is InAppContentFetchingError) + } + + // endregion + + // region loadImage — timeout + + @Test + fun `loadImage throws InAppContentFetchingError when timeout expires`() = runTest { + var thrownException: Throwable? = null + val job = launch { + runCatching { loader.loadImage("id1", URL) }.onFailure { thrownException = it } + } + + advanceTimeBy(3001) + job.join() + + assertTrue(thrownException is InAppContentFetchingError) + } + + @Test + fun `loadImage does not complete before timeout when no callback fires`() = runTest { + var completed = false + val job = launch { + runCatching { loader.loadImage("id1", URL) } + completed = true + } + + advanceTimeBy(2999) + assertFalse("Job must still be active before timeout", completed) + + job.cancel() + } + + // endregion + + // region cancelLoading + + @Test + fun `cancelLoading clears Glide request for given inAppId`() = runTest { + val target = mockk>(relaxed = true) + every { requestBuilder.preload(any(), any()) } returns target + + val job = launch { runCatching { loader.loadImage("id1", URL) } } + runCurrent() + + loader.cancelLoading("id1") + + verify { requestManager.clear(target) } + job.cancel() + } + + @Test + fun `cancelLoading is called when coroutine is cancelled by timeout`() = runTest { + val target = mockk>(relaxed = true) + every { requestBuilder.preload(any(), any()) } returns target + + val job = launch { runCatching { loader.loadImage("id1", URL) } } + runCurrent() + + advanceTimeBy(3001) + job.join() + + verify { requestManager.clear(target) } + } + + // endregion + + private companion object { + const val URL = "https://example.com/image.jpg" + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt index 71e300061..f690d7f9b 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt @@ -3,6 +3,7 @@ package cloud.mindbox.mobile_sdk.inapp.data.managers import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppFailuresWrapper import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason +import cloud.mindbox.mobile_sdk.models.operation.request.InAppClickRequest import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowRequest import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure import com.google.gson.Gson @@ -71,18 +72,53 @@ internal class InAppSerializationManagerTest { } @Test - fun `serializeToInAppActionString returns JSON with inAppId only`() { + fun `serializeToInAppTargetingString returns JSON with inAppId only when tags null`() { val expectedResult = "{\"inappId\":\"${inAppId}\"}" - val actualResult = inAppSerializationManager.serializeToInAppActionString(inAppId) + val actualResult = inAppSerializationManager.serializeToInAppTargetingString(inAppId, tags = null) assertEquals(expectedResult, actualResult) } @Test - fun `serializeToInAppActionString returns empty string on error`() { + fun `serializeToInAppTargetingString returns JSON with inAppId and tags when tags present`() { + val expectedResult = "{\"inappId\":\"${inAppId}\",\"tags\":{\"templateType\":\"Popup\"}}" + val actualResult = inAppSerializationManager.serializeToInAppTargetingString( + inAppId, + tags = mapOf("templateType" to "Popup"), + ) + assertEquals(expectedResult, actualResult) + } + + @Test + fun `serializeToInAppTargetingString returns empty string on error`() { + val gson: Gson = mockk() + every { gson.toJson(any(), any>()) } throws Error("errorMessage") + inAppSerializationManager = InAppSerializationManagerImpl(gson) + val actualResult = inAppSerializationManager.serializeToInAppTargetingString(inAppId, tags = null) + assertEquals("", actualResult) + } + + @Test + fun `serializeToInAppClickActionString returns JSON with inAppId and tags`() { + val tags = mapOf("templateType" to "Popup") + val actualResult = inAppSerializationManager.serializeToInAppClickActionString(inAppId, tags) + val parsed = gson.fromJson(actualResult, InAppClickRequest::class.java) + assertEquals(inAppId, parsed.inAppId) + assertEquals(tags, parsed.tags) + } + + @Test + fun `serializeToInAppClickActionString omits tags when null`() { + val expectedResult = "{\"inappId\":\"${inAppId}\"}" + val actualResult = inAppSerializationManager.serializeToInAppClickActionString(inAppId, null) + assertEquals(expectedResult, actualResult) + } + + @Test + fun `serializeToInAppClickActionString returns empty string on error`() { val gson: Gson = mockk() every { gson.toJson(any(), any>()) } throws Error("errorMessage") inAppSerializationManager = InAppSerializationManagerImpl(gson) - val actualResult = inAppSerializationManager.serializeToInAppActionString(inAppId) + val actualResult = inAppSerializationManager.serializeToInAppClickActionString(inAppId, null) assertEquals("", actualResult) } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStoreTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStoreTest.kt new file mode 100644 index 000000000..3030e87ad --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStoreTest.kt @@ -0,0 +1,69 @@ +package cloud.mindbox.mobile_sdk.inapp.data.managers + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import cloud.mindbox.mobile_sdk.managers.SharedPreferencesManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class InAppWebViewLearnedHostsStoreTest { + + private val store = InAppWebViewLearnedHostsStore() + + @Before + fun setUp() { + SharedPreferencesManager.with(ApplicationProvider.getApplicationContext()) + SharedPreferencesManager.deleteAll() + } + + @Test + fun `hosts are empty by default`() { + assertTrue(store.hosts("Some.Endpoint").isEmpty()) + } + + @Test + fun `merge persists per endpoint and roundtrips`() { + store.merge("Endpoint.A", listOf("a.mindbox.ru", "b.mindbox.ru")) + store.merge("Endpoint.B", listOf("c.mindbox.ru")) + + assertEquals(listOf("a.mindbox.ru", "b.mindbox.ru"), store.hosts("Endpoint.A")) + assertEquals(listOf("c.mindbox.ru"), store.hosts("Endpoint.B")) + } + + @Test + fun `merge is newest first deduplicated and capped`() { + store.merge("Endpoint.A", (1..10).map { index -> "old$index.ru" }) + store.merge("Endpoint.A", listOf("new1.ru", "old1.ru", "new2.ru")) + + val hosts = store.hosts("Endpoint.A") + assertEquals(12, hosts.size) + assertEquals(listOf("new1.ru", "old1.ru", "new2.ru", "old1.ru").distinct().take(3), hosts.take(3)) + assertTrue(hosts.contains("old9.ru")) + } + + @Test + fun `merge drops oldest hosts beyond MAX_HOSTS`() { + store.merge("Endpoint.A", (1..12).map { index -> "old$index.ru" }) + store.merge("Endpoint.A", (1..5).map { index -> "new$index.ru" }) + + val hosts = store.hosts("Endpoint.A") + assertEquals(12, hosts.size) + assertEquals((1..5).map { index -> "new$index.ru" }, hosts.take(5)) + assertEquals((1..7).map { index -> "old$index.ru" }, hosts.drop(5)) + assertTrue((8..12).none { index -> hosts.contains("old$index.ru") }) + } + + @Test + fun `merge ignores blank input`() { + store.merge("Endpoint.A", listOf(" ", "")) + store.merge("", listOf("host.ru")) + + assertTrue(store.hosts("Endpoint.A").isEmpty()) + assertTrue(store.hosts("").isEmpty()) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt index df681930d..25569b02e 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt @@ -147,9 +147,10 @@ class InAppRepositoryTest { @Test fun `send in app clicked success`() { val testInAppId = "testInAppId" + val tags = mapOf("templateType" to "Popup") val serializedString = "serializedString" - every { inAppSerializationManager.serializeToInAppActionString(any()) } returns serializedString - inAppRepository.sendInAppClicked(testInAppId) + every { inAppSerializationManager.serializeToInAppClickActionString(testInAppId, tags) } returns serializedString + inAppRepository.sendInAppClicked(testInAppId, tags) verify(exactly = 1) { MindboxEventManager.inAppClicked(context, serializedString) } @@ -159,8 +160,8 @@ class InAppRepositoryTest { fun `send in app clicked empty string`() { val testInAppId = "testInAppId" val serializedString = "" - every { inAppSerializationManager.serializeToInAppActionString(any()) } returns serializedString - inAppRepository.sendInAppClicked(testInAppId) + every { inAppSerializationManager.serializeToInAppClickActionString(any(), any()) } returns serializedString + inAppRepository.sendInAppClicked(testInAppId, null) verify(exactly = 0) { MindboxEventManager.inAppClicked(context, serializedString) } @@ -169,9 +170,10 @@ class InAppRepositoryTest { @Test fun `send user targeted success`() { val testInAppId = "testInAppId" + val tags = mapOf("templateType" to "Popup") val serializedString = "serializedString" - every { inAppSerializationManager.serializeToInAppActionString(any()) } returns serializedString - inAppRepository.sendUserTargeted(testInAppId) + every { inAppSerializationManager.serializeToInAppTargetingString(testInAppId, tags) } returns serializedString + inAppRepository.sendUserTargeted(testInAppId, tags) verify(exactly = 1) { MindboxEventManager.sendUserTargeted(context, serializedString) } @@ -181,8 +183,8 @@ class InAppRepositoryTest { fun `send user targeted string`() { val testInAppId = "testInAppId" val serializedString = "" - every { inAppSerializationManager.serializeToInAppActionString(any()) } returns serializedString - inAppRepository.sendUserTargeted(testInAppId) + every { inAppSerializationManager.serializeToInAppTargetingString(any(), any()) } returns serializedString + inAppRepository.sendUserTargeted(testInAppId, null) verify(exactly = 0) { MindboxEventManager.sendUserTargeted(context, serializedString) } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt index 4d73a19fd..d378d8f78 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt @@ -98,7 +98,8 @@ internal class MobileConfigRepositoryImplTest { mobileConfigSettingsManager = mockk(relaxed = true), integerPositiveValidator = mockk(relaxed = true), inappSettingsManager = mockk(relaxed = true), - featureToggleManager = mockk(relaxed = true) + featureToggleManager = mockk(relaxed = true), + inAppWebViewPrewarmManager = mockk(relaxed = true) ) } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt index 6f7dd52de..a78a231a7 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt @@ -7,6 +7,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppContentFetcher import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.checkers.Checker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppEventManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFilteringManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFrequencyManager @@ -91,6 +92,9 @@ class InAppInteractorImplTest { @RelaxedMockK private lateinit var inAppFailureTracker: InAppFailureTracker + @RelaxedMockK + private lateinit var featureToggleManager: FeatureToggleManager + @Before fun setup() { interactor = InAppInteractorImpl( @@ -178,7 +182,8 @@ class InAppInteractorImplTest { inAppTargetingErrorRepository, inAppContentFetcher, inAppRepository, - inAppFailureTracker + inAppFailureTracker, + featureToggleManager ) interactor = InAppInteractorImpl( diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt index 03407e358..61e00249e 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt @@ -2,12 +2,14 @@ package cloud.mindbox.mobile_sdk.inapp.domain import android.content.Context import cloud.mindbox.mobile_sdk.di.MindboxDI +import cloud.mindbox.mobile_sdk.inapp.data.managers.SEND_INAPP_TAGS_FEATURE import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.mapper.InAppMapper import cloud.mindbox.mobile_sdk.inapp.data.repositories.InAppGeoRepositoryImpl import cloud.mindbox.mobile_sdk.inapp.data.repositories.InAppSegmentationRepositoryImpl import cloud.mindbox.mobile_sdk.inapp.data.repositories.InAppTargetingErrorRepositoryImpl import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppContentFetcher +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.GeoSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppGeoRepository @@ -44,7 +46,7 @@ internal class InAppProcessingManagerTest { private val mockInAppRepository = mockk { every { - sendUserTargeted(any()) + sendUserTargeted(any(), any()) } just runs every { saveTargetedInAppWithEvent(any(), any()) @@ -85,6 +87,7 @@ internal class InAppProcessingManagerTest { private val geoSerializationManager: GeoSerializationManager = mockk(relaxed = true) private val gatewayManager: GatewayManager = mockk(relaxed = true) private val inAppFailureTracker: InAppFailureTracker = mockk(relaxed = true) + private val featureToggleManager: FeatureToggleManager = mockk(relaxed = true) private val inAppTargetingErrorRepository: InAppTargetingErrorRepository = spyk(InAppTargetingErrorRepositoryImpl(sessionStorageManager)) @@ -137,7 +140,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = inAppTargetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = inAppFailureTracker + inAppFailureTracker = inAppFailureTracker, + featureToggleManager = featureToggleManager ) private val inAppProcessingManagerTestImpl = InAppProcessingManagerImpl( @@ -146,7 +150,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = inAppTargetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = inAppFailureTracker + inAppFailureTracker = inAppFailureTracker, + featureToggleManager = featureToggleManager ) private fun setupTestGeoRepositoryForErrorScenario() { @@ -177,7 +182,7 @@ internal class InAppProcessingManagerTest { InAppEventType.OrdinalEvent(EventType.AsyncOperation(""), "") ) verify(exactly = 0) { - mockInAppRepository.sendUserTargeted(any()) + mockInAppRepository.sendUserTargeted(any(), any()) } } @@ -206,7 +211,42 @@ internal class InAppProcessingManagerTest { InAppEventType.OrdinalEvent(EventType.AsyncOperation(""), "") ) verify(exactly = 2) { - mockInAppRepository.sendUserTargeted(any()) + mockInAppRepository.sendUserTargeted(any(), any()) + } + } + + @Test + fun `sendTargetedInApp passes inApp tags when tags feature enabled`() = runTest { + val tags = mapOf("templateType" to "Popup") + every { featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) } returns true + val testInApp = mockk(relaxed = true) + every { testInApp.id } returns "123" + every { testInApp.tags } returns tags + every { testInApp.targeting.checkTargeting(any()) } returns true + coEvery { testInApp.targeting.fetchTargetingInfo(any()) } just runs + inAppProcessingManager.sendTargetedInApp( + testInApp, + InAppEventType.OrdinalEvent(EventType.AsyncOperation(""), "") + ) + verify(exactly = 1) { + mockInAppRepository.sendUserTargeted(inAppId = "123", tags = tags) + } + } + + @Test + fun `sendTargetedInApp passes null tags when tags feature disabled`() = runTest { + every { featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) } returns false + val testInApp = mockk(relaxed = true) + every { testInApp.id } returns "123" + every { testInApp.tags } returns mapOf("templateType" to "Popup") + every { testInApp.targeting.checkTargeting(any()) } returns true + coEvery { testInApp.targeting.fetchTargetingInfo(any()) } just runs + inAppProcessingManager.sendTargetedInApp( + testInApp, + InAppEventType.OrdinalEvent(EventType.AsyncOperation(""), "") + ) + verify(exactly = 1) { + mockInAppRepository.sendUserTargeted(inAppId = "123", tags = null) } } @@ -431,7 +471,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = mockk(relaxed = true), inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = mockk(relaxed = true) + inAppFailureTracker = mockk(relaxed = true), + featureToggleManager = featureToggleManager ) val expectedResult = InAppStub.getInApp().copy( @@ -467,7 +508,7 @@ internal class InAppProcessingManagerTest { inAppProcessingManagerTestImpl.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any(), any()) } assertEquals(GeoFetchStatus.GEO_FETCH_ERROR, sessionStorageManager.geoFetchStatus) } @@ -485,7 +526,7 @@ internal class InAppProcessingManagerTest { ) ) inAppProcessingManagerTestImpl.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any(), any()) } assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR, sessionStorageManager.customerSegmentationFetchStatus) } @@ -505,7 +546,7 @@ internal class InAppProcessingManagerTest { ) ) inAppProcessingManager.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any(), any()) } } @Test @@ -517,7 +558,7 @@ internal class InAppProcessingManagerTest { targeting = InAppStub.getTargetingCountryNode().copy(kind = Kind.NEGATIVE) ) inAppProcessingManagerTestImpl.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any(), any()) } assertEquals(GeoFetchStatus.GEO_FETCH_ERROR, sessionStorageManager.geoFetchStatus) } @@ -529,7 +570,7 @@ internal class InAppProcessingManagerTest { targeting = InAppStub.getTargetingSegmentNode().copy(kind = Kind.NEGATIVE) ) inAppProcessingManagerTestImpl.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any(), any()) } assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR, sessionStorageManager.customerSegmentationFetchStatus) } @@ -543,7 +584,7 @@ internal class InAppProcessingManagerTest { } ) inAppProcessingManager.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any(), any()) } } @Test @@ -561,7 +602,7 @@ internal class InAppProcessingManagerTest { inAppProcessingManagerTestImpl.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any(), any()) } } @Test @@ -584,7 +625,7 @@ internal class InAppProcessingManagerTest { inAppProcessingManagerTestImpl.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any(), any()) } } @Test @@ -604,7 +645,7 @@ internal class InAppProcessingManagerTest { inAppProcessingManagerTestImpl.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any(), any()) } } @Test @@ -653,7 +694,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = targetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = failureTracker + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager ) val testInAppList = listOf( InAppStub.getInApp().copy( @@ -735,7 +777,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = targetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = failureTracker + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager ) val result = processingManager.chooseInAppToShow(testInAppList, event) @@ -780,7 +823,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = targetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = failureTracker + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager ) val testInAppList = listOf( InAppStub.getInApp().copy( @@ -817,6 +861,140 @@ internal class InAppProcessingManagerTest { } } + @Test + fun `collected failure carries inApp tags when tags feature enabled`() = runTest { + val errorDetails = "Customer segmentation fetch failed. statusCode=500" + val tags = mapOf("templateType" to "Popup") + every { featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) } returns true + val segmentationRepo = mockk { + coEvery { fetchCustomerSegmentations() } throws CustomerSegmentationError(VolleyError()) + every { getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + every { setCustomerSegmentationStatus(any()) } just runs + every { getCustomerSegmentations() } returns listOf( + SegmentationCheckInAppStub.getCustomerSegmentation().copy( + segmentation = "segmentationEI", segment = "segmentEI" + ) + ) + every { getProductSegmentationFetched(any()) } returns ProductSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS + } + val targetingErrorRepository = mockk { + every { getError(TargetingErrorKey.CustomerSegmentation) } returns errorDetails + every { saveError(any(), any()) } just runs + every { clearErrors() } just runs + } + setDIModule(mockkInAppGeoRepository, segmentationRepo, targetingErrorRepository) + val failureTracker = mockk(relaxed = true) + val processingManager = InAppProcessingManagerImpl( + inAppGeoRepository = mockkInAppGeoRepository, + inAppSegmentationRepository = segmentationRepo, + inAppTargetingErrorRepository = targetingErrorRepository, + inAppContentFetcher = mockkInAppContentFetcher, + inAppRepository = mockInAppRepository, + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager + ) + val testInAppList = listOf( + InAppStub.getInApp().copy( + id = "123", + tags = tags, + targeting = InAppStub.getTargetingSegmentNode().copy( + type = "", + kind = Kind.POSITIVE, + segmentationExternalId = "segmentationEI", + segmentExternalId = "segmentEI" + ), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = "123")) + ) + ), + InAppStub.getInApp().copy( + id = "validId", + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = "validId")) + ) + ) + ) + + processingManager.chooseInAppToShow(testInAppList, event) + + verify(exactly = 1) { + failureTracker.collectFailure( + inAppId = "123", + failureReason = FailureReason.CUSTOMER_SEGMENT_REQUEST_FAILED, + errorDetails = errorDetails, + tags = tags + ) + } + } + + @Test + fun `collected failure carries null tags when tags feature disabled`() = runTest { + val errorDetails = "Customer segmentation fetch failed. statusCode=500" + val tags = mapOf("templateType" to "Popup") + every { featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) } returns false + val segmentationRepo = mockk { + coEvery { fetchCustomerSegmentations() } throws CustomerSegmentationError(VolleyError()) + every { getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + every { setCustomerSegmentationStatus(any()) } just runs + every { getCustomerSegmentations() } returns listOf( + SegmentationCheckInAppStub.getCustomerSegmentation().copy( + segmentation = "segmentationEI", segment = "segmentEI" + ) + ) + every { getProductSegmentationFetched(any()) } returns ProductSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS + } + val targetingErrorRepository = mockk { + every { getError(TargetingErrorKey.CustomerSegmentation) } returns errorDetails + every { saveError(any(), any()) } just runs + every { clearErrors() } just runs + } + setDIModule(mockkInAppGeoRepository, segmentationRepo, targetingErrorRepository) + val failureTracker = mockk(relaxed = true) + val processingManager = InAppProcessingManagerImpl( + inAppGeoRepository = mockkInAppGeoRepository, + inAppSegmentationRepository = segmentationRepo, + inAppTargetingErrorRepository = targetingErrorRepository, + inAppContentFetcher = mockkInAppContentFetcher, + inAppRepository = mockInAppRepository, + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager + ) + val testInAppList = listOf( + InAppStub.getInApp().copy( + id = "123", + tags = tags, + targeting = InAppStub.getTargetingSegmentNode().copy( + type = "", + kind = Kind.POSITIVE, + segmentationExternalId = "segmentationEI", + segmentExternalId = "segmentEI" + ), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = "123")) + ) + ), + InAppStub.getInApp().copy( + id = "validId", + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = "validId")) + ) + ) + ) + + processingManager.chooseInAppToShow(testInAppList, event) + + verify(exactly = 1) { + failureTracker.collectFailure( + inAppId = "123", + failureReason = FailureReason.CUSTOMER_SEGMENT_REQUEST_FAILED, + errorDetails = errorDetails, + tags = null + ) + } + } + @Test fun `trackTargetingErrorIfAny does not collect customer segmentation failure when error was not saved`() = runTest { val segmentationRepo = mockk { @@ -843,7 +1021,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = targetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = failureTracker + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager ) val testInAppList = listOf( InAppStub.getInApp().copy( @@ -903,7 +1082,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = targetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = failureTracker + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager ) val testInAppList = listOf( InAppStub.getInApp().copy( @@ -980,7 +1160,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = targetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = failureTracker + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager ) val testInAppList = listOf( InAppStub.getInApp().copy( diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtensionTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtensionTest.kt index 5f7dafb09..121d037d2 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtensionTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtensionTest.kt @@ -10,12 +10,18 @@ import com.android.volley.TimeoutError import com.android.volley.VolleyError import com.bumptech.glide.load.HttpException import com.bumptech.glide.load.engine.GlideException +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Test import java.net.ConnectException import java.net.SocketTimeoutException import java.net.UnknownHostException +import kotlin.time.Duration.Companion.milliseconds internal class TrackingFailureExtensionTest { @@ -119,4 +125,16 @@ internal class TrackingFailureExtensionTest { val inAppError = InAppContentFetchingError(glideException) assertFalse(inAppError.shouldTrackImageDownloadError()) } + + @Test + fun `shouldTrackImageDownloadError returns false when cause is TimeoutCancellationException`() { + var inAppError: InAppContentFetchingError? = null + try { + runBlocking { withTimeout(1.milliseconds) { delay(100.milliseconds) } } + } catch (e: TimeoutCancellationException) { + inAppError = InAppContentFetchingError(e) + } + assertNotNull(inAppError) + assertFalse(inAppError!!.shouldTrackImageDownloadError()) + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/ActivityManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/ActivityManagerTest.kt index 07e46426d..0c6e9dfe4 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/ActivityManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/ActivityManagerTest.kt @@ -34,6 +34,17 @@ internal class ActivityManagerTest { context = context ) val url = "https://mindbox.ru" + val componentName = "testComponentName" + val packageName = "com.example" + val packageManager = shadowOf(RuntimeEnvironment.getApplication().packageManager) + packageManager.addActivityIfNotPresent(ComponentName(packageName, componentName)) + packageManager.addIntentFilterForActivity( + ComponentName(packageName, componentName), + IntentFilter(Intent.ACTION_VIEW).apply { + addCategory(Intent.CATEGORY_DEFAULT) + addDataScheme("https") + } + ) every { callbackInteractor.isValidUrl(url) } returns true @@ -78,24 +89,10 @@ internal class ActivityManagerTest { } @Test - fun `try open url doesn't open deeplink`() { + fun `tryOpenUrl should return false when no activity can handle url`() { context = ApplicationProvider.getApplicationContext() activityManager = ActivityManagerImpl(callbackInteractor, context) val url = "https://pushok-mindbox.onelink.me/13Z2/a97bb56f" - val componentName = "testComponentName" - val packageName = "com.example" - val packageManager = shadowOf(RuntimeEnvironment.getApplication().packageManager) - packageManager.addActivityIfNotPresent(ComponentName(packageName, componentName)) - packageManager.addIntentFilterForActivity( - ComponentName( - packageName, - componentName - ), - IntentFilter(Intent.ACTION_VIEW).apply { - addCategory(Intent.CATEGORY_DEFAULT) - addDataScheme("https") - } - ) every { callbackInteractor.isValidUrl(url) } returns true diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt index c0e33f37f..7ef0c8017 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt @@ -1,8 +1,11 @@ package cloud.mindbox.mobile_sdk.inapp.presentation import android.util.Log +import cloud.mindbox.mobile_sdk.inapp.data.managers.SEND_INAPP_TAGS_FEATURE import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppActionCallbacks import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl import cloud.mindbox.mobile_sdk.managers.UserVisitManager @@ -59,6 +62,8 @@ internal class InAppMessageManagerTest { private val timeProvider = mockk() + private val featureToggleManager = mockk() + /** * sets a thread to be used as main dispatcher for running on JVM * **/ @@ -71,6 +76,7 @@ internal class InAppMessageManagerTest { coEvery { inAppMessageInteractor.listenToTargetingEvents() } just runs + every { featureToggleManager.isEnabled(any()) } returns true every { Log.isLoggable(any(), any()) }.answers { @@ -95,7 +101,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) coEvery { inAppMessageInteractor.fetchMobileConfig() @@ -118,7 +125,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) mockkObject(LoggingExceptionHandler) every { MindboxPreferences.inAppConfig } returns "test" @@ -157,7 +165,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) coEvery { inAppMessageInteractor.processEventAndConfig() @@ -171,7 +180,7 @@ internal class InAppMessageManagerTest { advanceUntilIdle() verify(exactly = 1) { inAppMessageDelayedManager.process(inApp, any()) } - verify(exactly = 1) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any()) } + verify(exactly = 1) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any(), any()) } } @Test @@ -188,7 +197,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) coEvery { inAppMessageInteractor.listenToTargetingEvents() @@ -211,7 +221,7 @@ internal class InAppMessageManagerTest { advanceUntilIdle() verify(exactly = 1) { inAppMessageDelayedManager.process(inApp, any()) } coVerify(exactly = 1) { inAppMessageInteractor.listenToTargetingEvents() } - verify(exactly = 0) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any()) } + verify(exactly = 0) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any(), any()) } } @Test @@ -228,7 +238,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) coEvery { inAppMessageInteractor.listenToTargetingEvents() @@ -251,7 +262,7 @@ internal class InAppMessageManagerTest { advanceUntilIdle() verify(exactly = 1) { inAppMessageDelayedManager.process(inApp, any()) } coVerify(exactly = 1) { inAppMessageInteractor.listenToTargetingEvents() } - verify(exactly = 0) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any()) } + verify(exactly = 0) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any(), any()) } } @Test @@ -264,7 +275,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) coEvery { inAppMessageInteractor.processEventAndConfig() @@ -299,7 +311,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) mockkConstructor(NetworkResponse::class) val networkResponse = mockk() @@ -334,7 +347,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) mockkConstructor(NetworkResponse::class) val networkResponse = mockk() @@ -368,4 +382,89 @@ internal class InAppMessageManagerTest { assertEquals(expectedInappList, resultInappList) } + + @Test + fun `tags feature on - tags passed to show and click`() = runTest { + val tags = mapOf("templateType" to "Popup") + val inAppToShowFlow = MutableSharedFlow>() + val inApp = InAppStub.getInApp().copy(tags = tags) + val inAppMessage = inApp.form.variants.first() + var capturedCallbacks: InAppActionCallbacks? = null + var capturedTags: Map? = null + every { featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) } returns true + every { inAppMessageViewDisplayer.isInAppActive() } returns false + every { inAppMessageInteractor.areShowAndFrequencyLimitsAllowed(any()) } returns true + every { inAppMessageInteractor.sendInAppClicked(any(), any()) } just runs + every { inAppMessageDelayedManager.inAppToShowFlow } returns inAppToShowFlow + every { inAppMessageDelayedManager.process(inApp, any()) } coAnswers { + this@runTest.launch { inAppToShowFlow.emit(inApp to Milliseconds(0L)) } + } + every { inAppMessageViewDisplayer.tryShowInAppMessage(any(), any(), any(), any()) } answers { + capturedCallbacks = arg(1) + capturedTags = arg(3) + } + inAppMessageManager = InAppMessageManagerImpl( + inAppMessageViewDisplayer, + inAppMessageInteractor, + testDispatcher, + monitoringRepository, + sessionStorageManager, + userVisitManager, + inAppMessageDelayedManager, + timeProvider, + featureToggleManager + ) + coEvery { inAppMessageInteractor.processEventAndConfig() } answers { + flow { emit(inApp to Milliseconds(0L)) } + } + + inAppMessageManager.listenEventAndInApp() + advanceUntilIdle() + + assertEquals(tags, capturedTags) + capturedCallbacks!!.onInAppClick.onClick() + verify(exactly = 1) { inAppMessageInteractor.sendInAppClicked(inAppMessage.inAppId, tags) } + } + + @Test + fun `tags feature off - tags are null for show and click`() = runTest { + val inAppToShowFlow = MutableSharedFlow>() + val inApp = InAppStub.getInApp().copy(tags = mapOf("templateType" to "Popup")) + val inAppMessage = inApp.form.variants.first() + var capturedCallbacks: InAppActionCallbacks? = null + var capturedTags: Map? = mapOf("sentinel" to "value") + every { featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) } returns false + every { inAppMessageViewDisplayer.isInAppActive() } returns false + every { inAppMessageInteractor.areShowAndFrequencyLimitsAllowed(any()) } returns true + every { inAppMessageInteractor.sendInAppClicked(any(), any()) } just runs + every { inAppMessageDelayedManager.inAppToShowFlow } returns inAppToShowFlow + every { inAppMessageDelayedManager.process(inApp, any()) } coAnswers { + this@runTest.launch { inAppToShowFlow.emit(inApp to Milliseconds(0L)) } + } + every { inAppMessageViewDisplayer.tryShowInAppMessage(any(), any(), any(), any()) } answers { + capturedCallbacks = arg(1) + capturedTags = arg(3) + } + inAppMessageManager = InAppMessageManagerImpl( + inAppMessageViewDisplayer, + inAppMessageInteractor, + testDispatcher, + monitoringRepository, + sessionStorageManager, + userVisitManager, + inAppMessageDelayedManager, + timeProvider, + featureToggleManager + ) + coEvery { inAppMessageInteractor.processEventAndConfig() } answers { + flow { emit(inApp to Milliseconds(0L)) } + } + + inAppMessageManager.listenEventAndInApp() + advanceUntilIdle() + + assertEquals(null, capturedTags) + capturedCallbacks!!.onInAppClick.onClick() + verify(exactly = 1) { inAppMessageInteractor.sendInAppClicked(inAppMessage.inAppId, null) } + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt index 6aa831908..bc294f907 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt @@ -1,12 +1,16 @@ package cloud.mindbox.mobile_sdk.inapp.presentation import cloud.mindbox.mobile_sdk.di.MindboxDI +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.presentation.callbacks.ComposableInAppCallback +import cloud.mindbox.mobile_sdk.inapp.presentation.view.InAppViewHolder +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason import com.google.gson.Gson import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkAll +import io.mockk.verify import org.junit.After import org.junit.Assert.assertNotSame import org.junit.Assert.assertSame @@ -17,12 +21,15 @@ import org.junit.Test internal class InAppMessageViewDisplayerImplTest { private lateinit var displayer: InAppMessageViewDisplayerImpl + private lateinit var failureTracker: InAppFailureTracker @Before fun setUp() { mockkObject(MindboxDI) - every { MindboxDI.appModule } returns mockk { + failureTracker = mockk(relaxed = true) + every { MindboxDI.appModule } returns mockk(relaxed = true) { every { gson } returns Gson() + every { inAppFailureTracker } returns failureTracker } displayer = InAppMessageViewDisplayerImpl(mockk()) } @@ -84,10 +91,47 @@ internal class InAppMessageViewDisplayerImplTest { assertTrue(displayer.currentCallback() is ComposableInAppCallback) } + @Test + fun `reattach presentation failure propagates restored holder tags`() { + val expectedTags = mapOf("templateType" to "Popup", "campaign" to "summer") + val inAppId = "reattach-inapp-id" + + val restoredHolder = mockk>(relaxed = true) + every { restoredHolder.canReuseOnRestore(inAppId) } returns true + every { restoredHolder.wrapper.tags } returns expectedTags + + // pausedHolder present, currentActivity stays null -> root is null -> reattach failure branch + displayer.setPrivateField("pausedHolder", restoredHolder) + + val reattached = displayer.invokePrivateWithString("tryReattachRestoredInApp", inAppId) as Boolean + + assertTrue("reattach should be attempted for a reusable paused holder", reattached) + verify(exactly = 1) { + failureTracker.sendFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = "failed to reattach inApp: currentRoot is null", + tags = expectedTags, + ) + } + } + // Accesses the private inAppCallback field via reflection private fun InAppMessageViewDisplayerImpl.currentCallback(): InAppCallback { val field = InAppMessageViewDisplayerImpl::class.java.getDeclaredField("inAppCallback") field.isAccessible = true return field.get(this) as InAppCallback } + + private fun InAppMessageViewDisplayerImpl.setPrivateField(name: String, value: Any?) { + val field = InAppMessageViewDisplayerImpl::class.java.getDeclaredField(name) + field.isAccessible = true + field.set(this, value) + } + + private fun InAppMessageViewDisplayerImpl.invokePrivateWithString(name: String, arg: String): Any? { + val method = InAppMessageViewDisplayerImpl::class.java.getDeclaredMethod(name, String::class.java) + method.isAccessible = true + return method.invoke(this, arg) + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicyTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicyTest.kt new file mode 100644 index 000000000..88befd3fa --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicyTest.kt @@ -0,0 +1,125 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation + +import cloud.mindbox.mobile_sdk.inapp.data.managers.MobileConfigSerializationManagerImpl +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import com.google.gson.Gson +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +internal class InAppWebViewCachePolicyTest { + + private val serializationManager = MobileConfigSerializationManagerImpl(gson = Gson()) + + @Before + fun setUp() { + mockkObject(MindboxPreferences) + } + + @After + fun tearDown() { + unmockkObject(MindboxPreferences) + } + + @Test + fun `isCacheEnabled is false when the cached config disables the toggle`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(cacheToggle = false) + + val policy = InAppWebViewCachePolicy(serializationManager) + + assertEquals(false, policy.isCacheEnabled) + } + + @Test + fun `isCacheEnabled is true when the cached config explicitly enables the toggle`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(cacheToggle = true) + + val policy = InAppWebViewCachePolicy(serializationManager) + + assertEquals(true, policy.isCacheEnabled) + } + + @Test + fun `isCacheEnabled defaults to true when the cached config has no toggle`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(cacheToggle = null) + + val policy = InAppWebViewCachePolicy(serializationManager) + + assertEquals(true, policy.isCacheEnabled) + } + + @Test + fun `isCacheEnabled defaults to true when there is no cached config`() { + every { MindboxPreferences.inAppConfig } returns "" + + val policy = InAppWebViewCachePolicy(serializationManager) + + assertEquals(true, policy.isCacheEnabled) + } + + @Test + fun `isCacheEnabled is latched for the process and ignores a later cached config change`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(cacheToggle = false) + val policy = InAppWebViewCachePolicy(serializationManager) + assertEquals(false, policy.isCacheEnabled) + + // A fresh config lands and flips the cached toggle mid-session — the already + // latched decision must not change, or the WebView cache would be split between + // two behaviors within the same launch. + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(cacheToggle = true) + + assertEquals(false, policy.isCacheEnabled) + } + + @Test + fun `prime latches the toggle from an already-parsed config blank without touching the cached config`() { + val configBlank = serializationManager.deserializeToConfigDtoBlank(cachedConfigJson(cacheToggle = false)) + val policy = InAppWebViewCachePolicy(serializationManager) + + // MindboxPreferences.inAppConfig is deliberately left unstubbed: if isCacheEnabled + // fell back to parsing the cache itself instead of using the primed value, this + // would throw on the unstubbed mock. + policy.prime(configBlank) + + assertEquals(false, policy.isCacheEnabled) + } + + @Test + fun `prime is a no-op once isCacheEnabled has already latched a value`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(cacheToggle = true) + val policy = InAppWebViewCachePolicy(serializationManager) + assertEquals(true, policy.isCacheEnabled) + + val disabledBlank = serializationManager.deserializeToConfigDtoBlank(cachedConfigJson(cacheToggle = false)) + policy.prime(disabledBlank) + + assertEquals(true, policy.isCacheEnabled) + } + + @Test + fun `prime keeps the first primed value on a later prime call`() { + val policy = InAppWebViewCachePolicy(serializationManager) + val enabledBlank = serializationManager.deserializeToConfigDtoBlank(cachedConfigJson(cacheToggle = true)) + val disabledBlank = serializationManager.deserializeToConfigDtoBlank(cachedConfigJson(cacheToggle = false)) + + policy.prime(enabledBlank) + policy.prime(disabledBlank) + + assertEquals(true, policy.isCacheEnabled) + } + + private fun cachedConfigJson(cacheToggle: Boolean?): String { + val toggle = cacheToggle?.let { "\"MobileSdkShouldCacheInAppWebView\": $it" }.orEmpty() + return """ + { + "settings": { + "featureToggles": { $toggle } + } + } + """.trimIndent() + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt new file mode 100644 index 000000000..c7faa4e3d --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt @@ -0,0 +1,590 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation + +import cloud.mindbox.mobile_sdk.InitializeLock +import cloud.mindbox.mobile_sdk.Mindbox +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi +import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadBlankDto +import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto +import cloud.mindbox.mobile_sdk.inapp.data.managers.MobileConfigSerializationManagerImpl +import cloud.mindbox.mobile_sdk.inapp.data.managers.PREWARM_INAPP_WEBVIEW_FEATURE +import cloud.mindbox.mobile_sdk.inapp.data.validators.WebViewLayerValidator +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSerializationManager +import cloud.mindbox.mobile_sdk.inapp.domain.models.Form +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppConfig +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType +import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer +import cloud.mindbox.mobile_sdk.inapp.webview.InAppWebViewPrewarmEngine +import cloud.mindbox.mobile_sdk.managers.DbManager +import cloud.mindbox.mobile_sdk.managers.GatewayManager +import cloud.mindbox.mobile_sdk.models.Configuration +import cloud.mindbox.mobile_sdk.models.InAppStub +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import cloud.mindbox.mobile_sdk.utils.Constants +import cloud.mindbox.mobile_sdk.utils.RuntimeTypeAdapterFactory +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.spyk +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class, InternalMindboxApi::class) +internal class InAppWebViewPrewarmManagerImplTest { + + private val configuration = Configuration( + previousInstallationId = "", + previousDeviceUUID = "", + endpointId = "Test.Endpoint", + domain = "api.mindbox.ru", + packageName = "test.package", + versionName = "1.0", + versionCode = "1", + subscribeCustomerIfCreated = false, + shouldCreateCustomer = false + ) + + private val webViewInApp = InAppStub.getInApp().copy( + form = Form( + variants = listOf( + InAppType.WebView( + inAppId = "id", + type = "webview", + layers = listOf( + Layer.WebViewLayer( + baseUrl = "https://inapp.local/popup", + contentUrl = "https://mobile-static.mindbox.ru/content/index.html", + type = "webview", + params = emptyMap() + ) + ) + ) + ) + ) + ) + + private lateinit var engine: InAppWebViewPrewarmEngine + private lateinit var gatewayManager: GatewayManager + private lateinit var featureToggleManager: FeatureToggleManager + private lateinit var mobileConfigSerializationManager: MobileConfigSerializationManager + private lateinit var webViewCachePolicy: InAppWebViewCachePolicy + private lateinit var service: InAppWebViewPrewarmManagerImpl + + @Before + fun setUp() { + mockkObject(Mindbox) + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher()) + mockkObject(DbManager) + every { DbManager.listenConfigurations() } returns flowOf(configuration) + mockkObject(MindboxPreferences) + every { MindboxPreferences.deviceUuid } returns "test-device-uuid" + mockkObject(InitializeLock) + coEvery { InitializeLock.await(any()) } returns Unit + engine = mockk(relaxed = true) + gatewayManager = mockk(relaxed = true) + coEvery { gatewayManager.fetchWebViewContent(any()) } returns "" + featureToggleManager = mockk(relaxed = true) { + every { isEnabled(any()) } returns true + } + // spyk, not a plain instance: lets tests verify the cached config blank is + // deserialized only once and shared with the cache policy, not parsed twice. + mobileConfigSerializationManager = spyk(MobileConfigSerializationManagerImpl(gson = configGson())) + webViewCachePolicy = InAppWebViewCachePolicy(mobileConfigSerializationManager) + service = InAppWebViewPrewarmManagerImpl( + engine = engine, + mobileConfigSerializationManager = mobileConfigSerializationManager, + gatewayManager = lazyOf(gatewayManager), + inAppValidator = mockk(relaxed = true) { + every { validateInAppVersion(any()) } returns true + }, + webViewLayerValidator = WebViewLayerValidator(), + learnedHostsStore = mockk(relaxed = true) { + every { hosts(any()) } returns listOf("learned-cdn.mindbox.ru") + }, + featureToggleManager = featureToggleManager, + webViewCachePolicy = webViewCachePolicy + ) + } + + @After + fun tearDown() { + unmockkObject(Mindbox) + unmockkObject(DbManager) + unmockkObject(MindboxPreferences) + unmockkObject(InitializeLock) + } + + private fun configWith(vararg inApps: cloud.mindbox.mobile_sdk.inapp.domain.models.InApp) = InAppConfig( + inApps = inApps.toList(), + monitoring = emptyList(), + operations = emptyMap(), + abtests = emptyList() + ) + + @Test + fun `prewarmResources loads preconnect and content page once`() { + service.prewarmResources(configWith(webViewInApp)) + service.prewarmResources(configWith(webViewInApp)) + + verify(exactly = 1) { + engine.loadPreconnectPage( + html = match { html -> + html.contains("https://mobile-static.mindbox.ru") && + html.contains("https://api.mindbox.ru") && + html.contains("https://learned-cdn.mindbox.ru") + }, + baseUrl = "https://inapp.local/popup", + userAgentSuffix = any() + ) + } + coVerify(exactly = 1) { gatewayManager.fetchWebViewContent("https://mobile-static.mindbox.ru/content/index.html") } + verify(exactly = 1) { + engine.loadContentPage( + html = "", + // Official prewarm contract: the content page's document URL carries the params. + baseUrl = "https://inapp.local/popup?prewarm=1&endpointId=Test.Endpoint&deviceUuid=test-device-uuid", + userAgentSuffix = any() + ) + } + } + + @Test + fun `prewarm webview is released by network idle well before the hard cap`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + // Page reports a stable resource list whose last download finished long ago. + every { engine.evaluateJavaScript(any(), any()) } answers { + secondArg<(String?) -> Unit>().invoke("\"6:5000\"") + } + + service.prewarmResources(configWith(webViewInApp)) + verify(exactly = 0) { engine.release() } + + // Two stable polls after the baseline one -> idle at the third second, not at 30s. + advanceTimeBy(3_100) + verify(exactly = 1) { engine.release() } + verify(exactly = 0) { engine.abort() } + } + + @Test + fun `garbage probes are charged the probe timeout and drain the cap budget early`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + every { engine.evaluateJavaScript(any(), any()) } answers { + secondArg<(String?) -> Unit>().invoke("\"\"") + } + + service.prewarmResources(configWith(webViewInApp)) + + // Each 1s poll yields a garbage (null) probe charged the 2s probe timeout on top of + // its own second, so the 30-unit budget drains after 10 polls — the cap is a + // wall-clock bound even when the page never answers usefully. + advanceTimeBy(9_500) + verify(exactly = 0) { engine.release() } + advanceTimeBy(1_000) + verify(exactly = 1) { engine.release() } + } + + @Test + fun `a real show during the settle poll stops the poller at the next tick`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + // The page keeps downloading (entry count grows every poll), so the poll never + // goes idle by itself. + var count = 0 + every { engine.evaluateJavaScript(any(), any()) } answers { + count++ + secondArg<(String?) -> Unit>().invoke("\"$count:100\"") + } + + service.prewarmResources(configWith(webViewInApp)) + advanceTimeBy(2_500) + service.onRealShowWillStart() + advanceTimeBy(60_000) + + // The poller stops (job cancelled; the per-tick hasAborted guard is the backstop): + // the terminal abort stays the only teardown, no second release lands mid-show. + verify(exactly = 1) { engine.abort() } + verify(exactly = 0) { engine.release() } + } + + @Test + fun `terminal preempt during the content fetch prevents both the content load and the settle poll`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + // The real show starts while the prewarm is suspended in the content fetch. + coEvery { gatewayManager.fetchWebViewContent(any()) } coAnswers { + service.onRealShowWillStart() + "" + } + + service.prewarmResources(configWith(webViewInApp)) + advanceTimeBy(31_000) + + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + // Only the terminal abort released the engine; no zombie poll produced a second one. + verify(exactly = 1) { engine.abort() } + verify(exactly = 0) { engine.release() } + } + + @Test + fun `a no-layers config arriving mid-fetch stops the content load from resurrecting a webview`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + coEvery { gatewayManager.fetchWebViewContent(any()) } coAnswers { + service.prewarmResources(configWith(InAppStub.getInApp())) + "" + } + + service.prewarmResources(configWith(webViewInApp)) + advanceTimeBy(31_000) + + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + // The resumed prewarm must RELEASE, not bare-return: its preconnect load already + // created a WebView, and this path schedules no settle poll to free it later. + // (First release comes from the no-layers branch itself, second from the resume.) + verify(exactly = 2) { engine.release() } + } + + @Test + fun `a no-layers config arriving before the preconnect load keeps the webview from being created`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + // The fresh no-layers config lands while the prewarm is suspended reading the + // saved configuration — before it has touched the engine at all. + every { DbManager.listenConfigurations() } answers { + service.prewarmResources(configWith(InAppStub.getInApp())) + flowOf(configuration) + } + + service.prewarmResources(configWith(webViewInApp)) + advanceTimeBy(31_000) + + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + } + + @Test + fun `an attempt without a saved configuration does not consume the one-shot`() { + every { DbManager.listenConfigurations() } returnsMany listOf( + kotlinx.coroutines.flow.emptyFlow(), + flowOf(configuration) + ) + + // First attempt: configuration read fails -> skipped, one-shot must survive. + service.prewarmResources(configWith(webViewInApp)) + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + + // Second attempt with a readable configuration warms normally. + service.prewarmResources(configWith(webViewInApp)) + verify(exactly = 1) { + engine.loadContentPage( + any(), + "https://inapp.local/popup?prewarm=1&endpointId=Test.Endpoint&deviceUuid=test-device-uuid", + any() + ) + } + } + + @Test + fun `an attempt that trips the no-layers recheck does not consume the one-shot`() { + // The fresh no-layers config lands while this attempt is suspended reading the + // saved configuration — before the CAS. Per the doc comment, only an attempt that + // reaches the engine may consume the one-shot: this one must not. + every { DbManager.listenConfigurations() } answers { + service.prewarmResources(configWith(InAppStub.getInApp())) + flowOf(configuration) + } + + service.prewarmResources(configWith(webViewInApp)) + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + + // A later, valid attempt must still be able to warm — the one-shot survived. + every { DbManager.listenConfigurations() } returns flowOf(configuration) + service.prewarmResources(configWith(webViewInApp)) + + verify(exactly = 1) { + engine.loadContentPage( + any(), + "https://inapp.local/popup?prewarm=1&endpointId=Test.Endpoint&deviceUuid=test-device-uuid", + any() + ) + } + } + + @Test + fun `prewarmResources releases warm webview when config has no webview inapps`() { + service.prewarmResources(configWith(InAppStub.getInApp())) + + verify(exactly = 1) { engine.release() } + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + } + + @Test + fun `prewarmResources releases warm webview and skips warming when the feature toggle is off`() { + every { featureToggleManager.isEnabled(PREWARM_INAPP_WEBVIEW_FEATURE) } returns false + + service.prewarmResources(configWith(webViewInApp)) + + verify(exactly = 1) { engine.release() } + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + coVerify(exactly = 0) { gatewayManager.fetchWebViewContent(any()) } + } + + @Test + fun `prewarmResources warms normally once the feature toggle turns back on`() { + every { featureToggleManager.isEnabled(PREWARM_INAPP_WEBVIEW_FEATURE) } returns false + service.prewarmResources(configWith(webViewInApp)) + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + + every { featureToggleManager.isEnabled(PREWARM_INAPP_WEBVIEW_FEATURE) } returns true + service.prewarmResources(configWith(webViewInApp)) + + verify(exactly = 1) { + engine.loadContentPage( + any(), + "https://inapp.local/popup?prewarm=1&endpointId=Test.Endpoint&deviceUuid=test-device-uuid", + any() + ) + } + } + + @Test + fun `real show preempts prewarm terminally and blocks later attempts`() { + service.onRealShowWillStart() + service.prewarmResources(configWith(webViewInApp)) + + verify(exactly = 1) { engine.abort() } + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + } + + @Test + fun `terminate aborts the engine and blocks later attempts, same as a real show`() { + service.terminate() + service.prewarmResources(configWith(webViewInApp)) + + verify(exactly = 1) { engine.abort() } + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + } + + @Test + fun `terminate stops an in-flight settle poll like onRealShowWillStart does`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + var count = 0 + every { engine.evaluateJavaScript(any(), any()) } answers { + count++ + secondArg<(String?) -> Unit>().invoke("\"$count:100\"") + } + + service.prewarmResources(configWith(webViewInApp)) + advanceTimeBy(2_500) + service.terminate() + advanceTimeBy(60_000) + + verify(exactly = 1) { engine.abort() } + verify(exactly = 0) { engine.release() } + } + + @Test + fun `prewarmOnInit warms from cached config without validation pipeline`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson() + + service.prewarmOnInit() + + verify(exactly = 1) { engine.loadPreconnectPage(any(), "https://inapp.local/popup", any()) } + verify(exactly = 1) { + engine.loadContentPage( + any(), + "https://inapp.local/popup?prewarm=1&endpointId=Test.Endpoint&deviceUuid=test-device-uuid", + any() + ) + } + } + + @Test + fun `prewarmOnInit awaits the migration lock before reading the cached config`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson() + + service.prewarmOnInit() + + coVerify(exactly = 1) { InitializeLock.await(InitializeLock.State.MIGRATION) } + } + + @Test + fun `prewarmOnInit is a no-op once a prior attempt already reached the engine`() { + // Reaches the engine and consumes the one-shot. + service.prewarmResources(configWith(webViewInApp)) + + service.prewarmOnInit() + + // Not even the cheap cached-config read happens on the short-circuited path. + verify(exactly = 0) { MindboxPreferences.inAppConfig } + coVerify(exactly = 0) { InitializeLock.await(any()) } + } + + @Test + fun `prewarmOnInit does not warm a modal whose first layer is not webview`() { + // Same gate as the real pipeline (InAppMapper): webview must be the FIRST layer of + // the modal to ever become a WebView in-app; an image-then-webview modal never will. + every { MindboxPreferences.inAppConfig } returns """ + { + "inapps": [ + { + "id": "cached-webview", + "sdkVersion": { "min": 1, "max": null }, + "targeting": { "${'$'}type": "true" }, + "form": { + "variants": [ + { + "${'$'}type": "modal", + "content": { + "background": { + "layers": [ + { "${'$'}type": "image" }, + { + "${'$'}type": "webview", + "baseUrl": "https://inapp.local/popup", + "contentUrl": "https://mobile-static.mindbox.ru/content/index.html", + "params": { "formId": "159510" } + } + ] + }, + "elements": [] + } + } + ] + } + } + ] + } + """.trimIndent() + + service.prewarmOnInit() + + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + } + + @Test + fun `prewarmOnInit does nothing without cached config`() { + every { MindboxPreferences.inAppConfig } returns "" + + service.prewarmOnInit() + + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + } + + @Test + fun `prewarmOnInit does nothing when the cached config disables the feature toggle`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(prewarmToggle = false) + + service.prewarmOnInit() + + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + } + + @Test + fun `prewarmOnInit warms when the cached config explicitly enables the feature toggle`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(prewarmToggle = true) + + service.prewarmOnInit() + + verify(exactly = 1) { engine.loadPreconnectPage(any(), "https://inapp.local/popup", any()) } + } + + @Test + fun `prewarmOnInit primes the cache toggle from its own parse instead of a second deserialize`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(prewarmToggle = true, cacheToggle = false) + + service.prewarmOnInit() + + assertEquals(false, webViewCachePolicy.isCacheEnabled) + verify(exactly = 1) { mobileConfigSerializationManager.deserializeToConfigDtoBlank(any()) } + } + + @Test + fun `prewarmOnInit primes the cache toggle even when the prewarm toggle is off`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(prewarmToggle = false, cacheToggle = false) + + service.prewarmOnInit() + + assertEquals(false, webViewCachePolicy.isCacheEnabled) + } + + /** Same `${'$'}type` adapters the production gson registers for the form payload path. */ + private fun configGson(): Gson = GsonBuilder() + .registerTypeAdapterFactory( + RuntimeTypeAdapterFactory + .of(PayloadBlankDto::class.java, Constants.TYPE_JSON_NAME, true) + .registerSubtype(PayloadBlankDto.ModalWindowBlankDto::class.java, PayloadDto.ModalWindowDto.MODAL_JSON_NAME) + .registerSubtype(PayloadBlankDto.SnackBarBlankDto::class.java, PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME) + ) + .registerTypeAdapterFactory( + RuntimeTypeAdapterFactory + .of(BackgroundDto.LayerDto::class.java, Constants.TYPE_JSON_NAME, true) + .registerSubtype( + BackgroundDto.LayerDto.ImageLayerDto::class.java, + BackgroundDto.LayerDto.ImageLayerDto.IMAGE_TYPE_JSON_NAME + ) + .registerSubtype( + BackgroundDto.LayerDto.WebViewLayerDto::class.java, + BackgroundDto.LayerDto.WebViewLayerDto.WEBVIEW_TYPE_JSON_NAME + ) + ) + .create() + + private fun cachedConfigJson(prewarmToggle: Boolean? = null, cacheToggle: Boolean? = null): String { + val toggles = listOfNotNull( + prewarmToggle?.let { "\"MobileSdkShouldPrewarmInAppWebView\": $it" }, + cacheToggle?.let { "\"MobileSdkShouldCacheInAppWebView\": $it" } + ) + val settings = toggles.takeIf { it.isNotEmpty() } + ?.let { ", \"settings\": { \"featureToggles\": { ${it.joinToString(", ")} } }" } + .orEmpty() + return """ + { + "inapps": [ + { + "id": "cached-webview", + "sdkVersion": { "min": 1, "max": null }, + "targeting": { "${'$'}type": "true" }, + "form": { + "variants": [ + { + "${'$'}type": "modal", + "content": { + "background": { + "layers": [ + { + "${'$'}type": "webview", + "baseUrl": "https://inapp.local/popup", + "contentUrl": "https://mobile-static.mindbox.ru/content/index.html", + "params": { "formId": "159510" } + } + ] + }, + "elements": [] + } + } + ] + } + } + ]$settings + } + """.trimIndent() + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppPositionControllerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppPositionControllerTest.kt new file mode 100644 index 000000000..3150741cf --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppPositionControllerTest.kt @@ -0,0 +1,88 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.app.Dialog +import android.view.Window +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +internal class InAppPositionControllerTest { + + private val controller = InAppPositionController() + + @Test + fun `finds dialog nested in child fragment manager`() { + val nestedDialog = dialogFragment() + val host = plainFragment(children = listOf(nestedDialog)) + val rootFm = fragmentManager(host) + + assertSame(nestedDialog, controller.findTopDialogFragment(rootFm)) + } + + @Test + fun `returns the top-most dialog, not the first one`() { + val first = dialogFragment() + val last = dialogFragment() + val rootFm = fragmentManager(first, last) + + assertSame(last, controller.findTopDialogFragment(rootFm)) + } + + @Test + fun `ignores dialog that is not added`() { + val added = dialogFragment(isAdded = true) + val notAdded = dialogFragment(isAdded = false) + val rootFm = fragmentManager(added, notAdded) + + assertSame(added, controller.findTopDialogFragment(rootFm)) + } + + @Test + fun `ignores dialog without a window`() { + val withWindow = dialogFragment(hasWindow = true) + val withoutWindow = dialogFragment(hasWindow = false) + val rootFm = fragmentManager(withWindow, withoutWindow) + + assertSame(withWindow, controller.findTopDialogFragment(rootFm)) + } + + @Test + fun `returns null when there are no dialogs`() { + val rootFm = fragmentManager(plainFragment(), plainFragment()) + + assertNull(controller.findTopDialogFragment(rootFm)) + } + + @Test + fun `returns null for an empty fragment manager`() { + assertNull(controller.findTopDialogFragment(fragmentManager())) + } + + private fun fragmentManager(vararg fragments: Fragment): FragmentManager = mockk { + every { this@mockk.fragments } returns fragments.toList() + } + + private fun plainFragment( + children: List = emptyList(), + isAdded: Boolean = true, + ): Fragment = mockk { + every { this@mockk.isAdded } returns isAdded + every { childFragmentManager } returns fragmentManager(*children.toTypedArray()) + } + + private fun dialogFragment( + isAdded: Boolean = true, + hasWindow: Boolean = true, + ): DialogFragment = mockk { + every { this@mockk.isAdded } returns isAdded + every { childFragmentManager } returns fragmentManager() + every { dialog } returns mockk { + every { window } returns if (hasWindow) mockk() else null + } + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt index ee49e2892..e03426d71 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt @@ -3,7 +3,10 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.app.Application import cloud.mindbox.mobile_sdk.managers.MindboxEventManager import cloud.mindbox.mobile_sdk.models.MindboxError +import cloud.mindbox.mobile_sdk.models.ValidationMessage +import com.google.gson.Gson import io.mockk.* +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals @@ -17,7 +20,7 @@ class WebViewOperationExecutorTest { @Before fun onTestStart() { - executor = MindboxWebViewOperationExecutor() + executor = MindboxWebViewOperationExecutor(Gson()) mockkObject(MindboxEventManager) } @@ -31,7 +34,7 @@ class WebViewOperationExecutorTest { val context: Application = mockk() val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit - executor.executeAsyncOperation(context, payload) + executor.executeAsyncOperation(context, payload, tags = null) verify(exactly = 1) { MindboxEventManager.asyncOperation( context = context, @@ -41,12 +44,122 @@ class WebViewOperationExecutorTest { } } + @Test + fun `executeAsyncOperation adds top-level tags to body when tags present`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation(context, payload, tags = mapOf("templateType" to "Popup")) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home","tags":{"templateType":"Popup"}}""", + ) + } + } + + @Test + fun `executeAsyncOperation does not add tags when tags empty`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation(context, payload, tags = emptyMap()) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home"}""", + ) + } + } + + @Test + fun `executeAsyncOperation adds in-app tags when existing tags is json null`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home","tags":null}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation(context, payload, tags = mapOf("templateType" to "Popup")) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home","tags":{"templateType":"Popup"}}""", + ) + } + } + + @Test + fun `executeAsyncOperation merges in-app tags into existing tags without collision`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home","tags":{"client":"own"}}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation(context, payload, tags = mapOf("templateType" to "Popup")) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home","tags":{"client":"own","templateType":"Popup"}}""", + ) + } + } + + @Test + fun `executeAsyncOperation keeps client value and skips in-app value on key collision`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home","tags":{"templateType":"ClientOwn"}}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation(context, payload, tags = mapOf("templateType" to "Popup")) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home","tags":{"templateType":"ClientOwn"}}""", + ) + } + } + + @Test + fun `executeAsyncOperation merges only non-colliding keys when tags partially overlap`() { + val context: Application = mockk() + val payload: String = + """{"operation":"OpenScreen","body":{"screen":"home","tags":{"templateType":"ClientOwn","keep":"x"}}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation( + context, + payload, + tags = mapOf("templateType" to "Popup", "campaign" to "summer"), + ) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home","tags":{"templateType":"ClientOwn","keep":"x","campaign":"summer"}}""", + ) + } + } + + @Test + fun `executeAsyncOperation keeps client tags untouched when existing tags is not an object`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home","tags":"raw"}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation(context, payload, tags = mapOf("templateType" to "Popup")) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home","tags":"raw"}""", + ) + } + } + @Test fun `executeAsyncOperation throws when payload misses operation`() { val context: Application = mockk() val payload: String = """{"body":{"screen":"home"}}""" try { - executor.executeAsyncOperation(context, payload) + executor.executeAsyncOperation(context, payload, tags = null) fail("Expected IllegalArgumentException") } catch (exception: IllegalArgumentException) { assertEquals("Operation is not provided", exception.message) @@ -59,7 +172,7 @@ class WebViewOperationExecutorTest { val context: Application = mockk() val payload: String = """{"operation":"OpenScreen"}""" try { - executor.executeAsyncOperation(context, payload) + executor.executeAsyncOperation(context, payload, tags = null) fail("Expected IllegalArgumentException") } catch (exception: IllegalArgumentException) { assertEquals("Body is not provided", exception.message) @@ -68,15 +181,27 @@ class WebViewOperationExecutorTest { } @Test - fun `executeAsyncOperation throws when payload is invalid json empty or null`() { + fun `executeAsyncOperation throws IllegalArgumentException when payload is null`() { + val context: Application = mockk() + try { + executor.executeAsyncOperation(context, payload = null, tags = null) + fail("Expected IllegalArgumentException") + } catch (exception: IllegalArgumentException) { + assertEquals("Payload is not provided", exception.message) + } + verify(exactly = 0) { MindboxEventManager.asyncOperation(any(), any(), any()) } + } + + @Test + fun `executeAsyncOperation throws IllegalArgumentException when payload is invalid json`() { val context: Application = mockk() - val payloads: List = listOf("not-json", "", null) - payloads.forEach { payload: String? -> + val payloads: List = listOf("not-json", "") + payloads.forEach { payload: String -> try { - executor.executeAsyncOperation(context, payload) - fail("Expected exception for payload: $payload") - } catch (exception: Exception) { - // Expected: payload cannot be parsed to required JSON object. + executor.executeAsyncOperation(context, payload, tags = null) + fail("Expected IllegalArgumentException for payload: $payload") + } catch (exception: IllegalArgumentException) { + assertEquals("Payload is not a valid JSON object", exception.message) } } verify(exactly = 0) { MindboxEventManager.asyncOperation(any(), any(), any()) } @@ -97,7 +222,7 @@ class WebViewOperationExecutorTest { val onSuccess: (String) -> Unit = arg(2) onSuccess(expectedResponse) } - val actualResponse: String = executor.executeSyncOperation(payload) + val actualResponse: String = executor.executeSyncOperation(payload, tags = null) assertEquals(expectedResponse, actualResponse) verify(exactly = 1) { MindboxEventManager.syncOperation( @@ -110,9 +235,65 @@ class WebViewOperationExecutorTest { } @Test - fun `executeSyncOperation throws IllegalStateException when event manager returns error`() = runTest { + fun `executeSyncOperation adds top-level tags and still returns response`() = runTest { + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" + val expectedResponse: String = """{"result":"ok"}""" + every { + MindboxEventManager.syncOperation( + name = any(), + bodyJson = any(), + onSuccess = any(), + onError = any(), + ) + } answers { + val onSuccess: (String) -> Unit = arg(2) + onSuccess(expectedResponse) + } + val actualResponse: String = executor.executeSyncOperation(payload, tags = mapOf("templateType" to "Popup")) + assertEquals(expectedResponse, actualResponse) + verify(exactly = 1) { + MindboxEventManager.syncOperation( + name = "OpenScreen", + bodyJson = """{"screen":"home","tags":{"templateType":"Popup"}}""", + onSuccess = any(), + onError = any(), + ) + } + } + + @Test + fun `executeSyncOperation merges tags keeping client value on collision and still returns response`() = runTest { + val payload: String = + """{"operation":"OpenScreen","body":{"screen":"home","tags":{"templateType":"ClientOwn"}}}""" + val expectedResponse: String = """{"result":"ok"}""" + every { + MindboxEventManager.syncOperation( + name = any(), + bodyJson = any(), + onSuccess = any(), + onError = any(), + ) + } answers { + val onSuccess: (String) -> Unit = arg(2) + onSuccess(expectedResponse) + } + val actualResponse: String = executor.executeSyncOperation( + payload, + tags = mapOf("templateType" to "Popup", "campaign" to "summer"), + ) + assertEquals(expectedResponse, actualResponse) + verify(exactly = 1) { + MindboxEventManager.syncOperation( + name = "OpenScreen", + bodyJson = """{"screen":"home","tags":{"templateType":"ClientOwn","campaign":"summer"}}""", + onSuccess = any(), + onError = any(), + ) + } + } + + private fun executeSyncOperationExpectingError(error: MindboxError): WebViewSyncOperationException = runBlocking { val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" - val expectedError: MindboxError = MindboxError.Unknown(Throwable("network failure")) every { MindboxEventManager.syncOperation( name = any(), @@ -122,21 +303,90 @@ class WebViewOperationExecutorTest { ) } answers { val onError: (MindboxError) -> Unit = arg(3) - onError(expectedError) + onError(error) } try { - executor.executeSyncOperation(payload) - fail("Expected IllegalStateException") - } catch (exception: IllegalStateException) { - assertEquals(expectedError.toJson(), exception.message) + executor.executeSyncOperation(payload, tags = null) + throw AssertionError("Expected WebViewSyncOperationException") + } catch (exception: WebViewSyncOperationException) { + exception } } + @Test + fun `executeSyncOperation protocol error payload is the data contents in iOS format`() { + val exception = executeSyncOperationExpectingError( + MindboxError.Protocol( + statusCode = 400, + status = "ProtocolError", + errorMessage = "Operation OpenScreen not found", + errorId = "error-id-1", + httpStatusCode = 400, + ) + ) + assertEquals( + """{"status":"ProtocolError","errorMessage":"Operation OpenScreen not found","errorId":"error-id-1","httpStatusCode":"400"}""", + exception.payloadJson, + ) + } + + @Test + fun `executeSyncOperation internal server error payload is the data contents in iOS format`() { + val exception = executeSyncOperationExpectingError( + MindboxError.InternalServer( + statusCode = 500, + status = "InternalServerError", + errorMessage = "Something went wrong", + errorId = null, + httpStatusCode = 500, + ) + ) + assertEquals( + """{"status":"InternalServerError","errorMessage":"Something went wrong","errorId":"","httpStatusCode":"500"}""", + exception.payloadJson, + ) + } + + @Test + fun `executeSyncOperation validation error payload is the data contents with validationMessages`() { + val exception = executeSyncOperationExpectingError( + MindboxError.Validation( + statusCode = 200, + status = "ValidationError", + validationMessages = listOf( + ValidationMessage(message = "Invalid email", location = "/customer/email") + ), + ) + ) + assertEquals( + """{"status":"ValidationError","validationMessages":[{"message":"Invalid email","location":"/customer/email"}]}""", + exception.payloadJson, + ) + } + + @Test + fun `executeSyncOperation network error payload is the data contents without envelope`() { + val exception = executeSyncOperationExpectingError(MindboxError.UnknownServer()) + assertEquals( + """{"errorMessage":"Cannot reach server","httpStatusCode":"null"}""", + exception.payloadJson, + ) + } + + @Test + fun `executeSyncOperation unknown error payload is the data contents without envelope`() { + val exception = executeSyncOperationExpectingError(MindboxError.Unknown(Throwable("network failure"))) + assertEquals( + """{"errorKey":"unknown","errorName":"java.lang.Throwable","errorMessage":"network failure"}""", + exception.payloadJson, + ) + } + @Test fun `executeSyncOperation throws when payload misses body`() = runTest { val payload: String = """{"operation":"OpenScreen"}""" try { - executor.executeSyncOperation(payload) + executor.executeSyncOperation(payload, tags = null) fail("Expected IllegalArgumentException") } catch (exception: IllegalArgumentException) { assertEquals("Body is not provided", exception.message) @@ -152,14 +402,32 @@ class WebViewOperationExecutorTest { } @Test - fun `executeSyncOperation throws when payload is invalid json empty or null`() = runTest { - val payloads: List = listOf("not-json", "", null) - payloads.forEach { payload: String? -> + fun `executeSyncOperation throws IllegalArgumentException when payload is null`() = runTest { + try { + executor.executeSyncOperation(payload = null, tags = null) + fail("Expected IllegalArgumentException") + } catch (exception: IllegalArgumentException) { + assertEquals("Payload is not provided", exception.message) + } + verify(exactly = 0) { + MindboxEventManager.syncOperation( + name = any(), + bodyJson = any(), + onSuccess = any(), + onError = any(), + ) + } + } + + @Test + fun `executeSyncOperation throws IllegalArgumentException when payload is invalid json`() = runTest { + val payloads: List = listOf("not-json", "") + payloads.forEach { payload: String -> try { - executor.executeSyncOperation(payload) - fail("Expected exception for payload: $payload") - } catch (exception: Exception) { - // Expected: payload cannot be parsed to required JSON object. + executor.executeSyncOperation(payload, tags = null) + fail("Expected IllegalArgumentException for payload: $payload") + } catch (exception: IllegalArgumentException) { + assertEquals("Payload is not a valid JSON object", exception.message) } } verify(exactly = 0) { diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyCheckerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyCheckerTest.kt new file mode 100644 index 000000000..a7ff1f95c --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyCheckerTest.kt @@ -0,0 +1,106 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class WebViewReadyCheckerTest { + + /** + * Drives the checker synchronously: `evaluate` answers from a scripted sequence + * (empty -> "false") and scheduled retries run immediately unless held for the + * cancellation test. + */ + private class Harness( + private val answers: MutableList, + private val runScheduledImmediately: Boolean = true + ) { + var evaluateCount = 0 + private set + val scheduledDelays = mutableListOf() + val pendingWork = mutableListOf<() -> Unit>() + + fun makeChecker(): WebViewReadyChecker = WebViewReadyChecker( + evaluate = { _, resultCallback -> + evaluateCount++ + resultCallback(if (answers.isEmpty()) "false" else answers.removeAt(0)) + }, + schedule = { delayMillis, action -> + scheduledDelays.add(delayMillis) + if (runScheduledImmediately) action() else pendingWork.add(action) + } + ) + } + + @Test + fun `an immediately ready page passes on the first attempt`() { + val harness = Harness(mutableListOf("true")) + var readyCalls = 0 + + harness.makeChecker().run( + script = "check", + expectedResult = "true", + onReady = { readyCalls++ }, + onGiveUp = { throw AssertionError("must not give up") } + ) + + assertEquals(1, readyCalls) + assertEquals(1, harness.evaluateCount) + assertTrue(harness.scheduledDelays.isEmpty()) + } + + @Test + fun `a module evaluating after onPageFinished passes on a retry instead of closing the show`() { + val harness = Harness(mutableListOf("false", null, "true")) + var readyCalls = 0 + + harness.makeChecker().run( + script = "check", + expectedResult = "true", + onReady = { readyCalls++ }, + onGiveUp = { throw AssertionError("must not give up") } + ) + + assertEquals(1, readyCalls) + assertEquals(3, harness.evaluateCount) + assertEquals( + listOf(WebViewReadyChecker.RETRY_DELAY_MS, WebViewReadyChecker.RETRY_DELAY_MS), + harness.scheduledDelays + ) + } + + @Test + fun `a page that never boots gives up only after the full retry budget`() { + val harness = Harness(mutableListOf()) + val giveUpReasons = mutableListOf() + + harness.makeChecker().run( + script = "check", + expectedResult = "true", + onReady = { throw AssertionError("must not become ready") }, + onGiveUp = { reason -> giveUpReasons.add(reason) } + ) + + assertEquals(1, giveUpReasons.size) + assertEquals(WebViewReadyChecker.MAX_ATTEMPTS, harness.evaluateCount) + } + + @Test + fun `cancel abandons the poll without ever resolving`() { + val harness = Harness(mutableListOf(), runScheduledImmediately = false) + val checker = harness.makeChecker() + checker.run( + script = "check", + expectedResult = "true", + onReady = { throw AssertionError("must not become ready") }, + onGiveUp = { throw AssertionError("must not give up") } + ) + assertEquals(1, harness.evaluateCount) + + checker.cancel() + harness.pendingWork.forEach { work -> work() } + + // The cancelled checker neither evaluates again nor resolves. + assertEquals(1, harness.evaluateCount) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/LifecycleManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/LifecycleManagerTest.kt new file mode 100644 index 000000000..54367eccc --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/LifecycleManagerTest.kt @@ -0,0 +1,1032 @@ +package cloud.mindbox.mobile_sdk.managers + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import cloud.mindbox.mobile_sdk.models.DIRECT +import cloud.mindbox.mobile_sdk.models.LINK +import cloud.mindbox.mobile_sdk.models.PUSH +import cloud.mindbox.mobile_sdk.pushes.PushNotificationManager.IS_OPENED_FROM_PUSH_BUNDLE_KEY +import io.mockk.mockk +import io.mockk.junit4.MockKRule +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], manifest = Config.NONE) +internal class LifecycleManagerTest { + + @get:Rule + val mockkRule = MockKRule(this) + + private val trackVisitEvents = mutableListOf>() + private val startedActivities = mutableListOf() + private val resumedActivities = mutableListOf() + private val pausedActivities = mutableListOf() + private val stoppedActivities = mutableListOf() + + @Before + fun setUp() { + trackVisitEvents.clear() + startedActivities.clear() + resumedActivities.clear() + pausedActivities.clear() + stoppedActivities.clear() + } + + @After + fun tearDown() { + LifecycleManager.instance = null + } + + /** Manager with all callbacks wired to shared collections. */ + private fun createManager( + currentActivityName: String? = null, + currentIntent: Intent? = null, + isAppInBackground: Boolean = false, + ) = LifecycleManager( + currentActivityName = currentActivityName, + currentIntent = currentIntent, + isAppInBackground = isAppInBackground, + ).also { manager -> + manager.callbacks = object : LifecycleManager.Callbacks { + override fun onActivityStarted(activity: Activity) { + startedActivities += activity + } + + override fun onActivityResumed(activity: Activity) { + resumedActivities += activity + } + + override fun onActivityPaused(activity: Activity) { + pausedActivities += activity + } + + override fun onActivityStopped(activity: Activity) { + stoppedActivities += activity + } + + override fun onTrackVisitReady(source: String?, requestUrl: String?) { + trackVisitEvents += source to requestUrl + } + } + } + + /** Manager with NO callbacks — simulates the pre-init state. */ + private fun createManagerNoCallbacks( + isAppInBackground: Boolean = true, + ) = LifecycleManager( + currentActivityName = null, + currentIntent = null, + isAppInBackground = isAppInBackground, + ) + + /** Attach a minimal track-visit listener after manager construction (simulates late init). */ + private fun listenTrackVisit(manager: LifecycleManager) { + manager.callbacks = object : LifecycleManager.Callbacks { + override fun onTrackVisitReady(source: String?, requestUrl: String?) { + trackVisitEvents += source to requestUrl + } + } + } + + private fun buildActivityA(intent: Intent = Intent()): Activity = + Robolectric.buildActivity(LifecycleTestActivityA::class.java, intent).create().get() + + private fun buildActivityB(intent: Intent = Intent()): Activity = + Robolectric.buildActivity(LifecycleTestActivityB::class.java, intent).create().get() + + private fun mockOwner(): LifecycleOwner = mockk(relaxed = true) + + // region — null-safety: no crash before callbacks set + + @Test + fun `onActivityStarted does not crash when all callbacks are null`() { + createManagerNoCallbacks().onActivityStarted(mockk(relaxed = true)) + } + + @Test + fun `onActivityResumed does not crash when callback is null`() { + createManagerNoCallbacks().onActivityResumed(mockk(relaxed = true)) + } + + @Test + fun `onActivityPaused does not crash when callback is null`() { + createManagerNoCallbacks().onActivityPaused(mockk(relaxed = true)) + } + + @Test + fun `onActivityStopped does not crash when callback is null`() { + createManagerNoCallbacks().onActivityStopped(mockk(relaxed = true)) + } + + @Test + fun `onNewIntent does not crash when callbacks is null`() { + createManagerNoCallbacks().onNewIntent(Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com"))) + } + + // endregion + + // region — track visit NOT sent when callbacks is null + + @Test + fun `foreground transition does not send track visit when callbacks is null`() { + val manager = createManagerNoCallbacks() + manager.onActivityStarted(mockk(relaxed = true)) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_STOP) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_START) + assertTrue(trackVisitEvents.isEmpty()) + } + + @Test + fun `onNewIntent does not send track visit when callbacks is null`() { + createManagerNoCallbacks().onNewIntent(Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com"))) + assertTrue(trackVisitEvents.isEmpty()) + } + + @Test + fun `onNewIntent with push intent does not send track visit when callbacks is null`() { + val intent = Intent().apply { putExtra(IS_OPENED_FROM_PUSH_BUNDLE_KEY, true) } + createManagerNoCallbacks().onNewIntent(intent) + assertTrue(trackVisitEvents.isEmpty()) + } + + // endregion + + // region — track visit sent after callbacks set (init flow) + + @Test + fun `foreground sends track visit after callbacks is set`() { + val manager = createManagerNoCallbacks() + manager.onActivityStarted(mockk(relaxed = true)) + listenTrackVisit(manager) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_STOP) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_START) + assertEquals(1, trackVisitEvents.size) + } + + @Test + fun `onNewIntent sends LINK track visit for https deeplink after callbacks set`() { + val manager = createManagerNoCallbacks() + listenTrackVisit(manager) + val uri = Uri.parse("https://example.com/promo") + manager.onNewIntent(Intent(Intent.ACTION_VIEW, uri)) + assertEquals(1, trackVisitEvents.size) + assertEquals(LINK, trackVisitEvents[0].first) + assertEquals(uri.toString(), trackVisitEvents[0].second) + } + + @Test + fun `onNewIntent sends PUSH track visit for push intent after callbacks set`() { + val manager = createManagerNoCallbacks() + listenTrackVisit(manager) + val intent = Intent().apply { putExtra(IS_OPENED_FROM_PUSH_BUNDLE_KEY, true) } + manager.onNewIntent(intent) + assertEquals(1, trackVisitEvents.size) + assertEquals(PUSH, trackVisitEvents[0].first) + } + + @Test + fun `repeated onNewIntent with same intent sends DIRECT on second call`() { + val manager = createManagerNoCallbacks() + listenTrackVisit(manager) + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com")) + manager.onNewIntent(intent) + manager.onNewIntent(intent) + assertEquals(2, trackVisitEvents.size) + assertEquals(LINK, trackVisitEvents[0].first) + assertEquals(DIRECT, trackVisitEvents[1].first) + } + + // endregion + + // region — source detection via onActivityStarted + + @Test + fun `onActivityStarted sends DIRECT trackVisit for plain intent on same activity`() { + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + manager.onActivityStarted(buildActivityA(Intent())) + assertEquals(1, trackVisitEvents.size) + assertEquals(DIRECT to null, trackVisitEvents[0]) + } + + @Test + fun `onActivityStarted sends LINK trackVisit for HTTP deeplink intent`() { + val url = "http://example.com/promo" + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + manager.onActivityStarted(buildActivityA(Intent(Intent.ACTION_VIEW, Uri.parse(url)))) + assertEquals(1, trackVisitEvents.size) + assertEquals(LINK to url, trackVisitEvents[0]) + } + + @Test + fun `onActivityStarted sends LINK trackVisit for HTTPS deeplink intent`() { + val url = "https://example.com/campaign" + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + manager.onActivityStarted(buildActivityA(Intent(Intent.ACTION_VIEW, Uri.parse(url)))) + assertEquals(1, trackVisitEvents.size) + assertEquals(LINK to url, trackVisitEvents[0]) + } + + @Test + fun `onActivityStarted sends PUSH trackVisit for push-opened intent`() { + val intent = Intent().apply { putExtra(IS_OPENED_FROM_PUSH_BUNDLE_KEY, true) } + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + manager.onActivityStarted(buildActivityA(intent)) + assertEquals(1, trackVisitEvents.size) + assertEquals(PUSH to null, trackVisitEvents[0]) + } + + @Test + fun `onActivityStarted sends DIRECT when intentChanged is false on second call`() { + val url = "https://example.com" + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + val activity = buildActivityA(intent) + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + manager.onActivityStarted(activity) + trackVisitEvents.clear() + // hash already known → isTrackVisitSent returns true but sends nothing + assertTrue(manager.isTrackVisitSent()) + assertEquals(0, trackVisitEvents.size) + } + + // endregion + + // region — onActivityStarted send / no-send conditions + + @Test + fun `onActivityStarted does not send when same intent instance used again`() { + val intent = Intent().apply { putExtra("key", "value") } + val activity = buildActivityA(intent) + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + manager.onActivityStarted(activity) + trackVisitEvents.clear() + manager.onActivityStarted(activity) + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `onActivityStarted does not send when app is in background`() { + val manager = createManager(isAppInBackground = true) + manager.onActivityStarted(buildActivityA(Intent())) + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `onActivityStarted resets isAppInBackground after being called in background`() { + val manager = createManager(isAppInBackground = true) + manager.onActivityStarted(buildActivityA(Intent())) + trackVisitEvents.clear() + manager.onActivityStarted(buildActivityA(Intent().apply { putExtra("seq", 2) })) + assertEquals(1, trackVisitEvents.size) + } + + @Test + fun `onActivityStarted does not send DIRECT trackVisit for different activity class`() { + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + manager.onActivityStarted(buildActivityB(Intent())) + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `onActivityStarted sends LINK trackVisit for different activity class with deeplink`() { + val url = "https://example.com" + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + manager.onActivityStarted(buildActivityB(Intent(Intent.ACTION_VIEW, Uri.parse(url)))) + assertEquals(1, trackVisitEvents.size) + assertEquals(LINK to url, trackVisitEvents[0]) + } + + @Test + fun `onActivityStarted sends PUSH trackVisit for different activity class with push intent`() { + val intent = Intent().apply { putExtra(IS_OPENED_FROM_PUSH_BUNDLE_KEY, true) } + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + manager.onActivityStarted(buildActivityB(intent)) + assertEquals(1, trackVisitEvents.size) + assertEquals(PUSH to null, trackVisitEvents[0]) + } + + @Test + fun `onActivityStarted invokes onActivityStarted callback`() { + val manager = createManager() + val activity = buildActivityA() + manager.onActivityStarted(activity) + assertEquals(1, startedActivities.size) + assertSame(activity, startedActivities[0]) + } + + // endregion + + // region — isCurrentActivityResumed + + @Test + fun `isCurrentActivityResumed is true by default`() { + assertTrue(createManager().isCurrentActivityResumed) + } + + @Test + fun `isCurrentActivityResumed is false after onActivityPaused`() { + val manager = createManager() + manager.onActivityPaused(mockk(relaxed = true)) + assertFalse(manager.isCurrentActivityResumed) + } + + @Test + fun `isCurrentActivityResumed is true after onActivityResumed`() { + val manager = createManager() + manager.onActivityPaused(mockk(relaxed = true)) + manager.onActivityResumed(mockk(relaxed = true)) + assertTrue(manager.isCurrentActivityResumed) + } + + @Test + fun `onActivityResumed sets isCurrentActivityResumed to true and invokes callback`() { + val manager = createManager() + val activity = buildActivityA() + manager.onActivityPaused(activity) + resumedActivities.clear() + manager.onActivityResumed(activity) + assertTrue(manager.isCurrentActivityResumed) + assertEquals(1, resumedActivities.size) + assertSame(activity, resumedActivities[0]) + } + + @Test + fun `onActivityPaused sets isCurrentActivityResumed to false and invokes callback`() { + val manager = createManager() + val activity = buildActivityA() + manager.onActivityPaused(activity) + assertFalse(manager.isCurrentActivityResumed) + assertEquals(1, pausedActivities.size) + assertSame(activity, pausedActivities[0]) + } + + @Test + fun `isCurrentActivityResumed toggles correctly across resume-pause cycles`() { + val manager = createManager() + val activity = buildActivityA() + manager.onActivityResumed(activity) + assertTrue(manager.isCurrentActivityResumed) + manager.onActivityPaused(activity) + assertFalse(manager.isCurrentActivityResumed) + manager.onActivityResumed(activity) + assertTrue(manager.isCurrentActivityResumed) + } + + // endregion + + // region — all activity callbacks invoked when assigned + + @Test + fun `all activity callbacks are invoked when assigned`() { + val started = mutableListOf() + val resumed = mutableListOf() + val paused = mutableListOf() + val stopped = mutableListOf() + + val manager = LifecycleManager( + currentActivityName = null, + currentIntent = null, + isAppInBackground = false, + ) + manager.callbacks = object : LifecycleManager.Callbacks { + override fun onActivityStarted(activity: Activity) { + started += activity + } + + override fun onActivityResumed(activity: Activity) { + resumed += activity + } + + override fun onActivityPaused(activity: Activity) { + paused += activity + } + + override fun onActivityStopped(activity: Activity) { + stopped += activity + } + } + + val activity = mockk(relaxed = true) + manager.onActivityStarted(activity) + manager.onActivityResumed(activity) + manager.onActivityPaused(activity) + manager.onActivityStopped(activity) + + assertEquals(1, started.size) + assertEquals(1, resumed.size) + assertEquals(1, paused.size) + assertEquals(1, stopped.size) + assertSame(activity, started[0]) + assertSame(activity, resumed[0]) + assertSame(activity, paused[0]) + assertSame(activity, stopped[0]) + } + + // endregion + + // region — onActivityStopped + + @Test + fun `onActivityStopped invokes onActivityStopped callback`() { + val manager = createManager( + currentActivityName = LifecycleTestActivityA::class.java.name, + currentIntent = Intent(), + ) + val activity = buildActivityA() + manager.onActivityStopped(activity) + assertEquals(1, stoppedActivities.size) + assertSame(activity, stoppedActivities[0]) + } + + @Test + fun `onActivityStopped updates currentIntent when both fields are null`() { + val manager = createManager(currentActivityName = null, currentIntent = null) + val intent = Intent().apply { putExtra("stopped", true) } + val activity = buildActivityA(intent) + manager.onActivityStopped(activity) + // currentIntent is now set → isTrackVisitSent returns true + assertTrue(manager.isTrackVisitSent()) + } + + @Test + fun `onActivityStopped does not override currentIntent when both fields are already set`() { + val originalIntent = Intent().apply { putExtra("original", true) } + val manager = createManager( + currentActivityName = LifecycleTestActivityA::class.java.name, + currentIntent = originalIntent, + ) + manager.onActivityStopped(buildActivityB(Intent().apply { putExtra("other", true) })) + trackVisitEvents.clear() + // currentIntent unchanged → isTrackVisitSent still returns true (has an intent) + assertTrue(manager.isTrackVisitSent()) + } + + // endregion + + // region — app lifecycle (foreground / background) + + @Test + fun `ON_STOP sets app to background`() { + val manager = createManagerNoCallbacks() + listenTrackVisit(manager) + manager.onActivityStarted(mockk(relaxed = true)) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_STOP) + assertTrue(trackVisitEvents.isEmpty()) + } + + @Test + fun `ON_STOP sets app to background so next onActivityStarted skips trackVisit`() { + val manager = createManager() + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_STOP) + manager.onActivityStarted(buildActivityA(Intent())) + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `ON_START sends trackVisit with currentIntent`() { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com")) + val manager = createManager(currentIntent = intent) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_START) + assertEquals(1, trackVisitEvents.size) + } + + @Test + fun `ON_START does not send trackVisit when currentIntent is null`() { + val manager = createManager(currentIntent = null) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_START) + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `ON_STOP then ON_START sends one trackVisit on return to foreground`() { + val intent = Intent() + val activity = buildActivityA(intent) + val owner = mockOwner() + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + manager.onActivityStarted(activity) + manager.onActivityResumed(activity) + manager.onActivityPaused(activity) + manager.onActivityStopped(activity) + manager.onStateChanged(owner, Lifecycle.Event.ON_STOP) + trackVisitEvents.clear() + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + assertEquals(1, trackVisitEvents.size) + } + + @Test + fun `other lifecycle events do not send trackVisit`() { + val manager = createManager(currentIntent = Intent()) + val owner = mockOwner() + for (event in listOf( + Lifecycle.Event.ON_CREATE, + Lifecycle.Event.ON_RESUME, + Lifecycle.Event.ON_PAUSE, + Lifecycle.Event.ON_DESTROY, + )) { + manager.onStateChanged(owner, event) + } + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `keepalive timer fires onTrackVisitReady`() { + val manager = createManagerNoCallbacks() + listenTrackVisit(manager) + manager.onActivityStarted(mockk(relaxed = true)) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_STOP) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_START) + // at least one track visit was sent (which in production also starts the timer) + assertEquals(1, trackVisitEvents.size) + } + + // endregion + + // region — scheduleReinitTrackVisit + // + // scheduleReinitTrackVisit() sets pendingVisit = true so the next callbacks assignment + // (via attachLifecycleCallbacks during Mindbox.init reinit) dispatches a track-visit + // immediately through the new endpoint. The backend uses this to learn the device is + // active in the new environment. + + @Test + fun `scheduleReinitTrackVisit dispatches visit immediately when callbacks are replaced`() { + // Simulate app already running with a known intent + val manager = LifecycleManager( + currentActivityName = null, + currentIntent = Intent(), + isAppInBackground = false, + ) + // Reinit: schedule before replacing callbacks (mirrors setupLifecycleManager order) + manager.scheduleReinitTrackVisit() + // attachLifecycleCallbacks() replaces callbacks → pendingVisit = true → dispatch + listenTrackVisit(manager) + assertEquals("reinit must send exactly one visit via new callbacks", 1, trackVisitEvents.size) + } + + @Test + fun `scheduleReinitTrackVisit sends DIRECT source for plain intent`() { + val manager = LifecycleManager( + currentActivityName = null, + currentIntent = Intent(), + isAppInBackground = false, + ) + manager.scheduleReinitTrackVisit() + listenTrackVisit(manager) + assertEquals(1, trackVisitEvents.size) + assertEquals(DIRECT, trackVisitEvents[0].first) + assertNull(trackVisitEvents[0].second) + } + + @Test + fun `scheduleReinitTrackVisit sends LINK source when current intent carries a deeplink`() { + val url = "https://example.com/promo" + val manager = LifecycleManager( + currentActivityName = null, + currentIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url)), + isAppInBackground = false, + ) + manager.scheduleReinitTrackVisit() + listenTrackVisit(manager) + assertEquals(1, trackVisitEvents.size) + assertEquals(LINK, trackVisitEvents[0].first) + assertEquals(url, trackVisitEvents[0].second) + } + + @Test + fun `scheduleReinitTrackVisit does not send when currentIntent is null`() { + // e.g. very early reinit before any activity has started + val manager = createManagerNoCallbacks(isAppInBackground = false) + manager.scheduleReinitTrackVisit() + listenTrackVisit(manager) + assertEquals("no visit when intent is still null", 0, trackVisitEvents.size) + } + + @Test + fun `scheduleReinitTrackVisit does not suppress following foreground visits`() { + val owner = mockOwner() + val manager = LifecycleManager( + currentActivityName = null, + currentIntent = Intent(), + isAppInBackground = false, + ) + // Reinit dispatch + manager.scheduleReinitTrackVisit() + listenTrackVisit(manager) + assertEquals(1, trackVisitEvents.size) + // Normal background + foreground must still produce a visit + manager.onStateChanged(owner, Lifecycle.Event.ON_STOP) + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + assertEquals("foreground after reinit must add exactly one more visit", 2, trackVisitEvents.size) + } + + @Test + fun `each scheduleReinitTrackVisit call triggers one visit per callbacks replacement`() { + val manager = LifecycleManager( + currentActivityName = null, + currentIntent = Intent(), + isAppInBackground = false, + ) + manager.scheduleReinitTrackVisit() + listenTrackVisit(manager) + assertEquals(1, trackVisitEvents.size) + + // Second reinit + manager.scheduleReinitTrackVisit() + listenTrackVisit(manager) + assertEquals(2, trackVisitEvents.size) + } + + // endregion + + // region — isTrackVisitSent + + @Test + fun `isTrackVisitSent returns false when currentIntent is null`() { + val manager = createManager(currentIntent = null) + assertFalse(manager.isTrackVisitSent()) + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `isTrackVisitSent returns true and sends trackVisit for new intent hash`() { + val manager = createManager(currentIntent = Intent()) + val result = manager.isTrackVisitSent() + assertTrue(result) + assertEquals(1, trackVisitEvents.size) + } + + @Test + fun `isTrackVisitSent returns true but does not resend for already-known intent hash`() { + val manager = createManager(currentIntent = Intent()) + manager.isTrackVisitSent() + trackVisitEvents.clear() + val result = manager.isTrackVisitSent() + assertTrue(result) + assertEquals(0, trackVisitEvents.size) + } + + // endregion + + // region — onNewIntent + + @Test + fun `onNewIntent does nothing for null intent`() { + createManager().onNewIntent(null) + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `onNewIntent sends LINK trackVisit for deeplink intent`() { + val url = "https://example.com/promo" + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + val manager = createManager() + manager.onNewIntent(intent) + assertEquals(1, trackVisitEvents.size) + assertEquals(LINK to url, trackVisitEvents[0]) + } + + @Test + fun `onNewIntent sends PUSH trackVisit for push intent`() { + val intent = Intent().apply { putExtra(IS_OPENED_FROM_PUSH_BUNDLE_KEY, true) } + val manager = createManager() + manager.onNewIntent(intent) + assertEquals(1, trackVisitEvents.size) + assertEquals(PUSH to null, trackVisitEvents[0]) + } + + @Test + fun `onNewIntent does not send trackVisit for plain intent without data or push key`() { + val manager = createManager() + manager.onNewIntent(Intent()) + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `onNewIntent sends DIRECT on second call with same intent`() { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com")) + val manager = createManager() + manager.onNewIntent(intent) + trackVisitEvents.clear() + manager.onNewIntent(intent) + assertEquals(1, trackVisitEvents.size) + assertEquals(DIRECT, trackVisitEvents[0].first) + } + + @Test + fun `onNewIntent sets skipNextTrackVisit when app is in background`() { + val deeplink = Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com")) + val manager = createManager(currentIntent = Intent(), isAppInBackground = true) + manager.onNewIntent(deeplink) + trackVisitEvents.clear() + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_START) + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `onNewIntent when not in background does not set skipNextTrackVisit`() { + val deeplink = Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com")) + val manager = createManager(currentIntent = Intent(), isAppInBackground = false) + manager.onNewIntent(deeplink) + trackVisitEvents.clear() + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_START) + assertEquals(1, trackVisitEvents.size) + } + + // endregion + + // region — intent hash deduplication + + @Test + fun `different intents each trigger separate trackVisit`() { + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + for (i in 1..5) { + manager.onActivityStarted(buildActivityA(Intent().apply { putExtra("seq", i) })) + } + assertEquals(5, trackVisitEvents.size) + } + + @Test + fun `after MAX_INTENT_HASHES entries oldest hash is evicted allowing reuse`() { + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + val firstIntent = Intent().apply { putExtra("id", 0) } + manager.onActivityStarted(buildActivityA(firstIntent)) + for (i in 1..50) { + manager.onActivityStarted(buildActivityA(Intent().apply { putExtra("id", i) })) + } + trackVisitEvents.clear() + manager.onActivityStarted(buildActivityA(firstIntent)) + assertEquals(1, trackVisitEvents.size) + } + + @Test + fun `reusing an intent whose hash is still in list does not send trackVisit`() { + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + val intent = Intent().apply { putExtra("id", 99) } + manager.onActivityStarted(buildActivityA(intent)) + trackVisitEvents.clear() + manager.onActivityStarted(buildActivityA(intent)) + assertEquals(0, trackVisitEvents.size) + } + + // endregion + + // region — callbacks set after foreground transition (late-init scenarios) + + @Test + fun `track visit dispatched when callbacks set after onMovedToForeground with null callbacks`() { + // Simulates Activity-init: LifecycleManager registered early, activity starts before init + val manager = createManagerNoCallbacks(isAppInBackground = true) + val owner = mockOwner() + // onActivityStarted clears background flag and records the intent + manager.onActivityStarted(buildActivityA(Intent())) + // ProcessLifecycle ON_START fires → onMovedToForeground, but callbacks are still null + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + assertEquals("no track visit yet — callbacks not set", 0, trackVisitEvents.size) + + // Mindbox.init() sets callbacks + listenTrackVisit(manager) + + assertEquals("pending track visit must be dispatched immediately on callbacks set", 1, trackVisitEvents.size) + } + + @Test + fun `no extra track visit when callbacks set while still in background`() { + // Simulates Application.onCreate() init: callbacks set before any activity starts + val manager = createManagerNoCallbacks(isAppInBackground = true) + listenTrackVisit(manager) + // No activity has started yet — no track visit should be sent + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `pending is cleared on background so next foreground sends exactly one track visit`() { + val manager = createManagerNoCallbacks(isAppInBackground = true) + val owner = mockOwner() + manager.onActivityStarted(buildActivityA(Intent())) + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + // Goes back to background before callbacks are set + manager.onStateChanged(owner, Lifecycle.Event.ON_STOP) + listenTrackVisit(manager) + // Pending was cleared on background → no dispatch on callbacks set + assertEquals(0, trackVisitEvents.size) + // Normal foreground cycle now sends exactly one track visit + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + assertEquals(1, trackVisitEvents.size) + } + + @Test + fun `deeplink source derived from stored intent state on late callbacks set`() { + // Source (LINK) and URL are re-computed from currentIntent/intentChanged at dispatch time, + // not stored as parameters — same as iOS deriving visit info from stored state. + val manager = createManagerNoCallbacks(isAppInBackground = true) + val owner = mockOwner() + val url = "https://example.com/promo" + manager.onActivityStarted(buildActivityA(Intent(Intent.ACTION_VIEW, Uri.parse(url)))) + // ON_START fires with callbacks null — sets pendingVisit=true, stores nothing else + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + // callbacks setter fires dispatchCurrentVisit → derives LINK from currentIntent + listenTrackVisit(manager) + assertEquals(1, trackVisitEvents.size) + assertEquals(LINK, trackVisitEvents[0].first) + assertEquals(url, trackVisitEvents[0].second) + } + + // endregion + + // region — Case 3: foreground fires before first activity (foregroundedWithoutIntent flag) + // + // Scenario: MindboxLifecycleInitializer did NOT run. Mindbox.init() is called from + // Application.onCreate(), so callbacks are set before any activity starts. + // Because ProcessLifecycleOwnerInitializer registered LifecycleDispatcher first, the + // process-level ON_START event fires *before* LifecycleManager.onActivityStarted. + // At that moment currentIntent is null, so the visit must be deferred until + // onActivityStarted provides the intent. + + @Test + fun `track visit sent when ON_START fires before first onActivityStarted`() { + val manager = createManagerNoCallbacks(isAppInBackground = true) + listenTrackVisit(manager) // callbacks set in Application.onCreate, before any activity + + // ON_START fires first (currentIntent still null) — no visit yet + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_START) + assertEquals("no track visit yet — currentIntent is null", 0, trackVisitEvents.size) + + // onActivityStarted fires after, supplying the intent + manager.onActivityStarted(buildActivityA(Intent())) + assertEquals("track visit must be dispatched once intent arrives", 1, trackVisitEvents.size) + } + + @Test + fun `DIRECT source sent in Case 3 for plain cold-start intent`() { + val manager = createManagerNoCallbacks(isAppInBackground = true) + listenTrackVisit(manager) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_START) + manager.onActivityStarted(buildActivityA(Intent())) + assertEquals(1, trackVisitEvents.size) + assertEquals(DIRECT, trackVisitEvents[0].first) + assertNull(trackVisitEvents[0].second) + } + + @Test + fun `LINK source sent in Case 3 when first activity carries a deeplink intent`() { + val url = "https://example.com/promo" + val manager = createManagerNoCallbacks(isAppInBackground = true) + listenTrackVisit(manager) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_START) + manager.onActivityStarted(buildActivityA(Intent(Intent.ACTION_VIEW, Uri.parse(url)))) + assertEquals(1, trackVisitEvents.size) + assertEquals(LINK, trackVisitEvents[0].first) + assertEquals(url, trackVisitEvents[0].second) + } + + @Test + fun `foregroundedWithoutIntent is cleared on background so stale flag does not fire later`() { + val owner = mockOwner() + val manager = createManagerNoCallbacks(isAppInBackground = true) + listenTrackVisit(manager) + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + manager.onStateChanged(owner, Lifecycle.Event.ON_STOP) + // Activity starts after background — flag is gone, no track visit from Case 3 path + manager.onActivityStarted(buildActivityA(Intent())) + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `Case 3 full cycle then background-foreground sends exactly two track visits total`() { + val owner = mockOwner() + val manager = createManagerNoCallbacks(isAppInBackground = true) + listenTrackVisit(manager) + + // Case 3 first foreground + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + manager.onActivityStarted(buildActivityA(Intent())) + assertEquals(1, trackVisitEvents.size) + + // User backgrounds and returns + manager.onStateChanged(owner, Lifecycle.Event.ON_STOP) + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + assertEquals("second foreground must add exactly one more visit", 2, trackVisitEvents.size) + } + + @Test + fun `scheduleReinitTrackVisit in Case 3 still sends deferred visit when activity provides intent`() { + // Reinit happens in Case 3 (no initializer + Application.onCreate). + // pendingVisit = true from scheduleReinitTrackVisit; callbacks replaced immediately after. + // dispatchCurrentVisit returns early (currentIntent == null). + // foregroundedWithoutIntent path then delivers the visit once the activity starts. + val owner = mockOwner() + val manager = createManagerNoCallbacks(isAppInBackground = true) + manager.scheduleReinitTrackVisit() + listenTrackVisit(manager) + assertEquals("no visit yet — intent is null at dispatch time", 0, trackVisitEvents.size) + // ON_START fires before activity (Case 3) + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + // Activity starts, providing the intent + manager.onActivityStarted(buildActivityA(Intent())) + assertEquals("reinit must not suppress the deferred Case 3 visit", 1, trackVisitEvents.size) + } + + // endregion + + // region — initialization order + + @Test + fun `manager with null currentActivityName does not send DIRECT for first activity start`() { + val manager = createManager(currentActivityName = null, currentIntent = null) + manager.onActivityStarted(buildActivityA(Intent())) + // areActivitiesEqual = (null == ActivityA.name) = false, source = DIRECT → no send + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `manager with null currentActivityName sends non-DIRECT trackVisit for first activity start`() { + val url = "https://example.com" + val manager = createManager(currentActivityName = null, currentIntent = null) + manager.onActivityStarted(buildActivityA(Intent(Intent.ACTION_VIEW, Uri.parse(url)))) + // areActivitiesEqual = false, source = LINK → sends + assertEquals(1, trackVisitEvents.size) + assertEquals(LINK to url, trackVisitEvents[0]) + } + + @Test + fun `full session lifecycle produces exactly two trackVisit events`() { + val intent = Intent() + val activity = buildActivityA(intent) + val owner = mockOwner() + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + // Launch + manager.onActivityStarted(activity) + manager.onActivityResumed(activity) + // User presses home + manager.onActivityPaused(activity) + manager.onActivityStopped(activity) + manager.onStateChanged(owner, Lifecycle.Event.ON_STOP) + // User returns + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + manager.onActivityStarted(activity) + manager.onActivityResumed(activity) + assertEquals(2, trackVisitEvents.size) + } + + @Test + fun `scheduleReinitTrackVisit while backgrounded sends visit on foreground and does not block subsequent visits`() { + // Typical reinit-while-backgrounded scenario: + // 1. Initial launch → visit #1 + // 2. User backgrounds the app + // 3. Mindbox.init() called again (reinit) → scheduleReinitTrackVisit + callbacks replaced + // → visit #2 dispatched immediately via dispatchCurrentVisit (new endpoint) + // 4. User returns to foreground → visit #3 + // 5. User backgrounds and foregrounds again → visit #4 + val intent = Intent() + val activity = buildActivityA(intent) + val owner = mockOwner() + val manager = LifecycleManager( + currentActivityName = LifecycleTestActivityA::class.java.name, + currentIntent = null, + isAppInBackground = false, + ) + listenTrackVisit(manager) // first init callbacks + + // Initial activity launch + manager.onActivityStarted(activity) // visit #1 + manager.onActivityResumed(activity) + manager.onActivityPaused(activity) + manager.onActivityStopped(activity) + manager.onStateChanged(owner, Lifecycle.Event.ON_STOP) + assertEquals(1, trackVisitEvents.size) + + // Reinit while backgrounded: schedule then replace callbacks (mirrors Mindbox.init flow) + manager.scheduleReinitTrackVisit() + listenTrackVisit(manager) + assertEquals("reinit dispatches visit immediately through new callbacks", 2, trackVisitEvents.size) + + // User returns + manager.onStateChanged(owner, Lifecycle.Event.ON_START) // visit #3 + assertEquals(3, trackVisitEvents.size) + + // Another background + foreground cycle + manager.onStateChanged(owner, Lifecycle.Event.ON_STOP) + manager.onStateChanged(owner, Lifecycle.Event.ON_START) // visit #4 + assertEquals(4, trackVisitEvents.size) + } + + // endregion +} + +private fun assertSame(expected: Any, actual: Any) { + assertTrue("Expected same instance", expected === actual) +} + +internal class LifecycleTestActivityA : Activity() + +internal class LifecycleTestActivityB : Activity() diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializerTest.kt new file mode 100644 index 000000000..f36c32e4c --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializerTest.kt @@ -0,0 +1,170 @@ +package cloud.mindbox.mobile_sdk.managers + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.test.core.app.ApplicationProvider +import cloud.mindbox.mobile_sdk.getCurrentProcessName +import cloud.mindbox.mobile_sdk.isMainProcess +import cloud.mindbox.mobile_sdk.models.LINK +import io.mockk.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class MindboxLifecycleInitializerTest { + + private lateinit var context: Application + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + mockkStatic("cloud.mindbox.mobile_sdk.ExtensionsKt") + } + + @After + fun tearDown() { + LifecycleManager.instance = null + unmockkAll() + } + + @Test + fun `instance is null before create is called`() { + assertNull(LifecycleManager.instance) + } + + @Test + fun `creates and stores LifecycleManager instance in main process`() { + every { any().getCurrentProcessName() } returns context.packageName + every { any().isMainProcess(context.packageName) } returns true + + MindboxLifecycleInitializer().create(context) + + assertNotNull(LifecycleManager.instance) + } + + @Test + fun `skips registration in non-main process`() { + val nonMainProcessName = "${context.packageName}:push" + every { any().getCurrentProcessName() } returns nonMainProcessName + every { any().isMainProcess(nonMainProcessName) } returns false + + MindboxLifecycleInitializer().create(context) + + assertNull( + "LifecycleManager must not be created outside the main process", + LifecycleManager.instance, + ) + } + + @Test + fun `calling create twice keeps the first instance`() { + every { any().getCurrentProcessName() } returns context.packageName + every { any().isMainProcess(any()) } returns true + + MindboxLifecycleInitializer().create(context) + val firstInstance = LifecycleManager.instance + + MindboxLifecycleInitializer().create(context) + + assertNotNull(LifecycleManager.instance) + assertSame( + "second create must be a no-op — the existing instance must be kept to avoid a leaked observer", + firstInstance, + LifecycleManager.instance, + ) + } + + @Test + fun `isAppInBackground true - suppresses track visit on first onActivityStarted`() { + every { any().getCurrentProcessName() } returns context.packageName + every { any().isMainProcess(any()) } returns true + + // Validate Robolectric environment assumption before trusting the assertion below. + val state = ProcessLifecycleOwner.get().lifecycle.currentState + assertFalse( + "Test requires ProcessLifecycleOwner below STARTED; got $state", + state.isAtLeast(Lifecycle.State.STARTED), + ) + + MindboxLifecycleInitializer().create(context) + + val manager = checkNotNull(LifecycleManager.instance) + val events = mutableListOf>() + manager.callbacks = object : LifecycleManager.Callbacks { + override fun onTrackVisitReady(source: String?, requestUrl: String?) { + events.add(source to requestUrl) + } + } + + // https intent → source = LINK, which bypasses the `!sameActivity && DIRECT` guard, so + // a visit would be dispatched if isAppInBackground were false. + val deepLinkIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com/promo")) + val activity = mockk(relaxed = true) { + every { this@mockk.intent } returns deepLinkIntent + } + + manager.onActivityStarted(activity) + + assertTrue( + "Track visit must NOT be dispatched on first onActivityStarted when the manager " + + "was created while the app was in the background (isAppInBackground = true)", + events.isEmpty(), + ) + } + + @Test + fun `isAppInBackground false - dispatches LINK track visit on onActivityStarted with https intent`() { + val deepLinkUrl = "https://example.com/promo" + val deepLinkIntent = Intent(Intent.ACTION_VIEW, Uri.parse(deepLinkUrl)) + + val manager = LifecycleManager( + currentActivityName = null, + currentIntent = null, + isAppInBackground = false, + ) + val events = mutableListOf>() + manager.callbacks = object : LifecycleManager.Callbacks { + override fun onTrackVisitReady(source: String?, requestUrl: String?) { + events.add(source to requestUrl) + } + } + val activity = mockk(relaxed = true) { + every { this@mockk.intent } returns deepLinkIntent + } + + manager.onActivityStarted(activity) + + assertEquals("Exactly one track visit must be dispatched", 1, events.size) + assertEquals("Source must be LINK for an https intent", LINK, events[0].first) + assertEquals("URL must equal the deep-link URI", deepLinkUrl, events[0].second) + } + + @Test + fun `non-main process skips creation regardless of process name format`() { + val processNames = listOf( + "${context.packageName}:firebase", + "${context.packageName}:push", + "com.yandex.metrica", + ":remote", + ) + + processNames.forEach { name -> + LifecycleManager.instance = null + every { any().getCurrentProcessName() } returns name + every { any().isMainProcess(name) } returns false + + MindboxLifecycleInitializer().create(context) + + assertNull("Process '$name' must not create a LifecycleManager", LifecycleManager.instance) + } + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxSetupLifecycleManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxSetupLifecycleManagerTest.kt new file mode 100644 index 000000000..017aa025b --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxSetupLifecycleManagerTest.kt @@ -0,0 +1,140 @@ +package cloud.mindbox.mobile_sdk.managers + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import cloud.mindbox.mobile_sdk.Mindbox +import io.mockk.spyk +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Tests for [Mindbox.setupLifecycleManager] and [Mindbox.attachLifecycleCallbacks]. + * + * Both methods are private, so they are invoked via reflection. The observable side-effects + * (changes to [LifecycleManager.instance] and its [LifecycleManager.callbacks] field) are used + * to assert correctness. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], manifest = Config.NONE) +internal class MindboxSetupLifecycleManagerTest { + + private val context: Application = ApplicationProvider.getApplicationContext() + + @After + fun tearDown() { + LifecycleManager.instance = null + setFirstInitCall(true) + unmockkAll() + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private fun setFirstInitCall(value: Boolean) { + val field = Mindbox::class.java.getDeclaredField("firstInitCall") + field.isAccessible = true + (field.get(Mindbox) as AtomicBoolean).set(value) + } + + private fun callSetupLifecycleManager(ctx: Context = context) { + val method = Mindbox::class.java + .getDeclaredMethod("setupLifecycleManager", Context::class.java) + method.isAccessible = true + method.invoke(Mindbox, ctx) + } + + private fun callAttachLifecycleCallbacks() { + val method = Mindbox::class.java.getDeclaredMethod("attachLifecycleCallbacks") + method.isAccessible = true + method.invoke(Mindbox) + } + + // ── setupLifecycleManager ───────────────────────────────────────────────── + + @Test + fun `register is called as fallback when startup initializer did not run`() { + assertNull(LifecycleManager.instance) + + callSetupLifecycleManager() + + assertNotNull(LifecycleManager.instance) + } + + @Test + fun `existing instance is kept when startup initializer already ran`() { + val existing = LifecycleManager(null, null, isAppInBackground = true) + LifecycleManager.instance = existing + + callSetupLifecycleManager() + + assertSame( + "register must not be called when already registered", + existing, + LifecycleManager.instance, + ) + } + + @Test + fun `scheduleReinitTrackVisit is called when already registered and it is not the first init`() { + val spy = spyk(LifecycleManager(null, null, isAppInBackground = true)) + LifecycleManager.instance = spy + setFirstInitCall(false) + + callSetupLifecycleManager() + + verify(exactly = 1) { spy.scheduleReinitTrackVisit() } + } + + @Test + fun `scheduleReinitTrackVisit is not called on the first init even when already registered`() { + val spy = spyk(LifecycleManager(null, null, isAppInBackground = true)) + LifecycleManager.instance = spy + // firstInitCall is true by default — no override needed + + callSetupLifecycleManager() + + verify(exactly = 0) { spy.scheduleReinitTrackVisit() } + } + + // ── attachLifecycleCallbacks ────────────────────────────────────────────── + + @Test + fun `attachLifecycleCallbacks sets callbacks when instance exists`() { + LifecycleManager.instance = LifecycleManager(null, null, isAppInBackground = true) + assertNull(LifecycleManager.instance!!.callbacks) + + callAttachLifecycleCallbacks() + + assertNotNull(LifecycleManager.instance!!.callbacks) + } + + @Test + fun `attachLifecycleCallbacks is a no-op when instance is null`() { + assertNull(LifecycleManager.instance) + + callAttachLifecycleCallbacks() // must not throw + } + + @Test + fun `attachLifecycleCallbacks replaces callbacks on each call`() { + val manager = LifecycleManager(null, null, isAppInBackground = true) + LifecycleManager.instance = manager + + callAttachLifecycleCallbacks() + val first = manager.callbacks + + callAttachLifecycleCallbacks() + val second = manager.callbacks + + assertNotNull(first) + assertNotNull(second) + assertNotSame("each init call must install a fresh Callbacks instance", first, second) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequestStub.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequestStub.kt deleted file mode 100644 index 0249c78ca..000000000 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequestStub.kt +++ /dev/null @@ -1,7 +0,0 @@ -package cloud.mindbox.mobile_sdk.models.operation.request - -internal class InAppHandleRequestStub { - companion object { - fun get() = InAppHandleRequest(inAppId = "") - } -} diff --git a/settings.gradle b/settings.gradle index 18e565307..0ac912068 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,5 @@ include ':sdk' +include ':detekt-rules' include ':mindbox-huawei' include ':mindbox-firebase' include ':mindbox-rustore'