[
  {
    "path": ".github/dependabot.yml",
    "content": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where the package manifests are located.\n# Please see the documentation for all configuration options:\n# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates\n\nversion: 2\nupdates:\n  - package-ecosystem: \"gradle\" # See documentation for possible values\n    directory: \"/\" # Location of package manifests\n    schedule:\n      interval: \"daily\"\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\" # Location of package manifests\n    schedule:\n      interval: \"weekly\""
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "\n"
  },
  {
    "path": ".github/release.yml",
    "content": "changelog:\n  exclude:\n    labels:\n      - ignore-for-release\n    authors:\n      - someuser\n  categories:\n    - title: Breaking Changes 🛠\n      labels:\n        - breaking-change\n    - title: Exciting New Features 🎉\n      labels:\n        - enhancement\n    - title: Dependencies\n      labels:\n        - dependencies\n    - title: Espresso test\n      labels:\n        - Espresso\n    - title: Pipeline\n      labels:\n        - pipeline\n    - title: Other Changes\n      labels:\n        - \"*\""
  },
  {
    "path": ".github/workflows/pull-request-ci.yml",
    "content": "name: PullRequest\n\non:\n  push:\n    branches:\n      - master\n  pull_request:\n\nenv:\n  BRANCH_NAME: ${{ github.head_ref || github.ref_name }}\n\njobs:\n  env-job:\n    runs-on: ubuntu-latest\n    outputs:\n      modified-branch-name: ${{ steps.env.outputs.MODIFIED_BRANCH_NAME }}\n    name: Modify branch name\n    steps:\n      - name: Sets MODIFIED_BRANCH_NAME\n        id: env\n        env:\n          name: \"${{env.BRANCH_NAME}}\"\n        run: |\n          echo \"MODIFIED_BRANCH_NAME=${name//\\//-}\" >> ${GITHUB_OUTPUT}\n          cat ${GITHUB_OUTPUT}\n\n  buildTest:\n    name: Build & Unit-Tests\n    runs-on: ${{ matrix.os }}\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [ ubuntu-latest ]\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n      - name: Set up JDK\n        uses: actions/setup-java@v5\n        with:\n          distribution: 'adopt'\n          java-version: 17\n      - uses: gradle/actions/wrapper-validation@v6\n      - name: Build project\n        run: ./gradlew assembleDebug\n      - name: Run tests\n        run: ./gradlew test\n      - name: Jacoco\n        run: ./gradlew :tracker:jacocoTestReport --no-daemon\n      - name: Codecov\n        run: bash <(curl -s https://codecov.io/bash)\n  Check:\n    name: Check\n    runs-on: ${{ matrix.os }}\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [ ubuntu-latest ]\n    needs:\n      - env-job\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n      - name: Install JDK\n        uses: actions/setup-java@v5\n        with:\n          distribution: 'adopt'\n          java-version: 17\n      - name: Code checks\n        run: ./gradlew check\n      - name: Archive Lint report\n        uses: actions/upload-artifact@v7\n        if: ${{ always() }}\n        with:\n          name: Matomo-Lint-${{ needs.env-job.outputs.modified-branch-name }}\n          path: tracker/build/reports/lint-results.html\n\n  Espresso:\n    runs-on: ${{ matrix.os }}\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [ ubuntu-latest ]\n        api: [ 30 ]\n        abi: [ x86_64 ]\n        tag: [ default ]\n    needs:\n      - env-job\n    steps:\n      - name: kvm support\n        run: |\n          egrep -c '(vmx|svm)' /proc/cpuinfo\n          id\n          sudo adduser $USER kvm\n          sudo chown -R $USER /dev/kvm\n          id\n      - uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n          submodules: true\n      - name: show envs\n        run: |\n          echo ${{ needs.env-job.outputs.modified-branch-name }}\n      - name: set up JDK 17\n        uses: actions/setup-java@v5\n        with:\n          distribution: 'adopt'\n          java-version: 17\n      - name: Install Android SDK\n        uses: hannesa2/action-android/install-sdk@0.1.16.7\n      - name: Run instrumentation tests\n        uses: hannesa2/action-android/emulator-run-cmd@0.1.16.7\n        with:\n          cmd: ./gradlew cAT --continue\n          api: ${{ matrix.api }}\n          tag: ${{ matrix.tag }}\n          abi: ${{ matrix.abi }}\n          cmdOptions: -noaudio -no-boot-anim -no-window -metrics-collection\n      - name: Archive Espresso results\n        uses: actions/upload-artifact@v7\n        if: ${{ always() }}\n        with:\n          name: matomo-Espresso-${{ needs.env-job.outputs.modified-branch-name }}\n          path: |\n            ./**/build/reports/androidTests/connected\n            ./**/build/outputs\n            !./**/build/outputs/apk\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release with changelog\n\non:\n  push:\n    tags:\n      - '*'\n\njobs:\n  release:\n    runs-on: ${{ matrix.os }}\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [ ubuntu-latest ]\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n      - name: Install JDK ${{ matrix.java_version }}\n        uses: actions/setup-java@v5\n        with:\n          distribution: 'adopt'\n          java-version: 17\n      - name: Build project\n        run: ./gradlew assembleRelease\n        env:\n          VERSION: ${{ github.ref }}\n      - name: Get the version\n        id: tagger\n        uses: jimschubert/query-tag-action@v2\n        with:\n          skip-unshallow: 'true'\n          abbrev: false\n          commit-ish: HEAD\n      - name: Check pre-release\n        run: |\n          echo \"tag=${{steps.tagger.outputs.tag}}\"\n          if [[ ${{ steps.tagger.outputs.tag }} == *alpha* || ${{ steps.tagger.outputs.tag }} == *beta* ]]\n          then\n             prerelease=true\n          else\n             prerelease=false\n          fi\n          echo \"PRE_RELEASE=$prerelease\" >> $GITHUB_ENV\n          echo \"prerelease=$prerelease\"\n      - name: Create Release\n        uses: softprops/action-gh-release@v3.0.0\n        with:\n          tag_name: ${{steps.tagger.outputs.tag}}\n          name: ${{steps.tagger.outputs.tag}}\n          prerelease: ${{ env.PRE_RELEASE }}\n          generate_release_notes: true\n          files: ./tracker/build/outputs/aar/tracker-release.aar\n        env:\n          GITHUB_TOKEN: ${{ secrets.FINE_GRAINED_PATN }}\n"
  },
  {
    "path": ".github/workflows/update-gradle-wrapper.yml",
    "content": "name: Update Gradle Wrapper\n\non:\n  schedule:\n    - cron: \"0 6 * * MON\"\n\njobs:\n  update-gradle-wrapper:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v6\n      - name: Install JDK\n        uses: actions/setup-java@v5\n        with:\n          distribution: 'adopt'\n          java-version: 17\n      - name: Update Gradle Wrapper\n        uses: gradle-update/update-gradle-wrapper-action@v2\n        with:\n          repo-token: ${{ secrets.GITHUB_TOKEN }}\n          set-distribution-checksum: false\n"
  },
  {
    "path": ".gitignore",
    "content": "# Gradle files\n.gradle/\nbuild/\n\n# Local configuration file (sdk path, etc)\nlocal.properties\n\n# Android Studio generated folders\n.navigation/\ncaptures/\n.externalNativeBuild\n\n# IntelliJ project files\n*.iml\n.idea/\n\n# Misc\n.DS_Store\nThumbs.db\n\n# Keystore files\n*.jks\n"
  },
  {
    "path": "LICENSE",
    "content": "Copyright 2018 Matomo team\n\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n* Redistributions of source code must retain the above copyright\n  notice, this list of conditions and the following disclaimer.\n\n* Redistributions in binary form must reproduce the above copyright\n  notice, this list of conditions and the following disclaimer in the\n  documentation and/or other materials provided with the distribution.\n\n* Neither the name of Matomo team nor the names of its contributors\n  may be used to endorse or promote products derived from this\n  software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n"
  },
  {
    "path": "README.md",
    "content": "Matomo SDK for Android\n========================\n\n[![](https://jitpack.io/v/matomo-org/matomo-sdk-android.svg)](https://jitpack.io/#matomo-org/matomo-sdk-android)\n![Build](https://github.com/matomo-org/matomo-sdk-android/actions/workflows/pull-request-ci.yml/badge.svg)\n[![Codecov](https://codecov.io/gh/matomo-org/matomo-sdk-android/branch/master/graph/badge.svg)](https://codecov.io/gh/matomo-org/matomo-sdk-android?branch=master)\n\nWelcome to the [Matomo](http://matomo.org) Tracking SDK for Android. This library helps you send analytics data from Android apps to Matomo instances. Until v4 this library was known as **Piwik** Tracking SDK for Android.\n\n__Features__:\n* Caching and offline support\n* Graceful reconnection handling\n* WIFI-only mode\n* Thread-safe support for multiple trackers\n* Support for custom connection implementations\n* Complete [Matomo HTTP API](https://developer.matomo.org/api-reference/tracking-api) support\n    * [Custom dimensions](https://matomo.org/docs/custom-dimensions/)\n    * [Event Tracking](https://matomo.org/docs/event-tracking/)\n    * [Content Tracking](https://matomo.org/docs/content-tracking/)\n    * [Ecommerce](https://matomo.org/docs/ecommerce-analytics/)\n* Checksum based app install/upgrade tracking\n\n## Quickstart\nFor the not so quick start, [see here](https://github.com/matomo-org/matomo-sdk-android/wiki/Getting-started) or look at our [demo app](https://github.com/matomo-org/matomo-sdk-android/tree/master/exampleapp)\n\n* [Setup Matomo](https://matomo.org/docs/installation/) on your server.\n* Include the library in your app modules `build.gradle` file\n  via [JitPack](https://jitpack.io/#matomo-org/matomo-sdk-android)\n\n```groovy\nrepositories {\n  maven { url 'https://jitpack.io' }\n}\ndependencies {\n  implementation 'com.github.matomo-org:matomo-sdk-android:<latest-version>'\n}\n```\n\n* Now you need to initialize your `Tracker`. It's recommended to store it as singleton. You can extend `MatomoApplication` or create and store a `Tracker` instance yourself:\n```java\nimport org.matomo.sdk.TrackerBuilder;\n\npublic class YourApplication extends Application {\n    private Tracker tracker;\n    public synchronized Tracker getTracker() {\n        if (tracker == null){\n            tracker = TrackerBuilder.createDefault(\"http://domain.tld/matomo.php\", 1).build(Matomo.getInstance(this));\n        }\n        return tracker;\n    }\n}\n```\n\n* The `TrackHelper` class is the easiest way to submit events to your tracker:\n```java\n// The `Tracker` instance from the previous step\nTracker tracker = ((MatomoApplication) getApplication()).getTracker();\n// Track a screen view\nTrackHelper.track().screen(\"/activity_main/activity_settings\").title(\"Settings\").with(tracker);\n// Monitor your app installs\nTrackHelper.track().download().with(tracker);\n```\n\n* Something not working? Check [here](https://github.com/matomo-org/matomo-sdk-android/wiki/Troubleshooting).\n\n## License\nAndroid SDK for Matomo is released under the BSD-3 Clause license, see [LICENSE](https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE).\n"
  },
  {
    "path": "build.gradle",
    "content": "buildscript {\n    ext.kotlin_version = \"2.2.20\"\n    repositories {\n        google()\n        maven { url \"https://plugins.gradle.org/m2/\" }\n        maven { url \"https://s01.oss.sonatype.org/content/repositories/snapshots\" }\n    }\n    dependencies {\n        classpath \"com.android.tools.build:gradle:8.13.0\"\n        classpath \"com.mxalbert.gradle:jacoco-android:0.2.1\"\n        classpath \"org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version\"\n    }\n}\n\nallprojects {\n    repositories {\n        google()\n        maven { url \"https://plugins.gradle.org/m2/\" }\n        maven { url \"https://jitpack.io\" }\n    }\n    ext {\n        globalMinSdkVersion = 21\n        globalTargetSdkVersion = 35\n        globalCompileSdkVersion = 35\n    }\n}\n"
  },
  {
    "path": "exampleapp/README.md",
    "content": "# Demo Application Matomo Android SDK\n\n## Description\n\nExample of using the Matomo Tracking SDK for Android\n\n```\n./gradlew :example:clean :example:installDebug\n```"
  },
  {
    "path": "exampleapp/build.gradle",
    "content": "plugins {\n    id \"com.android.application\"\n    id \"kotlin-android\"\n}\n\nandroid {\n    namespace \"org.matomo.demo\"\n    defaultConfig {\n        applicationId \"org.matomo.demo\"\n        minSdkVersion project.ext.globalMinSdkVersion\n        compileSdk project.ext.globalCompileSdkVersion\n        targetSdkVersion project.ext.globalTargetSdkVersion\n        versionCode 2\n        versionName \"2.0\"\n\n        testInstrumentationRunner \"androidx.test.runner.AndroidJUnitRunner\"\n        testInstrumentationRunnerArguments useTestStorageService: \"true\"\n    }\n    buildTypes {\n        release {\n        }\n    }\n    compileOptions {\n        sourceCompatibility JavaVersion.VERSION_17\n        targetCompatibility JavaVersion.VERSION_17\n    }\n    kotlinOptions {\n        jvmTarget = \"17\"\n    }\n}\n\ndependencies {\n    implementation project(\":tracker\")\n    implementation \"androidx.appcompat:appcompat:1.7.1\"\n    implementation \"androidx.legacy:legacy-support-v4:1.0.0\"\n    implementation \"com.github.AppDevNext.Logcat:LogcatCoreLib:3.4\"\n    implementation \"org.jetbrains.kotlin:kotlin-stdlib-jdk8\"\n\n    androidTestImplementation \"androidx.test.ext:junit-ktx:1.3.0\"\n    androidTestUtil \"androidx.test.services:test-services:1.6.0\"\n    androidTestImplementation \"androidx.test.espresso:espresso-core:3.7.0\"\n}\n"
  },
  {
    "path": "exampleapp/src/androidTest/java/org/matomo/demo/SmokeTest.kt",
    "content": "package org.matomo.demo\n\nimport android.graphics.Bitmap\nimport androidx.test.core.graphics.writeToTestStorage\nimport androidx.test.espresso.Espresso.onView\nimport androidx.test.espresso.action.ViewActions.captureToBitmap\nimport androidx.test.espresso.action.ViewActions.click\nimport androidx.test.espresso.matcher.ViewMatchers.isRoot\nimport androidx.test.espresso.matcher.ViewMatchers.withId\nimport androidx.test.ext.junit.rules.activityScenarioRule\nimport androidx.test.ext.junit.runners.AndroidJUnit4\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.rules.TestName\nimport org.junit.runner.RunWith\n\n@RunWith(AndroidJUnit4::class)\nclass SmokeTest {\n\n    @get:Rule\n    val activityScenarioRule = activityScenarioRule<DemoActivity>()\n\n    @get:Rule\n    var nameRule = TestName()\n\n    @Test\n    fun testExpand() {\n        onView(withId(R.id.trackMainScreenViewButton)).perform(click())\n        onView(isRoot())\n            .perform(captureToBitmap { bitmap: Bitmap -> bitmap.writeToTestStorage(\"${javaClass.simpleName}_${nameRule.methodName}-trackMainScreenViewButton\") })\n\n        onView(withId(R.id.trackDispatchNow)).perform(click())\n        onView(isRoot())\n            .perform(captureToBitmap { bitmap: Bitmap -> bitmap.writeToTestStorage(\"${javaClass.simpleName}_${nameRule.methodName}-trackDispatchNow\") })\n\n        onView(withId(R.id.trackCustomVarsButton)).perform(click())\n        onView(isRoot())\n            .perform(captureToBitmap { bitmap: Bitmap -> bitmap.writeToTestStorage(\"${javaClass.simpleName}_${nameRule.methodName}-trackCustomVarsButton\") })\n\n        onView(withId(R.id.raiseExceptionButton)).perform(click())\n        onView(isRoot())\n            .perform(captureToBitmap { bitmap: Bitmap -> bitmap.writeToTestStorage(\"${javaClass.simpleName}_${nameRule.methodName}-raiseExceptionButton\") })\n\n        onView(withId(R.id.addEcommerceItemButton)).perform(click())\n        onView(isRoot())\n            .perform(captureToBitmap { bitmap: Bitmap -> bitmap.writeToTestStorage(\"${javaClass.simpleName}_${nameRule.methodName}-addEcommerceItemButton\") })\n\n        onView(withId(R.id.trackEcommerceCartUpdateButton)).perform(click())\n        onView(isRoot())\n            .perform(captureToBitmap { bitmap: Bitmap -> bitmap.writeToTestStorage(\"${javaClass.simpleName}_${nameRule.methodName}-trackEcommerceCartUpdateButton\") })\n\n        onView(withId(R.id.completeEcommerceOrderButton)).perform(click())\n        onView(isRoot())\n            .perform(captureToBitmap { bitmap: Bitmap -> bitmap.writeToTestStorage(\"${javaClass.simpleName}_${nameRule.methodName}-completeEcommerceOrderButton\") })\n\n        onView(withId(R.id.trackGoalButton)).perform(click())\n        onView(isRoot())\n            .perform(captureToBitmap { bitmap: Bitmap -> bitmap.writeToTestStorage(\"${javaClass.simpleName}_${nameRule.methodName}-trackGoalButton\") })\n\n        onView(withId(R.id.goalTextEditView)).perform(click())\n        onView(isRoot())\n            .perform(captureToBitmap { bitmap: Bitmap -> bitmap.writeToTestStorage(\"${javaClass.simpleName}_${nameRule.methodName}-goalTextEditView\") })\n    }\n\n}\n"
  },
  {
    "path": "exampleapp/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n    <uses-permission android:name=\"android.permission.INTERNET\"/>\n    <uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>\n    <uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>\n\n    <application\n        android:name=\"org.matomo.demo.DemoApp\"\n        android:allowBackup=\"true\"\n        android:icon=\"@drawable/ic_launcher\"\n        android:label=\"@string/app_name\"\n        android:theme=\"@style/AppTheme\">\n        <activity\n            android:name=\"org.matomo.demo.DemoActivity\"\n            android:alwaysRetainTaskState=\"true\"\n            android:launchMode=\"singleInstance\"\n            android:exported=\"true\">\n            <intent-filter>\n                <action android:name=\"android.intent.action.MAIN\"/>\n\n                <category android:name=\"android.intent.category.LAUNCHER\"/>\n            </intent-filter>\n        </activity>\n        <activity\n            android:name=\"org.matomo.demo.SettingsActivity\"\n            android:alwaysRetainTaskState=\"true\"\n            android:label=\"@string/title_activity_settings\"\n            android:launchMode=\"singleInstance\"\n            android:parentActivityName=\"org.matomo.demo.DemoActivity\">\n            <meta-data\n                android:name=\"android.support.PARENT_ACTIVITY\"\n                android:value=\"org.matomo.demo.DemoActivity\"/>\n        </activity>\n    </application>\n\n</manifest>\n"
  },
  {
    "path": "exampleapp/src/main/java/org/matomo/demo/DemoActivity.kt",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\npackage org.matomo.demo\n\nimport android.content.Intent\nimport android.os.Bundle\nimport android.view.Menu\nimport android.view.MenuItem\nimport android.view.View\nimport android.widget.EditText\nimport androidx.appcompat.app.AppCompatActivity\nimport org.matomo.sdk.QueryParams\nimport org.matomo.sdk.TrackMe\nimport org.matomo.sdk.Tracker\nimport org.matomo.sdk.extra.EcommerceItems\nimport org.matomo.sdk.extra.MatomoApplication\nimport org.matomo.sdk.extra.TrackHelper\n\nclass DemoActivity : AppCompatActivity() {\n    private var cartItems: Int = 0\n    private var items: EcommerceItems? = null\n\n    private val tracker: Tracker\n        get() = (application as MatomoApplication).tracker\n\n\n    override fun onCreate(savedInstanceState: Bundle?) {\n        super.onCreate(savedInstanceState)\n        setContentView(R.layout.activity_demo)\n        items = EcommerceItems()\n\n        findViewById<View>(R.id.trackMainScreenViewButton).setOnClickListener { v: View? ->\n            TrackHelper.track(TrackMe().set(QueryParams.SESSION_START, 1))\n                .screen(\"/\")\n                .title(\"Main screen\")\n                .with(tracker)\n        }\n\n        findViewById<View>(R.id.trackDispatchNow).setOnClickListener { v: View? -> tracker.dispatch() }\n\n        findViewById<View>(R.id.trackCustomVarsButton).setOnClickListener { v: View? ->\n            TrackHelper.track()\n                .screen(\"/custom_vars\")\n                .title(\"Custom Vars\")\n                .variable(1, \"first\", \"var\")\n                .variable(2, \"second\", \"long value\")\n                .with(tracker)\n        }\n\n        findViewById<View>(R.id.raiseExceptionButton).setOnClickListener { v: View? ->\n            TrackHelper.track()\n                .exception(Exception(\"OnPurposeException\"))\n                .description(\"Crash button\")\n                .fatal(false)\n                .with(tracker)\n        }\n\n        findViewById<View>(R.id.trackGoalButton).setOnClickListener { v: View? ->\n            var revenue: Float\n            try {\n                revenue = (findViewById<View>(R.id.goalTextEditView) as EditText).text.toString().toInt().toFloat()\n            } catch (e: Exception) {\n                TrackHelper.track().exception(e).description(\"wrong revenue\").with(tracker)\n                revenue = 0f\n            }\n            TrackHelper.track().goal(1).revenue(revenue).with(tracker)\n        }\n\n        findViewById<View>(R.id.addEcommerceItemButton).setOnClickListener { v: View? ->\n            val skus: List<String> = mutableListOf(\"00001\", \"00002\", \"00003\", \"00004\")\n            val names: List<String> = mutableListOf(\"Silly Putty\", \"Fishing Rod\", \"Rubber Boots\", \"Cool Ranch Doritos\")\n            val categories: List<String> = mutableListOf(\"Toys & Games\", \"Hunting & Fishing\", \"Footwear\", \"Grocery\")\n            val prices: List<Int> = mutableListOf(449, 3495, 2450, 250)\n\n            val index = cartItems % 4\n            val quantity = (cartItems / 4) + 1\n\n            items!!.addItem(\n                EcommerceItems.Item(skus[index])\n                    .name(names[index])\n                    .category(categories[index])\n                    .price(prices[index])\n                    .quantity(quantity)\n            )\n            cartItems++\n        }\n\n        findViewById<View>(R.id.trackEcommerceCartUpdateButton).setOnClickListener { v: View? ->\n            TrackHelper.track()\n                .cartUpdate(8600)\n                .items(items)\n                .with(tracker)\n        }\n\n        findViewById<View>(R.id.completeEcommerceOrderButton).setOnClickListener { v: View? ->\n            TrackHelper.track()\n                .order((10000 * Math.random()).toString(), 10000)\n                .subTotal(1000)\n                .tax(2000)\n                .shipping(3000)\n                .discount(500)\n                .items(items)\n                .with(tracker)\n        }\n    }\n\n    override fun onCreateOptionsMenu(menu: Menu): Boolean {\n        menuInflater.inflate(R.menu.demo, menu)\n        return true\n    }\n\n    override fun onOptionsItemSelected(item: MenuItem): Boolean {\n        val id = item.itemId\n        if (id == R.id.action_settings) {\n            val intent = Intent(this, SettingsActivity::class.java)\n            intent.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)\n            startActivity(intent)\n            return true\n        }\n        return super.onOptionsItemSelected(item)\n    }\n}\n"
  },
  {
    "path": "exampleapp/src/main/java/org/matomo/demo/DemoApp.kt",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\npackage org.matomo.demo\n\nimport info.hannes.timber.DebugFormatTree\nimport org.matomo.sdk.TrackMe\nimport org.matomo.sdk.TrackerBuilder\nimport org.matomo.sdk.extra.DimensionQueue\nimport org.matomo.sdk.extra.DownloadTracker.Extra\nimport org.matomo.sdk.extra.MatomoApplication\nimport org.matomo.sdk.extra.TrackHelper\nimport timber.log.Timber\nimport timber.log.Timber.Forest.plant\n\nclass DemoApp : MatomoApplication() {\n    override fun onCreateTrackerConfig(): TrackerBuilder {\n        return TrackerBuilder.createDefault(\"https://demo2.matomo.org/matomo.php\", 81)\n    }\n\n    override fun onCreate() {\n        super.onCreate()\n        onInitTracker()\n    }\n\n    private fun onInitTracker() {\n        // Print debug output when working on an app.\n        plant(DebugFormatTree())\n\n        // When working on an app we don't want to skew tracking results.\n        // getMatomo().setDryRun(BuildConfig.DEBUG);\n\n        // If you want to set a specific userID other than the random UUID token, do it NOW to ensure all future actions use that token.\n        // Changing it later will track new events as belonging to a different user.\n        // String userEmail = ....preferences....getString\n        // getTracker().setUserId(userEmail);\n\n        // Track this app install, this will only trigger once per app version.\n        // i.e. \"http://org.matomo.demo:1/185DECB5CFE28FDB2F45887022D668B4\"\n        TrackHelper.track().download().identifier(Extra.ApkChecksum(this)).with(tracker)\n        // Alternative:\n        // i.e. \"http://org.matomo.demo:1/com.android.vending\"\n        // getTracker().download();\n        val dimensionQueue = DimensionQueue(tracker)\n\n        // This will be send the next time something is tracked.\n        dimensionQueue.add(0, \"test\")\n        tracker.addTrackingCallback { trackMe: TrackMe? ->\n            Timber.i(\"Tracker.Callback.onTrack(%s)\", trackMe)\n            trackMe\n        }\n    }\n}"
  },
  {
    "path": "exampleapp/src/main/java/org/matomo/demo/SettingsActivity.java",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\n\npackage org.matomo.demo;\n\nimport android.annotation.SuppressLint;\nimport android.app.Activity;\nimport android.os.Bundle;\nimport android.text.Editable;\nimport android.text.TextWatcher;\nimport android.view.View;\nimport android.widget.Button;\nimport android.widget.CheckBox;\nimport android.widget.EditText;\n\nimport org.matomo.sdk.extra.MatomoApplication;\nimport org.matomo.sdk.extra.TrackHelper;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\n\nimport timber.log.Timber;\n\n\npublic class SettingsActivity extends Activity {\n\n    private void refreshUI(final Activity settingsActivity) {\n        // auto track button\n        Button button = findViewById(R.id.bindtoapp);\n        button.setOnClickListener(new View.OnClickListener() {\n            @Override\n            public void onClick(View v) {\n                TrackHelper.track().screens(getApplication()).with(((MatomoApplication) getApplication()).getTracker());\n            }\n        });\n\n        // Dry run\n        CheckBox dryRun = findViewById(R.id.dryRunCheckbox);\n        dryRun.setChecked(((MatomoApplication) getApplication()).getTracker().getDryRunTarget() != null);\n        dryRun.setOnClickListener(new View.OnClickListener() {\n            @Override\n            public void onClick(View v) {\n                ((MatomoApplication) getApplication()).getTracker().setDryRunTarget(((CheckBox) v).isChecked() ? Collections.synchronizedList(new ArrayList<>()) : null);\n            }\n        });\n\n        // out out\n        CheckBox optOut = findViewById(R.id.optOutCheckbox);\n        optOut.setChecked(((MatomoApplication) getApplication()).getTracker().isOptOut());\n        optOut.setOnClickListener(new View.OnClickListener() {\n            @Override\n            public void onClick(View v) {\n                ((MatomoApplication) getApplication()).getTracker().setOptOut(((CheckBox) v).isChecked());\n            }\n        });\n\n        // dispatch interval\n        EditText input = findViewById(R.id.dispatchIntervallInput);\n        input.setText(Long.toString(\n                ((MatomoApplication) getApplication()).getTracker().getDispatchInterval()\n        ));\n        input.addTextChangedListener(\n                new TextWatcher() {\n                    @Override\n                    public void onTextChanged(CharSequence charSequence, int i, int i2, int i3) {\n                        try {\n                            int interval = Integer.parseInt(charSequence.toString().trim());\n                            ((MatomoApplication) getApplication()).getTracker()\n                                    .setDispatchInterval(interval);\n                        } catch (NumberFormatException e) {\n                            Timber.d(\"not a number: %s\", charSequence.toString());\n                        }\n                    }\n\n                    @Override\n                    public void beforeTextChanged(CharSequence charSequence, int i, int i2, int i3) {\n                    }\n\n                    @Override\n                    public void afterTextChanged(Editable editable) {\n                    }\n                }\n\n        );\n\n        //session Timeout Input\n        input = findViewById(R.id.sessionTimeoutInput);\n        input.setText(Long.toString(\n                (((MatomoApplication) getApplication()).getTracker().getSessionTimeout() / 60000)\n        ));\n        input.addTextChangedListener(\n                new TextWatcher() {\n                    @SuppressLint(\"SetTextI18n\")\n                    @Override\n                    public void onTextChanged(CharSequence charSequence, int i, int i2, int i3) {\n                        try {\n                            int timeoutMin = Integer.parseInt(charSequence.toString().trim());\n                            timeoutMin = Math.abs(timeoutMin);\n                            ((MatomoApplication) getApplication()).getTracker()\n                                    .setSessionTimeout(timeoutMin * 60);\n                        } catch (NumberFormatException e) {\n                            ((EditText) settingsActivity.findViewById(R.id.sessionTimeoutInput)).setText(\"30\");\n                            Timber.d(\"not a number: %s\", charSequence.toString());\n                        }\n                    }\n\n                    @Override\n                    public void beforeTextChanged(CharSequence charSequence, int i, int i2, int i3) {\n                    }\n\n                    @Override\n                    public void afterTextChanged(Editable editable) {\n                    }\n                }\n\n        );\n\n    }\n\n    @Override\n    public void onCreate(Bundle savedInstanceState) {\n        super.onCreate(savedInstanceState);\n        setContentView(R.layout.activity_settings);\n        refreshUI(this);\n    }\n\n}\n"
  },
  {
    "path": "exampleapp/src/main/res/layout/activity_demo.xml",
    "content": "<LinearLayout xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    xmlns:tools=\"http://schemas.android.com/tools\"\n    android:layout_width=\"match_parent\"\n    android:layout_height=\"match_parent\"\n    android:orientation=\"vertical\"\n    android:paddingLeft=\"@dimen/activity_horizontal_margin\"\n    android:paddingTop=\"@dimen/activity_vertical_margin\"\n    android:paddingRight=\"@dimen/activity_horizontal_margin\"\n    android:paddingBottom=\"@dimen/activity_vertical_margin\"\n    tools:context=\"org.matomo.demo.DemoActivity\">\n\n    <Button\n        android:id=\"@+id/trackMainScreenViewButton\"\n        android:layout_width=\"match_parent\"\n        android:layout_height=\"wrap_content\"\n        android:text=\"@string/track_screen_vew_button_label\" />\n\n    <Button\n        android:id=\"@+id/trackDispatchNow\"\n        android:layout_width=\"match_parent\"\n        android:layout_height=\"wrap_content\"\n        android:text=\"@string/action_dispatch_now\" />\n\n    <Button\n        android:id=\"@+id/trackCustomVarsButton\"\n        android:layout_width=\"match_parent\"\n        android:layout_height=\"wrap_content\"\n        android:text=\"@string/action_track_custom_vars\" />\n\n    <Button\n        android:id=\"@+id/raiseExceptionButton\"\n        android:layout_width=\"match_parent\"\n        android:layout_height=\"wrap_content\"\n        android:text=\"@string/action_divide_by_zero\" />\n\n    <Button\n        android:id=\"@+id/addEcommerceItemButton\"\n        android:layout_width=\"match_parent\"\n        android:layout_height=\"wrap_content\"\n        android:text=\"@string/action_add_ecommerce_item\" />\n\n    <Button\n        android:id=\"@+id/trackEcommerceCartUpdateButton\"\n        android:layout_width=\"match_parent\"\n        android:layout_height=\"wrap_content\"\n        android:text=\"@string/action_track_ecommerce_cart_update\" />\n\n    <Button\n        android:id=\"@+id/completeEcommerceOrderButton\"\n        android:layout_width=\"match_parent\"\n        android:layout_height=\"wrap_content\"\n        android:text=\"@string/action_track_ecommerce_order\" />\n\n    <LinearLayout\n        android:layout_width=\"match_parent\"\n        android:layout_height=\"wrap_content\"\n        android:orientation=\"horizontal\">\n\n        <Button\n            android:id=\"@+id/trackGoalButton\"\n            android:layout_width=\"wrap_content\"\n            android:layout_height=\"wrap_content\"\n            android:text=\"@string/action_track_goal\" />\n\n        <EditText\n            android:id=\"@+id/goalTextEditView\"\n            android:layout_width=\"0dp\"\n            android:layout_height=\"wrap_content\"\n            android:layout_weight=\"1\"\n            android:ems=\"10\"\n            android:hint=\"1\"\n            android:inputType=\"number\"\n            android:text=\"1\"\n            tools:ignore=\"HardcodedText\" />\n\n        <TextView\n            android:id=\"@+id/textView\"\n            android:layout_width=\"wrap_content\"\n            android:layout_height=\"wrap_content\"\n            android:text=\"$ Revenue\"\n            android:textAppearance=\"?android:attr/textAppearanceMedium\"\n            tools:ignore=\"HardcodedText\" />\n    </LinearLayout>\n</LinearLayout>\n"
  },
  {
    "path": "exampleapp/src/main/res/layout/activity_settings.xml",
    "content": "<RelativeLayout xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    xmlns:tools=\"http://schemas.android.com/tools\"\n    android:layout_width=\"match_parent\"\n    android:layout_height=\"match_parent\"\n    android:paddingLeft=\"@dimen/activity_horizontal_margin\"\n    android:paddingTop=\"@dimen/activity_vertical_margin\"\n    android:paddingRight=\"@dimen/activity_horizontal_margin\"\n    android:paddingBottom=\"@dimen/activity_vertical_margin\"\n    tools:context=\"org.matomo.demo.SettingsActivity\"\n    tools:ignore=\"TextViewEdits,HardcodedText,Autofill,LabelFor\">\n\n    <TableLayout\n        android:id=\"@+id/tableLayout\"\n        android:layout_width=\"fill_parent\"\n        android:layout_height=\"wrap_content\">\n\n        <TableRow\n            android:layout_width=\"fill_parent\"\n            android:layout_height=\"wrap_content\"\n            android:layout_marginBottom=\"5dp\">\n\n            <TextView\n                android:layout_width=\"match_parent\"\n                android:layout_height=\"wrap_content\"\n                android:layout_column=\"0\"\n                android:inputType=\"number\"\n                android:text=\"Dispatch Interval\"\n                android:textAppearance=\"?android:attr/textAppearanceMedium\" />\n\n            <EditText\n                android:id=\"@+id/dispatchIntervallInput\"\n                android:layout_width=\"match_parent\"\n                android:layout_height=\"wrap_content\"\n                android:layout_column=\"1\"\n                android:width=\"60dip\"\n                android:ems=\"10\"\n                android:inputType=\"numberSigned\"\n                android:text=\"5\" />\n\n            <TextView\n                android:layout_width=\"match_parent\"\n                android:layout_height=\"wrap_content\"\n                android:layout_column=\"2\"\n                android:text=\"(sec)\"\n                android:textAppearance=\"?android:attr/textAppearanceMedium\" />\n        </TableRow>\n\n        <TableRow\n            android:layout_width=\"fill_parent\"\n            android:layout_height=\"wrap_content\"\n            android:layout_marginBottom=\"5dp\">\n\n            <TextView\n                android:layout_width=\"wrap_content\"\n                android:layout_height=\"wrap_content\"\n                android:layout_column=\"0\"\n                android:inputType=\"number\"\n                android:text=\"Session timeout \"\n                android:textAppearance=\"?android:attr/textAppearanceMedium\" />\n\n            <EditText\n                android:id=\"@+id/sessionTimeoutInput\"\n                android:layout_width=\"wrap_content\"\n                android:layout_height=\"wrap_content\"\n                android:layout_column=\"1\"\n                android:width=\"60dip\"\n                android:ems=\"10\"\n                android:inputType=\"numberSigned\"\n                android:text=\"30\" />\n\n            <TextView\n                android:layout_width=\"wrap_content\"\n                android:layout_height=\"wrap_content\"\n                android:layout_column=\"2\"\n                android:text=\"(min)\"\n                android:textAppearance=\"?android:attr/textAppearanceMedium\" />\n        </TableRow>\n\n        <TableRow\n            android:layout_width=\"fill_parent\"\n            android:layout_height=\"wrap_content\"\n            android:layout_marginBottom=\"5dp\">\n\n            <CheckBox\n                android:id=\"@+id/dryRunCheckbox\"\n                android:layout_width=\"wrap_content\"\n                android:layout_height=\"wrap_content\"\n                android:layout_column=\"0\"\n                android:layout_span=\"3\"\n                android:checked=\"false\"\n                android:text=\"Dry Run\" />\n        </TableRow>\n\n        <TableRow\n            android:id=\"@+id/tableRow\"\n            android:layout_width=\"fill_parent\"\n            android:layout_height=\"wrap_content\"\n            android:layout_marginBottom=\"5dp\">\n\n            <CheckBox\n                android:id=\"@+id/optOutCheckbox\"\n                android:layout_width=\"wrap_content\"\n                android:layout_height=\"wrap_content\"\n                android:layout_column=\"0\"\n                android:layout_span=\"3\"\n                android:checked=\"false\"\n                android:text=\"Opt Out\" />\n        </TableRow>\n    </TableLayout>\n\n    <Button\n        android:id=\"@+id/bindtoapp\"\n        android:layout_width=\"wrap_content\"\n        android:layout_height=\"wrap_content\"\n        android:layout_column=\"0\"\n        android:layout_below=\"@+id/tableLayout\"\n        android:layout_alignEnd=\"@+id/tableLayout\"\n        android:layout_alignRight=\"@+id/tableLayout\"\n        android:layout_alignParentStart=\"true\"\n        android:layout_alignParentLeft=\"true\"\n        android:layout_gravity=\"bottom\"\n        android:text=\"Auto track activities\" />\n</RelativeLayout>\n"
  },
  {
    "path": "exampleapp/src/main/res/menu/demo.xml",
    "content": "<menu xmlns:android=\"http://schemas.android.com/apk/res/android\"\n      xmlns:app=\"http://schemas.android.com/apk/res-auto\"\n      xmlns:tools=\"http://schemas.android.com/tools\"\n      tools:context=\"org.matomo.demo.DemoActivity\">\n    <item android:id=\"@+id/action_settings\"\n        android:title=\"@string/action_settings\"\n        android:orderInCategory=\"100\"\n        app:showAsAction=\"never\" />\n</menu>\n"
  },
  {
    "path": "exampleapp/src/main/res/values/dimens.xml",
    "content": "<resources>\n    <!-- Default screen margins, per the Android Design guidelines. -->\n    <dimen name=\"activity_horizontal_margin\">16dp</dimen>\n    <dimen name=\"activity_vertical_margin\">16dp</dimen>\n</resources>\n"
  },
  {
    "path": "exampleapp/src/main/res/values/strings.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n\n    <string name=\"app_name\">Matomo Example App</string>\n    <string name=\"action_settings\">Settings</string>\n    <string name=\"track_screen_vew_button_label\">Track Screen View</string>\n    <string name=\"title_activity_settings\">Settings</string>\n    <string name=\"action_dispatch_now\">Dispatch now</string>\n    <string name=\"action_track_custom_vars\">Track Custom Vars</string>\n    <string name=\"action_divide_by_zero\">Divide by zero</string>\n    <string name=\"action_add_ecommerce_item\">Add Ecommerce Item</string>\n    <string name=\"action_track_ecommerce_cart_update\">Track Ecommerce Cart Update</string>\n    <string name=\"action_track_ecommerce_order\">Track Ecommerce Order</string>\n    <string name=\"action_track_goal\">Track Goal</string>\n\n</resources>\n"
  },
  {
    "path": "exampleapp/src/main/res/values/styles.xml",
    "content": "<resources>\n\n    <!-- Base application theme. -->\n    <style name=\"AppTheme\" parent=\"Theme.AppCompat.Light.DarkActionBar\">\n        <!-- Customize your theme here. -->\n    </style>\n\n</resources>\n"
  },
  {
    "path": "exampleapp/src/main/res/values-w820dp/dimens.xml",
    "content": "<resources>\n    <!-- Example customization of dimensions originally defined in res/values/dimens.xml\n         (such as screen margins) for screens with more than 820dp of available width. This\n         would include 7\" and 10\" devices in landscape (~960dp and ~1280dp respectively). -->\n    <dimen name=\"activity_horizontal_margin\">64dp</dimen>\n</resources>\n"
  },
  {
    "path": "gradle/wrapper/gradle-wrapper.properties",
    "content": "distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-8.13-bin.zip\nnetworkTimeout=10000\nvalidateDistributionUrl=true\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\n"
  },
  {
    "path": "gradle.properties",
    "content": "# Project-wide Gradle settings.\n\n# IDE (e.g. Android Studio) users:\n# Gradle settings configured through the IDE *will override*\n# any settings specified in this file.\n\n# For more details on how to configure your build environment visit\n# http://www.gradle.org/docs/current/userguide/build_environment.html\n\n# Specifies the JVM arguments used for the daemon process.\n# The setting is particularly useful for tweaking memory settings.\nandroid.enableJetifier=true\nandroid.nonFinalResIds=false\nandroid.nonTransitiveRClass=false\nandroid.useAndroidX=true\norg.gradle.jvmargs=-Xmx2536m\n\n# When configured, Gradle will run in incubating parallel mode.\n# This option should only be used with decoupled projects. More details, visit\n# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects\n# org.gradle.parallel=true\n"
  },
  {
    "path": "gradlew",
    "content": "#!/bin/sh\n\n#\n# Copyright © 2015-2021 the original authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n# SPDX-License-Identifier: Apache-2.0\n#\n\n##############################################################################\n#\n#   Gradle start up script for POSIX generated by Gradle.\n#\n#   Important for running:\n#\n#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is\n#       noncompliant, but you have some other compliant shell such as ksh or\n#       bash, then to run this script, type that shell name before the whole\n#       command line, like:\n#\n#           ksh Gradle\n#\n#       Busybox and similar reduced shells will NOT work, because this script\n#       requires all of these POSIX shell features:\n#         * functions;\n#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,\n#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;\n#         * compound commands having a testable exit status, especially «case»;\n#         * various built-in commands including «command», «set», and «ulimit».\n#\n#   Important for patching:\n#\n#   (2) This script targets any POSIX shell, so it avoids extensions provided\n#       by Bash, Ksh, etc; in particular arrays are avoided.\n#\n#       The \"traditional\" practice of packing multiple parameters into a\n#       space-separated string is a well documented source of bugs and security\n#       problems, so this is (mostly) avoided, by progressively accumulating\n#       options in \"$@\", and eventually passing that to Java.\n#\n#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,\n#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;\n#       see the in-line comments for details.\n#\n#       There are tweaks for specific operating systems such as AIX, CygWin,\n#       Darwin, MinGW, and NonStop.\n#\n#   (3) This script is generated from the Groovy template\n#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt\n#       within the Gradle project.\n#\n#       You can find Gradle at https://github.com/gradle/gradle/.\n#\n##############################################################################\n\n# Attempt to set APP_HOME\n\n# Resolve links: $0 may be a link\napp_path=$0\n\n# Need this for daisy-chained symlinks.\nwhile\n    APP_HOME=${app_path%\"${app_path##*/}\"}  # leaves a trailing /; empty if no leading path\n    [ -h \"$app_path\" ]\ndo\n    ls=$( ls -ld \"$app_path\" )\n    link=${ls#*' -> '}\n    case $link in             #(\n      /*)   app_path=$link ;; #(\n      *)    app_path=$APP_HOME$link ;;\n    esac\ndone\n\n# This is normally unused\n# shellcheck disable=SC2034\nAPP_BASE_NAME=${0##*/}\n# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)\nAPP_HOME=$( cd -P \"${APP_HOME:-./}\" > /dev/null && printf '%s\\n' \"$PWD\" ) || exit\n\n# Use the maximum available, or set MAX_FD != -1 to use that value.\nMAX_FD=maximum\n\nwarn () {\n    echo \"$*\"\n} >&2\n\ndie () {\n    echo\n    echo \"$*\"\n    echo\n    exit 1\n} >&2\n\n# OS specific support (must be 'true' or 'false').\ncygwin=false\nmsys=false\ndarwin=false\nnonstop=false\ncase \"$( uname )\" in                #(\n  CYGWIN* )         cygwin=true  ;; #(\n  Darwin* )         darwin=true  ;; #(\n  MSYS* | MINGW* )  msys=true    ;; #(\n  NONSTOP* )        nonstop=true ;;\nesac\n\nCLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar\n\n\n# Determine the Java command to use to start the JVM.\nif [ -n \"$JAVA_HOME\" ] ; then\n    if [ -x \"$JAVA_HOME/jre/sh/java\" ] ; then\n        # IBM's JDK on AIX uses strange locations for the executables\n        JAVACMD=$JAVA_HOME/jre/sh/java\n    else\n        JAVACMD=$JAVA_HOME/bin/java\n    fi\n    if [ ! -x \"$JAVACMD\" ] ; then\n        die \"ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nelse\n    JAVACMD=java\n    if ! command -v java >/dev/null 2>&1\n    then\n        die \"ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nfi\n\n# Increase the maximum file descriptors if we can.\nif ! \"$cygwin\" && ! \"$darwin\" && ! \"$nonstop\" ; then\n    case $MAX_FD in #(\n      max*)\n        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.\n        # shellcheck disable=SC2039,SC3045\n        MAX_FD=$( ulimit -H -n ) ||\n            warn \"Could not query maximum file descriptor limit\"\n    esac\n    case $MAX_FD in  #(\n      '' | soft) :;; #(\n      *)\n        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.\n        # shellcheck disable=SC2039,SC3045\n        ulimit -n \"$MAX_FD\" ||\n            warn \"Could not set maximum file descriptor limit to $MAX_FD\"\n    esac\nfi\n\n# Collect all arguments for the java command, stacking in reverse order:\n#   * args from the command line\n#   * the main class name\n#   * -classpath\n#   * -D...appname settings\n#   * --module-path (only if needed)\n#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.\n\n# For Cygwin or MSYS, switch paths to Windows format before running java\nif \"$cygwin\" || \"$msys\" ; then\n    APP_HOME=$( cygpath --path --mixed \"$APP_HOME\" )\n    CLASSPATH=$( cygpath --path --mixed \"$CLASSPATH\" )\n\n    JAVACMD=$( cygpath --unix \"$JAVACMD\" )\n\n    # Now convert the arguments - kludge to limit ourselves to /bin/sh\n    for arg do\n        if\n            case $arg in                                #(\n              -*)   false ;;                            # don't mess with options #(\n              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath\n                    [ -e \"$t\" ] ;;                      #(\n              *)    false ;;\n            esac\n        then\n            arg=$( cygpath --path --ignore --mixed \"$arg\" )\n        fi\n        # Roll the args list around exactly as many times as the number of\n        # args, so each arg winds up back in the position where it started, but\n        # possibly modified.\n        #\n        # NB: a `for` loop captures its iteration list before it begins, so\n        # changing the positional parameters here affects neither the number of\n        # iterations, nor the values presented in `arg`.\n        shift                   # remove old arg\n        set -- \"$@\" \"$arg\"      # push replacement arg\n    done\nfi\n\n\n# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\nDEFAULT_JVM_OPTS='\"-Xmx64m\" \"-Xms64m\"'\n\n# Collect all arguments for the java command:\n#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,\n#     and any embedded shellness will be escaped.\n#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be\n#     treated as '${Hostname}' itself on the command line.\n\nset -- \\\n        \"-Dorg.gradle.appname=$APP_BASE_NAME\" \\\n        -classpath \"$CLASSPATH\" \\\n        org.gradle.wrapper.GradleWrapperMain \\\n        \"$@\"\n\n# Stop when \"xargs\" is not available.\nif ! command -v xargs >/dev/null 2>&1\nthen\n    die \"xargs is not available\"\nfi\n\n# Use \"xargs\" to parse quoted args.\n#\n# With -n1 it outputs one arg per line, with the quotes and backslashes removed.\n#\n# In Bash we could simply go:\n#\n#   readarray ARGS < <( xargs -n1 <<<\"$var\" ) &&\n#   set -- \"${ARGS[@]}\" \"$@\"\n#\n# but POSIX shell has neither arrays nor command substitution, so instead we\n# post-process each arg (as a line of input to sed) to backslash-escape any\n# character that might be a shell metacharacter, then use eval to reverse\n# that process (while maintaining the separation between arguments), and wrap\n# the whole thing up as a single \"set\" statement.\n#\n# This will of course break if any of these variables contains a newline or\n# an unmatched quote.\n#\n\neval \"set -- $(\n        printf '%s\\n' \"$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS\" |\n        xargs -n1 |\n        sed ' s~[^-[:alnum:]+,./:=@_]~\\\\&~g; ' |\n        tr '\\n' ' '\n    )\" '\"$@\"'\n\nexec \"$JAVACMD\" \"$@\"\n"
  },
  {
    "path": "gradlew.bat",
    "content": "@rem\r\n@rem Copyright 2015 the original author or authors.\r\n@rem\r\n@rem Licensed under the Apache License, Version 2.0 (the \"License\");\r\n@rem you may not use this file except in compliance with the License.\r\n@rem You may obtain a copy of the License at\r\n@rem\r\n@rem      https://www.apache.org/licenses/LICENSE-2.0\r\n@rem\r\n@rem Unless required by applicable law or agreed to in writing, software\r\n@rem distributed under the License is distributed on an \"AS IS\" BASIS,\r\n@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r\n@rem See the License for the specific language governing permissions and\r\n@rem limitations under the License.\r\n@rem\r\n@rem SPDX-License-Identifier: Apache-2.0\r\n@rem\r\n\r\n@if \"%DEBUG%\"==\"\" @echo off\r\n@rem ##########################################################################\r\n@rem\r\n@rem  Gradle startup script for Windows\r\n@rem\r\n@rem ##########################################################################\r\n\r\n@rem Set local scope for the variables with windows NT shell\r\nif \"%OS%\"==\"Windows_NT\" setlocal\r\n\r\nset DIRNAME=%~dp0\r\nif \"%DIRNAME%\"==\"\" set DIRNAME=.\r\n@rem This is normally unused\r\nset APP_BASE_NAME=%~n0\r\nset APP_HOME=%DIRNAME%\r\n\r\n@rem Resolve any \".\" and \"..\" in APP_HOME to make it shorter.\r\nfor %%i in (\"%APP_HOME%\") do set APP_HOME=%%~fi\r\n\r\n@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\r\nset DEFAULT_JVM_OPTS=\"-Xmx64m\" \"-Xms64m\"\r\n\r\n@rem Find java.exe\r\nif defined JAVA_HOME goto findJavaFromJavaHome\r\n\r\nset JAVA_EXE=java.exe\r\n%JAVA_EXE% -version >NUL 2>&1\r\nif %ERRORLEVEL% equ 0 goto execute\r\n\r\necho. 1>&2\r\necho ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2\r\necho. 1>&2\r\necho Please set the JAVA_HOME variable in your environment to match the 1>&2\r\necho location of your Java installation. 1>&2\r\n\r\ngoto fail\r\n\r\n:findJavaFromJavaHome\r\nset JAVA_HOME=%JAVA_HOME:\"=%\r\nset JAVA_EXE=%JAVA_HOME%/bin/java.exe\r\n\r\nif exist \"%JAVA_EXE%\" goto execute\r\n\r\necho. 1>&2\r\necho ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2\r\necho. 1>&2\r\necho Please set the JAVA_HOME variable in your environment to match the 1>&2\r\necho location of your Java installation. 1>&2\r\n\r\ngoto fail\r\n\r\n:execute\r\n@rem Setup the command line\r\n\r\nset CLASSPATH=%APP_HOME%\\gradle\\wrapper\\gradle-wrapper.jar\r\n\r\n\r\n@rem Execute Gradle\r\n\"%JAVA_EXE%\" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% \"-Dorg.gradle.appname=%APP_BASE_NAME%\" -classpath \"%CLASSPATH%\" org.gradle.wrapper.GradleWrapperMain %*\r\n\r\n:end\r\n@rem End local scope for the variables with windows NT shell\r\nif %ERRORLEVEL% equ 0 goto mainEnd\r\n\r\n:fail\r\nrem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of\r\nrem the _cmd.exe /c_ return code!\r\nset EXIT_CODE=%ERRORLEVEL%\r\nif %EXIT_CODE% equ 0 set EXIT_CODE=1\r\nif not \"\"==\"%GRADLE_EXIT_CONSOLE%\" exit %EXIT_CODE%\r\nexit /b %EXIT_CODE%\r\n\r\n:mainEnd\r\nif \"%OS%\"==\"Windows_NT\" endlocal\r\n\r\n:omega\r\n"
  },
  {
    "path": "jitpack.yml",
    "content": "jdk:\n  - openjdk17"
  },
  {
    "path": "settings.gradle",
    "content": "include \":exampleapp\"\ninclude \":tracker\"\n"
  },
  {
    "path": "tracker/build.gradle",
    "content": "plugins {\n    id \"com.android.library\"\n    id \"kotlin-android\"\n    id \"maven-publish\"\n    id \"com.mxalbert.gradle.jacoco-android\"\n}\n\nandroid {\n    namespace \"org.matomo.sdk\"\n    defaultConfig {\n        minSdkVersion project.ext.globalMinSdkVersion\n        compileSdk project.ext.globalCompileSdkVersion\n        targetSdkVersion project.ext.globalTargetSdkVersion\n    }\n\n    buildTypes {\n        release {\n            minifyEnabled false\n            proguardFiles getDefaultProguardFile(\"proguard-android-optimize.txt\"), \"proguard-rules.pro\"\n        }\n    }\n    compileOptions {\n        sourceCompatibility JavaVersion.VERSION_17\n        targetCompatibility JavaVersion.VERSION_17\n    }\n    kotlinOptions {\n        jvmTarget = \"17\"\n    }\n    testOptions.unitTests.includeAndroidResources = true\n}\n\ndependencies {\n    implementation \"androidx.annotation:annotation:1.9.1\"\n    implementation \"com.github.AppDevNext.Logcat:LogcatCoreLib:3.4\"\n    implementation \"org.jetbrains.kotlin:kotlin-stdlib-jdk8\"\n\n    testImplementation \"org.awaitility:awaitility:4.3.0\"\n    testImplementation \"androidx.test:core:1.7.0\"\n    // Robolectric\n    testImplementation \"junit:junit:4.13.2\"\n    testImplementation \"org.hamcrest:hamcrest-core:3.0\"\n    testImplementation \"org.hamcrest:hamcrest-library:3.0\"\n    testImplementation \"org.hamcrest:hamcrest-integration:1.3\"\n\n    testImplementation \"com.squareup.okhttp3:mockwebserver:5.3.2\"\n\n    // Mocktio\n    testImplementation \"org.mockito:mockito-core:5.20.0\"\n    testImplementation \"org.json:json:20250517\"\n    testImplementation \"org.robolectric:robolectric:4.15.1\"\n}\n\njacoco {\n    toolVersion = \"0.8.10\"\n}\n\ntasks.withType(Test).configureEach {\n    jacoco.includeNoLocationClasses = true\n    jacoco.excludes = [\"jdk.internal.*\"]\n}\n\n/**\n * Javadoc\n */\nandroid.libraryVariants.configureEach { variant ->\n    task(\"generate${variant.name.capitalize()}Javadoc\", type: Javadoc) {\n        title = \"Documentation for Android $android.defaultConfig.versionName b$android.defaultConfig.versionCode\"\n        destinationDir = new File(\"${project.getProjectDir()}/build/docs/javadoc/\")\n        ext.androidJar = \"${android.sdkDirectory}/platforms/${android.compileSdkVersion}/android.jar\"\n        source = variant.javaCompiler.source\n        doFirst {\n            classpath = files(variant.javaCompile.classpath.files) + files(ext.androidJar)\n        }\n\n        description \"Generates Javadoc for $variant.name.\"\n\n        options.memberLevel = JavadocMemberLevel.PRIVATE\n        options.links(\"http://docs.oracle.com/javase/7/docs/api/\")\n        options.links(\"http://developer.android.com/reference/reference/\")\n        exclude \"**/BuildConfig.java\"\n        exclude \"**/R.java\"\n    }\n}\n\npublishing {\n    publications {\n        release(MavenPublication) {\n            afterEvaluate {\n                from components.release {\n                    pom {\n                        licenses {\n                            license {\n                                name = \"BSD 3-Clause 'New' or 'Revised' License\"\n                                url = \"https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE\"\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n\n@SuppressWarnings(\"unused\")\nstatic def getTag() {\n    def process = \"git describe --tags\".execute()\n    return process.text.toString().trim()\n}\n"
  },
  {
    "path": "tracker/lint.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<lint>\n    <issue id=\"TimberTagLength\" severity=\"ignore\"/>\n    <issue id=\"StringFormatInTimber\" severity=\"ignore\"/>\n    <issue id=\"TimberExceptionLogging\" severity=\"ignore\"/>\n    <issue id=\"ThrowableNotAtBeginning\" severity=\"ignore\"/>\n    <issue id=\"LogNotTimber\" severity=\"ignore\"/>\n    <issue id=\"BinaryOperationInTimber\" severity=\"ignore\"/>\n    <issue id=\"TimberArgCount\" severity=\"ignore\"/>\n    <issue id=\"TimberArgTypes\" severity=\"ignore\"/>\n</lint>\n"
  },
  {
    "path": "tracker/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\"\n          xmlns:tools=\"http://schemas.android.com/tools\">\n\n    <!-- added suppression for timber since 4.1.2 has minsdk 15 because google doesn't show it in the dashboard -->\n    <uses-sdk tools:overrideLibrary=\"timber.log\"/>\n\n    <uses-permission android:name=\"android.permission.INTERNET\"/>\n    <uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>\n\n    <application>\n        <!-- Suppress warning about unrestricted access to this receiver this is need to check which app store installed the app -->\n        <receiver\n            android:name=\"org.matomo.sdk.extra.InstallReferrerReceiver\"\n            android:exported=\"true\"\n            tools:ignore=\"ExportedReceiver\">\n            <intent-filter>\n                <action android:name=\"com.android.vending.INSTALL_REFERRER\"/>\n            </intent-filter>\n        </receiver>\n    </application>\n</manifest>\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/LegacySettingsPorter.kt",
    "content": "package org.matomo.sdk\n\nimport android.content.SharedPreferences\nimport java.util.UUID\n\nclass LegacySettingsPorter(matomo: Matomo) {\n    private val mLegacyPrefs: SharedPreferences\n\n    init {\n        mLegacyPrefs = matomo.preferences\n    }\n\n    fun port(tracker: Tracker) {\n        val newSettings = tracker.preferences\n        if (mLegacyPrefs.getBoolean(LEGACY_PREF_OPT_OUT, false)) {\n            newSettings.edit()\n                .putBoolean(Tracker.PREF_KEY_TRACKER_OPTOUT, true)\n                .apply()\n            mLegacyPrefs.edit().remove(LEGACY_PREF_OPT_OUT).apply()\n        }\n        if (mLegacyPrefs.contains(LEGACY_PREF_USER_ID)) {\n            newSettings.edit()\n                .putString(Tracker.PREF_KEY_TRACKER_USERID, mLegacyPrefs.getString(LEGACY_PREF_USER_ID, UUID.randomUUID().toString()))\n                .apply()\n            mLegacyPrefs.edit().remove(LEGACY_PREF_USER_ID).apply()\n        }\n        if (mLegacyPrefs.contains(LEGACY_PREF_FIRST_VISIT)) {\n            newSettings.edit().putLong(\n                Tracker.PREF_KEY_TRACKER_FIRSTVISIT,\n                mLegacyPrefs.getLong(LEGACY_PREF_FIRST_VISIT, -1L)\n            ).apply()\n            mLegacyPrefs.edit().remove(LEGACY_PREF_FIRST_VISIT).apply()\n        }\n        if (mLegacyPrefs.contains(LEGACY_PREF_VISITCOUNT)) {\n            newSettings.edit().putLong(\n                Tracker.PREF_KEY_TRACKER_VISITCOUNT,\n                mLegacyPrefs.getInt(LEGACY_PREF_VISITCOUNT, 0)\n                    .toLong()\n            ).apply()\n            mLegacyPrefs.edit().remove(LEGACY_PREF_VISITCOUNT).apply()\n        }\n        if (mLegacyPrefs.contains(LEGACY_PREF_PREV_VISIT)) {\n            newSettings.edit().putLong(\n                Tracker.PREF_KEY_TRACKER_PREVIOUSVISIT,\n                mLegacyPrefs.getLong(LEGACY_PREF_PREV_VISIT, -1)\n            ).apply()\n            mLegacyPrefs.edit().remove(LEGACY_PREF_PREV_VISIT).apply()\n        }\n        for ((key) in mLegacyPrefs.all) {\n            if (key.startsWith(\"downloaded:\")) {\n                newSettings.edit().putBoolean(key, true).apply()\n                mLegacyPrefs.edit().remove(key).apply()\n            }\n        }\n    }\n\n    companion object {\n        const val LEGACY_PREF_OPT_OUT = \"matomo.optout\"\n        const val LEGACY_PREF_USER_ID = \"tracker.userid\"\n        const val LEGACY_PREF_FIRST_VISIT = \"tracker.firstvisit\"\n        const val LEGACY_PREF_VISITCOUNT = \"tracker.visitcount\"\n        const val LEGACY_PREF_PREV_VISIT = \"tracker.previousvisit\"\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/Matomo.kt",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\npackage org.matomo.sdk\n\nimport android.annotation.SuppressLint\nimport android.content.Context\nimport android.content.SharedPreferences\nimport org.matomo.sdk.dispatcher.DefaultDispatcherFactory\nimport org.matomo.sdk.dispatcher.DispatcherFactory\nimport org.matomo.sdk.tools.BuildInfo\nimport org.matomo.sdk.tools.Checksum\nimport org.matomo.sdk.tools.DeviceHelper\nimport org.matomo.sdk.tools.PropertySource\nimport timber.log.Timber\n\nclass Matomo private constructor(context: Context) {\n    private val preferenceMap: MutableMap<Tracker, SharedPreferences?> = HashMap()\n    val context: Context = context.applicationContext\n\n    /**\n     * Base preferences, tracker independent.\n     */\n    val preferences: SharedPreferences = context.getSharedPreferences(BASE_PREFERENCE_FILE, Context.MODE_PRIVATE)\n\n    /**\n     * If you want to use your own [org.matomo.sdk.dispatcher.Dispatcher]\n     */\n    var dispatcherFactory: DispatcherFactory = DefaultDispatcherFactory()\n\n    /**\n     * @return Tracker specific settings object\n     */\n    fun getTrackerPreferences(tracker: Tracker): SharedPreferences? {\n        synchronized(preferenceMap) {\n            var newPrefs = preferenceMap[tracker]\n            if (newPrefs == null) {\n                val prefName: String = try {\n                    \"org.matomo.sdk_\" + Checksum.getMD5Checksum(tracker.name)\n                } catch (e: Exception) {\n                    Timber.e(e)\n                    \"org.matomo.sdk_\" + tracker.name\n                }\n                newPrefs = context.getSharedPreferences(prefName, Context.MODE_PRIVATE)\n                preferenceMap[tracker] = newPrefs\n            }\n            return newPrefs\n        }\n    }\n\n    val deviceHelper: DeviceHelper\n        get() = DeviceHelper(context, PropertySource(), BuildInfo())\n\n    companion object {\n        private const val LOGGER_PREFIX = \"MATOMO:\"\n        private const val BASE_PREFERENCE_FILE = \"org.matomo.sdk\"\n\n        @SuppressLint(\"StaticFieldLeak\")\n        @Volatile\n        private var sInstance: Matomo? = null\n\n        @JvmStatic\n        fun getInstance(context: Context): Matomo {\n            return sInstance ?: synchronized(Matomo::class.java) {\n                sInstance ?: Matomo(context).also {\n                    sInstance = it\n                }\n            }\n        }\n\n        @JvmStatic\n        fun tag(vararg classes: Class<*>): String {\n            val tags = arrayOfNulls<String>(classes.size)\n            for (i in classes.indices) {\n                tags[i] = classes[i].simpleName\n            }\n            return tag(*tags)\n        }\n\n        @JvmStatic\n        fun tag(vararg tags: String?): String {\n            val sb = StringBuilder(LOGGER_PREFIX)\n            for (i in tags.indices) {\n                sb.append(tags[i])\n                if (i < tags.size - 1) sb.append(\":\")\n            }\n            return sb.toString()\n        }\n    }\n}"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/QueryParams.java",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\n\npackage org.matomo.sdk;\n\n/**\n * Query parameters supported by the tracking HTTP API.\n * See <a href=\"http://developer.matomo.org/api-reference/tracking-api\">Tracking HTTP API</a>\n */\npublic enum QueryParams {\n    //Required parameters\n    /**\n     * The ID of the website we're tracking a visit/action for.\n     * <p>\n     * (required)\n     */\n    SITE_ID(\"idsite\"),\n    /**\n     * Required for tracking, must be set to one, eg, rec=1.\n     * <p>\n     * (required)\n     */\n    RECORD(\"rec\"),\n    /**\n     * The full URL for the current action.\n     * <p>\n     * (required)\n     */\n    URL_PATH(\"url\"),\n\n\n    //Recommended parameters\n    /**\n     * The title of the action being tracked.<p>\n     * It is possible to <a href=\"http://matomo.org/faq/how-to/faq_62\">use slashes / to set one or several categories for this action.</a>\n     * For example, Help / Feedback will create the Action Feedback in the category Help.\n     * <p>\n     * (recommended)\n     */\n    ACTION_NAME(\"action_name\"),\n    /**\n     * The unique visitor ID, must be a 16 characters hexadecimal string.<p>\n     * Every unique visitor must be assigned a different ID and this ID must not change after it is assigned.\n     * If this value is not set Matomo will still track visits, but the unique visitors metric might be less accurate.\n     * <p>\n     * (recommended)\n     */\n    VISITOR_ID(\"_id\"),\n    /**\n     * Meant to hold a random value that is generated before each request.<p>\n     * Using it helps avoid the tracking request being cached by the browser or a proxy.\n     * <p>\n     * (recommended)\n     */\n    RANDOM_NUMBER(\"rand\"),\n    /**\n     * The parameter apiv=1 defines the api version to use (currently always set to 1)\n     * <p>\n     * (recommended)\n     */\n    API_VERSION(\"apiv\"),\n\n\n    // Optional User info\n    /**\n     * The full HTTP Referrer URL.<p>\n     * This value is used to determine how someone got to your website (ie, through a website, search engine or campaign).\n     */\n    REFERRER(\"urlref\"),\n    /**\n     * Visit scope <a href=\"http://matomo.org/docs/custom-variables/\">custom variables</a>.<p>\n     * This is a JSON encoded string of the custom variable array.\n     * @deprecated Consider using <a href=\"http://matomo.org/docs/custom-dimensions/\">Custom Dimensions</a>\n     * @see org.matomo.sdk.extra.CustomDimension\n     */\n    @Deprecated\n    VISIT_SCOPE_CUSTOM_VARIABLES(\"_cvar\"),\n    /**\n     * The current count of visits for this visitor.<p>\n     * To set this value correctly, it would be required to store the value for each visitor in your application (using sessions or persisting in a database).\n     * Then you would manually increment the counts by one on each new visit or \"session\", depending on how you choose to define a visit.\n     * This value is used to populate the report Visitors &gt; Engagement &gt; Visits by visit number.\n     */\n    TOTAL_NUMBER_OF_VISITS(\"_idvc\"),\n    /**\n     * The UNIX timestamp of this visitor's previous visit (seconds since Jan 01 1970. (UTC)).<p>\n     * This parameter is used to populate the report Visitors &gt; Engagement &gt; Visits by days since last visit.\n     */\n    PREVIOUS_VISIT_TIMESTAMP(\"_viewts\"),\n    /**\n     * The UNIX timestamp of this visitor's first visit (seconds since Jan 01 1970. (UTC)).<p>\n     * This could be set to the date where the user first started using your software/app, or when he/she created an account.\n     * This parameter is used to populate the Goals &gt; Days to Conversion report.\n     */\n    FIRST_VISIT_TIMESTAMP(\"_idts\"),\n    /**\n     * The Campaign name (see <a href=\"http://matomo.org/docs/tracking-campaigns/\">Tracking Campaigns</a>).<p>\n     * Used to populate the Referrers &gt; Campaigns report.\n     * Note: this parameter will only be used for the first pageview of a visit.\n     */\n    CAMPAIGN_NAME(\"_rcn\"),\n    /**\n     * The Campaign Keyword (see <a href=\"http://matomo.org/docs/tracking-campaigns/\">Tracking Campaigns</a>).<p>\n     * Used to populate the Referrers &gt; Campaigns report (clicking on a campaign loads all keywords for this campaign).\n     * Note: this parameter will only be used for the first pageview of a visit.\n     */\n    CAMPAIGN_KEYWORD(\"_rck\"),\n    /**\n     * The resolution of the device the visitor is using, eg 1280x1024.\n     */\n    SCREEN_RESOLUTION(\"res\"),\n    /**\n     * The current hour (local time).\n     */\n    HOURS(\"h\"),\n    /**\n     * The current minute (local time).\n     */\n    MINUTES(\"m\"),\n    /**\n     * The current second (local time).\n     */\n    SECONDS(\"s\"),\n    /**\n     * An override value for the User-Agent HTTP header field.<p>\n     * The user agent is used to detect the operating system and browser used.\n     */\n    USER_AGENT(\"ua\"),\n    /**\n     * An override value for the Accept-Language HTTP header field.<p>\n     * This value is used to detect the visitor's country if <a href=\"http://matomo.org/faq/troubleshooting/faq_65\">GeoIP</a> is not enabled.\n     */\n    LANGUAGE(\"lang\"),\n    /**\n     * Defines the User ID for this request.<p>\n     * User ID is any non empty unique string identifying the user (such as an email address or a username).\n     * To access this value, users must be logged-in in your system so you can fetch this user ID from your system, and pass it to Matomo.\n     * The User ID appears in the visitor log, the Visitor profile, and you can Segment reports for one or several User ID (userId segment).\n     * When specified, the User ID will be \"enforced\". This means that if there is no recent visit with this User ID, a new one will be created.\n     * If a visit is found in the last 30 minutes with your specified User ID, then the new action will be recorded to this existing visit.\n     */\n    USER_ID(\"uid\"),\n    /**\n     * If set to 1, will force a new visit to be created for this action.\n     */\n    SESSION_START(\"new_visit\"),\n\n\n    // Optional Action info (measure Page view, Outlink, Download, Site search)\n    /**\n     * Page scope <a href=\"http://matomo.org/docs/custom-variables/\">custom variables</a>.\n     * This is a JSON encoded string of the custom variable array.\n     * @deprecated Consider using <a href=\"http://matomo.org/docs/custom-dimensions/\">Custom Dimensions</a>\n     * @see org.matomo.sdk.extra.CustomDimension\n     */\n    SCREEN_SCOPE_CUSTOM_VARIABLES(\"cvar\"),\n    /**\n     * An external URL the user has opened.<p>\n     * Used for tracking outlink clicks. We recommend to also set the url parameter to this same value.\n     */\n    LINK(\"link\"),\n    /**\n     * URL of a file the user has downloaded.<p>\n     * Used for tracking downloads. We recommend to also set the url parameter to this same value.\n     */\n    DOWNLOAD(\"download\"),\n    /**\n     * The Site Search keyword.<p>\n     * When specified, the request will not be tracked as a normal pageview but will instead be tracked as a <a href=\"http://matomo.org/docs/site-search/\">Site Search</a> request.\n     */\n    SEARCH_KEYWORD(\"search\"),\n    /**\n     * When {@link #SEARCH_KEYWORD} is specified, you can optionally specify a search category with this parameter.\n     */\n    SEARCH_CATEGORY(\"search_cat\"),\n    /**\n     * When {@link #SEARCH_KEYWORD} is specified, we also recommend to set this to the number of search results.\n     */\n    SEARCH_NUMBER_OF_HITS(\"search_count\"),\n    /**\n     * If specified, the tracking request will trigger a conversion for the goal of the website being tracked with this ID.\n     */\n    GOAL_ID(\"idgoal\"),\n    /**\n     * A monetary value that was generated as revenue by this goal conversion.<p>\n     * Only used if {@link #GOAL_ID} is specified in the request.\n     */\n    REVENUE(\"revenue\"),\n    /**\n     * Override for the datetime of the request (normally the current time is used).<p>\n     * This can be used to record visits and page views in the past.\n     * The expected format is: 2011-04-05 00:11:42 (remember to URL encode the value!).\n     * The datetime must be sent in UTC timezone.\n     * Events can only be backdated for a maximum time of 24h.\n     * Note: if you record data in the past, you will need to <a href=\"http://matomo.org/faq/how-to/faq_59\">force Matomo to re-process reports for the past dates.</a>\n     */\n    DATETIME_OF_REQUEST(\"cdt\"),\n\n\n    /**\n     * The name of the content. For instance 'Ad Foo Bar'\n     *\n     * @see <a href=\"http://matomo.org/docs/content-tracking/\">Content Tracking</a>\n     */\n    CONTENT_NAME(\"c_n\"),\n    /**\n     * The actual content piece. For instance the path to an image, video, audio, any text\n     *\n     * @see <a href=\"http://matomo.org/docs/content-tracking/\">Content Tracking</a>\n     */\n    CONTENT_PIECE(\"c_p\"),\n    /**\n     * The target of the content. For instance the URL of a landing page\n     *\n     * @see <a href=\"http://matomo.org/docs/content-tracking/\">Content Tracking</a>\n     */\n    CONTENT_TARGET(\"c_t\"),\n    /**\n     * The name of the interaction with the content. For instance a 'click'\n     *\n     * @see <a href=\"http://matomo.org/docs/content-tracking/\">Content Tracking</a>\n     */\n    CONTENT_INTERACTION(\"c_i\"),\n\n    /**\n     * The event category. Must not be empty. (eg. Videos, Music, Games...)\n     *\n     * @see <a href=\"http://matomo.org/docs/event-tracking/\">Event Tracking</a>\n     */\n    EVENT_CATEGORY(\"e_c\"),\n    /**\n     * The event action. Must not be empty. (eg. Play, Pause, Duration, Add Playlist, Downloaded, Clicked...)\n     *\n     * @see <a href=\"http://matomo.org/docs/event-tracking/\">Event Tracking</a>\n     */\n    EVENT_ACTION(\"e_a\"),\n    /**\n     * The event name. (eg. a Movie name, or Song name, or File name...)\n     *\n     * @see <a href=\"http://matomo.org/docs/event-tracking/\">Event Tracking</a>\n     */\n    EVENT_NAME(\"e_n\"),\n    /**\n     * The event value. Must be a float or integer value (numeric), not a string.\n     *\n     * @see <a href=\"http://matomo.org/docs/event-tracking/\">Event Tracking</a>\n     */\n    EVENT_VALUE(\"e_v\"),\n\n    // Ecommerce parameters\n    /**\n     * Items in your cart or order for ecommerce tracking\n     */\n    ECOMMERCE_ITEMS(\"ec_items\"),\n\n    /**\n     * The amount of tax paid for the order\n     */\n    TAX(\"ec_tx\"),\n\n    /**\n     * The unique identifier for the order\n     */\n    ORDER_ID(\"ec_id\"),\n\n    /**\n     * The amount of shipping paid on the order\n     */\n    SHIPPING(\"ec_sh\"),\n\n    /**\n     * The amount of the discount on the order\n     */\n    DISCOUNT(\"ec_dt\"),\n\n    /**\n     * The sub total amount of the order\n     */\n    SUBTOTAL(\"ec_st\"),\n\n    // Other parameters\n    /**\n     * If set to 0 (send_image=0) Matomo will respond with a HTTP 204 response code instead of a GIF image.<p>\n     * This improves performance and can fix errors if images are not allowed to be obtained directly (eg Chrome Apps). Available since Matomo 2.10.0\n     */\n    SEND_IMAGE(\"send_image\");\n\n    private final String value;\n\n    QueryParams(String value) {\n        this.value = value;\n    }\n\n    public String toString() {\n        return value;\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/TrackMe.java",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\n\npackage org.matomo.sdk;\n\nimport androidx.annotation.NonNull;\nimport androidx.annotation.Nullable;\n\nimport java.util.HashMap;\nimport java.util.Map;\n\n/**\n * This objects represents one query to Matomo.\n * For each event send to Matomo a TrackMe gets created, either explicitly by you or implicitly by the Tracker.\n */\npublic class TrackMe {\n    private static final int DEFAULT_QUERY_CAPACITY = 14;\n    private final HashMap<String, String> mQueryParams = new HashMap<>(DEFAULT_QUERY_CAPACITY);\n\n    public TrackMe() { }\n\n    public TrackMe(TrackMe trackMe) {\n        mQueryParams.putAll(trackMe.mQueryParams);\n    }\n\n    /**\n     * Adds TrackMe to this TrackMe, overriding values if necessary.\n     */\n    public TrackMe putAll(@NonNull TrackMe trackMe) {\n        mQueryParams.putAll(trackMe.toMap());\n        return this;\n    }\n\n    /**\n     * Consider using {@link QueryParams} instead of raw strings\n     */\n    public synchronized TrackMe set(@NonNull String key, String value) {\n        if (value == null) mQueryParams.remove(key);\n        else if (value.length() > 0) mQueryParams.put(key, value);\n        return this;\n    }\n\n    /**\n     * Consider using {@link QueryParams} instead of raw strings\n     */\n    @Nullable\n    public synchronized String get(@NonNull String queryParams) {\n        return mQueryParams.get(queryParams);\n    }\n\n    /**\n     * You can set any additional Tracking API Parameters within the SDK.\n     * This includes for example the local time (parameters h, m and s).\n     * <pre>\n     * set(QueryParams.HOURS, \"10\");\n     * set(QueryParams.MINUTES, \"45\");\n     * set(QueryParams.SECONDS, \"30\");\n     * </pre>\n     *\n     * @param key   query params name\n     * @param value value\n     * @return tracker instance\n     */\n    public synchronized TrackMe set(@NonNull QueryParams key, String value) {\n        set(key.toString(), value);\n        return this;\n    }\n\n    public synchronized TrackMe set(@NonNull QueryParams key, int value) {\n        set(key, Integer.toString(value));\n        return this;\n    }\n\n    public synchronized TrackMe set(@NonNull QueryParams key, float value) {\n        set(key, Float.toString(value));\n        return this;\n    }\n\n    public synchronized TrackMe set(@NonNull QueryParams key, long value) {\n        set(key, Long.toString(value));\n        return this;\n    }\n\n    public synchronized boolean has(@NonNull QueryParams queryParams) {\n        return mQueryParams.containsKey(queryParams.toString());\n    }\n\n    /**\n     * Only sets the value if it doesn't exist.\n     *\n     * @param key   type\n     * @param value value\n     * @return this (for chaining)\n     */\n    public synchronized TrackMe trySet(@NonNull QueryParams key, int value) {\n        return trySet(key, String.valueOf(value));\n    }\n\n    /**\n     * Only sets the value if it doesn't exist.\n     *\n     * @param key   type\n     * @param value value\n     * @return this (for chaining)\n     */\n    public synchronized TrackMe trySet(@NonNull QueryParams key, float value) {\n        return trySet(key, String.valueOf(value));\n    }\n\n    public synchronized TrackMe trySet(@NonNull QueryParams key, long value) {\n        return trySet(key, String.valueOf(value));\n    }\n\n    /**\n     * Only sets the value if it doesn't exist.\n     *\n     * @param key   type\n     * @param value value\n     * @return this (for chaining)\n     */\n    public synchronized TrackMe trySet(@NonNull QueryParams key, String value) {\n        if (!has(key)) set(key, value);\n        return this;\n    }\n\n    /**\n     * The tracker calls this to get the final data that will be transmitted\n     *\n     * @return the parameter map, but without the base URL\n     */\n    public synchronized Map<String, String> toMap() {\n        return new HashMap<>(mQueryParams);\n    }\n\n    public synchronized String get(@NonNull QueryParams queryParams) {\n        return mQueryParams.get(queryParams.toString());\n    }\n\n    public synchronized boolean isEmpty() {\n        return mQueryParams.isEmpty();\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/Tracker.java",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\n\npackage org.matomo.sdk;\n\nimport android.content.SharedPreferences;\n\nimport org.matomo.sdk.dispatcher.DispatchMode;\nimport org.matomo.sdk.dispatcher.Dispatcher;\nimport org.matomo.sdk.dispatcher.Packet;\nimport org.matomo.sdk.tools.DeviceHelper;\n\nimport java.text.SimpleDateFormat;\nimport java.util.Date;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Random;\nimport java.util.UUID;\nimport java.util.regex.Pattern;\n\nimport androidx.annotation.Nullable;\nimport androidx.annotation.VisibleForTesting;\nimport timber.log.Timber;\n\n\n/**\n * Main tracking class\n * This class is threadsafe.\n */\n@SuppressWarnings(\"WeakerAccess\")\npublic class Tracker {\n    private static final String TAG = Matomo.tag(Tracker.class);\n\n    // Matomo default parameter values\n    private static final String DEFAULT_UNKNOWN_VALUE = \"unknown\";\n    private static final String DEFAULT_TRUE_VALUE = \"1\";\n    private static final String DEFAULT_RECORD_VALUE = DEFAULT_TRUE_VALUE;\n    private static final String DEFAULT_API_VERSION_VALUE = \"1\";\n\n    // Sharedpreference keys for persisted values\n    protected static final String PREF_KEY_TRACKER_OPTOUT = \"tracker.optout\";\n    protected static final String PREF_KEY_TRACKER_USERID = \"tracker.userid\";\n    protected static final String PREF_KEY_TRACKER_VISITORID = \"tracker.visitorid\";\n    protected static final String PREF_KEY_TRACKER_FIRSTVISIT = \"tracker.firstvisit\";\n    protected static final String PREF_KEY_TRACKER_VISITCOUNT = \"tracker.visitcount\";\n    protected static final String PREF_KEY_TRACKER_PREVIOUSVISIT = \"tracker.previousvisit\";\n    protected static final String PREF_KEY_OFFLINE_CACHE_AGE = \"tracker.cache.age\";\n    protected static final String PREF_KEY_OFFLINE_CACHE_SIZE = \"tracker.cache.size\";\n    protected static final String PREF_KEY_DISPATCHER_MODE = \"tracker.dispatcher.mode\";\n\n    private static final Pattern VALID_URLS = Pattern.compile(\"^(\\\\w+)(?:://)(.+?)$\");\n\n    private final Matomo mMatomo;\n    private final String mApiUrl;\n    private final int mSiteId;\n    private final String mDefaultApplicationBaseUrl;\n    private final Object mTrackingLock = new Object();\n    private final Dispatcher mDispatcher;\n    private final String mName;\n    private final Random mRandomAntiCachingValue = new Random(new Date().getTime());\n    private final TrackMe mDefaultTrackMe = new TrackMe();\n\n    private TrackMe mLastEvent;\n    private long mSessionTimeout = 30 * 60 * 1000;\n    private long mSessionStartTime = 0;\n    private boolean mOptOut;\n    private SharedPreferences mPreferences;\n\n    private final LinkedHashSet<Callback> mTrackingCallbacks = new LinkedHashSet<>();\n    private DispatchMode mDispatchMode;\n\n    protected Tracker(Matomo matomo, TrackerBuilder config) {\n        mMatomo = matomo;\n        mApiUrl = config.getApiUrl();\n        mSiteId = config.getSiteId();\n        mName = config.getTrackerName();\n        mDefaultApplicationBaseUrl = config.getApplicationBaseUrl();\n\n        new LegacySettingsPorter(mMatomo).port(this);\n\n        mOptOut = getPreferences().getBoolean(PREF_KEY_TRACKER_OPTOUT, false);\n\n        mDispatcher = mMatomo.getDispatcherFactory().build(this);\n        mDispatcher.setDispatchMode(getDispatchMode());\n\n        String userId = getPreferences().getString(PREF_KEY_TRACKER_USERID, null);\n        mDefaultTrackMe.set(QueryParams.USER_ID, userId);\n\n        String visitorId = getPreferences().getString(PREF_KEY_TRACKER_VISITORID, null);\n        if (visitorId == null) {\n            visitorId = makeRandomVisitorId();\n            getPreferences().edit().putString(PREF_KEY_TRACKER_VISITORID, visitorId).apply();\n        }\n        mDefaultTrackMe.set(QueryParams.VISITOR_ID, visitorId);\n\n        mDefaultTrackMe.set(QueryParams.SESSION_START, DEFAULT_TRUE_VALUE);\n\n        DeviceHelper deviceHelper = mMatomo.getDeviceHelper();\n\n        String resolution = DEFAULT_UNKNOWN_VALUE;\n        int[] res = deviceHelper.getResolution();\n        if (res != null) resolution = String.format(\"%sx%s\", res[0], res[1]);\n        mDefaultTrackMe.set(QueryParams.SCREEN_RESOLUTION, resolution);\n\n        mDefaultTrackMe.set(QueryParams.USER_AGENT, deviceHelper.getUserAgent());\n        mDefaultTrackMe.set(QueryParams.LANGUAGE, deviceHelper.getUserLanguage());\n        mDefaultTrackMe.set(QueryParams.URL_PATH, config.getApplicationBaseUrl());\n    }\n\n    public void addTrackingCallback(Callback callback) {\n        this.mTrackingCallbacks.add(callback);\n    }\n\n    public void removeTrackingCallback(Callback callback) {\n        this.mTrackingCallbacks.remove(callback);\n    }\n\n    public void reset() {\n        dispatch();\n\n        String visitorId = makeRandomVisitorId();\n\n        SharedPreferences prefs = getPreferences();\n\n        //noinspection SynchronizationOnLocalVariableOrMethodParameter\n        synchronized (prefs) {\n            SharedPreferences.Editor editor = mPreferences.edit();\n\n            editor.remove(PREF_KEY_TRACKER_VISITCOUNT);\n            editor.remove(PREF_KEY_TRACKER_PREVIOUSVISIT);\n            editor.remove(PREF_KEY_TRACKER_FIRSTVISIT);\n            editor.remove(PREF_KEY_TRACKER_USERID);\n            editor.remove(PREF_KEY_TRACKER_OPTOUT);\n\n            editor.putString(PREF_KEY_TRACKER_VISITORID, visitorId);\n\n            editor.apply();\n        }\n\n        mDefaultTrackMe.set(QueryParams.VISITOR_ID, visitorId);\n        mDefaultTrackMe.set(QueryParams.USER_ID, null);\n        mDefaultTrackMe.set(QueryParams.FIRST_VISIT_TIMESTAMP, null);\n        mDefaultTrackMe.set(QueryParams.TOTAL_NUMBER_OF_VISITS, null);\n        mDefaultTrackMe.set(QueryParams.PREVIOUS_VISIT_TIMESTAMP, null);\n        mDefaultTrackMe.set(QueryParams.SESSION_START, DEFAULT_TRUE_VALUE);\n        mDefaultTrackMe.set(QueryParams.VISIT_SCOPE_CUSTOM_VARIABLES, null);\n        mDefaultTrackMe.set(QueryParams.CAMPAIGN_NAME, null);\n        mDefaultTrackMe.set(QueryParams.CAMPAIGN_KEYWORD, null);\n\n        startNewSession();\n    }\n\n    /**\n     * Use this to disable this Tracker, e.g. if the user opted out of tracking.\n     * The Tracker will persist the choice and remain disable on next instance creation.<p>\n     *\n     * @param optOut true to disable reporting\n     */\n    public void setOptOut(boolean optOut) {\n        mOptOut = optOut;\n        getPreferences().edit().putBoolean(PREF_KEY_TRACKER_OPTOUT, optOut).apply();\n    }\n\n    /**\n     * @return true if Matomo is currently disabled\n     */\n    public boolean isOptOut() {\n        return mOptOut;\n    }\n\n    public String getName() {\n        return mName;\n    }\n\n    public Matomo getMatomo() {\n        return mMatomo;\n    }\n\n    public String getAPIUrl() {\n        return mApiUrl;\n    }\n\n    protected int getSiteId() {\n        return mSiteId;\n    }\n\n    /**\n     * Matomo will use the content of this object to fill in missing values before any transmission.\n     * While you can modify it's values, you can also just set them in your {@link TrackMe} object as already set values will not be overwritten.\n     *\n     * @return the default TrackMe object\n     */\n    public TrackMe getDefaultTrackMe() {\n        return mDefaultTrackMe;\n    }\n\n    public void startNewSession() {\n        synchronized (mTrackingLock) {\n            mSessionStartTime = 0;\n        }\n    }\n\n    public void setSessionTimeout(int milliseconds) {\n        synchronized (mTrackingLock) {\n            mSessionTimeout = milliseconds;\n        }\n    }\n\n    /**\n     * Default is 30min (30*60*1000).\n     *\n     * @return session timeout value in miliseconds\n     */\n    public long getSessionTimeout() {\n        return mSessionTimeout;\n    }\n\n    /**\n     * {@link Dispatcher#getConnectionTimeOut()}\n     */\n    public int getDispatchTimeout() {\n        return mDispatcher.getConnectionTimeOut();\n    }\n\n    /**\n     * {@link Dispatcher#setConnectionTimeOut(int)}\n     */\n    public void setDispatchTimeout(int timeout) {\n        mDispatcher.setConnectionTimeOut(timeout);\n    }\n\n    /**\n     * Processes all queued events in background thread\n     */\n    public void dispatch() {\n        if (mOptOut) return;\n        mDispatcher.forceDispatch();\n    }\n\n    /**\n     * Process all queued events and block until processing is complete\n     */\n    public void dispatchBlocking() {\n        if (mOptOut) return;\n        mDispatcher.forceDispatchBlocking();\n    }\n\n    /**\n     * Set the interval to 0 to dispatch events as soon as they are queued.\n     * If a negative value is used the dispatch timer will never run, a manual dispatch must be used.\n     *\n     * @param dispatchInterval in milliseconds\n     */\n    public Tracker setDispatchInterval(long dispatchInterval) {\n        mDispatcher.setDispatchInterval(dispatchInterval);\n        return this;\n    }\n\n    /**\n     * Defines if when dispatched, posted JSON must be Gzipped.\n     * Need to be handle from web server side with mod_deflate/APACHE lua_zlib/NGINX.\n     *\n     * @param dispatchGzipped boolean\n     */\n    public Tracker setDispatchGzipped(boolean dispatchGzipped) {\n        mDispatcher.setDispatchGzipped(dispatchGzipped);\n        return this;\n    }\n\n    /**\n     * @return in milliseconds\n     */\n    public long getDispatchInterval() {\n        return mDispatcher.getDispatchInterval();\n    }\n\n    /**\n     * For how long events should be stored if they could not be send.\n     * Events older than the set limit will be discarded on the next dispatch attempt.<br>\n     * The Matomo backend accepts backdated events for up to 24 hours by default.\n     * <p>\n     * &gt;0 = limit in ms<br>\n     * 0 = unlimited<br>\n     * -1 = disabled offline cache<br>\n     *\n     * @param age in milliseconds\n     */\n    public void setOfflineCacheAge(long age) {\n        getPreferences().edit().putLong(PREF_KEY_OFFLINE_CACHE_AGE, age).apply();\n    }\n\n    /**\n     * See {@link #setOfflineCacheAge(long)}\n     *\n     * @return maximum cache age in milliseconds\n     */\n    public long getOfflineCacheAge() {\n        return getPreferences().getLong(PREF_KEY_OFFLINE_CACHE_AGE, 24 * 60 * 60 * 1000);\n    }\n\n    /**\n     * How large the offline cache may be.\n     * If the limit is reached the oldest files will be deleted first.\n     * Events older than the set limit will be discarded on the next dispatch attempt.<br>\n     * The Matomo backend accepts backdated events for up to 24 hours by default.\n     * <p>\n     * &gt;0 = limit in byte<br>\n     * 0 = unlimited<br>\n     *\n     * @param size in byte\n     */\n    public void setOfflineCacheSize(long size) {\n        getPreferences().edit().putLong(PREF_KEY_OFFLINE_CACHE_SIZE, size).apply();\n    }\n\n    /**\n     * Maximum size the offline cache is allowed to grow to.\n     *\n     * @return size in byte\n     */\n    public long getOfflineCacheSize() {\n        return getPreferences().getLong(PREF_KEY_OFFLINE_CACHE_SIZE, 4 * 1024 * 1024);\n    }\n\n    /**\n     * The current dispatch behavior.\n     *\n     * @see DispatchMode\n     */\n    public DispatchMode getDispatchMode() {\n        if (mDispatchMode == null) {\n            String raw = getPreferences().getString(PREF_KEY_DISPATCHER_MODE, null);\n            mDispatchMode = DispatchMode.fromString(raw);\n            if (mDispatchMode == null) mDispatchMode = DispatchMode.ALWAYS;\n        }\n        return mDispatchMode;\n    }\n\n    /**\n     * Sets the dispatch mode.\n     *\n     * @see DispatchMode\n     */\n    public void setDispatchMode(DispatchMode mode) {\n        mDispatchMode = mode;\n        if (mode != DispatchMode.EXCEPTION) {\n            getPreferences().edit().putString(PREF_KEY_DISPATCHER_MODE, mode.toString()).apply();\n        }\n        mDispatcher.setDispatchMode(mode);\n    }\n\n    /**\n     * Defines the User ID for this request.\n     * User ID is any non empty unique string identifying the user (such as an email address or a username).\n     * To access this value, users must be logged-in in your system so you can\n     * fetch this user ID from your system, and pass it to Matomo.\n     * <p>\n     * When specified, the User ID will be \"enforced\".\n     * This means that if there is no recent visit with this User ID, a new one will be created.\n     * If a visit is found in the last 30 minutes with your specified User ID,\n     * then the new action will be recorded to this existing visit.\n     *\n     * @param userId passing null will delete the current user-id.\n     */\n    public Tracker setUserId(String userId) {\n        mDefaultTrackMe.set(QueryParams.USER_ID, userId);\n        getPreferences().edit().putString(PREF_KEY_TRACKER_USERID, userId).apply();\n        return this;\n    }\n\n    /**\n     * @return a user-id string, either the one you set or the one Matomo generated for you.\n     */\n    public String getUserId() {\n        return mDefaultTrackMe.get(QueryParams.USER_ID);\n    }\n\n    /**\n     * The unique visitor ID, must be a 16 characters hexadecimal string.\n     * Every unique visitor must be assigned a different ID and this ID must not change after it is assigned.\n     * If this value is not set Matomo will still track visits, but the unique visitors metric might be less accurate.\n     */\n    public Tracker setVisitorId(String visitorId) throws IllegalArgumentException {\n        if (confirmVisitorIdFormat(visitorId)) mDefaultTrackMe.set(QueryParams.VISITOR_ID, visitorId);\n        return this;\n    }\n\n    public String getVisitorId() {\n        return mDefaultTrackMe.get(QueryParams.VISITOR_ID);\n    }\n\n    private static final Pattern PATTERN_VISITOR_ID = Pattern.compile(\"^[0-9a-f]{16}$\");\n\n    private boolean confirmVisitorIdFormat(String visitorId) throws IllegalArgumentException {\n        if (PATTERN_VISITOR_ID.matcher(visitorId).matches()) return true;\n\n        throw new IllegalArgumentException(\"VisitorId: \" + visitorId + \" is not of valid format, \" +\n                \" the format must match the regular expression: \" + PATTERN_VISITOR_ID.pattern());\n    }\n\n    /**\n     * There parameters are only interesting for the very first query.\n     */\n    private void injectInitialParams(TrackMe trackMe) {\n        long firstVisitTime;\n        long visitCount;\n        long previousVisit;\n\n        SharedPreferences prefs = getPreferences();\n        // Protected against Trackers on other threads trying to do the same thing.\n        // This works because they would use the same preference object.\n        //noinspection SynchronizationOnLocalVariableOrMethodParameter\n        synchronized (prefs) {\n            SharedPreferences.Editor editor = prefs.edit();\n            visitCount = 1 + getPreferences().getLong(PREF_KEY_TRACKER_VISITCOUNT, 0);\n            editor.putLong(PREF_KEY_TRACKER_VISITCOUNT, visitCount);\n\n            firstVisitTime = prefs.getLong(PREF_KEY_TRACKER_FIRSTVISIT, -1);\n            if (firstVisitTime == -1) {\n                firstVisitTime = System.currentTimeMillis() / 1000;\n                editor.putLong(PREF_KEY_TRACKER_FIRSTVISIT, firstVisitTime);\n            }\n\n            previousVisit = prefs.getLong(PREF_KEY_TRACKER_PREVIOUSVISIT, -1);\n            editor.putLong(PREF_KEY_TRACKER_PREVIOUSVISIT, System.currentTimeMillis() / 1000);\n\n            editor.apply();\n        }\n\n        // trySet because the developer could have modded these after creating the Tracker\n        mDefaultTrackMe.trySet(QueryParams.FIRST_VISIT_TIMESTAMP, firstVisitTime);\n        mDefaultTrackMe.trySet(QueryParams.TOTAL_NUMBER_OF_VISITS, visitCount);\n\n        if (previousVisit != -1) mDefaultTrackMe.trySet(QueryParams.PREVIOUS_VISIT_TIMESTAMP, previousVisit);\n\n        trackMe.trySet(QueryParams.SESSION_START, mDefaultTrackMe.get(QueryParams.SESSION_START));\n        trackMe.trySet(QueryParams.FIRST_VISIT_TIMESTAMP, mDefaultTrackMe.get(QueryParams.FIRST_VISIT_TIMESTAMP));\n        trackMe.trySet(QueryParams.TOTAL_NUMBER_OF_VISITS, mDefaultTrackMe.get(QueryParams.TOTAL_NUMBER_OF_VISITS));\n        trackMe.trySet(QueryParams.PREVIOUS_VISIT_TIMESTAMP, mDefaultTrackMe.get(QueryParams.PREVIOUS_VISIT_TIMESTAMP));\n    }\n\n    /**\n     * These parameters are required for all queries.\n     */\n    private void injectBaseParams(TrackMe trackMe) {\n        trackMe.trySet(QueryParams.SITE_ID, mSiteId);\n        trackMe.trySet(QueryParams.RECORD, DEFAULT_RECORD_VALUE);\n        trackMe.trySet(QueryParams.API_VERSION, DEFAULT_API_VERSION_VALUE);\n        trackMe.trySet(QueryParams.RANDOM_NUMBER, mRandomAntiCachingValue.nextInt(100000));\n        trackMe.trySet(QueryParams.DATETIME_OF_REQUEST, new SimpleDateFormat(\"yyyy-MM-dd HH:mm:ssZ\", Locale.US).format(new Date()));\n        trackMe.trySet(QueryParams.SEND_IMAGE, \"0\");\n\n        trackMe.trySet(QueryParams.VISITOR_ID, mDefaultTrackMe.get(QueryParams.VISITOR_ID));\n        trackMe.trySet(QueryParams.USER_ID, mDefaultTrackMe.get(QueryParams.USER_ID));\n\n        trackMe.trySet(QueryParams.SCREEN_RESOLUTION, mDefaultTrackMe.get(QueryParams.SCREEN_RESOLUTION));\n        trackMe.trySet(QueryParams.USER_AGENT, mDefaultTrackMe.get(QueryParams.USER_AGENT));\n        trackMe.trySet(QueryParams.LANGUAGE, mDefaultTrackMe.get(QueryParams.LANGUAGE));\n\n        String urlPath = trackMe.get(QueryParams.URL_PATH);\n        if (urlPath == null) {\n            urlPath = mDefaultTrackMe.get(QueryParams.URL_PATH);\n        } else if (!VALID_URLS.matcher(urlPath).matches()) {\n            StringBuilder urlBuilder = new StringBuilder(mDefaultApplicationBaseUrl);\n            if (!mDefaultApplicationBaseUrl.endsWith(\"/\") && !urlPath.startsWith(\"/\")) {\n                urlBuilder.append(\"/\");\n            } else if (mDefaultApplicationBaseUrl.endsWith(\"/\") && urlPath.startsWith(\"/\")) {\n                urlPath = urlPath.substring(1);\n            }\n            urlPath = urlBuilder.append(urlPath).toString();\n        }\n\n        // https://github.com/matomo-org/matomo-sdk-android/issues/92\n        mDefaultTrackMe.set(QueryParams.URL_PATH, urlPath);\n        trackMe.set(QueryParams.URL_PATH, urlPath);\n    }\n\n    public Tracker track(TrackMe trackMe) {\n        synchronized (mTrackingLock) {\n            final boolean newSession = System.currentTimeMillis() - mSessionStartTime > mSessionTimeout;\n\n            if (newSession) {\n                mSessionStartTime = System.currentTimeMillis();\n                injectInitialParams(trackMe);\n            }\n\n            injectBaseParams(trackMe);\n\n            for (Callback callback : mTrackingCallbacks) {\n                trackMe = callback.onTrack(trackMe);\n                if (trackMe == null) {\n                    Timber.tag(TAG).d(\"Tracking aborted by %s\", callback);\n                    return this;\n                }\n            }\n\n            mLastEvent = trackMe;\n            if (!mOptOut) {\n                mDispatcher.submit(trackMe);\n                Timber.tag(TAG).d(\"Event added to the queue: %s\", trackMe);\n            } else {\n                Timber.tag(TAG).d(\"Event omitted due to opt out: %s\", trackMe);\n            }\n\n            return this;\n        }\n    }\n\n    public static String makeRandomVisitorId() {\n        return UUID.randomUUID().toString().replaceAll(\"-\", \"\").substring(0, 16);\n    }\n\n\n    public SharedPreferences getPreferences() {\n        if (mPreferences == null) mPreferences = mMatomo.getTrackerPreferences(this);\n        return mPreferences;\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) return true;\n        if (o == null || getClass() != o.getClass()) return false;\n\n        Tracker tracker = (Tracker) o;\n\n        if (mSiteId != tracker.mSiteId) return false;\n        if (!mApiUrl.equals(tracker.mApiUrl)) return false;\n        return mName.equals(tracker.mName);\n\n    }\n\n    @Override\n    public int hashCode() {\n        int result = mApiUrl.hashCode();\n        result = 31 * result + mSiteId;\n        result = 31 * result + mName.hashCode();\n        return result;\n    }\n\n    /**\n     * For testing purposes\n     *\n     * @return query of the event\n     */\n    @VisibleForTesting\n    public TrackMe getLastEventX() {\n        return mLastEvent;\n    }\n\n    /**\n     * Set a data structure here to put the Dispatcher into dry-run-mode.\n     * Data will be processed but at the last step just stored instead of transmitted.\n     * Set it to null to disable it.\n     *\n     * @param dryRunTarget a data structure the data should be passed into\n     */\n    public void setDryRunTarget(List<Packet> dryRunTarget) {\n        mDispatcher.setDryRunTarget(dryRunTarget);\n    }\n\n    /**\n     * If we are in dry-run mode then this will return a datastructure.\n     *\n     * @return a datastructure or null\n     */\n    public List<Packet> getDryRunTarget() {\n        return mDispatcher.getDryRunTarget();\n    }\n\n    public interface Callback {\n        /**\n         * This method will be called after parameter injection and before transmission within {@link Tracker#track(TrackMe)}.\n         * Blocking within this method will block tracking.\n         *\n         * @param trackMe The `TrackMe` that was passed to {@link Tracker#track(TrackMe)} after all data has been injected.\n         * @return The `TrackMe` that will be send, returning NULL here will abort transmission.\n         */\n        @Nullable\n        TrackMe onTrack(TrackMe trackMe);\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/TrackerBuilder.java",
    "content": "package org.matomo.sdk;\n\nimport java.net.MalformedURLException;\nimport java.net.URL;\n\n/**\n * Configuration details for a {@link Tracker}\n */\npublic class TrackerBuilder {\n    private final String mApiUrl;\n    private final int mSiteId;\n    private String mTrackerName;\n    private String mApplicationBaseUrl;\n\n    public static TrackerBuilder createDefault(String apiUrl, int siteId) {\n        return new TrackerBuilder(apiUrl, siteId, \"Default Tracker\");\n    }\n\n    /**\n     * @param apiUrl      Tracking HTTP API endpoint, for example, https://matomo.yourdomain.tld/matomo.php\n     * @param siteId      id of your site in the backend\n     * @param trackerName name of your tracker, will be used to store configuration data\n     */\n    public TrackerBuilder(String apiUrl, int siteId, String trackerName) {\n        try {\n            new URL(apiUrl);\n        } catch (MalformedURLException e) {\n            throw new RuntimeException(e);\n        }\n        mApiUrl = apiUrl;\n        mSiteId = siteId;\n        mTrackerName = trackerName;\n    }\n\n    public String getApiUrl() {\n        return mApiUrl;\n    }\n\n    public int getSiteId() {\n        return mSiteId;\n    }\n\n    /**\n     * A unique name for this Tracker. Used to store Tracker settings independent of URL and id changes.\n     */\n    public TrackerBuilder setTrackerName(String name) {\n        mTrackerName = name;\n        return this;\n    }\n\n    public String getTrackerName() {\n        return mTrackerName;\n    }\n\n    /**\n     * Domain used to build the required parameter url (http://developer.matomo.org/api-reference/tracking-api)\n     * Defaults to`https://your.packagename`\n     *\n     * @param domain your-domain.com\n     */\n    public TrackerBuilder setApplicationBaseUrl(String domain) {\n        mApplicationBaseUrl = domain;\n        return this;\n    }\n\n    public String getApplicationBaseUrl() {\n        return mApplicationBaseUrl;\n    }\n\n    public Tracker build(Matomo matomo) {\n        if (mApplicationBaseUrl == null) {\n            mApplicationBaseUrl = String.format(\"https://%s/\", matomo.getContext().getPackageName());\n        }\n        return new Tracker(matomo, this);\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) return true;\n        if (o == null || getClass() != o.getClass()) return false;\n\n        TrackerBuilder that = (TrackerBuilder) o;\n\n        return mSiteId == that.mSiteId && mApiUrl.equals(that.mApiUrl) && mTrackerName.equals(that.mTrackerName);\n    }\n\n    @Override\n    public int hashCode() {\n        int result = mApiUrl.hashCode();\n        result = 31 * result + mSiteId;\n        result = 31 * result + mTrackerName.hashCode();\n        return result;\n    }\n\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/dispatcher/DefaultDispatcher.kt",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\npackage org.matomo.sdk.dispatcher\n\nimport org.matomo.sdk.Matomo.Companion.tag\nimport org.matomo.sdk.TrackMe\nimport org.matomo.sdk.tools.Connectivity\nimport timber.log.Timber\nimport java.util.concurrent.Semaphore\nimport java.util.concurrent.TimeUnit\nimport kotlin.concurrent.Volatile\nimport kotlin.math.min\n\n\n/**\n * Responsible for transmitting packets to a server\n */\nclass DefaultDispatcher(\n    private val eventCache: EventCache,\n    private val connectivity: Connectivity,\n    private val packetFactory: PacketFactory,\n    private val packetSender: PacketSender\n) : Dispatcher {\n    private val threadControl = Any()\n    private val sleepToken = Semaphore(0)\n\n    @Volatile\n    private var timeOut = Dispatcher.DEFAULT_CONNECTION_TIMEOUT\n\n    @Volatile\n    private var dispatchInterval = Dispatcher.DEFAULT_DISPATCH_INTERVAL\n\n    @Volatile\n    private var retryCounter = 0\n\n    @Volatile\n    private var forcedBlocking = false\n\n    private var dispatchGzipped = false\n\n    @Volatile\n    private var dispatchMode = DispatchMode.ALWAYS\n\n    @Volatile\n    private var running = false\n\n    @Volatile\n    private var dispatchThread: Thread? = null\n    private var mDryRunTarget: MutableList<Packet>? = null\n\n    init {\n        packetSender.setGzipData(dispatchGzipped)\n        packetSender.setTimeout(timeOut.toLong())\n    }\n\n    /**\n     * Connection timeout in milliseconds\n     *\n     * @return timeout in milliseconds\n     */\n    override fun getConnectionTimeOut(): Int {\n        return timeOut\n    }\n\n    /**\n     * Timeout when trying to establish connection and when trying to read a response.\n     * Values take effect on next dispatch.\n     *\n     * @param timeOutIn timeout in milliseconds\n     */\n    override fun setConnectionTimeOut(timeOutIn: Int) {\n        timeOut = timeOutIn\n        packetSender.setTimeout(timeOut.toLong())\n    }\n\n    /**\n     * Packets are collected and dispatched in batches, this intervals sets the pause between batches.\n     *\n     * @param dispatchIntervalIn in milliseconds\n     */\n    override fun setDispatchInterval(dispatchIntervalIn: Long) {\n        dispatchInterval = dispatchIntervalIn\n        if (dispatchInterval != -1L)\n            launch()\n    }\n\n    override fun getDispatchInterval(): Long {\n        return dispatchInterval\n    }\n\n    /**\n     * Packets are collected and dispatched in batches. This boolean sets if post must be\n     * gzipped or not. Use of gzip needs mod_deflate/Apache ou lua_zlib/NGINX\n     *\n     * @param dispatchGzippedIn boolean\n     */\n    override fun setDispatchGzipped(dispatchGzippedIn: Boolean) {\n        dispatchGzipped = dispatchGzippedIn\n        packetSender.setGzipData(dispatchGzipped)\n    }\n\n    override fun getDispatchGzipped(): Boolean {\n        return dispatchGzipped\n    }\n\n    override fun setDispatchMode(dispatchModeIn: DispatchMode) {\n        this.dispatchMode = dispatchModeIn\n    }\n\n    override fun getDispatchMode(): DispatchMode {\n        return dispatchMode\n    }\n\n    private fun launch(): Boolean {\n        synchronized(threadControl) {\n            if (!running) {\n                running = true\n                val thread = Thread(loop)\n                thread.priority = Thread.MIN_PRIORITY\n                thread.name = \"Matomo-default-dispatcher\"\n                dispatchThread = thread\n                thread.start()\n                return true\n            }\n        }\n        return false\n    }\n\n    /**\n     * Starts the dispatcher for one cycle if it is currently not working.\n     * If the dispatcher is working it will skip the dispatch interval once.\n     */\n    override fun forceDispatch(): Boolean {\n        if (!launch()) {\n            retryCounter = 0\n            sleepToken.release()\n            return false\n        }\n        return true\n    }\n\n    override fun forceDispatchBlocking() {\n        synchronized(threadControl) {\n            // force thread to exit after it completes its dispatch loop\n            forcedBlocking = true\n        }\n\n        if (forceDispatch()) {\n            sleepToken.release()\n        }\n\n        val dispatchThreadLocal = dispatchThread\n\n        if (dispatchThreadLocal != null) {\n            try {\n                dispatchThreadLocal.join()\n            } catch (e: InterruptedException) {\n                Timber.tag(TAG).d(\"Interrupted while waiting for dispatch thread to complete\")\n            }\n        }\n\n        synchronized(threadControl) {\n            // re-enable default behavior\n            forcedBlocking = false\n        }\n    }\n\n    override fun clear() {\n        eventCache.clear()\n        // Try to exit the loop as the queue is empty\n        if (running) forceDispatch()\n    }\n\n    override fun submit(trackMe: TrackMe) {\n        eventCache.add(Event(trackMe.toMap()))\n        if (dispatchInterval != -1L) launch()\n    }\n\n    private val loop: Runnable = Runnable {\n        retryCounter = 0\n        while (running) {\n            try {\n                var sleepTime = dispatchInterval\n                if (retryCounter > 1)\n                    sleepTime += min(\n                        (retryCounter * dispatchInterval).toDouble(),\n                        (5 * dispatchInterval).toDouble()\n                    ).toLong()\n\n                // Either we wait the interval or forceDispatch() granted us one free pass\n                sleepToken.tryAcquire(sleepTime, TimeUnit.MILLISECONDS)\n            } catch (e: InterruptedException) {\n                Timber.tag(TAG).e(e)\n            }\n            if (eventCache.updateState(isOnline)) {\n                var count = 0\n                val drainedEvents: List<Event> = ArrayList()\n                eventCache.drainTo(drainedEvents)\n                Timber.tag(TAG).d(\"Drained %s events.\", drainedEvents.size)\n                for (packet in packetFactory.buildPackets(drainedEvents)) {\n                    var success: Boolean\n\n                    if (mDryRunTarget != null) {\n                        Timber.tag(TAG).d(\"DryRun, stored HttpRequest, now %d.\", mDryRunTarget!!.size)\n                        success = mDryRunTarget!!.add(packet)\n                    } else {\n                        success = packetSender.send(packet)\n                    }\n\n                    if (success) {\n                        count += packet.eventCount\n                        retryCounter = 0\n                    } else {\n                        // On network failure, requeue all un-sent events, but use isOnline to determine if events should be cached in\n                        // memory or disk\n                        Timber.tag(TAG).d(\"Failure while trying to send packet\")\n                        retryCounter++\n                        break\n                    }\n\n                    // Re-check network connectivity to early exit if we drop offline.  This speeds up how quickly the setOffline method will\n                    // take effect\n                    if (!isOnline) {\n                        Timber.tag(TAG).d(\"Disconnected during dispatch loop\")\n                        break\n                    }\n                }\n\n                Timber.tag(TAG).d(\"Dispatched %d events.\", count)\n                if (count < drainedEvents.size) {\n                    Timber.tag(TAG).d(\"Unable to send all events, re-queueing %d events\", drainedEvents.size - count)\n                    // Requeue events to the event cache that weren't processed (either PacketSender failure or we are now offline).  Once the\n                    // events are re-queued we update the event cache state to write the re-queued events to disk or to leave them in memory\n                    // depending on the connectivity state of the device.\n                    eventCache.requeue(drainedEvents.subList(count, drainedEvents.size))\n                    eventCache.updateState(isOnline)\n                }\n            }\n\n            synchronized(threadControl) {\n                // We may be done or this was a forced dispatch.  If we are in a blocking force dispatch we need to exit immediately to ensure\n                // the blocking doesn't take too long.\n                if (forcedBlocking || eventCache.isEmpty || dispatchInterval < 0) {\n                    running = false\n                }\n            }\n        }\n    }\n\n    private val isOnline: Boolean\n        get() {\n            if (!connectivity.isConnected) return false\n\n            return when (dispatchMode) {\n                DispatchMode.EXCEPTION -> false\n                DispatchMode.ALWAYS -> true\n                DispatchMode.WIFI_ONLY -> connectivity.type == Connectivity.Type.WIFI\n            }\n        }\n\n    override fun setDryRunTarget(dryRunTarget: MutableList<Packet>) {\n        mDryRunTarget = dryRunTarget\n    }\n\n    override fun getDryRunTarget(): List<Packet> {\n        return mDryRunTarget!!\n    }\n\n    companion object {\n        private val TAG = tag(DefaultDispatcher::class.java)\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/dispatcher/DefaultDispatcherFactory.kt",
    "content": "package org.matomo.sdk.dispatcher\n\nimport org.matomo.sdk.Tracker\nimport org.matomo.sdk.tools.Connectivity\n\nopen class DefaultDispatcherFactory : DispatcherFactory {\n    override fun build(tracker: Tracker): Dispatcher {\n        return DefaultDispatcher(\n            EventCache(EventDiskCache(tracker)),\n            Connectivity(tracker.matomo.context),\n            PacketFactory(tracker.apiUrl),\n            DefaultPacketSender()\n        )\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/dispatcher/DefaultPacketSender.kt",
    "content": "package org.matomo.sdk.dispatcher\n\nimport org.matomo.sdk.Matomo.Companion.tag\nimport timber.log.Timber\nimport java.io.BufferedReader\nimport java.io.BufferedWriter\nimport java.io.ByteArrayOutputStream\nimport java.io.IOException\nimport java.io.InputStreamReader\nimport java.io.OutputStream\nimport java.io.OutputStreamWriter\nimport java.net.HttpURLConnection\nimport java.net.URL\nimport java.nio.charset.StandardCharsets\nimport java.util.zip.GZIPOutputStream\n\nclass DefaultPacketSender : PacketSender {\n    private var mTimeout = Dispatcher.DEFAULT_CONNECTION_TIMEOUT.toLong()\n    private var mGzip = false\n\n    override fun send(packet: Packet): Boolean {\n        var urlConnection: HttpURLConnection? = null\n        try {\n            urlConnection = URL(packet.targetURL).openConnection() as HttpURLConnection\n\n            Timber.tag(TAG).v(\"Connection is open to %s\", urlConnection.url.toExternalForm())\n            Timber.tag(TAG).v(\"Sending: %s\", packet)\n\n            urlConnection.connectTimeout = mTimeout.toInt()\n            urlConnection.readTimeout = mTimeout.toInt()\n\n            // IF there is json data we have to do a post\n            if (packet.postData != null) { // POST\n                urlConnection.doOutput = true // Forces post\n                urlConnection.setRequestProperty(\"Content-Type\", \"application/json\")\n                urlConnection.setRequestProperty(\"charset\", \"utf-8\")\n\n                val toPost = packet.postData.toString()\n                if (mGzip) {\n                    urlConnection.addRequestProperty(\"Content-Encoding\", \"gzip\")\n                    val byteArrayOS = ByteArrayOutputStream()\n\n                    GZIPOutputStream(byteArrayOS).use { gzipStream ->\n                        gzipStream.write(toPost.toByteArray(StandardCharsets.UTF_8))\n                    }\n                    // If closing fails we assume the written data to be invalid.\n                    // Don't catch the exception and let it abort the `send(Packet)` call.\n                    var outputStream: OutputStream? = null\n                    try {\n                        outputStream = urlConnection.outputStream\n                        outputStream.write(byteArrayOS.toByteArray())\n                    } finally {\n                        if (outputStream != null) {\n                            try {\n                                outputStream.close()\n                            } catch (e: IOException) {\n                                // Failing to close the stream is not enough to consider the transmission faulty.\n                                Timber.tag(TAG).d(e, \"Failed to close output stream after writing gzipped POST data.\")\n                            }\n                        }\n                    }\n                } else {\n                    var writer: BufferedWriter? = null\n                    try {\n                        writer = BufferedWriter(OutputStreamWriter(urlConnection.outputStream, StandardCharsets.UTF_8))\n                        writer.write(toPost)\n                    } finally {\n                        if (writer != null) {\n                            try {\n                                writer.close()\n                            } catch (e: IOException) {\n                                // Failing to close the stream is not enough to consider the transmission faulty.\n                                Timber.tag(TAG).d(e, \"Failed to close output stream after writing POST data.\")\n                            }\n                        }\n                    }\n                }\n            } else { // GET\n                urlConnection.doOutput = false // Defaults to false, but for readability\n            }\n\n            val statusCode = urlConnection.responseCode\n            Timber.tag(TAG).v(\"Transmission finished (code=%d).\", statusCode)\n            val successful = checkResponseCode(statusCode)\n\n            if (successful) {\n                // https://github.com/matomo-org/matomo-sdk-android/issues/226\n\n                val `is` = urlConnection.inputStream\n                if (`is` != null) {\n                    try {\n                        `is`.close()\n                    } catch (e: IOException) {\n                        Timber.tag(TAG).d(e, \"Failed to close the error stream.\")\n                    }\n                }\n            } else {\n                // Consume the error stream (or at least close it) if the status code was non-OK (not 2XX)\n                val errorReason = StringBuilder()\n                var errorReader: BufferedReader? = null\n                try {\n                    errorReader = BufferedReader(InputStreamReader(urlConnection.errorStream))\n                    var line: String?\n                    while ((errorReader.readLine().also { line = it }) != null) errorReason.append(line)\n                } finally {\n                    if (errorReader != null) {\n                        try {\n                            errorReader.close()\n                        } catch (e: IOException) {\n                            Timber.tag(TAG).d(e, \"Failed to close the error stream.\")\n                        }\n                    }\n                }\n                Timber.tag(TAG).w(\"Transmission failed (code=%d, reason=%s)\", statusCode, errorReason.toString())\n            }\n\n            return successful\n        } catch (e: Exception) {\n            Timber.tag(TAG).e(e, \"Transmission failed unexpectedly.\")\n            return false\n        } finally {\n            urlConnection?.disconnect()\n        }\n    }\n\n    override fun setTimeout(timeout: Long) {\n        mTimeout = timeout\n    }\n\n    override fun setGzipData(gzip: Boolean) {\n        mGzip = gzip\n    }\n\n    companion object {\n        private val TAG = tag(DefaultPacketSender::class.java)\n        private fun checkResponseCode(code: Int): Boolean {\n            return code == HttpURLConnection.HTTP_NO_CONTENT || code == HttpURLConnection.HTTP_OK\n        }\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/dispatcher/DispatchMode.java",
    "content": "package org.matomo.sdk.dispatcher;\n\nimport androidx.annotation.Nullable;\n\n\npublic enum DispatchMode {\n    /**\n     * Dispatch always (default)\n     */\n    ALWAYS(\"always\"),\n    /**\n     * Dispatch only on WIFI\n     */\n    WIFI_ONLY(\"wifi_only\"),\n    /**\n     * The dispatcher will assume being offline. This is not persisted and will revert on app restart.\n     * Ensures no information is lost when tracking exceptions. See #247\n     */\n    EXCEPTION(\"exception\");\n\n    private final String key;\n\n    DispatchMode(String key) {this.key = key;}\n\n    @Override\n    public String toString() {\n        return key;\n    }\n\n    @Nullable\n    public static DispatchMode fromString(String raw) {\n        for (DispatchMode mode : DispatchMode.values()) {\n            if (mode.key.equals(raw)) return mode;\n        }\n        return null;\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/dispatcher/Dispatcher.java",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\n\npackage org.matomo.sdk.dispatcher;\n\nimport org.matomo.sdk.TrackMe;\n\nimport java.util.List;\n\n/**\n * Responsible for transmitting packets to a server\n */\npublic interface Dispatcher {\n    int DEFAULT_CONNECTION_TIMEOUT = 5 * 1000;  // 5s\n    long DEFAULT_DISPATCH_INTERVAL = 120 * 1000; // 120s\n\n    /**\n     * Connection timeout in milliseconds\n     *\n     * @return timeout in milliseconds\n     */\n    int getConnectionTimeOut();\n\n    /**\n     * Timeout when trying to establish connection and when trying to read a response.\n     * Values take effect on next dispatch.\n     *\n     * @param timeOut timeout in milliseconds\n     */\n    void setConnectionTimeOut(int timeOut);\n\n    /**\n     * Packets are collected and dispatched in batches, this intervals sets the pause between batches.\n     *\n     * @param dispatchInterval in milliseconds\n     */\n    void setDispatchInterval(long dispatchInterval);\n\n    long getDispatchInterval();\n\n    /**\n     * Packets are collected and dispatched in batches. This boolean sets if post must be\n     * gzipped or not. Use of gzip needs mod_deflate/Apache ou lua_zlib/NGINX\n     *\n     * @param dispatchGzipped boolean\n     */\n    void setDispatchGzipped(boolean dispatchGzipped);\n\n    boolean getDispatchGzipped();\n\n    void setDispatchMode(DispatchMode dispatchMode);\n\n    DispatchMode getDispatchMode();\n\n    /**\n     * Starts the dispatcher for one cycle if it is currently not working.\n     * If the dispatcher is working it will skip the dispatch interval once.\n     */\n    boolean forceDispatch();\n\n    /**\n     * Dispatch all events in the EventCache and return only after the dispatch is complete.\n     *\n     * This method may be invoked while the Runtime is being torn down and should not start new threads.\n     */\n    void forceDispatchBlocking();\n\n    /**\n     * To clear the dispatchers queue\n     */\n    void clear();\n\n    /**\n     * Submit for transmission\n     */\n    void submit(TrackMe trackMe);\n\n    /**\n     * For debugging purposes\n     * When this is non null then instead of sending data over the network it will be written into this list.\n     * Mind thread-safety!\n     */\n    void setDryRunTarget(List<Packet> dryRunTarget);\n\n    /**\n     * For debugging purposes\n     * Mind thread-safety!\n     */\n    List<Packet> getDryRunTarget();\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/dispatcher/DispatcherFactory.kt",
    "content": "package org.matomo.sdk.dispatcher\n\nimport org.matomo.sdk.Tracker\n\ninterface DispatcherFactory {\n    fun build(tracker: Tracker): Dispatcher\n}\n\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/dispatcher/Event.java",
    "content": "package org.matomo.sdk.dispatcher;\n\n\nimport org.matomo.sdk.Matomo;\n\nimport java.net.URLEncoder;\nimport java.util.Map;\n\nimport timber.log.Timber;\n\npublic class Event {\n    private static final String TAG = Matomo.tag(Event.class);\n    private final long mTimestamp;\n    private final String mQuery;\n\n    public Event(Map<String, String> eventData) {\n        this(urlEncodeUTF8(eventData));\n    }\n\n    public Event(String query) {\n        this(System.currentTimeMillis(), query);\n    }\n\n    public Event(long timestamp, String query) {\n        this.mTimestamp = timestamp;\n        this.mQuery = query;\n    }\n\n    public long getTimeStamp() {\n        return mTimestamp;\n    }\n\n    public String getEncodedQuery() {\n        return mQuery;\n    }\n\n    @Override\n    public String toString() {\n        return getEncodedQuery();\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) return true;\n        if (o == null || getClass() != o.getClass()) return false;\n\n        Event event = (Event) o;\n\n        return mTimestamp == event.mTimestamp && mQuery.equals(event.mQuery);\n\n    }\n\n    @Override\n    public int hashCode() {\n        int result = (int) (mTimestamp ^ (mTimestamp >>> 32));\n        result = 31 * result + mQuery.hashCode();\n        return result;\n    }\n\n    /**\n     * http://stackoverflow.com/q/4737841\n     *\n     * @param param raw data\n     * @return encoded string\n     */\n    private static String urlEncodeUTF8(String param) {\n        try {\n            return URLEncoder.encode(param, \"UTF-8\").replaceAll(\"\\\\+\", \"%20\");\n        } catch (Exception e) {\n            Timber.tag(TAG).e(e, \"Cannot encode %s\", param);\n            return \"\";\n        }\n    }\n\n    /**\n     * URL encodes a key-value map\n     */\n    private static String urlEncodeUTF8(Map<String, String> map) {\n        StringBuilder sb = new StringBuilder(100);\n        sb.append('?');\n        for (Map.Entry<String, String> entry : map.entrySet()) {\n            sb.append(urlEncodeUTF8(entry.getKey()));\n            sb.append('=');\n            sb.append(urlEncodeUTF8(entry.getValue()));\n            sb.append('&');\n        }\n\n        return sb.substring(0, sb.length() - 1);\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/dispatcher/EventCache.java",
    "content": "package org.matomo.sdk.dispatcher;\n\n\nimport org.matomo.sdk.Matomo;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.ListIterator;\nimport java.util.concurrent.LinkedBlockingDeque;\n\nimport timber.log.Timber;\n\npublic class EventCache {\n    private static final String TAG = Matomo.tag(EventCache.class);\n    private final LinkedBlockingDeque<Event> mQueue = new LinkedBlockingDeque<>();\n    private final EventDiskCache mDiskCache;\n\n    public EventCache(EventDiskCache cache) {\n        mDiskCache = cache;\n    }\n\n    public void add(Event event) {\n        mQueue.add(event);\n    }\n\n    public void drainTo(List<Event> drainedEvents) {\n        mQueue.drainTo(drainedEvents);\n    }\n\n    public void clear() {\n        mDiskCache.uncache();\n        mQueue.clear();\n    }\n\n    public boolean isEmpty() {\n        return mQueue.isEmpty() && mDiskCache.isEmpty();\n    }\n\n    public boolean updateState(boolean online) {\n        if (online) {\n            final List<Event> uncache = mDiskCache.uncache();\n            ListIterator<Event> it = uncache.listIterator(uncache.size());\n            while (it.hasPrevious()) {\n                // Anything from  disk cache is older then what the queue could currently contain.\n                mQueue.offerFirst(it.previous());\n            }\n            Timber.tag(TAG).d(\"Switched state to ONLINE, uncached %d events from disk.\", uncache.size());\n        } else if (!mQueue.isEmpty()) {\n            List<Event> toCache = new ArrayList<>();\n            mQueue.drainTo(toCache);\n            mDiskCache.cache(toCache);\n            Timber.tag(TAG).d(\"Switched state to OFFLINE, caching %d events to disk.\", toCache.size());\n        }\n        return online && !mQueue.isEmpty();\n    }\n\n    public void requeue(List<Event> events) {\n        for (Event e : events) {\n            mQueue.offerFirst(e);\n        }\n    }\n\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/dispatcher/EventDiskCache.java",
    "content": "package org.matomo.sdk.dispatcher;\n\n\nimport org.matomo.sdk.Matomo;\nimport org.matomo.sdk.Tracker;\n\nimport java.io.BufferedReader;\nimport java.io.File;\nimport java.io.FileInputStream;\nimport java.io.FileWriter;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.io.InputStreamReader;\nimport java.net.MalformedURLException;\nimport java.net.URL;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.concurrent.LinkedBlockingQueue;\n\nimport androidx.annotation.NonNull;\nimport androidx.annotation.Nullable;\nimport timber.log.Timber;\n\npublic class EventDiskCache {\n    private static final String TAG = Matomo.tag(EventDiskCache.class);\n    private static final String CACHE_DIR_NAME = \"piwik_cache\";\n    private static final String VERSION = \"1\";\n    private final LinkedBlockingQueue<File> mEventContainer = new LinkedBlockingQueue<>();\n    private final File mCacheDir;\n    private final long mMaxAge;\n    private final long mMaxSize;\n    private long mCurrentSize = 0;\n    private boolean mDelayedClear = false;\n\n    public EventDiskCache(Tracker tracker) {\n        mMaxAge = tracker.getOfflineCacheAge();\n        mMaxSize = tracker.getOfflineCacheSize();\n        File baseDir = new File(tracker.getMatomo().getContext().getCacheDir(), CACHE_DIR_NAME);\n        try {\n            mCacheDir = new File(baseDir, new URL(tracker.getAPIUrl()).getHost());\n        } catch (MalformedURLException e) {\n            throw new RuntimeException(e);\n        }\n        File[] storedContainers = mCacheDir.listFiles();\n        if (storedContainers != null) {\n            Arrays.sort(storedContainers);\n            for (File container : storedContainers) {\n                mCurrentSize += container.length();\n                mEventContainer.add(container);\n            }\n        }\n    }\n\n    // Must be called from a synchronized method\n    private void checkCacheLimits() {\n        long startTime = System.currentTimeMillis();\n        if (mMaxAge < 0) {\n            Timber.tag(TAG).d(\"Caching is disabled.\");\n            while (!mEventContainer.isEmpty()) {\n                File head = mEventContainer.poll();\n                if (head.delete()) {\n                    Timber.tag(TAG).e(\"Deleted cache container %s\", head.getPath());\n                }\n            }\n        } else if (mMaxAge > 0) {\n            final Iterator<File> iterator = mEventContainer.iterator();\n            while (iterator.hasNext()) {\n                File head = iterator.next();\n                long timestamp;\n                try {\n                    final String[] split = head.getName().split(\"_\");\n                    timestamp = Long.parseLong(split[1]);\n                } catch (Exception e) {\n                    Timber.tag(TAG).e(e);\n                    timestamp = 0;\n                }\n                if (timestamp < (System.currentTimeMillis() - mMaxAge)) {\n                    if (head.delete()) Timber.tag(TAG).e(\"Deleted cache container %s\", head.getPath());\n                    else Timber.tag(TAG).e(\"Failed to delete cache container %s\", head.getPath());\n                    iterator.remove();\n                } else {\n                    // List is sorted by age\n                    break;\n                }\n            }\n        }\n        if (mMaxSize != 0) {\n            final Iterator<File> iterator = mEventContainer.iterator();\n            while (iterator.hasNext() && mCurrentSize > mMaxSize) {\n                File head = iterator.next();\n                mCurrentSize -= head.length();\n                iterator.remove();\n                if (head.delete()) Timber.tag(TAG).e(\"Deleted cache container %s\", head.getPath());\n                else Timber.tag(TAG).e(\"Failed to delete cache container %s\", head.getPath());\n            }\n        }\n        long stopTime = System.currentTimeMillis();\n        Timber.tag(TAG).d(\"Cache check took %dms\", (stopTime - startTime));\n    }\n\n    private boolean isCachingEnabled() {\n        return mMaxAge >= 0;\n    }\n\n    public synchronized void cache(@NonNull List<Event> toCache) {\n        if (!isCachingEnabled() || toCache.isEmpty()) return;\n\n        checkCacheLimits();\n\n        long startTime = System.currentTimeMillis();\n\n        File container = writeEventFile(toCache);\n        if (container != null) {\n            mEventContainer.add(container);\n            mCurrentSize += container.length();\n        }\n        long stopTime = System.currentTimeMillis();\n        Timber.tag(TAG).d(\"Caching of %d events took %dms (%s)\", toCache.size(), (stopTime - startTime), container);\n    }\n\n    @NonNull\n    public synchronized List<Event> uncache() {\n        List<Event> events = new ArrayList<>();\n        if (!isCachingEnabled()) return events;\n\n        long startTime = System.currentTimeMillis();\n        while (!mEventContainer.isEmpty()) {\n            File head = mEventContainer.poll();\n            if (head != null) {\n                events.addAll(readEventFile(head));\n                if (!head.delete()) Timber.tag(TAG).e(\"Failed to delete cache container %s\", head.getPath());\n            }\n        }\n\n        checkCacheLimits();\n\n        long stopTime = System.currentTimeMillis();\n        Timber.tag(TAG).d(\"Uncaching of %d events took %dms\", events.size(), (stopTime - startTime));\n        return events;\n    }\n\n    public synchronized boolean isEmpty() {\n        if (!mDelayedClear) {\n            checkCacheLimits();\n            mDelayedClear = true;\n        }\n        return mEventContainer.isEmpty();\n    }\n\n    private List<Event> readEventFile(@NonNull File file) {\n        List<Event> events = new ArrayList<>();\n        if (!file.exists()) return events;\n\n        InputStream in = null;\n        try {\n            in = new FileInputStream(file);\n            InputStreamReader inputStreamReader = new InputStreamReader(in);\n            BufferedReader bufferedReader = new BufferedReader(inputStreamReader);\n\n            String versionLine = bufferedReader.readLine();\n            if (!VERSION.equals(versionLine)) return events;\n\n            final long cutoff = System.currentTimeMillis() - mMaxAge;\n            String line;\n            while ((line = bufferedReader.readLine()) != null) {\n                final int split = line.indexOf(\" \");\n                if (split == -1) continue;\n\n                try {\n                    long timestamp = Long.parseLong(line.substring(0, split));\n                    if (mMaxAge > 0 && timestamp < cutoff) continue;\n\n                    String query = line.substring(split + 1);\n                    events.add(new Event(timestamp, query));\n                } catch (Exception e) { Timber.tag(TAG).e(e); }\n            }\n        } catch (IOException e) {\n            Timber.tag(TAG).e(e);\n        } finally {\n            if (in != null) {\n                try { in.close(); } catch (IOException e) { Timber.tag(TAG).e(e); }\n            }\n        }\n\n        Timber.tag(TAG).d(\"Restored %d events from %s\", events.size(), file.getPath());\n        return events;\n    }\n\n    @Nullable\n    private File writeEventFile(@NonNull List<Event> events) {\n        if (events.isEmpty()) return null;\n\n        if (!mCacheDir.exists() && !mCacheDir.mkdirs())\n            Timber.tag(TAG).e(\"Failed to make disk-cache dir '%s'\", mCacheDir);\n\n        File newFile = new File(mCacheDir, \"events_\" + events.get(events.size() - 1).getTimeStamp());\n        FileWriter out = null;\n        boolean dataWritten = false;\n        try {\n            out = new FileWriter(newFile);\n            out.append(VERSION).append(\"\\n\");\n\n            final long cutoff = System.currentTimeMillis() - mMaxAge;\n            for (Event event : events) {\n                if (mMaxAge > 0 && event.getTimeStamp() < cutoff) continue;\n                out.append(String.valueOf(event.getTimeStamp())).append(\" \").append(event.getEncodedQuery()).append(\"\\n\");\n                dataWritten = true;\n            }\n        } catch (IOException e) {\n            Timber.tag(TAG).e(e);\n            //noinspection ResultOfMethodCallIgnored\n            newFile.delete();\n            return null;\n        } finally {\n            if (out != null) {\n                try { out.close(); } catch (IOException e) { Timber.tag(TAG).e(e); }\n            }\n        }\n\n        Timber.tag(TAG).d(\"Saved %d events to %s\", events.size(), newFile.getPath());\n\n        // If just version data was written delete the file.\n        if (dataWritten) return newFile;\n        else {\n            //noinspection ResultOfMethodCallIgnored\n            newFile.delete();\n            return null;\n        }\n    }\n\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/dispatcher/Packet.java",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\npackage org.matomo.sdk.dispatcher;\n\nimport androidx.annotation.NonNull;\nimport androidx.annotation.Nullable;\n\nimport org.json.JSONObject;\n\n/**\n * Data that can be send to the backend API via the Dispatcher\n */\npublic class Packet {\n    private final String mTargetURL;\n    private final JSONObject mPostData;\n    private final long mTimeStamp;\n    private final int mEventCount;\n\n    /**\n     * Constructor for GET requests\n     */\n    public Packet(String targetURL) {\n        this(targetURL, null, 1);\n    }\n\n    /**\n     * Constructor for POST requests\n     *\n     * @param targetURL  server\n     * @param JSONObject non null if HTTP POST packet\n     * @param eventCount number of events in this packet\n     */\n    public Packet(String targetURL, @Nullable JSONObject JSONObject, int eventCount) {\n        mTargetURL = targetURL;\n        mPostData = JSONObject;\n        mEventCount = eventCount;\n        mTimeStamp = System.currentTimeMillis();\n    }\n\n    public String getTargetURL() {\n        return mTargetURL;\n    }\n\n    /**\n     * @return may be null if it is a GET request\n     */\n    @Nullable\n    public JSONObject getPostData() {\n        return mPostData;\n    }\n\n    /**\n     * A timestamp to use when replaying offline data\n     */\n    public long getTimeStamp() {\n        return mTimeStamp;\n    }\n\n    /**\n     * Used to determine the event cache queue positions.\n     *\n     * @return how many events this packet contains\n     */\n    public int getEventCount() {\n        return mEventCount;\n    }\n\n    @NonNull\n    @Override\n    public String toString() {\n        StringBuilder sb = new StringBuilder(\"Packet(\");\n        if (mPostData != null) sb.append(\"type=POST, data=\").append(mPostData);\n        else sb.append(\"type=GET, data=\").append(mTargetURL);\n        return sb.append(\")\").toString();\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/dispatcher/PacketFactory.java",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\n\npackage org.matomo.sdk.dispatcher;\n\nimport androidx.annotation.NonNull;\nimport androidx.annotation.Nullable;\nimport androidx.annotation.VisibleForTesting;\nimport android.text.TextUtils;\n\nimport org.json.JSONArray;\nimport org.json.JSONException;\nimport org.json.JSONObject;\nimport org.matomo.sdk.Matomo;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\nimport timber.log.Timber;\n\n\npublic class PacketFactory {\n    private static final String TAG = Matomo.tag(PacketFactory.class);\n    @VisibleForTesting\n    public static final int PAGE_SIZE = 20;\n    private final String mApiUrl;\n\n    public PacketFactory(final String apiUrl) {\n        mApiUrl = apiUrl;\n    }\n\n    public List<Packet> buildPackets(final List<Event> events) {\n        if (events.isEmpty()) return Collections.emptyList();\n\n        if (events.size() == 1) {\n            Packet p = buildPacketForGet(events.get(0));\n            if (p == null) return Collections.emptyList();\n            else return Collections.singletonList(p);\n        }\n\n        int packets = (int) Math.ceil(events.size() * 1.0 / PAGE_SIZE);\n        List<Packet> freshPackets = new ArrayList<>(packets);\n        for (int i = 0; i < events.size(); i += PAGE_SIZE) {\n            List<Event> batch = events.subList(i, Math.min(i + PAGE_SIZE, events.size()));\n            final Packet packet;\n            if (batch.size() == 1) packet = buildPacketForGet(batch.get(0));\n            else packet = buildPacketForPost(batch);\n            if (packet != null) freshPackets.add(packet);\n        }\n        return freshPackets;\n    }\n\n    //{\n    //    \"requests\": [\"?idsite=1&url=http://example.org&action_name=Test bulk log Pageview&rec=1\",\n    //    \"?idsite=1&url=http://example.net/test.htm&action_name=Another bul k page view&rec=1\"]\n    //}\n    @Nullable\n    private Packet buildPacketForPost(List<Event> events) {\n        if (events.isEmpty()) return null;\n        try {\n            JSONObject params = new JSONObject();\n\n            JSONArray jsonArray = new JSONArray();\n            for (Event event : events) jsonArray.put(event.getEncodedQuery());\n            params.put(\"requests\", jsonArray);\n            return new Packet(mApiUrl, params, events.size());\n        } catch (JSONException e) {\n            Timber.tag(TAG).w(e, \"Cannot create json object:\\n%s\", TextUtils.join(\", \", events));\n        }\n        return null;\n    }\n\n    // \"http://domain.com/matomo.php?idsite=1&url=http://a.org&action_name=Test bulk log Pageview&rec=1\"\n    @Nullable\n    private Packet buildPacketForGet(@NonNull Event event) {\n        if (event.getEncodedQuery().isEmpty()) return null;\n        return new Packet(mApiUrl + event);\n    }\n\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/dispatcher/PacketSender.kt",
    "content": "package org.matomo.sdk.dispatcher\n\n\ninterface PacketSender {\n    /**\n     * @return true if successful\n     */\n    fun send(packet: Packet): Boolean\n\n    /**\n     * @param timeout in milliseconds\n     */\n    fun setTimeout(timeout: Long)\n\n    fun setGzipData(gzip: Boolean)\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/extra/CustomDimension.java",
    "content": "package org.matomo.sdk.extra;\n\n\nimport androidx.annotation.NonNull;\nimport androidx.annotation.Nullable;\n\nimport org.matomo.sdk.Matomo;\nimport org.matomo.sdk.TrackMe;\n\nimport timber.log.Timber;\n\n/**\n * Allows you to track Custom Dimensions.\n * In order to use this functionality install and configure\n * https://plugins.matomo.org/CustomDimensions plugin.\n */\npublic class CustomDimension {\n    private static final String TAG = Matomo.tag(CustomDimension.class);\n    private final int mId;\n    private final String mValue;\n\n    public CustomDimension(int id, String value) {\n        mId = id;\n        mValue = value;\n    }\n\n    public int getId() {\n        return mId;\n    }\n\n    public String getValue() {\n        return mValue;\n    }\n\n    /**\n     * This method sets a tracking API parameter dimension%dimensionId%=%dimensionValue%.\n     * Eg dimension1=foo or dimension2=bar.\n     * So the tracking API parameter starts with dimension followed by the set dimensionId.\n     * <p>\n     * Requires <a href=\"https://plugins.matomo.org/CustomDimensions\">Custom Dimensions</a> plugin (server-side)\n     *\n     * @param trackMe        into which the data should be inserted\n     * @param dimensionId    accepts values greater than 0\n     * @param dimensionValue is limited to 255 characters, you can pass null to delete a value\n     * @return true if the value was valid\n     */\n    public static boolean setDimension(@NonNull TrackMe trackMe, int dimensionId, @Nullable String dimensionValue) {\n        if (dimensionId < 1) {\n            Timber.tag(TAG).e(\"dimensionId should be great than 0 (arg: %d)\", dimensionId);\n            return false;\n        }\n        if (dimensionValue != null && dimensionValue.length() > 255) {\n            dimensionValue = dimensionValue.substring(0, 255);\n            Timber.tag(TAG).w(\"dimensionValue was truncated to 255 chars.\");\n        }\n        if (dimensionValue != null && dimensionValue.length() == 0) {\n            dimensionValue = null;\n        }\n        trackMe.set(formatDimensionId(dimensionId), dimensionValue);\n        return true;\n    }\n\n    public static boolean setDimension(TrackMe trackMe, CustomDimension dimension) {\n        return setDimension(trackMe, dimension.getId(), dimension.getValue());\n    }\n\n    @Nullable\n    public static String getDimension(TrackMe trackMe, int dimensionId) {\n        return trackMe.get(formatDimensionId(dimensionId));\n    }\n\n    private static String formatDimensionId(int id) {\n        return \"dimension\" + id;\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/extra/CustomVariables.java",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\n\npackage org.matomo.sdk.extra;\n\nimport androidx.annotation.NonNull;\nimport androidx.annotation.Nullable;\n\nimport org.json.JSONArray;\nimport org.json.JSONException;\nimport org.json.JSONObject;\nimport org.matomo.sdk.Matomo;\nimport org.matomo.sdk.QueryParams;\nimport org.matomo.sdk.TrackMe;\n\nimport java.util.Arrays;\nimport java.util.Iterator;\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\n\nimport timber.log.Timber;\n\n/**\n * A custom variable is a custom name-value pair that you can assign to your users or screen views,\n * and then visualize the reports of how many visits, conversions, etc. for each custom variable.\n * A custom variable is defined by a name — for example,\n * \"User status\" — and a value – for example, \"LoggedIn\" or \"Anonymous\".\n * <p>\n * You can track up to 5 custom variables for each user to your app,\n * and up to 5 custom variables for each screen view.\n * You may configure Matomo to track more custom variables: http://matomo.org/faq/how-to/faq_17931/\n * <p>\n * Desired json output:\n * {\n * \"1\":[\"OS\",\"iphone 5.0\"],\n * \"2\":[\"Matomo Mobile Version\",\"1.6.2\"],\n * \"3\":[\"Locale\",\"en::en\"],\n * \"4\":[\"Num Accounts\",\"2\"],\n * \"5\":[\"Level\",\"over9k\"]\n * }\n */\npublic class CustomVariables {\n    private final Map<String, JSONArray> mVars = new ConcurrentHashMap<>();\n\n    private static final String TAG = Matomo.tag(CustomVariables.class);\n    protected static final int MAX_LENGTH = 200;\n\n    public CustomVariables() {\n\n    }\n\n    public CustomVariables(@NonNull CustomVariables variables) {\n        mVars.putAll(variables.mVars);\n    }\n\n    public CustomVariables(@Nullable String json) {\n        if (json != null) {\n            try {\n                JSONObject jsonObject = new JSONObject(json);\n                final Iterator<String> it = jsonObject.keys();\n                while (it.hasNext()) {\n                    String key = it.next();\n                    put(key, jsonObject.getJSONArray(key));\n                }\n            } catch (JSONException e) {Timber.tag(TAG).e(e, \"Failed to create CustomVariables from JSON\");}\n        }\n    }\n\n    public CustomVariables putAll(CustomVariables customVariables) {\n        mVars.putAll(customVariables.mVars);\n        return this;\n    }\n\n    /**\n     * Custom variable names and values are limited to 200 characters in length each.\n     *\n     * @param index this Integer accepts values from 1 to 5.\n     *              A given custom variable name must always be stored in the same \"index\" per session.\n     *              For example, if you choose to store the variable name = \"Gender\" in index = 1\n     *              and you record another custom variable in index = 1, then the \"Gender\" variable\n     *              will be deleted and replaced with the new custom variable stored in index 1.\n     *              You may configure Matomo to track more custom variables than 5.\n     *              Read more: http://matomo.org/faq/how-to/faq_17931/\n     * @param name  of a specific Custom Variable such as \"User type\".\n     * @param value of a specific Custom Variable such as \"Customer\".\n     * @return super.put result if index in right range and name/value pair aren't null\n     */\n    public CustomVariables put(int index, String name, String value) {\n        if (index > 0 && name != null & value != null) {\n\n            if (name.length() > MAX_LENGTH) {\n                Timber.tag(TAG).w(\"Name is too long %s\", name);\n                name = name.substring(0, MAX_LENGTH);\n            }\n\n            if (value.length() > MAX_LENGTH) {\n                Timber.tag(TAG).w(\"Value is too long %s\", value);\n                value = value.substring(0, MAX_LENGTH);\n            }\n\n            put(Integer.toString(index), new JSONArray(Arrays.asList(name, value)));\n        } else Timber.tag(TAG).w(\"Index is out of range or name/value is null\");\n        return this;\n    }\n\n    /**\n     * @param index  index accepts values from 1 to 5.\n     * @param values packed key/value pair\n     * @return super.put result or null if key is null or value length is not equals 2\n     */\n    public CustomVariables put(String index, JSONArray values) {\n        if (values.length() == 2 && index != null) {\n            mVars.put(index, values);\n        } else Timber.tag(TAG).w(\"values.length() should be equal 2\");\n        return this;\n    }\n\n    public String toString() {\n        JSONObject json = new JSONObject(mVars);\n        return json.length() > 0 ? json.toString() : null;\n    }\n\n    public int size() {\n        return mVars.size();\n    }\n\n    /**\n     * Sets the custom variables with scope VISIT to a {@link TrackMe}.\n     */\n    public TrackMe injectVisitVariables(@NonNull TrackMe trackMe) {\n        //noinspection deprecation\n        trackMe.set(QueryParams.VISIT_SCOPE_CUSTOM_VARIABLES, this.toString());\n        return trackMe;\n    }\n\n    @NonNull\n    public TrackMe toVisitVariables() {\n        return injectVisitVariables(new TrackMe());\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/extra/DimensionQueue.java",
    "content": "package org.matomo.sdk.extra;\n\nimport org.matomo.sdk.Matomo;\nimport org.matomo.sdk.TrackMe;\nimport org.matomo.sdk.Tracker;\n\nimport java.util.ArrayList;\nimport java.util.Iterator;\nimport java.util.List;\n\nimport timber.log.Timber;\n\n/**\n * A helper class for custom dimensions. Acts like a queue for dimensions to be send.\n * On each tracking call it will insert as many saved dimensions as it is possible without overwriting existing information.\n */\npublic class DimensionQueue {\n    private static final String TAG = Matomo.tag(DimensionQueue.class);\n    private final List<CustomDimension> mOneTimeDimensions = new ArrayList<>();\n\n    public DimensionQueue(Tracker tracker) {\n        Tracker.Callback callback = DimensionQueue.this::onTrack;\n        tracker.addTrackingCallback(callback);\n    }\n\n    /**\n     * The added id-value-pair will be injected into the next tracked event,\n     * if that events slot for this ID is still empty.\n     */\n    public void add(int id, String value) {\n        mOneTimeDimensions.add(new CustomDimension(id, value));\n    }\n\n    private TrackMe onTrack(TrackMe trackMe) {\n        for (Iterator<CustomDimension> it = mOneTimeDimensions.iterator(); it.hasNext(); ) {\n            CustomDimension dim = it.next();\n            String existing = CustomDimension.getDimension(trackMe, dim.getId());\n            if (existing != null) {\n                Timber.tag(TAG).d(\"Setting dimension %s to slot %d would overwrite %s, skipping!\", dim.getValue(), dim.getId(), existing);\n            } else {\n                CustomDimension.setDimension(trackMe, dim);\n                it.remove();\n            }\n        }\n        return trackMe;\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/extra/DownloadTracker.java",
    "content": "package org.matomo.sdk.extra;\n\n\nimport android.content.ContentValues;\nimport android.content.Context;\nimport android.content.SharedPreferences;\nimport android.content.pm.PackageInfo;\nimport android.content.pm.PackageManager;\nimport androidx.annotation.NonNull;\nimport androidx.annotation.Nullable;\n\nimport org.matomo.sdk.Matomo;\nimport org.matomo.sdk.QueryParams;\nimport org.matomo.sdk.TrackMe;\nimport org.matomo.sdk.Tracker;\nimport org.matomo.sdk.tools.Checksum;\n\nimport java.io.File;\n\nimport timber.log.Timber;\n\npublic class DownloadTracker {\n    protected static final String TAG = Matomo.tag(DownloadTracker.class);\n    private static final String INSTALL_SOURCE_GOOGLE_PLAY = \"com.android.vending\";\n    private final Tracker mTracker;\n    private final Object mTrackOnceLock = new Object();\n    private final PackageManager mPackMan;\n    private final SharedPreferences mPreferences;\n    private final boolean mInternalTracking;\n    private String mVersion;\n    private final PackageInfo mPkgInfo;\n\n    public interface Extra {\n\n        /**\n         * Does your {@link Extra} implementation do work intensive stuff?\n         * Network? IO?\n         *\n         * @return true if this should be run async and on a sepperate thread.\n         */\n        boolean isIntensiveWork();\n\n        /**\n         * Example:\n         * <br>\n         * com.example.pkg:1/ABCDEF01234567\n         * <br>\n         * \"ABCDEF01234567\" is the extra identifier here.\n         *\n         * @return a string that will be used as extra identifier or null\n         */\n        @Nullable\n        String buildExtraIdentifier();\n\n        /**\n         * The MD5 checksum of the apk file.\n         * com.example.pkg:1/ABCDEF01234567\n         */\n        class ApkChecksum implements Extra {\n            private PackageInfo mPackageInfo;\n\n            public ApkChecksum(Context context) {\n                try {\n                    mPackageInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 0);\n                } catch (Exception e) {\n                    Timber.tag(TAG).e(e);\n                    mPackageInfo = null;\n                }\n            }\n\n            @Override\n            public boolean isIntensiveWork() {\n                return true;\n            }\n\n            @Nullable\n            @Override\n            public String buildExtraIdentifier() {\n                if (mPackageInfo != null && mPackageInfo.applicationInfo != null && mPackageInfo.applicationInfo.sourceDir != null) {\n                    try {\n                        return Checksum.getMD5Checksum(new File(mPackageInfo.applicationInfo.sourceDir));\n                    } catch (Exception e) { Timber.tag(TAG).e(e); }\n                }\n                return null;\n            }\n        }\n\n        /**\n         * Custom exta identifier. Supply your own \\o/.\n         */\n        @SuppressWarnings(\"unused\")\n        abstract class Custom implements Extra {\n        }\n\n        /**\n         * No extra identifier.\n         * com.example.pkg:1\n         */\n        class None implements Extra {\n\n            @Override\n            public boolean isIntensiveWork() {\n                return false;\n            }\n\n            @Nullable\n            @Override\n            public String buildExtraIdentifier() {\n                return null;\n            }\n        }\n    }\n\n    public DownloadTracker(Tracker tracker) {\n        this(tracker, getOurPackageInfo(tracker.getMatomo().getContext()));\n    }\n\n    private static PackageInfo getOurPackageInfo(Context context) {\n        try {\n            return context.getPackageManager().getPackageInfo(context.getPackageName(), 0);\n        } catch (PackageManager.NameNotFoundException e) {\n            Timber.tag(TAG).e(e);\n            throw new RuntimeException(e);\n        }\n    }\n\n    public DownloadTracker(Tracker tracker, @NonNull PackageInfo packageInfo) {\n        mTracker = tracker;\n        Context mContext = tracker.getMatomo().getContext();\n        mPreferences = tracker.getPreferences();\n        mPackMan = tracker.getMatomo().getContext().getPackageManager();\n        mPkgInfo = packageInfo;\n        mInternalTracking = mPkgInfo.packageName.equals(mContext.getPackageName());\n    }\n\n    public void setVersion(@Nullable String version) {\n        mVersion = version;\n    }\n\n    public String getVersion() {\n        if (mVersion != null) return mVersion;\n        return Integer.toString(mPkgInfo.versionCode);\n    }\n\n    public void trackOnce(TrackMe baseTrackme, @NonNull Extra extra) {\n        String firedKey = \"downloaded:\" + mPkgInfo.packageName + \":\" + getVersion();\n        synchronized (mTrackOnceLock) {\n            if (!mPreferences.getBoolean(firedKey, false)) {\n                mPreferences.edit().putBoolean(firedKey, true).apply();\n                trackNewAppDownload(baseTrackme, extra);\n            }\n        }\n    }\n\n    public void trackNewAppDownload(final TrackMe baseTrackme, @NonNull final Extra extra) {\n        // We can only get referrer information if we are tracking our own app download.\n        final boolean delay = mInternalTracking && INSTALL_SOURCE_GOOGLE_PLAY.equals(mPackMan.getInstallerPackageName(mPkgInfo.packageName));\n        if (delay) {\n            // Delay tracking incase we were called from within Application.onCreate\n            Timber.tag(TAG).d(\"Google Play is install source, deferring tracking.\");\n        }\n        final Thread trackTask = new Thread(() -> {\n            if (delay) try {Thread.sleep(3000);} catch (Exception e) { Timber.tag(ContentValues.TAG).e(e);}\n            trackNewAppDownloadInternal(baseTrackme, extra);\n        });\n        if (!delay && !extra.isIntensiveWork()) trackTask.run();\n        else trackTask.start();\n    }\n\n    private void trackNewAppDownloadInternal(TrackMe baseTrackMe, @NonNull Extra extra) {\n        Timber.tag(TAG).d(\"Tracking app download...\");\n\n        StringBuilder installIdentifier = new StringBuilder();\n        installIdentifier.append(\"http://\").append(mPkgInfo.packageName).append(\":\").append(getVersion());\n\n        String extraIdentifier = extra.buildExtraIdentifier();\n        if (extraIdentifier != null) installIdentifier.append(\"/\").append(extraIdentifier);\n\n        // Usual USEFUL values of this field will be: \"com.android.vending\" or \"com.android.browser\", i.e. app packagenames.\n        // This is not guaranteed, values can also look like: app_process /system/bin com.android.commands.pm.Pm install -r /storage/sdcard0/...\n        String referringApp = mPackMan.getInstallerPackageName(mPkgInfo.packageName);\n        if (referringApp != null && referringApp.length() > 200) referringApp = referringApp.substring(0, 200);\n\n        if (referringApp != null && referringApp.equals(INSTALL_SOURCE_GOOGLE_PLAY)) {\n            // For this type of install source we could have extra referral information\n            String referrerExtras = mTracker.getMatomo().getPreferences().getString(InstallReferrerReceiver.PREF_KEY_INSTALL_REFERRER_EXTRAS, null);\n            if (referrerExtras != null) referringApp = referringApp + \"/?\" + referrerExtras;\n        }\n\n        if (referringApp != null) referringApp = \"http://\" + referringApp;\n\n        mTracker.track(baseTrackMe\n                .set(QueryParams.EVENT_CATEGORY, \"Application\")\n                .set(QueryParams.EVENT_ACTION, \"downloaded\")\n                .set(QueryParams.ACTION_NAME, \"application/downloaded\")\n                .set(QueryParams.URL_PATH, \"/application/downloaded\")\n                .set(QueryParams.DOWNLOAD, installIdentifier.toString())\n                .set(QueryParams.REFERRER, referringApp)); // Can be null in which case the TrackMe removes the REFERRER parameter.\n\n        Timber.tag(TAG).d(\"... app download tracked.\");\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/extra/EcommerceItems.java",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\n\npackage org.matomo.sdk.extra;\n\nimport org.json.JSONArray;\nimport org.matomo.sdk.tools.CurrencyFormatter;\n\nimport java.util.HashMap;\nimport java.util.Map;\n\npublic class EcommerceItems {\n    private final Map<String, JSONArray> mItems = new HashMap<>();\n\n    /**\n     * Adds a product into the ecommerce order. Must be called for each product in the order.\n     * If the same sku is used twice, the first item is overwritten.\n     */\n    public void addItem(Item item) {\n        mItems.put(item.mSku, item.toJson());\n    }\n\n    public static class Item {\n        private final String mSku;\n        private String mCategory;\n        private Integer mPrice;\n        private Integer mQuantity;\n        private String mName;\n\n        /**\n         * If the same sku is used twice, the first item is overwritten.\n         *\n         * @param sku Unique identifier for the product\n         */\n        public Item(String sku) {\n            mSku = sku;\n        }\n\n        /**\n         * @param name Product name\n         */\n        public Item name(String name) {\n            mName = name;\n            return this;\n        }\n\n        /**\n         * @param category Product category\n         */\n        public Item category(String category) {\n            mCategory = category;\n            return this;\n        }\n\n        /**\n         * @param price Price of the product in cents\n         */\n        public Item price(int price) {\n            mPrice = price;\n            return this;\n        }\n\n        /**\n         * @param quantity Quantity\n         */\n        public Item quantity(int quantity) {\n            mQuantity = quantity;\n            return this;\n        }\n\n        public String getSku() {\n            return mSku;\n        }\n\n        public String getCategory() {\n            return mCategory;\n        }\n\n        public Integer getPrice() {\n            return mPrice;\n        }\n\n        public Integer getQuantity() {\n            return mQuantity;\n        }\n\n        public String getName() {\n            return mName;\n        }\n\n        protected JSONArray toJson() {\n            JSONArray item = new JSONArray();\n            item.put(mSku);\n            if (mName != null) item.put(mName);\n            if (mCategory != null) item.put(mCategory);\n            if (mPrice != null) item.put(CurrencyFormatter.priceString(mPrice));\n            if (mQuantity != null) item.put(String.valueOf(mQuantity));\n            return item;\n        }\n    }\n\n    /**\n     * Remove a product from an ecommerce order.\n     *\n     * @param sku unique identifier for the product\n     */\n    public void remove(String sku) {\n        mItems.remove(sku);\n    }\n\n    public void remove(Item item) {\n        mItems.remove(item.mSku);\n    }\n\n    /**\n     * Clears all items from the ecommerce order\n     */\n    public void clear() {\n        mItems.clear();\n    }\n\n    public String toJson() {\n        JSONArray jsonItems = new JSONArray();\n\n        for (JSONArray item : mItems.values()) {\n            jsonItems.put(item);\n        }\n        return jsonItems.toString();\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/extra/InstallReferrerReceiver.java",
    "content": "package org.matomo.sdk.extra;\n\nimport android.content.BroadcastReceiver;\nimport android.content.Context;\nimport android.content.Intent;\n\nimport org.matomo.sdk.Matomo;\n\nimport java.util.Collections;\nimport java.util.List;\n\nimport timber.log.Timber;\n\n\npublic class InstallReferrerReceiver extends BroadcastReceiver {\n    private static final String TAG = Matomo.tag(InstallReferrerReceiver.class);\n\n    // Google Play\n    static final String REFERRER_SOURCE_GPLAY = \"com.android.vending.INSTALL_REFERRER\";\n    static final String ARG_KEY_GPLAY_REFERRER = \"referrer\";\n\n    static final String PREF_KEY_INSTALL_REFERRER_EXTRAS = \"referrer.extras\";\n    static final List<String> RESPONSIBILITIES = Collections.singletonList(REFERRER_SOURCE_GPLAY);\n\n    @Override\n    public void onReceive(Context context, Intent intent) {\n        Timber.tag(TAG).d(intent.toString());\n        if (intent.getAction() == null || !RESPONSIBILITIES.contains(intent.getAction())) {\n            Timber.tag(TAG).w(\"Got called outside our responsibilities: %s\", intent.getAction());\n            return;\n        }\n        if (intent.getBooleanExtra(\"forwarded\", false)) {\n            Timber.tag(TAG).d(\"Dropping forwarded intent\");\n            return;\n        }\n        if (intent.getAction().equals(REFERRER_SOURCE_GPLAY)) {\n            String referrer = intent.getStringExtra(ARG_KEY_GPLAY_REFERRER);\n            if (referrer != null) {\n                final PendingResult result = goAsync();\n                new Thread(() -> {\n                    Matomo.getInstance(context.getApplicationContext()).getPreferences().edit().putString(PREF_KEY_INSTALL_REFERRER_EXTRAS, referrer).apply();\n                    Timber.tag(TAG).d(\"Stored Google Play referrer extras: %s\", referrer);\n                    result.finish();\n                }).start();\n            }\n        }\n        // Forward to other possible recipients\n        intent.setComponent(null);\n        intent.setPackage(context.getPackageName());\n        intent.putExtra(\"forwarded\", true);\n        context.sendBroadcast(intent);\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/extra/MatomoApplication.java",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\n\npackage org.matomo.sdk.extra;\n\nimport android.app.Application;\n\nimport org.matomo.sdk.Matomo;\nimport org.matomo.sdk.Tracker;\nimport org.matomo.sdk.TrackerBuilder;\n\npublic abstract class MatomoApplication extends Application {\n    private Tracker mMatomoTracker;\n\n    public Matomo getMatomo() {\n        return Matomo.getInstance(this);\n    }\n\n    /**\n     * Gives you an all purpose thread-safe persisted Tracker.\n     *\n     * @return a shared Tracker\n     */\n    public synchronized Tracker getTracker() {\n        if (mMatomoTracker == null) mMatomoTracker = onCreateTrackerConfig().build(getMatomo());\n        return mMatomoTracker;\n    }\n\n    /**\n     * See {@link TrackerBuilder}.\n     * You may be interested in {@link TrackerBuilder#createDefault(String, int)}\n     *\n     * @return the tracker configuration you want to use.\n     */\n    public abstract TrackerBuilder onCreateTrackerConfig();\n\n    @Override\n    public void onLowMemory() {\n        if (mMatomoTracker != null) mMatomoTracker.dispatch();\n        super.onLowMemory();\n    }\n\n    @Override\n    public void onTrimMemory(int level) {\n        if ((level == TRIM_MEMORY_UI_HIDDEN || level == TRIM_MEMORY_COMPLETE) && mMatomoTracker != null) {\n            mMatomoTracker.dispatch();\n        }\n        super.onTrimMemory(level);\n    }\n\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/extra/MatomoExceptionHandler.java",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\n\npackage org.matomo.sdk.extra;\n\nimport androidx.annotation.NonNull;\nimport androidx.annotation.Nullable;\n\nimport org.matomo.sdk.Matomo;\nimport org.matomo.sdk.TrackMe;\nimport org.matomo.sdk.Tracker;\nimport org.matomo.sdk.dispatcher.DispatchMode;\n\nimport timber.log.Timber;\n\n/**\n * An exception handler that wraps the existing exception handler and dispatches event to a {@link org.matomo.sdk.Tracker}.\n * <p>\n * Also see documentation for {@link TrackHelper#uncaughtExceptions()}\n */\npublic class MatomoExceptionHandler implements Thread.UncaughtExceptionHandler {\n    private static final String TAG = Matomo.tag(MatomoExceptionHandler.class);\n    private final Tracker mTracker;\n    private final TrackMe mTrackMe;\n    private final Thread.UncaughtExceptionHandler mDefaultExceptionHandler;\n\n    public MatomoExceptionHandler(@NonNull Tracker tracker, @Nullable TrackMe trackMe) {\n        mTracker = tracker;\n        mTrackMe = trackMe;\n        mDefaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler();\n    }\n\n    public Tracker getTracker() {\n        return mTracker;\n    }\n\n    /**\n     * This will give you the previous exception handler that is now wrapped.\n     */\n    public Thread.UncaughtExceptionHandler getDefaultExceptionHandler() {\n        return mDefaultExceptionHandler;\n    }\n\n    @Override\n    public void uncaughtException(@NonNull Thread thread, @NonNull Throwable ex) {\n        try {\n            String excInfo = ex.getMessage();\n\n            Tracker tracker = getTracker();\n\n            // Force the tracker into offline mode to ensure events are written to disk\n            tracker.setDispatchMode(DispatchMode.EXCEPTION);\n\n            TrackHelper.track(mTrackMe).exception(ex).description(excInfo).fatal(true).with(tracker);\n\n            // Immediately dispatch as the app might be dying after rethrowing the exception and block until the dispatch is completed\n            tracker.dispatchBlocking();\n        } catch (Exception e) {\n            Timber.tag(TAG).e(e, \"Couldn't track uncaught exception\");\n        } finally {\n            // re-throw critical exception further to the os (important)\n            if (getDefaultExceptionHandler() != null && getDefaultExceptionHandler() != this) {\n                getDefaultExceptionHandler().uncaughtException(thread, ex);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/extra/TrackHelper.java",
    "content": "package org.matomo.sdk.extra;\n\n\nimport android.app.Activity;\nimport android.app.Application;\nimport android.os.Bundle;\n\nimport androidx.annotation.Nullable;\n\nimport org.matomo.sdk.Matomo;\nimport org.matomo.sdk.QueryParams;\nimport org.matomo.sdk.TrackMe;\nimport org.matomo.sdk.Tracker;\nimport org.matomo.sdk.tools.ActivityHelper;\nimport org.matomo.sdk.tools.CurrencyFormatter;\n\nimport java.net.URL;\nimport java.util.HashMap;\nimport java.util.Map;\n\nimport timber.log.Timber;\n\npublic class TrackHelper {\n    private static final String TAG = Matomo.tag(TrackHelper.class);\n    protected final TrackMe mBaseTrackMe;\n\n    private TrackHelper() {\n        this(null);\n    }\n\n    private TrackHelper(@Nullable TrackMe baseTrackMe) {\n        if (baseTrackMe == null) baseTrackMe = new TrackMe();\n        mBaseTrackMe = baseTrackMe;\n    }\n\n    public static TrackHelper track() {\n        return new TrackHelper();\n    }\n\n    public static TrackHelper track(@Nullable TrackMe base) {\n        return new TrackHelper(base);\n    }\n\n    static abstract class BaseEvent {\n\n        private final TrackHelper mBaseBuilder;\n\n        BaseEvent(TrackHelper baseBuilder) {\n            mBaseBuilder = baseBuilder;\n        }\n\n        TrackMe getBaseTrackMe() {\n            return mBaseBuilder.mBaseTrackMe;\n        }\n\n        /**\n         * May throw an {@link IllegalArgumentException} if the TrackMe was build with incorrect arguments.\n         */\n        public abstract TrackMe build();\n\n        public void with(MatomoApplication matomoApplication) {\n            with(matomoApplication.getTracker());\n        }\n\n        public void with(Tracker tracker) {\n            TrackMe trackMe = build();\n            tracker.track(trackMe);\n        }\n\n        public boolean safelyWith(MatomoApplication matomoApplication) {\n            return safelyWith(matomoApplication.getTracker());\n        }\n\n        /**\n         * {@link #build()} can throw an exception on illegal arguments.\n         * This can be used to avoid crashes when using dynamic {@link TrackMe} arguments.\n         *\n         * @return false if an error occured, true if the TrackMe has been submitted to be dispatched.\n         */\n        public boolean safelyWith(Tracker tracker) {\n            try {\n                TrackMe trackMe = build();\n                tracker.track(trackMe);\n            } catch (IllegalArgumentException e) {\n                Timber.e(e);\n                return false;\n            }\n            return true;\n        }\n    }\n\n    /**\n     * To track a screenview.\n     *\n     * @param path Example: \"/user/settings/billing\"\n     * @return an object that allows addition of further details.\n     */\n    public Screen screen(String path) {\n        return new Screen(this, path);\n    }\n\n    /**\n     * Calls {@link #screen(String)} for an activity.\n     * Uses the activity-stack as path and activity title as names.\n     *\n     * @param activity the activity to track\n     */\n    public Screen screen(Activity activity) {\n        String breadcrumbs = ActivityHelper.getBreadcrumbs(activity);\n        return new Screen(this, ActivityHelper.breadcrumbsToPath(breadcrumbs)).title(breadcrumbs);\n    }\n\n    public static class Screen extends BaseEvent {\n        private final String mPath;\n        private final CustomVariables mCustomVariables = new CustomVariables();\n        private final Map<Integer, String> mCustomDimensions = new HashMap<>();\n        private String mTitle;\n        private String mCampaignName;\n        private String mCampaignKeyword;\n\n        Screen(TrackHelper baseBuilder, String path) {\n            super(baseBuilder);\n            mPath = path;\n        }\n\n        /**\n         * The title of the action being tracked. It is possible to use slashes / to set one or several categories for this action.\n         *\n         * @param title Example: Help / Feedback will create the Action Feedback in the category Help.\n         * @return this object to allow chaining calls\n         */\n        public Screen title(String title) {\n            mTitle = title;\n            return this;\n        }\n\n        /**\n         * Requires <a href=\"https://plugins.matomo.org/CustomDimensions\">Custom Dimensions</a> plugin (server-side)\n         *\n         * @param index          accepts values greater than 0\n         * @param dimensionValue is limited to 255 characters, you can pass null to delete a value\n         */\n        public Screen dimension(int index, String dimensionValue) {\n            mCustomDimensions.put(index, dimensionValue);\n            return this;\n        }\n\n        /**\n         * Custom Variable valid per screen.\n         * Only takes effect when setting prior to tracking the screen view.\n         *\n         * @see org.matomo.sdk.extra.CustomDimension and {@link #dimension(int, String)}\n         * @deprecated Consider using <a href=\"http://matomo.org/docs/custom-dimensions/\">Custom Dimensions</a>\n         */\n        @Deprecated\n        public Screen variable(int index, String name, String value) {\n            mCustomVariables.put(index, name, value);\n            return this;\n        }\n\n        /**\n         * The marketing campaign for this visit if the user opens the app for example because of an\n         * ad or a newsletter. Used to populate the <i>Referrers > Campaigns</i> report.\n         *\n         * @param name    the name of the campaign\n         * @param keyword the keyword of the campaign\n         * @return this object to allow chaining calls\n         */\n        public Screen campaign(String name, String keyword) {\n            mCampaignName = name;\n            mCampaignKeyword = keyword;\n            return this;\n        }\n\n        @Override\n        public TrackMe build() {\n            if (mPath == null) {\n                throw new IllegalArgumentException(\"Screen tracking requires a non-empty path\");\n            }\n\n            final TrackMe trackMe = new TrackMe(getBaseTrackMe())\n                    .set(QueryParams.URL_PATH, mPath)\n                    .set(QueryParams.ACTION_NAME, mTitle)\n                    .set(QueryParams.CAMPAIGN_NAME, mCampaignName)\n                    .set(QueryParams.CAMPAIGN_KEYWORD, mCampaignKeyword);\n            if (mCustomVariables.size() > 0) {\n                //noinspection deprecation\n                trackMe.set(QueryParams.SCREEN_SCOPE_CUSTOM_VARIABLES, mCustomVariables.toString());\n            }\n            for (Map.Entry<Integer, String> entry : mCustomDimensions.entrySet()) {\n                CustomDimension.setDimension(trackMe, entry.getKey(), entry.getValue());\n            }\n            return trackMe;\n        }\n    }\n\n    /**\n     * Events are a useful way to collect data about a user's interaction with interactive components of your app,\n     * like button presses or the use of a particular item in a game.\n     *\n     * @param category (required) – this String defines the event category.\n     *                 You might define event categories based on the class of user actions,\n     *                 like clicks or gestures or voice commands, or you might define them based upon the\n     *                 features available in your application (play, pause, fast forward, etc.).\n     * @param action   (required) this String defines the specific event action within the category specified.\n     *                 In the example, we are basically saying that the category of the event is user clicks,\n     *                 and the action is a button click.\n     * @return an object that allows addition of further details.\n     */\n    public EventBuilder event(String category, String action) {\n        return new EventBuilder(this, category, action);\n    }\n\n    public static class EventBuilder extends BaseEvent {\n        private final String mCategory;\n        private final String mAction;\n        private String mPath;\n        private String mName;\n        private Float mValue;\n\n        EventBuilder(TrackHelper builder, String category, String action) {\n            super(builder);\n            mCategory = category;\n            mAction = action;\n        }\n\n        /**\n         * The path under which this event occurred.\n         * Example: \"/user/settings/billing\", if you pass NULL, the last path set by #trackScreenView will be used.\n         */\n        public EventBuilder path(String path) {\n            mPath = path;\n            return this;\n        }\n\n        /**\n         * Defines a label associated with the event.\n         * For example, if you have multiple Button controls on a screen, you might use the label to specify the specific View control identifier that was clicked.\n         */\n        public EventBuilder name(String name) {\n            mName = name;\n            return this;\n        }\n\n        /**\n         * Defines a numeric value associated with the event.\n         * For example, if you were tracking \"Buy\" button clicks, you might log the number of items being purchased, or their total cost.\n         */\n        public EventBuilder value(Float value) {\n            mValue = value;\n            return this;\n        }\n\n        @Override\n        public TrackMe build() {\n            TrackMe trackMe = new TrackMe(getBaseTrackMe())\n                    .set(QueryParams.URL_PATH, mPath)\n                    .set(QueryParams.EVENT_CATEGORY, mCategory)\n                    .set(QueryParams.EVENT_ACTION, mAction)\n                    .set(QueryParams.EVENT_NAME, mName);\n            if (mValue != null) trackMe.set(QueryParams.EVENT_VALUE, mValue);\n            return trackMe;\n        }\n    }\n\n    /**\n     * By default, Goals in Matomo are defined as \"matching\" parts of the screen path or screen title.\n     * In this case a conversion is logged automatically. In some situations, you may want to trigger\n     * a conversion manually on other types of actions, for example:\n     * when a user submits a form\n     * when a user has stayed more than a given amount of time on the page\n     * when a user does some interaction in your Android application\n     *\n     * @param idGoal id of goal as defined in matomo goal settings\n     */\n    public Goal goal(int idGoal) {\n        return new Goal(this, idGoal);\n    }\n\n    public static class Goal extends BaseEvent {\n        private final int mIdGoal;\n        private Float mRevenue;\n\n        Goal(TrackHelper baseBuilder, int idGoal) {\n            super(baseBuilder);\n            mIdGoal = idGoal;\n        }\n\n        /**\n         * Tracking request will trigger a conversion for the goal of the website being tracked with this ID\n         *\n         * @param revenue a monetary value that was generated as revenue by this goal conversion.\n         */\n        public Goal revenue(Float revenue) {\n            mRevenue = revenue;\n            return this;\n        }\n\n        @Override\n        public TrackMe build() {\n            if (mIdGoal < 0) {\n                throw new IllegalArgumentException(\"Goal id needs to be >=0\");\n            }\n\n            TrackMe trackMe = new TrackMe(getBaseTrackMe()).set(QueryParams.GOAL_ID, mIdGoal);\n            if (mRevenue != null) trackMe.set(QueryParams.REVENUE, mRevenue);\n            return trackMe;\n        }\n    }\n\n    /**\n     * Tracks an  <a href=\"http://matomo.org/faq/new-to-matomo/faq_71/\">Outlink</a>\n     *\n     * @param url HTTPS, HTTP and FTPare valid\n     * @return this Tracker for chaining\n     */\n    public Outlink outlink(URL url) {\n        return new Outlink(this, url);\n    }\n\n    public static class Outlink extends BaseEvent {\n        private final URL mURL;\n\n        Outlink(TrackHelper baseBuilder, URL url) {\n            super(baseBuilder);\n            mURL = url;\n        }\n\n        @Override\n        public TrackMe build() {\n            if (mURL == null || mURL.toExternalForm().length() == 0) {\n                throw new IllegalArgumentException(\"Outlink tracking requires a non-empty URL\");\n            }\n            if (!mURL.getProtocol().equals(\"http\") && !mURL.getProtocol().equals(\"https\") && !mURL.getProtocol().equals(\"ftp\")) {\n                throw new IllegalArgumentException(\"Only http|https|ftp is supported for outlinks\");\n            }\n\n            return new TrackMe(getBaseTrackMe())\n                    .set(QueryParams.LINK, mURL.toExternalForm())\n                    .set(QueryParams.URL_PATH, mURL.toExternalForm());\n        }\n    }\n\n    /**\n     * Tracks an  <a href=\"http://matomo.org/docs/site-search/\">site search</a>\n     *\n     * @param keyword Searched query in the app\n     * @return this Tracker for chaining\n     */\n    public Search search(String keyword) {\n        return new Search(this, keyword);\n    }\n\n    public static class Search extends BaseEvent {\n        private final String mKeyword;\n        private String mCategory;\n        private Integer mCount;\n\n        Search(TrackHelper baseBuilder, String keyword) {\n            super(baseBuilder);\n            mKeyword = keyword;\n        }\n\n        /**\n         * You can optionally specify a search category with this parameter.\n         *\n         * @return this object, to chain calls.\n         */\n        public Search category(String category) {\n            mCategory = category;\n            return this;\n        }\n\n        /**\n         * We recommend to set the search count to the number of search results displayed on the results page.\n         * When keywords are tracked with a count of 0, they will appear in the \"No Result Search Keyword\" report.\n         *\n         * @return this object, to chain calls.\n         */\n        public Search count(Integer count) {\n            mCount = count;\n            return this;\n        }\n\n        @Override\n        public TrackMe build() {\n            TrackMe trackMe = new TrackMe(getBaseTrackMe())\n                    .set(QueryParams.SEARCH_KEYWORD, mKeyword)\n                    .set(QueryParams.SEARCH_CATEGORY, mCategory);\n            if (mCount != null) trackMe.set(QueryParams.SEARCH_NUMBER_OF_HITS, mCount);\n            return trackMe;\n        }\n    }\n\n    /**\n     * Sends a download event for this app.\n     * This only triggers an event once per app version unless you force it.<p>\n     * {@link Download#force()}\n     * <p class=\"note\">\n     * Resulting download url:<p>\n     * Case {@link org.matomo.sdk.extra.DownloadTracker.Extra.ApkChecksum}:<br>\n     * http://packageName:versionCode/apk-md5-checksum<br>\n     * <p>\n     * Case {@link org.matomo.sdk.extra.DownloadTracker.Extra.None}:<br>\n     * http://packageName:versionCode<p>\n     *\n     * @return this object, to chain calls.\n     */\n    public Download download(DownloadTracker downloadTracker) {\n        return new Download(downloadTracker, this);\n    }\n\n    public Download download() {\n        return new Download(null, this);\n    }\n\n    public static class Download {\n        private DownloadTracker mDownloadTracker;\n        private final TrackHelper mBaseBuilder;\n        private DownloadTracker.Extra mExtra = new DownloadTracker.Extra.None();\n        private boolean mForced = false;\n        private String mVersion;\n\n        Download(DownloadTracker downloadTracker, TrackHelper baseBuilder) {\n            mDownloadTracker = downloadTracker;\n            mBaseBuilder = baseBuilder;\n        }\n\n        /**\n         * Sets the identifier type for this download\n         *\n         * @param identifier {@link org.matomo.sdk.extra.DownloadTracker.Extra.ApkChecksum} or {@link org.matomo.sdk.extra.DownloadTracker.Extra.None}\n         * @return this object, to chain calls.\n         */\n        public Download identifier(DownloadTracker.Extra identifier) {\n            mExtra = identifier;\n            return this;\n        }\n\n        /**\n         * Normally a download event is only fired once per app version.\n         * If the download has already been tracked for this version, nothing happens.\n         * Calling this will force this download to be tracked.\n         *\n         * @return this object, to chain calls.\n         */\n        public Download force() {\n            mForced = true;\n            return this;\n        }\n\n        /**\n         * To track specific app versions. Useful if the app can change without the apk being updated (e.g. hybrid apps/web apps).\n         *\n         * @param version by default {@link android.content.pm.PackageInfo#versionCode} is used.\n         * @return this object, to chain calls.\n         */\n        public Download version(String version) {\n            mVersion = version;\n            return this;\n        }\n\n        public void with(Tracker tracker) {\n            if (mDownloadTracker == null) mDownloadTracker = new DownloadTracker(tracker);\n            if (mVersion != null) mDownloadTracker.setVersion(mVersion);\n            if (mForced) mDownloadTracker.trackNewAppDownload(mBaseBuilder.mBaseTrackMe, mExtra);\n            else mDownloadTracker.trackOnce(mBaseBuilder.mBaseTrackMe, mExtra);\n        }\n    }\n\n    /**\n     * Tracking the impressions\n     *\n     * @param contentName The name of the content. For instance 'Ad Foo Bar'\n     */\n    public ContentImpression impression(String contentName) {\n        return new ContentImpression(this, contentName);\n    }\n\n    public static class ContentImpression extends BaseEvent {\n        private final String mContentName;\n        private String mContentPiece;\n        private String mContentTarget;\n\n        ContentImpression(TrackHelper baseBuilder, String contentName) {\n            super(baseBuilder);\n            mContentName = contentName;\n        }\n\n        /**\n         * @param contentPiece The actual content. For instance the path to an image, video, audio, any text\n         */\n        public ContentImpression piece(String contentPiece) {\n            mContentPiece = contentPiece;\n            return this;\n        }\n\n        /**\n         * @param contentTarget The target of the content. For instance the URL of a landing page.\n         */\n        public ContentImpression target(String contentTarget) {\n            mContentTarget = contentTarget;\n            return this;\n        }\n\n        @Override\n        public TrackMe build() {\n            if (mContentName == null || mContentName.length() == 0) {\n                throw new IllegalArgumentException(\"Tracking content impressions requires a non-empty content-name\");\n            }\n            return new TrackMe(getBaseTrackMe())\n                    .set(QueryParams.CONTENT_NAME, mContentName)\n                    .set(QueryParams.CONTENT_PIECE, mContentPiece)\n                    .set(QueryParams.CONTENT_TARGET, mContentTarget);\n        }\n    }\n\n    /**\n     * Tracking the interactions<p>\n     * To map an interaction to an impression make sure to set the same value for contentName and contentPiece as\n     * the impression has.\n     *\n     * @param contentInteraction The name of the interaction with the content. For instance a 'click'\n     * @param contentName        The name of the content. For instance 'Ad Foo Bar'\n     */\n    public ContentInteraction interaction(String contentName, String contentInteraction) {\n        return new ContentInteraction(this, contentName, contentInteraction);\n    }\n\n    public static class ContentInteraction extends BaseEvent {\n        private final String mContentName;\n        private final String mInteraction;\n        private String mContentPiece;\n        private String mContentTarget;\n\n        ContentInteraction(TrackHelper baseBuilder, String contentName, String interaction) {\n            super(baseBuilder);\n            mContentName = contentName;\n            mInteraction = interaction;\n        }\n\n        /**\n         * @param contentPiece The actual content. For instance the path to an image, video, audio, any text\n         */\n        public ContentInteraction piece(String contentPiece) {\n            mContentPiece = contentPiece;\n            return this;\n        }\n\n        /**\n         * @param contentTarget The target the content leading to when an interaction occurs. For instance the URL of a landing page.\n         */\n        public ContentInteraction target(String contentTarget) {\n            mContentTarget = contentTarget;\n            return this;\n        }\n\n        @Override\n        public TrackMe build() {\n            if (mContentName == null || mContentName.length() == 0) {\n                throw new IllegalArgumentException(\"Content name needs to be non-empty\");\n            }\n            if (mInteraction == null || mInteraction.length() == 0) {\n                throw new IllegalArgumentException(\"Interaction name needs to be non-empty\");\n            }\n\n            return new TrackMe(getBaseTrackMe())\n                    .set(QueryParams.CONTENT_NAME, mContentName)\n                    .set(QueryParams.CONTENT_PIECE, mContentPiece)\n                    .set(QueryParams.CONTENT_TARGET, mContentTarget)\n                    .set(QueryParams.CONTENT_INTERACTION, mInteraction);\n        }\n    }\n\n\n    /**\n     * Tracks a shopping cart. Call this javascript function every time a user is adding, updating\n     * or deleting a product from the cart.\n     *\n     * @param grandTotal total value of items in cart\n     */\n    public CartUpdate cartUpdate(int grandTotal) {\n        return new CartUpdate(this, grandTotal);\n    }\n\n    public static class CartUpdate extends BaseEvent {\n        private final int mGrandTotal;\n        private EcommerceItems mEcommerceItems;\n\n        CartUpdate(TrackHelper baseBuilder, int grandTotal) {\n            super(baseBuilder);\n            mGrandTotal = grandTotal;\n        }\n\n        /**\n         * @param items Items included in the cart\n         */\n        public CartUpdate items(EcommerceItems items) {\n            mEcommerceItems = items;\n            return this;\n        }\n\n        @Override\n        public TrackMe build() {\n            if (mEcommerceItems == null) mEcommerceItems = new EcommerceItems();\n            return new TrackMe(getBaseTrackMe())\n                    .set(QueryParams.GOAL_ID, 0)\n                    .set(QueryParams.REVENUE, CurrencyFormatter.priceString(mGrandTotal))\n                    .set(QueryParams.ECOMMERCE_ITEMS, mEcommerceItems.toJson());\n        }\n    }\n\n    /**\n     * Tracks an Ecommerce order, including any ecommerce item previously added to the order.  All\n     * monetary values should be passed as an integer number of cents (or the smallest integer unit\n     * for your currency)\n     *\n     * @param orderId    (required) A unique string identifying the order\n     * @param grandTotal (required) total amount of the order, in cents\n     */\n    public Order order(String orderId, int grandTotal) {\n        return new Order(this, orderId, grandTotal);\n    }\n\n    public static class Order extends BaseEvent {\n        private final String mOrderId;\n        private final int mGrandTotal;\n        private EcommerceItems mEcommerceItems;\n        private Integer mDiscount;\n        private Integer mShipping;\n        private Integer mTax;\n        private Integer mSubTotal;\n\n        Order(TrackHelper baseBuilder, String orderId, int grandTotal) {\n            super(baseBuilder);\n            mOrderId = orderId;\n            mGrandTotal = grandTotal;\n        }\n\n        /**\n         * @param subTotal the subTotal for the order, in cents\n         */\n        public Order subTotal(Integer subTotal) {\n            mSubTotal = subTotal;\n            return this;\n        }\n\n        /**\n         * @param tax the tax for the order, in cents\n         */\n        public Order tax(Integer tax) {\n            mTax = tax;\n            return this;\n        }\n\n        /**\n         * @param shipping the shipping for the order, in cents\n         */\n        public Order shipping(Integer shipping) {\n            mShipping = shipping;\n            return this;\n        }\n\n        /**\n         * @param discount the discount for the order, in cents\n         */\n        public Order discount(Integer discount) {\n            mDiscount = discount;\n            return this;\n        }\n\n        /**\n         * @param items the items included in the order\n         */\n        public Order items(EcommerceItems items) {\n            mEcommerceItems = items;\n            return this;\n        }\n\n        @Override\n        public TrackMe build() {\n            if (mEcommerceItems == null) mEcommerceItems = new EcommerceItems();\n            return new TrackMe(getBaseTrackMe())\n                    .set(QueryParams.GOAL_ID, 0)\n                    .set(QueryParams.ORDER_ID, mOrderId)\n                    .set(QueryParams.REVENUE, CurrencyFormatter.priceString(mGrandTotal))\n                    .set(QueryParams.ECOMMERCE_ITEMS, mEcommerceItems.toJson())\n                    .set(QueryParams.SUBTOTAL, CurrencyFormatter.priceString(mSubTotal))\n                    .set(QueryParams.TAX, CurrencyFormatter.priceString(mTax))\n                    .set(QueryParams.SHIPPING, CurrencyFormatter.priceString(mShipping))\n                    .set(QueryParams.DISCOUNT, CurrencyFormatter.priceString(mDiscount));\n        }\n    }\n\n    /**\n     * Caught exceptions are errors in your app for which you've defined exception handling code,\n     * such as the occasional timeout of a network connection during a request for data.\n     * <p>\n     * This is just a different way to define an event.\n     * Keep in mind Matomo is not a crash tracker, use this sparingly.\n     * <p>\n     * For this to be useful you should ensure that proguard does not remove all classnames and line numbers.\n     * Also note that if this is used across different app versions and obfuscation is used, the same exception might be mapped to different obfuscated names by proguard.\n     * This would mean the same exception (event) is tracked as different events by Matomo.\n     *\n     * @param throwable exception instance\n     */\n    public Exception exception(Throwable throwable) {\n        return new Exception(this, throwable);\n    }\n\n    public static class Exception extends BaseEvent {\n        private final Throwable mThrowable;\n        private String mDescription;\n        private boolean mIsFatal;\n\n        Exception(TrackHelper baseBuilder, Throwable throwable) {\n            super(baseBuilder);\n            mThrowable = throwable;\n        }\n\n        /**\n         * @param description exception message\n         */\n        public Exception description(String description) {\n            mDescription = description;\n            return this;\n        }\n\n        /**\n         * @param isFatal true if it's fatal exception\n         */\n        public Exception fatal(boolean isFatal) {\n            mIsFatal = isFatal;\n            return this;\n        }\n\n        @Override\n        public TrackMe build() {\n            String className;\n            try {\n                StackTraceElement trace = mThrowable.getStackTrace()[0];\n                className = trace.getClassName() + \"/\" + trace.getMethodName() + \":\" + trace.getLineNumber();\n            } catch (java.lang.Exception e) {\n                Timber.tag(TAG).w(e, \"Couldn't get stack info\");\n                className = mThrowable.getClass().getName();\n            }\n            String actionName = \"exception/\" + (mIsFatal ? \"fatal/\" : \"\") + (className + \"/\") + mDescription;\n            return new TrackMe(getBaseTrackMe())\n                    .set(QueryParams.ACTION_NAME, actionName)\n                    .set(QueryParams.EVENT_CATEGORY, \"Exception\")\n                    .set(QueryParams.EVENT_ACTION, className)\n                    .set(QueryParams.EVENT_NAME, mDescription)\n                    .set(QueryParams.EVENT_VALUE, mIsFatal ? 1 : 0);\n        }\n    }\n\n    /**\n     * This will create an exception handler that wraps any existing exception handler.\n     * Exceptions will be caught, tracked, dispatched and then rethrown to the previous exception handler.\n     * <p>\n     * Be wary of relying on this for complete crash tracking..\n     * Think about how to deal with older app versions still throwing already fixed exceptions.\n     * <p>\n     * See discussion here: https://github.com/matomo-org/matomo-sdk-android/issues/28\n     */\n    public UncaughtExceptions uncaughtExceptions() {\n        return new UncaughtExceptions(this);\n    }\n\n    public static class UncaughtExceptions {\n        private final TrackHelper mBaseBuilder;\n\n        UncaughtExceptions(TrackHelper baseBuilder) {\n            mBaseBuilder = baseBuilder;\n        }\n\n        /**\n         * @param tracker the tracker that should receive the exception events.\n         * @return returns the new (but already active) exception handler.\n         */\n        public Thread.UncaughtExceptionHandler with(Tracker tracker) {\n            if (Thread.getDefaultUncaughtExceptionHandler() instanceof MatomoExceptionHandler) {\n                throw new RuntimeException(\"Trying to wrap an existing MatomoExceptionHandler.\");\n            }\n            Thread.UncaughtExceptionHandler handler = new MatomoExceptionHandler(tracker, mBaseBuilder.mBaseTrackMe);\n            Thread.setDefaultUncaughtExceptionHandler(handler);\n            return handler;\n        }\n    }\n\n    /**\n     * This method will bind a tracker to your application,\n     * causing it to automatically track Activities with {@link #screen(Activity)} within your app.\n     *\n     * @param app your app\n     * @return the registered callback, you need this if you wanted to unregister the callback again\n     */\n    public AppTracking screens(Application app) {\n        return new AppTracking(this, app);\n    }\n\n    public static class AppTracking {\n        private final Application mApplication;\n        private final TrackHelper mBaseBuilder;\n\n        public AppTracking(TrackHelper baseBuilder, Application application) {\n            mBaseBuilder = baseBuilder;\n            mApplication = application;\n        }\n\n        /**\n         * @param tracker the tracker to use\n         * @return the registered callback, you need this if you wanted to unregister the callback again\n         */\n        public Application.ActivityLifecycleCallbacks with(final Tracker tracker) {\n            final Application.ActivityLifecycleCallbacks callback = new Application.ActivityLifecycleCallbacks() {\n                @Override\n                public void onActivityCreated(Activity activity, Bundle bundle) {\n\n                }\n\n                @Override\n                public void onActivityStarted(Activity activity) {\n\n                }\n\n                @Override\n                public void onActivityResumed(Activity activity) {\n                    mBaseBuilder.screen(activity).with(tracker);\n                }\n\n                @Override\n                public void onActivityPaused(Activity activity) {\n\n                }\n\n                @Override\n                public void onActivityStopped(Activity activity) {\n                    if (activity != null && activity.isTaskRoot()) {\n                        tracker.dispatch();\n                    }\n                }\n\n                @Override\n                public void onActivitySaveInstanceState(Activity activity, Bundle bundle) {\n\n                }\n\n                @Override\n                public void onActivityDestroyed(Activity activity) {\n\n                }\n            };\n            mApplication.registerActivityLifecycleCallbacks(callback);\n            return callback;\n        }\n    }\n\n    public Dimension dimension(int id, String value) {\n        return new Dimension(mBaseTrackMe).dimension(id, value);\n    }\n\n    public static class Dimension extends TrackHelper {\n\n        Dimension(TrackMe base) {\n            super(base);\n        }\n\n        @Override\n        public Dimension dimension(int id, String value) {\n            CustomDimension.setDimension(mBaseTrackMe, id, value);\n            return this;\n        }\n    }\n\n\n    /**\n     * To track visit scoped custom variables.\n     *\n     * @see CustomVariables#put(int, String, String)\n     * @deprecated Consider using <a href=\"http://matomo.org/docs/custom-dimensions/\">Custom Dimensions</a>\n     */\n    @Deprecated\n    public VisitVariables visitVariables(int id, String name, String value) {\n        CustomVariables customVariables = new CustomVariables();\n        customVariables.put(id, name, value);\n        return visitVariables(customVariables);\n    }\n\n    /**\n     * To track visit scoped custom variables.\n     *\n     * @deprecated Consider using <a href=\"http://matomo.org/docs/custom-dimensions/\">Custom Dimensions</a>\n     */\n    @Deprecated\n    public VisitVariables visitVariables(CustomVariables customVariables) {\n        return new VisitVariables(this, customVariables);\n    }\n\n    @SuppressWarnings(\"deprecation\")\n    public static class VisitVariables extends TrackHelper {\n\n        public VisitVariables(TrackHelper baseBuilder, CustomVariables customVariables) {\n            super(baseBuilder.mBaseTrackMe);\n            CustomVariables mergedVariables = new CustomVariables(mBaseTrackMe.get(QueryParams.VISIT_SCOPE_CUSTOM_VARIABLES));\n            mergedVariables.putAll(customVariables);\n            mBaseTrackMe.set(QueryParams.VISIT_SCOPE_CUSTOM_VARIABLES, mergedVariables.toString());\n        }\n\n        /**\n         * @see CustomVariables#put(int, String, String)\n         */\n        public VisitVariables visitVariables(int id, String name, String value) {\n            CustomVariables customVariables = new CustomVariables(mBaseTrackMe.get(QueryParams.VISIT_SCOPE_CUSTOM_VARIABLES));\n            customVariables.put(id, name, value);\n            mBaseTrackMe.set(QueryParams.VISIT_SCOPE_CUSTOM_VARIABLES, customVariables.toString());\n            return this;\n        }\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/tools/ActivityHelper.java",
    "content": "package org.matomo.sdk.tools;\n\nimport android.app.Activity;\nimport android.text.TextUtils;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n\npublic class ActivityHelper {\n\n    public static String getBreadcrumbs(final Activity activity) {\n        Activity currentActivity = activity;\n        ArrayList<String> breadcrumbs = new ArrayList<>();\n\n        while (currentActivity != null) {\n            breadcrumbs.add(currentActivity.getTitle().toString());\n            currentActivity = currentActivity.getParent();\n        }\n        return joinSlash(breadcrumbs);\n    }\n\n    public static String joinSlash(List<String> sequence) {\n        if (sequence != null && sequence.size() > 0) {\n            return TextUtils.join(\"/\", sequence);\n        }\n        return \"\";\n    }\n\n    public static String breadcrumbsToPath(String breadcrumbs) {\n        return breadcrumbs.replaceAll(\"\\\\s\", \"\");\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/tools/BuildInfo.java",
    "content": "package org.matomo.sdk.tools;\n\n\nimport android.os.Build;\n\npublic class BuildInfo {\n    public String getRelease() {\n        return Build.VERSION.RELEASE;\n    }\n\n    public String getModel() {\n        return Build.MODEL;\n    }\n\n    public String getBuildId() {\n        return Build.ID;\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/tools/Checksum.java",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\n\npackage org.matomo.sdk.tools;\n\nimport java.io.File;\nimport java.io.FileInputStream;\nimport java.io.InputStream;\nimport java.security.MessageDigest;\n\n/**\n * Offers to calculate checksums\n */\npublic class Checksum {\n    private static final String HEXES = \"0123456789ABCDEF\";\n\n    /**\n     * Transforms byte into hex representation.\n     */\n    public static String getHex(byte[] raw) {\n        if (raw == null)\n            return null;\n        final StringBuilder hex = new StringBuilder(2 * raw.length);\n        for (final byte b : raw)\n            hex.append(HEXES.charAt((b & 0xF0) >> 4)).append(HEXES.charAt((b & 0x0F)));\n        return hex.toString();\n    }\n\n    /**\n     * MD5-Checksum for a string.\n     */\n    public static String getMD5Checksum(String string) throws Exception {\n        MessageDigest digest = java.security.MessageDigest.getInstance(\"MD5\");\n        digest.update(string.getBytes());\n        byte[] messageDigest = digest.digest();\n        return getHex(messageDigest);\n    }\n\n    /**\n     * MD5-Checksum for a file.\n     */\n    public static String getMD5Checksum(File file) throws Exception {\n        if (!file.isFile())\n            return null;\n        InputStream fis = new FileInputStream(file);\n        byte[] buffer = new byte[1024];\n        MessageDigest complete = MessageDigest.getInstance(\"MD5\");\n        int numRead;\n        do {\n            numRead = fis.read(buffer);\n            if (numRead > 0)\n                complete.update(buffer, 0, numRead);\n        } while (numRead != -1);\n        fis.close();\n        return getHex(complete.digest());\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/tools/Connectivity.java",
    "content": "package org.matomo.sdk.tools;\n\n\nimport android.content.Context;\nimport android.net.ConnectivityManager;\nimport android.net.NetworkInfo;\n\nimport static org.matomo.sdk.tools.Connectivity.Type.MOBILE;\nimport static org.matomo.sdk.tools.Connectivity.Type.NONE;\nimport static org.matomo.sdk.tools.Connectivity.Type.WIFI;\n\npublic class Connectivity {\n    private final ConnectivityManager mConnectivityManager;\n\n    public Connectivity(Context context) {\n        mConnectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);\n    }\n\n    public boolean isConnected() {\n        NetworkInfo network = mConnectivityManager.getActiveNetworkInfo();\n        return network != null && network.isConnected();\n    }\n\n    public enum Type {\n        NONE, MOBILE, WIFI\n    }\n\n    public Type getType() {\n        NetworkInfo network = mConnectivityManager.getActiveNetworkInfo();\n        if (network == null) return NONE;\n        if (network.getType() == ConnectivityManager.TYPE_WIFI) {\n            return WIFI;\n        } else return MOBILE;\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/tools/CurrencyFormatter.java",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\n\npackage org.matomo.sdk.tools;\n\nimport androidx.annotation.Nullable;\n\nimport java.math.BigDecimal;\n\npublic class CurrencyFormatter {\n    @Nullable\n    public static String priceString(@Nullable Integer cents) {\n        if (cents == null) return null;\n        return new BigDecimal(cents).movePointLeft(2).toPlainString();\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/tools/DeviceHelper.java",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\npackage org.matomo.sdk.tools;\n\nimport android.content.Context;\nimport android.util.DisplayMetrics;\nimport android.view.Display;\nimport android.view.WindowManager;\n\nimport org.matomo.sdk.Matomo;\n\nimport java.util.Locale;\n\nimport timber.log.Timber;\n\n/**\n * Helper class to gain information about the device we are running on\n */\npublic class DeviceHelper {\n    private static final String TAG = Matomo.tag(DeviceHelper.class);\n    private final Context mContext;\n    private final PropertySource mPropertySource;\n    private final BuildInfo mBuildInfo;\n\n    public DeviceHelper(Context context, PropertySource propertySource, BuildInfo buildInfo) {\n        mContext = context;\n        mPropertySource = propertySource;\n        mBuildInfo = buildInfo;\n    }\n\n    /**\n     * Returns user language\n     *\n     * @return language\n     */\n    public String getUserLanguage() {\n        return Locale.getDefault().getLanguage();\n    }\n\n    /**\n     * Returns android system user agent\n     *\n     * @return well formatted user agent\n     */\n    public String getUserAgent() {\n        String httpAgent = mPropertySource.getHttpAgent();\n        if (httpAgent == null || httpAgent.startsWith(\"Apache-HttpClient/UNAVAILABLE (java\")) {\n            String dalvik = mPropertySource.getJVMVersion();\n            if (dalvik == null) dalvik = \"0.0.0\";\n            String android = mBuildInfo.getRelease();\n            String model = mBuildInfo.getModel();\n            String build = mBuildInfo.getBuildId();\n            httpAgent = String.format(Locale.US,\n                    \"Dalvik/%s (Linux; U; Android %s; %s Build/%s)\",\n                    dalvik, android, model, build\n            );\n        }\n        return httpAgent;\n    }\n\n    /**\n     * Tries to get the most accurate device resolution.\n     * On devices below API17 resolution might not account for statusbar/softkeys.\n     *\n     * @return [width, height]\n     */\n    public int[] getResolution() {\n        int width, height;\n\n        Display display;\n        try {\n            WindowManager wm = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);\n            display = wm.getDefaultDisplay();\n        } catch (NullPointerException e) {\n            Timber.tag(TAG).e(e, \"Window service was not available from this context\");\n            return null;\n        }\n\n        // Recommended way to get the resolution but only available since API17\n        DisplayMetrics displayMetrics = new DisplayMetrics();\n        display.getRealMetrics(displayMetrics);\n        width = displayMetrics.widthPixels;\n        height = displayMetrics.heightPixels;\n\n        if (width == -1 || height == -1) {\n            // This is not accurate on all 4.2+ devices, usually the height is wrong due to statusbar/softkeys\n            // Better than nothing though.\n            DisplayMetrics dm = new DisplayMetrics();\n            display.getMetrics(dm);\n            width = dm.widthPixels;\n            height = dm.heightPixels;\n        }\n\n        return new int[]{width, height};\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/tools/PropertySource.java",
    "content": "package org.matomo.sdk.tools;\n\n\nimport androidx.annotation.Nullable;\n\npublic class PropertySource {\n    @Nullable\n    public String getHttpAgent() {\n        return getSystemProperty(\"http.agent\");\n    }\n\n    @Nullable\n    public String getJVMVersion() {\n        return getSystemProperty(\"java.vm.version\");\n    }\n\n    @Nullable\n    public String getSystemProperty(String key) {\n        return System.getProperty(key);\n    }\n}\n"
  },
  {
    "path": "tracker/src/main/java/org/matomo/sdk/tools/UrlHelper.java",
    "content": "/*\n *\n *  * Android SDK for Matomo\n *  *\n *  * @link https://github.com/matomo-org/matomo-android-sdk\n *  * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n *\n */\n\npackage org.matomo.sdk.tools;\n\nimport androidx.annotation.NonNull;\nimport androidx.annotation.Nullable;\nimport android.util.Pair;\n\nimport java.io.UnsupportedEncodingException;\nimport java.net.URI;\nimport java.net.URLDecoder;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Scanner;\n\n/**\n * Helps us with Urls.\n */\npublic class UrlHelper {\n    private static final String PARAMETER_SEPARATOR = \"&\";\n    private static final String NAME_VALUE_SEPARATOR = \"=\";\n\n    // Inspired by https://github.com/android/platform_external_apache-http/blob/master/src/org/apache/http/client/utils/URLEncodedUtils.java\n    // Helper due to Apache http deprecation\n\n    public static List<Pair<String, String>> parse(@NonNull final URI uri, @Nullable final String encoding) {\n        List<Pair<String, String>> result = Collections.emptyList();\n        final String query = uri.getRawQuery();\n        if (query != null && !query.isEmpty()) {\n            result = new ArrayList<>();\n            parse(result, new Scanner(query), encoding);\n        }\n        return result;\n    }\n\n    public static void parse(@NonNull final List<Pair<String, String>> parameters, @NonNull final Scanner scanner, @Nullable final String encoding) {\n        scanner.useDelimiter(PARAMETER_SEPARATOR);\n        while (scanner.hasNext()) {\n            final String[] nameValue = scanner.next().split(NAME_VALUE_SEPARATOR);\n            if (nameValue.length == 0 || nameValue.length > 2)\n                throw new IllegalArgumentException(\"bad parameter\");\n\n            final String name = decode(nameValue[0], encoding);\n            String value = null;\n            if (nameValue.length == 2)\n                value = decode(nameValue[1], encoding);\n            parameters.add(new Pair<>(name, value));\n        }\n    }\n\n\n    private static String decode(@NonNull final String content, @Nullable final String encoding) {\n        try {\n            return URLDecoder.decode(content, encoding != null ? encoding : \"UTF-8\");\n        } catch (UnsupportedEncodingException problem) {\n            throw new IllegalArgumentException(problem);\n        }\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/LegacySettingsPorterTest.java",
    "content": "package org.matomo.sdk;\n\n\nimport android.annotation.SuppressLint;\nimport android.content.SharedPreferences;\n\nimport org.junit.Before;\nimport org.junit.Test;\nimport org.junit.runner.RunWith;\nimport org.mockito.Mock;\nimport org.mockito.junit.MockitoJUnitRunner;\nimport org.mockito.stubbing.Answer;\n\nimport java.util.HashMap;\nimport java.util.Map;\n\nimport testhelpers.BaseTest;\n\nimport static org.mockito.ArgumentMatchers.anyBoolean;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\n@SuppressLint(\"CommitPrefEdits\")\n@RunWith(MockitoJUnitRunner.class)\npublic class LegacySettingsPorterTest extends BaseTest {\n    @Mock Matomo mMatomo;\n    @Mock SharedPreferences mPrefs;\n    @Mock SharedPreferences.Editor mPrefsEditor;\n    @Mock SharedPreferences mTrackerPrefs;\n    @Mock SharedPreferences.Editor mTrackerPrefsEditor;\n    @Mock Tracker mTracker;\n    private LegacySettingsPorter mPorter;\n\n\n    @Before\n    public void setup() {\n        when(mPrefs.edit()).thenReturn(mPrefsEditor);\n        when(mPrefsEditor.remove(anyString())).thenReturn(mPrefsEditor);\n\n        when(mTrackerPrefs.edit()).thenReturn(mTrackerPrefsEditor);\n        when(mTrackerPrefsEditor.putBoolean(anyString(), anyBoolean())).thenReturn(mTrackerPrefsEditor);\n        when(mTrackerPrefsEditor.putLong(anyString(), anyLong())).thenReturn(mTrackerPrefsEditor);\n        when(mTrackerPrefsEditor.putString(anyString(), anyString())).thenReturn(mTrackerPrefsEditor);\n\n        when(mMatomo.getPreferences()).thenReturn(mPrefs);\n        when(mTracker.getPreferences()).thenReturn(mTrackerPrefs);\n        mPorter = new LegacySettingsPorter(mMatomo);\n    }\n\n    @Test\n    public void testPort_optOut_empty() {\n        when(mPrefs.getBoolean(LegacySettingsPorter.LEGACY_PREF_OPT_OUT, false)).thenReturn(false);\n        mPorter.port(mTracker);\n\n        verify(mTrackerPrefs, never()).edit();\n        verify(mPrefs, never()).edit();\n    }\n\n    @Test\n    public void testPort_optOut_exists() {\n        when(mPrefs.getBoolean(LegacySettingsPorter.LEGACY_PREF_OPT_OUT, false)).thenReturn(true);\n        mPorter.port(mTracker);\n\n        verify(mPrefs).getBoolean(LegacySettingsPorter.LEGACY_PREF_OPT_OUT, false);\n        verify(mTrackerPrefs).edit();\n        verify(mTrackerPrefsEditor).putBoolean(Tracker.PREF_KEY_TRACKER_OPTOUT, true);\n        verify(mPrefsEditor).remove(LegacySettingsPorter.LEGACY_PREF_OPT_OUT);\n    }\n\n    @Test\n    public void testPort_userId_empty() {\n        when(mPrefs.contains(LegacySettingsPorter.LEGACY_PREF_USER_ID)).thenReturn(false);\n        mPorter.port(mTracker);\n\n        verify(mTrackerPrefs, never()).edit();\n        verify(mPrefs, never()).edit();\n    }\n\n    @Test\n    public void testPort_userId_exists() {\n        when(mPrefs.contains(LegacySettingsPorter.LEGACY_PREF_USER_ID)).thenReturn(true);\n        when(mPrefs.getString(eq(LegacySettingsPorter.LEGACY_PREF_USER_ID), anyString())).thenReturn(\"test\");\n        mPorter.port(mTracker);\n\n        verify(mPrefs).getString(eq(LegacySettingsPorter.LEGACY_PREF_USER_ID), anyString());\n        verify(mTrackerPrefs).edit();\n        verify(mTrackerPrefsEditor).putString(Tracker.PREF_KEY_TRACKER_USERID, \"test\");\n        verify(mPrefsEditor).remove(LegacySettingsPorter.LEGACY_PREF_USER_ID);\n    }\n\n    @Test\n    public void testPort_firstVisit_empty() {\n        when(mPrefs.contains(LegacySettingsPorter.LEGACY_PREF_FIRST_VISIT)).thenReturn(false);\n        mPorter.port(mTracker);\n\n        verify(mTrackerPrefs, never()).edit();\n        verify(mPrefs, never()).edit();\n    }\n\n    @Test\n    public void testPort_firstVisit_exists() {\n        when(mPrefs.contains(LegacySettingsPorter.LEGACY_PREF_FIRST_VISIT)).thenReturn(true);\n        when(mPrefs.getLong(LegacySettingsPorter.LEGACY_PREF_FIRST_VISIT, -1L)).thenReturn(1338L);\n        mPorter.port(mTracker);\n\n        verify(mPrefs).getLong(LegacySettingsPorter.LEGACY_PREF_FIRST_VISIT, -1L);\n        verify(mTrackerPrefs).edit();\n        verify(mTrackerPrefsEditor).putLong(Tracker.PREF_KEY_TRACKER_FIRSTVISIT, 1338);\n        verify(mPrefsEditor).remove(LegacySettingsPorter.LEGACY_PREF_FIRST_VISIT);\n    }\n\n    @Test\n    public void testPort_visitCount_empty() {\n        when(mPrefs.contains(LegacySettingsPorter.LEGACY_PREF_VISITCOUNT)).thenReturn(false);\n        mPorter.port(mTracker);\n\n        verify(mTrackerPrefs, never()).edit();\n        verify(mPrefs, never()).edit();\n    }\n\n    @Test\n    public void testPort_visitCount_exists() {\n        when(mPrefs.contains(LegacySettingsPorter.LEGACY_PREF_VISITCOUNT)).thenReturn(true);\n        when(mPrefs.getInt(LegacySettingsPorter.LEGACY_PREF_VISITCOUNT, 0)).thenReturn(16);\n        mPorter.port(mTracker);\n\n        verify(mPrefs).getInt(LegacySettingsPorter.LEGACY_PREF_VISITCOUNT, 0);\n        verify(mTrackerPrefs).edit();\n        verify(mTrackerPrefsEditor).putLong(Tracker.PREF_KEY_TRACKER_VISITCOUNT, 16L);\n        verify(mPrefsEditor).remove(LegacySettingsPorter.LEGACY_PREF_VISITCOUNT);\n    }\n\n    @Test\n    public void testPort_previousVisit_empty() {\n        when(mPrefs.contains(LegacySettingsPorter.LEGACY_PREF_PREV_VISIT)).thenReturn(false);\n        mPorter.port(mTracker);\n\n        verify(mTrackerPrefs, never()).edit();\n        verify(mPrefs, never()).edit();\n    }\n\n    @Test\n    public void testPort_previousVisit_exists() {\n        when(mPrefs.contains(LegacySettingsPorter.LEGACY_PREF_PREV_VISIT)).thenReturn(true);\n        when(mPrefs.getLong(LegacySettingsPorter.LEGACY_PREF_PREV_VISIT, -1)).thenReturn(1111L);\n        mPorter.port(mTracker);\n\n        verify(mPrefs).getLong(LegacySettingsPorter.LEGACY_PREF_PREV_VISIT, -1);\n        verify(mTrackerPrefs).edit();\n        verify(mTrackerPrefsEditor).putLong(Tracker.PREF_KEY_TRACKER_PREVIOUSVISIT, 1111L);\n        verify(mPrefsEditor).remove(LegacySettingsPorter.LEGACY_PREF_PREV_VISIT);\n    }\n\n    @Test\n    public void testDownloadMapping_empty() {\n        final Map<String, ?> map = new HashMap<>();\n        when(mPrefs.getAll()).thenAnswer((Answer<Map<String, ?>>) invocation -> map);\n        mPorter.port(mTracker);\n\n        verify(mPrefs).getAll();\n        verify(mTrackerPrefs, never()).edit();\n    }\n\n    @Test\n    public void testDownloadMapping_exists() {\n        final Map<String, Object> map = new HashMap<>();\n        String key1 = \"downloaded:testkey1\";\n        map.put(key1, true);\n        String key2 = \"downloaded:testkey2\";\n        map.put(key2, false);\n        String key3 = \"testkey2\";\n        map.put(key3, 123465);\n\n        when(mPrefs.getAll()).thenAnswer((Answer<Map<String, ?>>) invocation -> map);\n        mPorter.port(mTracker);\n\n        verify(mPrefs).getAll();\n        verify(mPrefsEditor).remove(key1);\n        verify(mPrefsEditor).remove(key2);\n        verify(mPrefsEditor, never()).remove(key3);\n        verify(mTrackerPrefsEditor).putBoolean(key1, true);\n        verify(mTrackerPrefsEditor).putBoolean(key2, true);\n        verify(mTrackerPrefsEditor, never()).putBoolean(eq(key3), anyBoolean());\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/MatomoTest.java",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\n\npackage org.matomo.sdk;\n\nimport android.annotation.SuppressLint;\nimport android.app.Application;\n\nimport org.junit.Test;\nimport org.junit.runner.RunWith;\nimport org.matomo.sdk.dispatcher.DefaultDispatcher;\nimport org.matomo.sdk.dispatcher.DefaultDispatcherFactory;\nimport org.matomo.sdk.dispatcher.Dispatcher;\nimport org.matomo.sdk.dispatcher.DispatcherFactory;\nimport org.matomo.sdk.dispatcher.EventCache;\nimport org.matomo.sdk.dispatcher.EventDiskCache;\nimport org.matomo.sdk.dispatcher.Packet;\nimport org.matomo.sdk.dispatcher.PacketFactory;\nimport org.matomo.sdk.dispatcher.PacketSender;\nimport org.matomo.sdk.extra.TrackHelper;\nimport org.matomo.sdk.tools.Connectivity;\nimport org.robolectric.annotation.Config;\n\nimport androidx.annotation.NonNull;\nimport androidx.test.core.app.ApplicationProvider;\n\nimport testhelpers.BaseTest;\nimport testhelpers.FullEnvTestRunner;\nimport testhelpers.MatomoTestApplication;\n\nimport static org.hamcrest.Matchers.is;\nimport static org.hamcrest.Matchers.not;\nimport static org.hamcrest.Matchers.nullValue;\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertNotEquals;\nimport static org.junit.Assert.assertNotNull;\nimport static org.hamcrest.MatcherAssert.assertThat;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.timeout;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\n\n@Config(sdk = 28, manifest = Config.NONE, application = MatomoTestApplication.class)\n@RunWith(FullEnvTestRunner.class)\npublic class MatomoTest extends BaseTest {\n\n    @Test\n    public void testNewTracker() {\n        MatomoTestApplication app = ApplicationProvider.getApplicationContext();\n        Tracker tracker = app.onCreateTrackerConfig().build(Matomo.getInstance(ApplicationProvider.getApplicationContext()));\n        assertNotNull(tracker);\n        assertEquals(app.onCreateTrackerConfig().getApiUrl(), tracker.getAPIUrl());\n        assertEquals(app.onCreateTrackerConfig().getSiteId(), tracker.getSiteId());\n    }\n\n    @Test\n    public void testNormalTracker() {\n        Matomo matomo = Matomo.getInstance(ApplicationProvider.getApplicationContext());\n        Tracker tracker = new TrackerBuilder(\"http://test/matomo.php\", 1, \"Default Tracker\").build(matomo);\n        assertEquals(\"http://test/matomo.php\", tracker.getAPIUrl());\n        assertEquals(1, tracker.getSiteId());\n    }\n\n    @Test\n    public void testTrackerNaming() {\n        // TODO can we somehow detect naming collisions on tracker creation?\n        // Would probably requiring us to track created trackers\n    }\n\n    @SuppressLint(\"InlinedApi\")\n    @Test\n    public void testLowMemoryDispatch() {\n        MatomoTestApplication app = ApplicationProvider.getApplicationContext();\n        final PacketSender packetSender = mock(PacketSender.class);\n        app.getMatomo().setDispatcherFactory(new DefaultDispatcherFactory() {\n            @NonNull\n            @Override\n            public Dispatcher build(@NonNull Tracker tracker) {\n                return new DefaultDispatcher(\n                        new EventCache(new EventDiskCache(tracker)),\n                        new Connectivity(tracker.getMatomo().getContext()),\n                        new PacketFactory(tracker.getAPIUrl()),\n                        packetSender\n                );\n            }\n        });\n        Tracker tracker = app.getTracker();\n        assertNotNull(tracker);\n        tracker.setDispatchInterval(-1);\n\n        tracker.track(TrackHelper.track().screen(\"test\").build());\n        tracker.dispatch();\n        verify(packetSender, timeout(500).times(1)).send(any(Packet.class));\n\n        tracker.track(TrackHelper.track().screen(\"test\").build());\n        verify(packetSender, timeout(500).times(1)).send(any(Packet.class));\n\n        app.onTrimMemory(Application.TRIM_MEMORY_UI_HIDDEN);\n        verify(packetSender, timeout(500).atLeast(2)).send(any(Packet.class));\n    }\n\n    @Test\n    public void testGetSettings() {\n        Tracker tracker1 = mock(Tracker.class);\n        when(tracker1.getName()).thenReturn(\"1\");\n        Tracker tracker2 = mock(Tracker.class);\n        when(tracker2.getName()).thenReturn(\"2\");\n        Tracker tracker3 = mock(Tracker.class);\n        when(tracker3.getName()).thenReturn(\"1\");\n\n        final Matomo matomo = Matomo.getInstance(ApplicationProvider.getApplicationContext());\n        assertEquals(matomo.getTrackerPreferences(tracker1), matomo.getTrackerPreferences(tracker1));\n        assertNotEquals(matomo.getTrackerPreferences(tracker1), matomo.getTrackerPreferences(tracker2));\n        assertEquals(matomo.getTrackerPreferences(tracker1), matomo.getTrackerPreferences(tracker3));\n    }\n\n    @Test\n    public void testSetDispatcherFactory() {\n        final Matomo matomo = Matomo.getInstance(ApplicationProvider.getApplicationContext());\n        Dispatcher dispatcher = mock(Dispatcher.class);\n        DispatcherFactory factory = mock(DispatcherFactory.class);\n        when(factory.build(any(Tracker.class))).thenReturn(dispatcher);\n        assertThat(matomo.getDispatcherFactory(), is(not(nullValue())));\n        matomo.setDispatcherFactory(factory);\n        assertThat(matomo.getDispatcherFactory(), is(factory));\n    }\n\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/TrackMeTest.java",
    "content": "package org.matomo.sdk;\n\nimport org.junit.Test;\nimport org.junit.runner.RunWith;\nimport org.mockito.junit.MockitoJUnitRunner;\n\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.UUID;\n\nimport testhelpers.BaseTest;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertFalse;\nimport static org.junit.Assert.assertNotNull;\nimport static org.junit.Assert.assertNull;\nimport static org.junit.Assert.assertTrue;\n\n\n@RunWith(MockitoJUnitRunner.class)\npublic class TrackMeTest extends BaseTest {\n    @Test\n    public void testSourcingFromOtherTrackMe() {\n        TrackMe base = new TrackMe();\n        for (QueryParams param : QueryParams.values()) {\n            String testValue = UUID.randomUUID().toString();\n            base.set(param, testValue);\n        }\n\n        TrackMe offSpring = new TrackMe(base);\n        for (QueryParams param : QueryParams.values()) {\n            assertEquals(base.get(param), offSpring.get(param));\n        }\n    }\n\n    @Test\n    public void testAdd_overwrite() {\n        TrackMe a = new TrackMe();\n        a.set(QueryParams.URL_PATH, \"pathA\");\n        a.set(QueryParams.EVENT_NAME, \"name\");\n        TrackMe b = new TrackMe();\n        b.set(QueryParams.URL_PATH, \"pathB\");\n        a.putAll(b);\n        assertEquals(\"pathB\", a.get(QueryParams.URL_PATH));\n        assertEquals(\"pathB\", b.get(QueryParams.URL_PATH));\n        assertEquals(\"name\", a.get(QueryParams.EVENT_NAME));\n\n        b.putAll(a);\n        assertEquals(\"pathB\", a.get(QueryParams.URL_PATH));\n        assertEquals(\"pathB\", b.get(QueryParams.URL_PATH));\n        assertEquals(\"name\", a.get(QueryParams.EVENT_NAME));\n        assertEquals(\"name\", b.get(QueryParams.EVENT_NAME));\n\n    }\n\n    @Test\n    public void testSet() {\n        TrackMe trackMe = new TrackMe();\n        trackMe.set(QueryParams.HOURS, \"String\");\n        assertEquals(\"String\", trackMe.get(QueryParams.HOURS));\n\n        trackMe = new TrackMe();\n        trackMe.set(QueryParams.HOURS, 1f);\n        assertEquals(String.valueOf(1f), trackMe.get(QueryParams.HOURS));\n\n        trackMe = new TrackMe();\n        trackMe.set(QueryParams.HOURS, 1L);\n        assertEquals(String.valueOf(1L), trackMe.get(QueryParams.HOURS));\n\n        trackMe = new TrackMe();\n        trackMe.set(QueryParams.HOURS, 1);\n        assertEquals(String.valueOf(1), trackMe.get(QueryParams.HOURS));\n\n        trackMe = new TrackMe();\n        trackMe.set(QueryParams.HOURS, null);\n        assertNull(trackMe.get(QueryParams.HOURS));\n    }\n\n    @Test\n    public void testTrySet() {\n        TrackMe trackMe = new TrackMe();\n        trackMe.trySet(QueryParams.HOURS, \"A\");\n        trackMe.trySet(QueryParams.HOURS, \"B\");\n        assertEquals(\"A\", trackMe.get(QueryParams.HOURS));\n\n        trackMe = new TrackMe();\n        trackMe.trySet(QueryParams.HOURS, 1f);\n        trackMe.trySet(QueryParams.HOURS, 2f);\n        assertEquals(String.valueOf(1f), trackMe.get(QueryParams.HOURS));\n\n        trackMe = new TrackMe();\n        trackMe.trySet(QueryParams.HOURS, 1L);\n        trackMe.trySet(QueryParams.HOURS, 2L);\n        assertEquals(String.valueOf(1L), trackMe.get(QueryParams.HOURS));\n\n        trackMe = new TrackMe();\n        trackMe.trySet(QueryParams.HOURS, 1);\n        trackMe.trySet(QueryParams.HOURS, 2);\n        assertEquals(String.valueOf(1), trackMe.get(QueryParams.HOURS));\n\n        trackMe = new TrackMe();\n        trackMe.trySet(QueryParams.HOURS, \"A\");\n        trackMe.trySet(QueryParams.HOURS, null);\n        assertNotNull(trackMe.get(QueryParams.HOURS));\n    }\n\n    @Test\n    public void testSetAll() {\n        TrackMe trackMe = new TrackMe();\n        Map<QueryParams, String> testValues = new HashMap<>();\n        for (QueryParams param : QueryParams.values()) {\n            String testValue = UUID.randomUUID().toString();\n            trackMe.set(param, testValue);\n            testValues.put(param, testValue);\n        }\n        assertEquals(QueryParams.values().length, testValues.size());\n\n        for (QueryParams param : QueryParams.values()) {\n            assertTrue(trackMe.has(param));\n            assertEquals(testValues.get(param), trackMe.get(param));\n        }\n        for (QueryParams param : QueryParams.values()) {\n            trackMe.set(param, null);\n            assertFalse(trackMe.has(param));\n            assertNull(trackMe.get(param));\n        }\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/TrackerBuilderTest.java",
    "content": "package org.matomo.sdk;\n\nimport android.content.Context;\n\nimport org.junit.Test;\nimport org.junit.runner.RunWith;\nimport org.mockito.junit.MockitoJUnitRunner;\n\nimport testhelpers.BaseTest;\n\nimport static org.hamcrest.core.Is.is;\nimport static org.hamcrest.core.IsNot.not;\nimport static org.hamcrest.MatcherAssert.assertThat;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\n@RunWith(MockitoJUnitRunner.class)\npublic class TrackerBuilderTest extends BaseTest {\n    String mTestUrl = \"https://example.com/matomo.php\";\n\n    @Test\n    public void testApplicationDomain() {\n        Matomo matomo = mock(Matomo.class);\n        Context context = mock(Context.class);\n        when(matomo.getContext()).thenReturn(context);\n        when(context.getPackageName()).thenReturn(\"some.pkg\");\n\n        TrackerBuilder trackerBuilder = new TrackerBuilder(mTestUrl, 1337, \"\");\n        try {\n            trackerBuilder.build(matomo);\n        } catch (Exception ignore) {}\n        assertThat(trackerBuilder.getApplicationBaseUrl(), is(\"https://some.pkg/\"));\n\n        trackerBuilder.setApplicationBaseUrl(\"rest://something\");\n        assertThat(trackerBuilder.getApplicationBaseUrl(), is(\"rest://something\"));\n    }\n\n    @Test\n    public void testSiteId() {\n        TrackerBuilder trackerBuilder = new TrackerBuilder(mTestUrl, 1337, \"\");\n        assertThat(trackerBuilder.getSiteId(), is(1337));\n    }\n\n    @Test\n    public void testGetName() {\n        TrackerBuilder trackerBuilder = new TrackerBuilder(mTestUrl, 1337, \"Default Tracker\");\n        assertThat(trackerBuilder.getTrackerName(), is(\"Default Tracker\"));\n        trackerBuilder.setTrackerName(\"strawberry\");\n        assertThat(trackerBuilder.getTrackerName(), is(\"strawberry\"));\n\n    }\n\n    @Test\n    public void testEquals() {\n        TrackerBuilder trackerBuilder1 = new TrackerBuilder(mTestUrl, 1337, \"a\");\n        TrackerBuilder trackerBuilder2 = new TrackerBuilder(mTestUrl, 1337, \"a\");\n        TrackerBuilder trackerBuilder3 = new TrackerBuilder(mTestUrl, 1336, \"b\");\n        assertThat(trackerBuilder1, is(trackerBuilder2));\n        assertThat(trackerBuilder1, is(not(trackerBuilder3)));\n    }\n\n    @Test\n    public void testHashCode() {\n        TrackerBuilder trackerBuilder = new TrackerBuilder(mTestUrl, 1337, \"Tracker\");\n        int result = mTestUrl.hashCode();\n        result = 31 * result + 1337;\n        result = 31 * result + \"Tracker\".hashCode();\n        assertThat(result, is(trackerBuilder.hashCode()));\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/TrackerTest.java",
    "content": "package org.matomo.sdk;\n\nimport android.content.Context;\nimport android.content.SharedPreferences;\n\nimport org.junit.Before;\nimport org.junit.Test;\nimport org.matomo.sdk.dispatcher.DispatchMode;\nimport org.matomo.sdk.dispatcher.Dispatcher;\nimport org.matomo.sdk.dispatcher.DispatcherFactory;\nimport org.matomo.sdk.extra.TrackHelper;\nimport org.matomo.sdk.tools.DeviceHelper;\nimport org.mockito.ArgumentCaptor;\nimport org.mockito.Mock;\nimport org.mockito.MockitoAnnotations;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Random;\nimport java.util.UUID;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.prefs.Preferences;\n\nimport testhelpers.TestHelper;\nimport testhelpers.TestPreferences;\n\nimport static org.hamcrest.MatcherAssert.assertThat;\nimport static org.hamcrest.Matchers.notNullValue;\nimport static org.hamcrest.Matchers.nullValue;\nimport static org.hamcrest.core.Is.is;\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertFalse;\nimport static org.junit.Assert.assertNotEquals;\nimport static org.junit.Assert.assertNotNull;\nimport static org.junit.Assert.assertNull;\nimport static org.junit.Assert.assertTrue;\nimport static org.junit.Assert.fail;\nimport static org.matomo.sdk.QueryParams.FIRST_VISIT_TIMESTAMP;\nimport static org.matomo.sdk.QueryParams.PREVIOUS_VISIT_TIMESTAMP;\nimport static org.matomo.sdk.QueryParams.SESSION_START;\nimport static org.matomo.sdk.QueryParams.TOTAL_NUMBER_OF_VISITS;\nimport static org.matomo.sdk.QueryParams.VISITOR_ID;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.doAnswer;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport androidx.annotation.Nullable;\n\n\n@SuppressWarnings(\"PointlessArithmeticExpression\")\npublic class TrackerTest {\n    ArgumentCaptor<TrackMe> mCaptor = ArgumentCaptor.forClass(TrackMe.class);\n    @Mock Matomo mMatomo;\n    @Mock Context mContext;\n    @Mock Dispatcher mDispatcher;\n    @Mock DispatcherFactory mDispatcherFactory;\n    @Mock DeviceHelper mDeviceHelper;\n    SharedPreferences mTrackerPreferences = new TestPreferences();\n    SharedPreferences mPreferences = new TestPreferences();\n    @Mock TrackerBuilder mTrackerBuilder;\n\n    @Before\n    public void setup() {\n        MockitoAnnotations.openMocks(this);\n        when(mMatomo.getContext()).thenReturn(mContext);\n        when(mMatomo.getTrackerPreferences(any(Tracker.class))).thenReturn(mTrackerPreferences);\n        when(mMatomo.getPreferences()).thenReturn(mPreferences);\n        when(mMatomo.getDispatcherFactory()).thenReturn(mDispatcherFactory);\n        when(mDispatcherFactory.build(any(Tracker.class))).thenReturn(mDispatcher);\n        when(mMatomo.getDeviceHelper()).thenReturn(mDeviceHelper);\n        when(mDeviceHelper.getResolution()).thenReturn(new int[]{480, 800});\n        when(mDeviceHelper.getUserAgent()).thenReturn(\"aUserAgent\");\n        when(mDeviceHelper.getUserLanguage()).thenReturn(\"en\");\n\n        String mApiUrl = \"http://example.com\";\n        when(mTrackerBuilder.getApiUrl()).thenReturn(mApiUrl);\n        int mSiteId = 11;\n        when(mTrackerBuilder.getSiteId()).thenReturn(mSiteId);\n        String mTrackerName = \"Default Tracker\";\n        when(mTrackerBuilder.getTrackerName()).thenReturn(mTrackerName);\n        when(mTrackerBuilder.getApplicationBaseUrl()).thenReturn(\"http://this.is.our.package/\");\n\n        mTrackerPreferences.edit().clear();\n        mPreferences.edit().clear();\n    }\n\n    @Test\n    public void testGetPreferences() {\n        Tracker tracker1 = new Tracker(mMatomo, mTrackerBuilder);\n        verify(mMatomo).getTrackerPreferences(tracker1);\n    }\n\n    /**\n     * Issue \"Tracker.trackEvent should default URL to the last viewed\"\n     * <a href=\"https://github.com/matomo-org/matomo-sdk-android/issues/92\">Go to Github issue</a>\n     */\n    @Test\n    public void testLastScreenUrl() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n\n        tracker.track(new TrackMe());\n        verify(mDispatcher).submit(mCaptor.capture());\n        assertEquals(\"http://this.is.our.package/\", mCaptor.getValue().get(QueryParams.URL_PATH));\n\n        tracker.track(new TrackMe().set(QueryParams.URL_PATH, \"http://some.thing.com/foo/bar\"));\n        verify(mDispatcher, times(2)).submit(mCaptor.capture());\n        assertEquals(\"http://some.thing.com/foo/bar\", mCaptor.getValue().get(QueryParams.URL_PATH));\n\n        tracker.track(new TrackMe().set(QueryParams.URL_PATH, \"http://some.other/thing\"));\n        verify(mDispatcher, times(3)).submit(mCaptor.capture());\n        assertEquals(\"http://some.other/thing\", mCaptor.getValue().get(QueryParams.URL_PATH));\n\n        tracker.track(new TrackMe());\n        verify(mDispatcher, times(4)).submit(mCaptor.capture());\n        assertEquals(\"http://some.other/thing\", mCaptor.getValue().get(QueryParams.URL_PATH));\n\n        tracker.track(new TrackMe().set(QueryParams.URL_PATH, \"thang\"));\n        verify(mDispatcher, times(5)).submit(mCaptor.capture());\n        assertEquals(\"http://this.is.our.package/thang\", mCaptor.getValue().get(QueryParams.URL_PATH));\n    }\n\n    @Test\n    public void testSetDispatchInterval() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        tracker.setDispatchInterval(1);\n        verify(mDispatcher).setDispatchInterval(1);\n        tracker.getDispatchInterval();\n        verify(mDispatcher).getDispatchInterval();\n    }\n\n    @Test\n    public void testSetDispatchTimeout() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        int timeout = 1337;\n        tracker.setDispatchTimeout(timeout);\n        verify(mDispatcher).setConnectionTimeOut(timeout);\n        tracker.getDispatchTimeout();\n        verify(mDispatcher).getConnectionTimeOut();\n    }\n\n    @Test\n    public void testGetOfflineCacheAge_defaultValue() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        assertEquals(24 * 60 * 60 * 1000, tracker.getOfflineCacheAge());\n    }\n\n    @Test\n    public void testSetOfflineCacheAge() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        tracker.setOfflineCacheAge(80085);\n        assertEquals(80085, tracker.getOfflineCacheAge());\n    }\n\n    @Test\n    public void testGetOfflineCacheSize_defaultValue() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        assertEquals(4 * 1024 * 1024, tracker.getOfflineCacheSize());\n    }\n\n    @Test\n    public void testSetOfflineCacheSize() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        tracker.setOfflineCacheSize(16 * 1000 * 1000);\n        assertEquals(16 * 1000 * 1000, tracker.getOfflineCacheSize());\n    }\n\n    @Test\n    public void testDispatchMode_default() {\n        mTrackerPreferences.edit().clear();\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        assertEquals(DispatchMode.ALWAYS, tracker.getDispatchMode());\n        verify(mDispatcher, times(1)).setDispatchMode(DispatchMode.ALWAYS);\n    }\n\n    @Test\n    public void testDispatchMode_change() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        tracker.setDispatchMode(DispatchMode.WIFI_ONLY);\n        assertEquals(DispatchMode.WIFI_ONLY, tracker.getDispatchMode());\n        verify(mDispatcher, times(1)).setDispatchMode(DispatchMode.WIFI_ONLY);\n    }\n\n    @Test\n    public void testDispatchMode_fallback() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        tracker.getPreferences().edit().putString(Tracker.PREF_KEY_DISPATCHER_MODE, \"lol\").apply();\n        assertEquals(DispatchMode.ALWAYS, tracker.getDispatchMode());\n        verify(mDispatcher, times(1)).setDispatchMode(DispatchMode.ALWAYS);\n    }\n\n    @Test\n    public void testSetDispatchMode_propagation() {\n        mTrackerPreferences.edit().clear();\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        verify(mDispatcher, times(1)).setDispatchMode(any());\n    }\n\n    @Test\n    public void testSetDispatchMode_propagation_change() {\n        mTrackerPreferences.edit().clear();\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        tracker.setDispatchMode(DispatchMode.WIFI_ONLY);\n        tracker.setDispatchMode(DispatchMode.WIFI_ONLY);\n        assertEquals(DispatchMode.WIFI_ONLY, tracker.getDispatchMode());\n        verify(mDispatcher, times(2)).setDispatchMode(DispatchMode.WIFI_ONLY);\n        verify(mDispatcher, times(3)).setDispatchMode(any());\n    }\n\n    @Test\n    public void testSetDispatchMode_exception() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        tracker.setDispatchMode(DispatchMode.WIFI_ONLY); // This is persisted\n        tracker.setDispatchMode(DispatchMode.EXCEPTION); // This isn't\n        assertEquals(DispatchMode.EXCEPTION, tracker.getDispatchMode());\n        verify(mDispatcher, times(1)).setDispatchMode(DispatchMode.EXCEPTION);\n\n        tracker = new Tracker(mMatomo, mTrackerBuilder);\n        assertEquals(DispatchMode.WIFI_ONLY, tracker.getDispatchMode());\n    }\n\n    @Test\n    public void testsetDispatchGzip() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        tracker.setDispatchGzipped(true);\n        verify(mDispatcher).setDispatchGzipped(true);\n    }\n\n    @Test\n    public void testOptOut_set() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        tracker.setOptOut(true);\n        assertTrue(tracker.isOptOut());\n        tracker.setOptOut(false);\n        assertFalse(tracker.isOptOut());\n    }\n\n    @Test\n    public void testOptOut_init() {\n        mTrackerPreferences.edit().putBoolean(Tracker.PREF_KEY_TRACKER_OPTOUT, false).apply();\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        assertFalse(tracker.isOptOut());\n        mTrackerPreferences.edit().putBoolean(Tracker.PREF_KEY_TRACKER_OPTOUT, true).apply();\n        tracker = new Tracker(mMatomo, mTrackerBuilder);\n        assertTrue(tracker.isOptOut());\n    }\n\n    @Test\n    public void testDispatch() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        tracker.dispatch();\n        verify(mDispatcher).forceDispatch();\n        tracker.dispatch();\n        verify(mDispatcher, times(2)).forceDispatch();\n    }\n\n    @Test\n    public void testDispatch_optOut() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        tracker.setOptOut(true);\n        tracker.dispatch();\n        verify(mDispatcher, never()).forceDispatch();\n        tracker.setOptOut(false);\n        tracker.dispatch();\n        verify(mDispatcher).forceDispatch();\n    }\n\n    @Test\n    public void testGetSiteId() {\n        when(mTrackerBuilder.getSiteId()).thenReturn(11);\n        assertEquals(new Tracker(mMatomo, mTrackerBuilder).getSiteId(), 11);\n    }\n\n    @Test\n    public void testGetMatomo() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        assertEquals(mMatomo, tracker.getMatomo());\n    }\n\n    @Test\n    public void testSetURL() {\n        when(mTrackerBuilder.getApplicationBaseUrl()).thenReturn(\"http://test.com/\");\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n\n        TrackMe trackMe = new TrackMe();\n        tracker.track(trackMe);\n        assertEquals(\"http://test.com/\", trackMe.get(QueryParams.URL_PATH));\n\n        trackMe.set(QueryParams.URL_PATH, \"me\");\n        tracker.track(trackMe);\n        assertEquals(\"http://test.com/me\", trackMe.get(QueryParams.URL_PATH));\n\n        // override protocol\n        trackMe.set(QueryParams.URL_PATH, \"https://my.com/secure\");\n        tracker.track(trackMe);\n        assertEquals(\"https://my.com/secure\", trackMe.get(QueryParams.URL_PATH));\n    }\n\n    @Test\n    public void testApplicationDomain() {\n        when(mTrackerBuilder.getApplicationBaseUrl()).thenReturn(\"http://my-domain.com\");\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n\n        TrackHelper.track().screen(\"test/test\").title(\"Test title\").with(tracker);\n        verify(mDispatcher).submit(mCaptor.capture());\n        validateDefaultQuery(mCaptor.getValue());\n        assertEquals(\"http://my-domain.com/test/test\", mCaptor.getValue().get(QueryParams.URL_PATH));\n    }\n\n    @Test(expected = IllegalArgumentException.class)\n    public void testVisitorId_invalid_short() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        String tooShortVisitorId = \"0123456789ab\";\n        tracker.setVisitorId(tooShortVisitorId);\n        assertNotEquals(tooShortVisitorId, tracker.getVisitorId());\n    }\n\n    @Test(expected = IllegalArgumentException.class)\n    public void testVisitorId_invalid_long() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        String tooLongVisitorId = \"0123456789abcdefghi\";\n        tracker.setVisitorId(tooLongVisitorId);\n        assertNotEquals(tooLongVisitorId, tracker.getVisitorId());\n    }\n\n    @Test(expected = IllegalArgumentException.class)\n    public void testVisitorId_invalid_charset() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        String invalidCharacterVisitorId = \"01234-6789-ghief\";\n        tracker.setVisitorId(invalidCharacterVisitorId);\n        assertNotEquals(invalidCharacterVisitorId, tracker.getVisitorId());\n    }\n\n    @Test\n    public void testVisitorId_init() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        assertThat(tracker.getVisitorId(), is(notNullValue()));\n    }\n\n    @Test\n    public void testVisitorId_restore() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        assertThat(tracker.getVisitorId(), is(notNullValue()));\n        String visitorId = tracker.getVisitorId();\n\n        tracker = new Tracker(mMatomo, mTrackerBuilder);\n        assertThat(tracker.getVisitorId(), is(visitorId));\n    }\n\n    @Test\n    public void testVisitorId_dispatch() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        String visitorId = \"0123456789abcdef\";\n        tracker.setVisitorId(visitorId);\n        assertEquals(visitorId, tracker.getVisitorId());\n\n        tracker.track(new TrackMe());\n        verify(mDispatcher).submit(mCaptor.capture());\n        assertEquals(visitorId, mCaptor.getValue().get(QueryParams.VISITOR_ID));\n\n        tracker.track(new TrackMe());\n        verify(mDispatcher, times(2)).submit(mCaptor.capture());\n        assertEquals(visitorId, mCaptor.getValue().get(QueryParams.VISITOR_ID));\n    }\n\n    @Test\n    public void testUserID_init() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        assertNull(tracker.getDefaultTrackMe().get(QueryParams.USER_ID));\n        assertNull(tracker.getUserId());\n    }\n\n    @Test\n    public void testUserID_restore() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        assertNull(tracker.getUserId());\n        tracker.setUserId(\"cake\");\n        assertThat(tracker.getUserId(), is(\"cake\"));\n\n        tracker = new Tracker(mMatomo, mTrackerBuilder);\n        assertThat(tracker.getUserId(), is(\"cake\"));\n        assertThat(tracker.getDefaultTrackMe().get(QueryParams.USER_ID), is(\"cake\"));\n    }\n\n    @Test\n    public void testUserID_invalid() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        assertNull(tracker.getUserId());\n\n        tracker.setUserId(\"test\");\n        assertEquals(tracker.getUserId(), \"test\");\n\n        tracker.setUserId(\"\");\n        assertEquals(tracker.getUserId(), \"test\");\n\n        tracker.setUserId(null);\n        assertNull(tracker.getUserId());\n\n        String uuid = UUID.randomUUID().toString();\n        tracker.setUserId(uuid);\n        assertEquals(uuid, tracker.getUserId());\n        assertEquals(uuid, tracker.getUserId());\n    }\n\n    @Test\n    public void testUserID_dispatch() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        String uuid = UUID.randomUUID().toString();\n        tracker.setUserId(uuid);\n\n        tracker.track(new TrackMe());\n        verify(mDispatcher).submit(mCaptor.capture());\n        assertEquals(uuid, mCaptor.getValue().get(QueryParams.USER_ID));\n\n        tracker.track(new TrackMe());\n        verify(mDispatcher, times(2)).submit(mCaptor.capture());\n        assertEquals(uuid, mCaptor.getValue().get(QueryParams.USER_ID));\n    }\n\n    @Test\n    public void testGetResolution() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        TrackMe trackMe = new TrackMe();\n        tracker.track(trackMe);\n        verify(mDispatcher).submit(mCaptor.capture());\n        assertEquals(\"480x800\", mCaptor.getValue().get(QueryParams.SCREEN_RESOLUTION));\n    }\n\n    @Test\n    public void testSetNewSession() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        TrackMe trackMe = new TrackMe();\n        tracker.track(trackMe);\n        verify(mDispatcher).submit(mCaptor.capture());\n        assertEquals(\"1\", mCaptor.getValue().get(QueryParams.SESSION_START));\n\n        tracker.startNewSession();\n        TrackHelper.track().screen(\"\").with(tracker);\n        verify(mDispatcher, times(2)).submit(mCaptor.capture());\n        assertEquals(\"1\", mCaptor.getValue().get(QueryParams.SESSION_START));\n    }\n\n    @Test\n    public void testSetNewSessionRaceCondition() {\n        for (int retry = 0; retry < 5; retry++) {\n            final List<TrackMe> trackMes = Collections.synchronizedList(new ArrayList<>());\n            doAnswer(invocation -> {\n                trackMes.add(invocation.getArgument(0));\n                return null;\n            }).when(mDispatcher).submit(any(TrackMe.class));\n            final Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n            tracker.setDispatchInterval(0);\n            int count = 20;\n            for (int i = 0; i < count; i++) {\n                new Thread(() -> {\n                    TestHelper.sleep(10);\n                    TrackHelper.track().screen(\"Test\").with(tracker);\n                }).start();\n            }\n            TestHelper.sleep(500);\n            assertEquals(count, trackMes.size());\n            int found = 0;\n            for (TrackMe trackMe : trackMes) {\n                if (trackMe.get(QueryParams.SESSION_START) != null) found++;\n            }\n            assertEquals(1, found);\n        }\n    }\n\n    @Test\n    public void testSetSessionTimeout() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        tracker.setSessionTimeout(10000);\n\n        TrackHelper.track().screen(\"test1\").with(tracker);\n        assertThat(tracker.getLastEventX().get(QueryParams.SESSION_START), notNullValue());\n\n        TrackHelper.track().screen(\"test2\").with(tracker);\n        assertThat(tracker.getLastEventX().get(QueryParams.SESSION_START), nullValue());\n\n        tracker.setSessionTimeout(0);\n        TestHelper.sleep(1);\n        TrackHelper.track().screen(\"test3\").with(tracker);\n        assertThat(tracker.getLastEventX().get(QueryParams.SESSION_START), notNullValue());\n\n        tracker.setSessionTimeout(10000);\n        assertEquals(tracker.getSessionTimeout(), 10000);\n        TrackHelper.track().screen(\"test3\").with(tracker);\n        assertThat(tracker.getLastEventX().get(QueryParams.SESSION_START), nullValue());\n    }\n\n    @Test\n    public void testCheckSessionTimeout() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        tracker.setSessionTimeout(0);\n        TrackHelper.track().screen(\"test\").with(tracker);\n        verify(mDispatcher).submit(mCaptor.capture());\n        assertEquals(\"1\", mCaptor.getValue().get(QueryParams.SESSION_START));\n        TestHelper.sleep(1);\n        TrackHelper.track().screen(\"test\").with(tracker);\n        verify(mDispatcher, times(2)).submit(mCaptor.capture());\n        assertEquals(\"1\", mCaptor.getValue().get(QueryParams.SESSION_START));\n        tracker.setSessionTimeout(60000);\n        TrackHelper.track().screen(\"test\").with(tracker);\n        verify(mDispatcher, times(3)).submit(mCaptor.capture());\n        assertNull(mCaptor.getValue().get(SESSION_START));\n    }\n\n    @Test\n    public void testReset() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        Tracker.Callback callback = new Tracker.Callback() {\n            @Nullable\n            @Override\n            public TrackMe onTrack(TrackMe trackMe) {\n                return null;\n            }\n        };\n        tracker.addTrackingCallback(callback);\n        tracker.getDefaultTrackMe().set(QueryParams.VISIT_SCOPE_CUSTOM_VARIABLES, \"custom1\");\n        tracker.getDefaultTrackMe().set(QueryParams.CAMPAIGN_NAME, \"campaign_name\");\n        tracker.getDefaultTrackMe().set(QueryParams.CAMPAIGN_KEYWORD, \"campaign_keyword\");\n\n        TrackHelper.track().screen(\"test1\").with(tracker);\n        tracker.startNewSession();\n        TrackHelper.track().screen(\"test2\").with(tracker);\n\n        String preResetDefaultVisitorId = tracker.getDefaultTrackMe().get(VISITOR_ID);\n        String preResetFirstVisitTimestamp = tracker.getDefaultTrackMe().get(FIRST_VISIT_TIMESTAMP);\n        String preResetTotalNumberOfVisits = tracker.getDefaultTrackMe().get(TOTAL_NUMBER_OF_VISITS);\n        String preResetPreviousVisitTimestamp = tracker.getDefaultTrackMe().get(PREVIOUS_VISIT_TIMESTAMP);\n\n        tracker.reset();\n\n        SharedPreferences prefs = tracker.getPreferences();\n\n        assertNotEquals(preResetDefaultVisitorId, tracker.getVisitorId());\n        assertNotEquals(preResetDefaultVisitorId, tracker.getDefaultTrackMe().get(VISITOR_ID));\n        assertNotEquals(preResetDefaultVisitorId, prefs.getString(Tracker.PREF_KEY_TRACKER_VISITORID, \"\"));\n\n        assertNotEquals(preResetFirstVisitTimestamp, tracker.getDefaultTrackMe().get(FIRST_VISIT_TIMESTAMP));\n        assertNotEquals(Long.parseLong(preResetFirstVisitTimestamp), prefs.getLong(Tracker.PREF_KEY_TRACKER_FIRSTVISIT, -1));\n\n        assertNotEquals(preResetPreviousVisitTimestamp, tracker.getDefaultTrackMe().get(PREVIOUS_VISIT_TIMESTAMP));\n        assertNotEquals(Long.parseLong(preResetPreviousVisitTimestamp), prefs.getLong(Tracker.PREF_KEY_TRACKER_PREVIOUSVISIT, -1));\n\n        assertNotEquals(preResetTotalNumberOfVisits, tracker.getDefaultTrackMe().get(TOTAL_NUMBER_OF_VISITS));\n        assertNotEquals(preResetTotalNumberOfVisits, prefs.getString(Tracker.PREF_KEY_TRACKER_VISITCOUNT, \"\"));\n\n        assertNull(tracker.getDefaultTrackMe().get(QueryParams.VISIT_SCOPE_CUSTOM_VARIABLES));\n        assertNull(tracker.getDefaultTrackMe().get(QueryParams.CAMPAIGN_NAME));\n        assertNull(tracker.getDefaultTrackMe().get(QueryParams.CAMPAIGN_KEYWORD));\n    }\n\n    @Test\n    public void testTrackerEquals() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        TrackerBuilder builder2 = mock(TrackerBuilder.class);\n        when(builder2.getApiUrl()).thenReturn(\"http://localhost\");\n        when(builder2.getSiteId()).thenReturn(100);\n        when(builder2.getTrackerName()).thenReturn(\"Default Tracker\");\n        Tracker tracker2 = new Tracker(mMatomo, builder2);\n\n        TrackerBuilder builder3 = mock(TrackerBuilder.class);\n        when(builder3.getApiUrl()).thenReturn(\"http://example.com\");\n        when(builder3.getSiteId()).thenReturn(11);\n        when(builder3.getTrackerName()).thenReturn(\"Default Tracker\");\n        Tracker tracker3 = new Tracker(mMatomo, builder3);\n\n        assertNotNull(tracker);\n        assertNotEquals(tracker, tracker2);\n        assertEquals(tracker, tracker3);\n    }\n\n    @Test\n    public void testTrackerHashCode() {\n        assertEquals(new Tracker(mMatomo, mTrackerBuilder).hashCode(), new Tracker(mMatomo, mTrackerBuilder).hashCode());\n    }\n\n    @Test\n    public void testUrlPathCorrection() {\n        when(mTrackerBuilder.getApplicationBaseUrl()).thenReturn(\"https://package/\");\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        String[] paths = new String[]{null, \"\", \"/\",};\n        for (String path : paths) {\n            TrackMe trackMe = new TrackMe();\n            trackMe.set(QueryParams.URL_PATH, path);\n            tracker.track(trackMe);\n            assertEquals(\"https://package/\", trackMe.get(QueryParams.URL_PATH));\n        }\n    }\n\n    @Test\n    public void testSetUserAgent() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        TrackMe trackMe = new TrackMe();\n        tracker.track(trackMe);\n        assertEquals(\"aUserAgent\", trackMe.get(QueryParams.USER_AGENT));\n\n        // Custom developer specified useragent\n        trackMe = new TrackMe();\n        String customUserAgent = \"Mozilla/5.0 (Linux; U; Android 2.2.1; en-us; Nexus One Build/FRG83) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0\";\n        trackMe.set(QueryParams.USER_AGENT, customUserAgent);\n        tracker.track(trackMe);\n        assertEquals(customUserAgent, trackMe.get(QueryParams.USER_AGENT));\n\n        // Modifying default TrackMe, no USER_AGENT\n        trackMe = new TrackMe();\n        tracker.getDefaultTrackMe().set(QueryParams.USER_AGENT, null);\n        tracker.track(trackMe);\n        assertNull(trackMe.get(QueryParams.USER_AGENT));\n    }\n\n    @Test\n    public void testFirstVisitTimeStamp() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        assertEquals(-1, tracker.getPreferences().getLong(Tracker.PREF_KEY_TRACKER_FIRSTVISIT, -1));\n\n        TrackHelper.track().event(\"TestCategory\", \"TestAction\").with(tracker);\n        verify(mDispatcher).submit(mCaptor.capture());\n        TrackMe trackMe1 = mCaptor.getValue();\n        TestHelper.sleep(10);\n        // make sure we are tracking in seconds\n        assertTrue(Math.abs((System.currentTimeMillis() / 1000) - Long.parseLong(trackMe1.get(FIRST_VISIT_TIMESTAMP))) < 2);\n\n        tracker = new Tracker(mMatomo, mTrackerBuilder);\n        TrackHelper.track().event(\"TestCategory\", \"TestAction\").with(tracker);\n        verify(mDispatcher, times(2)).submit(mCaptor.capture());\n        TrackMe trackMe2 = mCaptor.getValue();\n        assertEquals(Long.parseLong(trackMe1.get(FIRST_VISIT_TIMESTAMP)), Long.parseLong(trackMe2.get(FIRST_VISIT_TIMESTAMP)));\n        assertEquals(tracker.getPreferences().getLong(Tracker.PREF_KEY_TRACKER_FIRSTVISIT, -1), Long.parseLong(trackMe1.get(FIRST_VISIT_TIMESTAMP)));\n    }\n\n    @Test\n    public void testTotalVisitCount() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        assertEquals(-1, tracker.getPreferences().getInt(Tracker.PREF_KEY_TRACKER_VISITCOUNT, -1));\n        assertNull(tracker.getDefaultTrackMe().get(QueryParams.TOTAL_NUMBER_OF_VISITS));\n\n        TrackHelper.track().event(\"TestCategory\", \"TestAction\").with(tracker);\n        verify(mDispatcher).submit(mCaptor.capture());\n        assertEquals(1, Integer.parseInt(mCaptor.getValue().get(QueryParams.TOTAL_NUMBER_OF_VISITS)));\n\n        tracker = new Tracker(mMatomo, mTrackerBuilder);\n        assertEquals(1, tracker.getPreferences().getLong(Tracker.PREF_KEY_TRACKER_VISITCOUNT, -1));\n        assertNull(tracker.getDefaultTrackMe().get(QueryParams.TOTAL_NUMBER_OF_VISITS));\n        TrackHelper.track().event(\"TestCategory\", \"TestAction\").with(tracker);\n        verify(mDispatcher, times(2)).submit(mCaptor.capture());\n        assertEquals(2, Integer.parseInt(mCaptor.getValue().get(QueryParams.TOTAL_NUMBER_OF_VISITS)));\n        assertEquals(2, tracker.getPreferences().getLong(Tracker.PREF_KEY_TRACKER_VISITCOUNT, -1));\n    }\n\n    @Test\n    public void testVisitCountMultipleThreads() throws Exception {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        int threadCount = 1000;\n        final CountDownLatch countDownLatch = new CountDownLatch(threadCount);\n        for (int i = 0; i < threadCount; i++) {\n            new Thread(() -> {\n                TestHelper.sleep(new Random().nextInt(20 - 0) + 0);\n                TrackHelper.track().event(\"TestCategory\", \"TestAction\").with(new Tracker(mMatomo, mTrackerBuilder));\n                countDownLatch.countDown();\n            }).start();\n        }\n        countDownLatch.await();\n        assertEquals(threadCount, mTrackerPreferences.getLong(Tracker.PREF_KEY_TRACKER_VISITCOUNT, 0));\n    }\n\n    @Test\n    public void testSessionStartRaceCondition() throws Exception {\n        final List<TrackMe> trackMes = Collections.synchronizedList(new ArrayList<>());\n        doAnswer(invocation -> {\n            trackMes.add(invocation.getArgument(0));\n            return null;\n        }).when(mDispatcher).submit(any(TrackMe.class));\n        when(mDispatcher.getConnectionTimeOut()).thenReturn(1000);\n        for (int i = 0; i < 1000; i++) {\n            trackMes.clear();\n            final Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n            final CountDownLatch countDownLatch = new CountDownLatch(10);\n            for (int j = 0; j < 10; j++) {\n                new Thread(() -> {\n                    try {\n                        TestHelper.sleep(new Random().nextInt(4 - 0) + 0);\n                        TrackMe trackMe = new TrackMe()\n                                .set(QueryParams.EVENT_ACTION, UUID.randomUUID().toString())\n                                .set(QueryParams.EVENT_CATEGORY, UUID.randomUUID().toString())\n                                .set(QueryParams.EVENT_NAME, UUID.randomUUID().toString())\n                                .set(QueryParams.EVENT_VALUE, 1);\n                        tracker.track(trackMe);\n                        countDownLatch.countDown();\n                    } catch (Exception e) {\n                        e.printStackTrace();\n                        fail();\n                    }\n                }).start();\n            }\n            countDownLatch.await();\n            for (TrackMe out : trackMes) {\n                if (trackMes.indexOf(out) == 0) {\n                    assertNotNull(i + \"#\" + out.toMap().size(), out.get(QueryParams.LANGUAGE));\n                    assertNotNull(out.get(FIRST_VISIT_TIMESTAMP));\n                    assertNotNull(out.get(SESSION_START));\n                } else {\n                    assertNull(out.get(FIRST_VISIT_TIMESTAMP));\n                    assertNull(out.get(SESSION_START));\n                }\n            }\n        }\n    }\n\n    @Test\n    public void testFirstVisitMultipleThreads() throws Exception {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        int threadCount = 100;\n        final CountDownLatch countDownLatch = new CountDownLatch(threadCount);\n        final List<Long> firstVisitTimes = Collections.synchronizedList(new ArrayList<>());\n        for (int i = 0; i < threadCount; i++) {\n            new Thread(() -> {\n                TestHelper.sleep(new Random().nextInt(20 - 0) + 0);\n                TrackHelper.track().event(\"TestCategory\", \"TestAction\").with(tracker);\n                long firstVisit = Long.parseLong(tracker.getDefaultTrackMe().get(FIRST_VISIT_TIMESTAMP));\n                firstVisitTimes.add(firstVisit);\n                countDownLatch.countDown();\n            }).start();\n        }\n        countDownLatch.await();\n        for (Long firstVisit : firstVisitTimes) assertEquals(firstVisitTimes.get(0), firstVisit);\n    }\n\n    @Test\n    public void testPreviousVisits() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        final List<Long> previousVisitTimes = new ArrayList<>();\n        for (int i = 0; i < 5; i++) {\n\n\n            TrackHelper.track().event(\"TestCategory\", \"TestAction\").with(tracker);\n            String previousVisit = tracker.getDefaultTrackMe().get(QueryParams.PREVIOUS_VISIT_TIMESTAMP);\n            if (previousVisit != null)\n                previousVisitTimes.add(Long.parseLong(previousVisit));\n            TestHelper.sleep(1010);\n\n        }\n        assertFalse(previousVisitTimes.contains(0L));\n        long lastTime = 0L;\n        for (Long time : previousVisitTimes) {\n            assertTrue(lastTime < time);\n            lastTime = time;\n        }\n    }\n\n    @Test\n    public void testPreviousVisit() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        // No timestamp yet\n        assertEquals(-1, tracker.getPreferences().getLong(Tracker.PREF_KEY_TRACKER_PREVIOUSVISIT, -1));\n        tracker = new Tracker(mMatomo, mTrackerBuilder);\n        TrackHelper.track().event(\"TestCategory\", \"TestAction\").with(tracker);\n        verify(mDispatcher).submit(mCaptor.capture());\n        long _startTime = System.currentTimeMillis() / 1000;\n        // There was no previous visit\n        assertNull(mCaptor.getValue().get(QueryParams.PREVIOUS_VISIT_TIMESTAMP));\n        TestHelper.sleep(1000);\n\n        // After the first visit we now have a timestamp for the previous visit\n        long previousVisit = tracker.getPreferences().getLong(Tracker.PREF_KEY_TRACKER_PREVIOUSVISIT, -1);\n        assertTrue(previousVisit - _startTime < 2000);\n        assertNotEquals(-1, previousVisit);\n        tracker = new Tracker(mMatomo, mTrackerBuilder);\n        TrackHelper.track().event(\"TestCategory\", \"TestAction\").with(tracker);\n        verify(mDispatcher, times(2)).submit(mCaptor.capture());\n        // Transmitted timestamp is the one from the first visit visit\n        assertEquals(previousVisit, Long.parseLong(mCaptor.getValue().get(QueryParams.PREVIOUS_VISIT_TIMESTAMP)));\n\n        TestHelper.sleep(1000);\n        tracker = new Tracker(mMatomo, mTrackerBuilder);\n        TrackHelper.track().event(\"TestCategory\", \"TestAction\").with(tracker);\n        verify(mDispatcher, times(3)).submit(mCaptor.capture());\n        // Now the timestamp changed as this is the 3rd visit.\n        assertNotEquals(previousVisit, Long.parseLong(mCaptor.getValue().get(QueryParams.PREVIOUS_VISIT_TIMESTAMP)));\n        TestHelper.sleep(1000);\n\n        previousVisit = tracker.getPreferences().getLong(Tracker.PREF_KEY_TRACKER_PREVIOUSVISIT, -1);\n        tracker = new Tracker(mMatomo, mTrackerBuilder);\n        TrackHelper.track().event(\"TestCategory\", \"TestAction\").with(tracker);\n        verify(mDispatcher, times(4)).submit(mCaptor.capture());\n        // Just make sure the timestamp in the 4th visit is from the 3rd visit\n        assertEquals(previousVisit, Long.parseLong(mCaptor.getValue().get(QueryParams.PREVIOUS_VISIT_TIMESTAMP)));\n\n        // Test setting a custom timestamp\n        TrackMe custom = new TrackMe();\n        custom.set(QueryParams.PREVIOUS_VISIT_TIMESTAMP, 1000L);\n        tracker.track(custom);\n        verify(mDispatcher, times(5)).submit(mCaptor.capture());\n        assertEquals(1000L, Long.parseLong(mCaptor.getValue().get(QueryParams.PREVIOUS_VISIT_TIMESTAMP)));\n    }\n\n    @Test\n    public void testTrackingCallback() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        Tracker.Callback callback = mock(Tracker.Callback.class);\n\n        TrackMe pre = new TrackMe();\n        tracker.track(pre);\n        verify(mDispatcher).submit(pre);\n        verify(callback, never()).onTrack(mCaptor.capture());\n\n        reset(mDispatcher, callback);\n        tracker.addTrackingCallback(callback);\n        tracker.track(new TrackMe());\n        verify(callback).onTrack(mCaptor.capture());\n        verify(mDispatcher, never()).submit(any());\n\n        reset(mDispatcher, callback);\n        TrackMe orig = new TrackMe();\n        TrackMe replaced = new TrackMe().set(\"some\", \"thing\");\n        when(callback.onTrack(orig)).thenReturn(replaced);\n        tracker.track(orig);\n        verify(callback).onTrack(orig);\n        verify(mDispatcher).submit(replaced);\n\n        reset(mDispatcher, callback);\n        TrackMe post = new TrackMe();\n        tracker.removeTrackingCallback(callback);\n        tracker.track(post);\n        verify(callback, never()).onTrack(any());\n        verify(mDispatcher).submit(post);\n    }\n\n    @Test\n    public void testTrackingCallbacks() {\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        Tracker.Callback callback1 = mock(Tracker.Callback.class);\n        Tracker.Callback callback2 = mock(Tracker.Callback.class);\n\n        TrackMe orig = new TrackMe();\n        TrackMe replaced = new TrackMe();\n        when(callback1.onTrack(orig)).thenReturn(replaced);\n        when(callback2.onTrack(replaced)).thenReturn(replaced);\n\n        tracker.addTrackingCallback(callback1);\n        tracker.addTrackingCallback(callback1);\n        tracker.addTrackingCallback(callback2);\n        tracker.track(orig);\n        verify(callback1).onTrack(orig);\n        verify(callback2).onTrack(replaced);\n        verify(mDispatcher).submit(replaced);\n\n        tracker.removeTrackingCallback(callback1);\n        tracker.track(orig);\n\n        verify(callback2).onTrack(orig);\n    }\n\n    private static void validateDefaultQuery(TrackMe params) {\n        assertEquals(params.get(QueryParams.SITE_ID), \"11\");\n        assertEquals(params.get(QueryParams.RECORD), \"1\");\n        assertEquals(params.get(QueryParams.SEND_IMAGE), \"0\");\n        assertEquals(params.get(QueryParams.VISITOR_ID).length(), 16);\n        assertTrue(params.get(QueryParams.URL_PATH).startsWith(\"http://\"));\n        assertTrue(Integer.parseInt(params.get(QueryParams.RANDOM_NUMBER)) > 0);\n    }\n\n    @Test\n    public void testCustomDispatcherFactory() {\n        Dispatcher dispatcher = mock(Dispatcher.class);\n        DispatcherFactory factory = mock(DispatcherFactory.class);\n        when(factory.build(any(Tracker.class))).thenReturn(dispatcher);\n        when(mMatomo.getDispatcherFactory()).thenReturn(factory);\n        Tracker tracker = new Tracker(mMatomo, mTrackerBuilder);\n        verify(factory).build(tracker);\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/dispatcher/DefaultDispatcherTest.java",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\npackage org.matomo.sdk.dispatcher;\n\nimport org.json.JSONArray;\nimport org.junit.Before;\nimport org.junit.Test;\nimport org.matomo.sdk.QueryParams;\nimport org.matomo.sdk.TrackMe;\nimport org.matomo.sdk.tools.Connectivity;\nimport org.mockito.ArgumentMatchers;\nimport org.mockito.Mock;\nimport org.mockito.MockitoAnnotations;\nimport org.mockito.stubbing.Answer;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Random;\nimport java.util.UUID;\nimport java.util.concurrent.LinkedBlockingQueue;\nimport java.util.concurrent.Semaphore;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicInteger;\n\nimport testhelpers.BaseTest;\nimport testhelpers.TestHelper;\n\nimport static org.awaitility.Awaitility.await;\nimport static org.hamcrest.MatcherAssert.assertThat;\nimport static org.hamcrest.core.Is.is;\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertFalse;\nimport static org.junit.Assert.assertNotEquals;\nimport static org.junit.Assert.assertTrue;\nimport static org.junit.Assert.fail;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyBoolean;\nimport static org.mockito.Mockito.doAnswer;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.timeout;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\n\npublic class DefaultDispatcherTest extends BaseTest {\n\n    DefaultDispatcher mDispatcher;\n    @Mock EventCache mEventCache;\n    @Mock PacketSender mPacketSender;\n    @Mock Connectivity mConnectivity;\n    final String mApiUrl = \"http://example.com\";\n\n    final LinkedBlockingQueue<Event> mEventCacheData = new LinkedBlockingQueue<>();\n\n    @Before\n    public void setup() throws Exception {\n        super.setup();\n        MockitoAnnotations.openMocks(this);\n        when(mConnectivity.isConnected()).thenReturn(true);\n        when(mConnectivity.getType()).thenReturn(Connectivity.Type.MOBILE);\n\n        doAnswer(invocation -> {\n            mEventCacheData.add(invocation.getArgument(0));\n            return null;\n        }).when(mEventCache).add(any(Event.class));\n        when(mEventCache.isEmpty()).then((Answer<Boolean>) invocation -> mEventCacheData.isEmpty());\n        when(mEventCache.updateState(anyBoolean())).thenAnswer(invocation -> (Boolean) invocation.getArgument(0) && !mEventCacheData.isEmpty());\n        doAnswer(invocation -> {\n            List<Event> drainTarget = invocation.getArgument(0);\n            mEventCacheData.drainTo(drainTarget);\n            return null;\n        }).when(mEventCache).drainTo(ArgumentMatchers.anyList());\n        doAnswer(invocation -> {\n            List<Event> toRequeue = invocation.getArgument(0);\n            mEventCacheData.addAll(toRequeue);\n            return null;\n        }).when(mEventCache).requeue(ArgumentMatchers.anyList());\n        doAnswer(invocation -> {\n            mEventCacheData.clear();\n            return null;\n        }).when(mEventCache).clear();\n        mDispatcher = new DefaultDispatcher(mEventCache, mConnectivity, new PacketFactory(mApiUrl), mPacketSender);\n    }\n\n    @Test\n    public void testClear() {\n        mDispatcher.clear();\n        verify(mEventCache).clear();\n    }\n\n    @Test\n    public void testClear_cleanExit() {\n        List<Packet> dryRunData = Collections.synchronizedList(new ArrayList<>());\n        mDispatcher.setDryRunTarget(dryRunData);\n        mDispatcher.submit(getTestEvent());\n        mDispatcher.forceDispatch();\n\n        TestHelper.sleep(100);\n        assertThat(dryRunData.size(), is(1));\n        dryRunData.clear();\n\n        when(mConnectivity.isConnected()).thenReturn(false);\n        mDispatcher.submit(getTestEvent());\n\n        TestHelper.sleep(100);\n        assertThat(mEventCacheData.size(), is(1));\n\n        mDispatcher.clear();\n\n        when(mConnectivity.isConnected()).thenReturn(true);\n        mDispatcher.forceDispatch();\n\n        TestHelper.sleep(100);\n        assertThat(dryRunData.size(), is(0));\n    }\n\n    @Test\n    public void testGetDispatchMode() {\n        assertEquals(DispatchMode.ALWAYS, mDispatcher.getDispatchMode());\n        mDispatcher.setDispatchMode(DispatchMode.WIFI_ONLY);\n        assertEquals(DispatchMode.WIFI_ONLY, mDispatcher.getDispatchMode());\n    }\n\n    @Test\n    public void testDispatchMode_wifiOnly() {\n        List<Packet> dryRunData = Collections.synchronizedList(new ArrayList<>());\n        mDispatcher.setDryRunTarget(dryRunData);\n        when(mConnectivity.getType()).thenReturn(Connectivity.Type.MOBILE);\n\n        mDispatcher.setDispatchMode(DispatchMode.WIFI_ONLY);\n        mDispatcher.submit(getTestEvent());\n        mDispatcher.forceDispatch();\n\n        verify(mEventCache, timeout(1000)).updateState(false);\n        verify(mEventCache, never()).drainTo(ArgumentMatchers.anyList());\n\n        when(mConnectivity.getType()).thenReturn(Connectivity.Type.WIFI);\n        mDispatcher.forceDispatch();\n        await().atMost(1, TimeUnit.SECONDS).until(dryRunData::size, is(1));\n\n        verify(mEventCache).updateState(true);\n        verify(mEventCache).drainTo(ArgumentMatchers.anyList());\n    }\n\n    @Test\n    public void testConnectivityChange() {\n        List<Packet> dryRunData = Collections.synchronizedList(new ArrayList<>());\n        mDispatcher.setDryRunTarget(dryRunData);\n        when(mConnectivity.isConnected()).thenReturn(false);\n\n        mDispatcher.submit(getTestEvent());\n        mDispatcher.forceDispatch();\n\n        verify(mEventCache, timeout(1000)).add(any());\n        verify(mEventCache, never()).drainTo(ArgumentMatchers.anyList());\n        assertThat(dryRunData.size(), is(0));\n\n        when(mConnectivity.isConnected()).thenReturn(true);\n        mDispatcher.forceDispatch();\n\n        await().atMost(1, TimeUnit.SECONDS).until(dryRunData::size, is(1));\n\n        verify(mEventCache).updateState(true);\n        verify(mEventCache).drainTo(ArgumentMatchers.anyList());\n    }\n\n    @Test\n    public void testGetDispatchGzipped() {\n        assertFalse(mDispatcher.getDispatchGzipped());\n        mDispatcher.setDispatchGzipped(true);\n        assertTrue(mDispatcher.getDispatchGzipped());\n        verify(mPacketSender).setGzipData(true);\n    }\n\n    @Test\n    public void testDefaultConnectionTimeout() {\n        assertEquals(Dispatcher.DEFAULT_CONNECTION_TIMEOUT, mDispatcher.getConnectionTimeOut());\n    }\n\n    @Test\n    public void testSetConnectionTimeout() {\n        mDispatcher.setConnectionTimeOut(100);\n        assertEquals(100, mDispatcher.getConnectionTimeOut());\n        verify(mPacketSender).setTimeout(100);\n    }\n\n    @Test\n    public void testDefaultDispatchInterval() {\n        assertEquals(Dispatcher.DEFAULT_DISPATCH_INTERVAL, mDispatcher.getDispatchInterval());\n    }\n\n    @Test\n    public void testForceDispatchTwice() {\n        mDispatcher.setDispatchInterval(-1);\n        mDispatcher.setConnectionTimeOut(20);\n        mDispatcher.submit(getTestEvent());\n\n        assertTrue(mDispatcher.forceDispatch());\n        assertFalse(mDispatcher.forceDispatch());\n    }\n\n    @Test\n    public void testMultiThreadDispatch() throws Exception {\n        List<Packet> dryRunData = Collections.synchronizedList(new ArrayList<>());\n        mDispatcher.setDryRunTarget(dryRunData);\n        mDispatcher.setDispatchInterval(20);\n\n        final int threadCount = 20;\n        final int queryCount = 100;\n        final List<String> createdEvents = Collections.synchronizedList(new ArrayList<>());\n        launchTestThreads(mApiUrl, mDispatcher, threadCount, queryCount, createdEvents);\n\n        checkForMIAs(threadCount * queryCount, createdEvents, dryRunData);\n    }\n\n    @Test\n    public void testForceDispatch() throws Exception {\n        List<Packet> dryRunData = Collections.synchronizedList(new ArrayList<>());\n        mDispatcher.setDryRunTarget(dryRunData);\n        mDispatcher.setDispatchInterval(-1L);\n\n        final int threadCount = 10;\n        final int queryCount = 10;\n        final List<String> createdEvents = Collections.synchronizedList(new ArrayList<>());\n        launchTestThreads(mApiUrl, mDispatcher, threadCount, queryCount, createdEvents);\n        TestHelper.sleep(500);\n        assertEquals(threadCount * queryCount, createdEvents.size());\n        assertEquals(0, dryRunData.size());\n        mDispatcher.forceDispatch();\n\n        checkForMIAs(threadCount * queryCount, createdEvents, dryRunData);\n    }\n\n    @Test\n    public void testBatchDispatch() throws Exception {\n        List<Packet> dryRunData = Collections.synchronizedList(new ArrayList<>());\n        mDispatcher.setDryRunTarget(dryRunData);\n        mDispatcher.setDispatchInterval(1500);\n\n        final int threadCount = 5;\n        final int queryCount = 5;\n        final List<String> createdEvents = Collections.synchronizedList(new ArrayList<>());\n        launchTestThreads(mApiUrl, mDispatcher, threadCount, queryCount, createdEvents);\n\n        await().atMost(2, TimeUnit.SECONDS).until(createdEvents::size, is(threadCount * queryCount));\n        assertEquals(0, dryRunData.size());\n\n        await().atMost(2, TimeUnit.SECONDS).until(createdEvents::size, is(threadCount * queryCount));\n        checkForMIAs(threadCount * queryCount, createdEvents, dryRunData);\n    }\n\n    @Test\n    public void testBlockingDispatch() throws Exception {\n        List<Packet> dryRunData = Collections.synchronizedList(new ArrayList<>());\n        mDispatcher.setDryRunTarget(dryRunData);\n        mDispatcher.setDispatchInterval(-1);\n\n        final int threadCount = 5;\n        final int queryCount = 5;\n        final List<String> createdEvents = Collections.synchronizedList(new ArrayList<>());\n        launchTestThreads(mApiUrl, mDispatcher, threadCount, queryCount, createdEvents);\n        await().atMost(2, TimeUnit.SECONDS).until(createdEvents::size, is(threadCount * queryCount));\n\n        assertEquals(dryRunData.size(), 0);\n        assertEquals(createdEvents.size(), threadCount * queryCount);\n\n        mDispatcher.forceDispatchBlocking();\n\n        List<String> flattenedQueries = getFlattenedQueries(dryRunData);\n        assertEquals(flattenedQueries.size(), threadCount * queryCount);\n    }\n\n    @Test\n    public void testBlockingDispatchInFlight() throws Exception {\n        List<Packet> dryRunData = Collections.synchronizedList(new ArrayList<>());\n        mDispatcher.setDryRunTarget(dryRunData);\n        mDispatcher.setDispatchInterval(20);\n\n        final int threadCount = 5;\n        final int queryCount = 5;\n        final List<String> createdEvents = Collections.synchronizedList(new ArrayList<>());\n        launchTestThreads(mApiUrl, mDispatcher, threadCount, queryCount, createdEvents);\n        await().atMost(2, TimeUnit.SECONDS).until(createdEvents::size, is(threadCount * queryCount));\n\n        assertEquals(createdEvents.size(), threadCount * queryCount);\n        assertNotEquals(new ArrayList(dryRunData).size(), 0);\n\n        mDispatcher.forceDispatchBlocking();\n\n        List<String> flattenedQueries = getFlattenedQueries(dryRunData);\n        assertEquals(flattenedQueries.size(), threadCount * queryCount);\n    }\n\n    @Test\n    public void testBlockingDispatchCollision() throws Exception {\n        final Semaphore lock = new Semaphore(0);\n        final AtomicInteger eventCount = new AtomicInteger(0);\n\n        mDispatcher.setDispatchInterval(-1);\n\n        when(mPacketSender.send(any())).thenAnswer((Answer<Boolean>) invocation -> {\n            Packet packet = invocation.getArgument(0);\n\n            eventCount.addAndGet(packet.getEventCount());\n\n            lock.release();\n            Thread.sleep(100);\n\n            return true;\n        });\n\n        final int threadCount = 7;\n        final int queryCount = 13;\n        final List<String> createdEvents = Collections.synchronizedList(new ArrayList<>());\n        launchTestThreads(mApiUrl, mDispatcher, threadCount, queryCount, createdEvents);\n\n        await().atMost(2, TimeUnit.SECONDS).until(createdEvents::size, is(threadCount * queryCount));\n\n        mDispatcher.forceDispatch();\n\n        lock.acquire();\n\n        mDispatcher.forceDispatchBlocking();\n\n        assertEquals(eventCount.get(), threadCount * queryCount);\n    }\n\n    @Test\n    public void testBlockingDispatchExceptionMode() {\n        mDispatcher.setDispatchInterval(200);\n\n        final int threadCount = 5;\n        final int queryCount = 10;\n\n        final List<String> createdEvents = Collections.synchronizedList(new ArrayList<>());\n        launchTestThreads(mApiUrl, mDispatcher, threadCount, queryCount, createdEvents);\n\n        final AtomicInteger sentEvents = new AtomicInteger(0);\n\n        when(mPacketSender.send(any())).thenAnswer((Answer<Boolean>) invocation -> {\n            Packet packet = invocation.getArgument(0);\n            sentEvents.addAndGet(packet.getEventCount());\n\n            mDispatcher.setDispatchMode(DispatchMode.EXCEPTION);\n\n            return true;\n        });\n\n        await().atMost(2, TimeUnit.SECONDS).until(createdEvents::size, is(threadCount * queryCount));\n\n        mDispatcher.forceDispatchBlocking();\n\n        int sentEventCount = sentEvents.get();\n\n        assertEquals(sentEventCount, PacketFactory.PAGE_SIZE);\n        assertEquals(mEventCacheData.size() + sentEventCount, threadCount * queryCount);\n    }\n\n    @Test\n    public void testDispatchRetryWithBackoff() {\n        AtomicInteger cnt = new AtomicInteger(0);\n        when(mPacketSender.send(any())).then((Answer<Boolean>) invocation -> cnt.incrementAndGet() > 5);\n\n        mDispatcher.setDispatchInterval(100);\n        mDispatcher.submit(getTestEvent());\n\n        await().atLeast(100, TimeUnit.MILLISECONDS).until(() -> cnt.get() == 1);\n        await().atLeast(100, TimeUnit.MILLISECONDS).until(() -> cnt.get() == 2);\n\n        await().atMost(1900, TimeUnit.MILLISECONDS).until(() -> cnt.get() == 5);\n\n        mDispatcher.submit(getTestEvent());\n        await().atMost(150, TimeUnit.MILLISECONDS).until(() -> cnt.get() == 5);\n    }\n\n    @Test\n    public void testDispatchInterval() {\n        List<Packet> dryRunData = Collections.synchronizedList(new ArrayList<>());\n        mDispatcher.setDryRunTarget(dryRunData);\n        mDispatcher.setDispatchInterval(500);\n        assertThat(dryRunData.isEmpty(), is(true));\n        mDispatcher.submit(getTestEvent());\n        await().atLeast(500, TimeUnit.MILLISECONDS).until(() -> dryRunData.size() == 1);\n    }\n\n    @Test\n    public void testRandomDispatchIntervals() throws Exception {\n        final List<Packet> dryRunData = Collections.synchronizedList(new ArrayList<>());\n        mDispatcher.setDryRunTarget(dryRunData);\n\n        final int threadCount = 10;\n        final int queryCount = 100;\n        final List<String> createdEvents = Collections.synchronizedList(new ArrayList<>());\n\n        new Thread(() -> {\n            try {\n                while (getFlattenedQueries(new ArrayList<>(dryRunData)).size() != threadCount * queryCount) {\n                    mDispatcher.setDispatchInterval(new Random().nextInt(20 + 1) - 1);\n                }\n            } catch (Exception e) {e.printStackTrace();}\n        }).start();\n\n        launchTestThreads(mApiUrl, mDispatcher, threadCount, queryCount, createdEvents);\n\n        checkForMIAs(threadCount * queryCount, createdEvents, dryRunData);\n    }\n\n    public static void checkForMIAs(int expectedEvents, List<String> createdEvents, List<Packet> dryRunOutput) throws Exception {\n        int previousEventCount = 0;\n        int previousFlatQueryCount = 0;\n        List<String> flattenedQueries;\n        int nothingHappenedCounter = 0;\n        while (true) {\n            TestHelper.sleep(100);\n            flattenedQueries = getFlattenedQueries(new ArrayList<>(dryRunOutput));\n            if (flattenedQueries.size() == expectedEvents) {\n                break;\n            } else {\n                flattenedQueries = getFlattenedQueries(new ArrayList<>(dryRunOutput));\n                int currentEventCount = createdEvents.size();\n                int currentFlatQueryCount = flattenedQueries.size();\n                if (previousEventCount != currentEventCount && previousFlatQueryCount != currentFlatQueryCount) {\n                    previousEventCount = currentEventCount;\n                    previousFlatQueryCount = currentFlatQueryCount;\n                    nothingHappenedCounter = 0;\n                } else {\n                    nothingHappenedCounter++;\n                    if (nothingHappenedCounter > 50)\n                        fail(\"Test seems stuck, nothing happens\");\n                }\n            }\n        }\n\n        assertEquals(flattenedQueries.size(), expectedEvents);\n        assertEquals(createdEvents.size(), expectedEvents);\n\n        // We are done, lets make sure can find all send queries in our dispatched results\n        while (!createdEvents.isEmpty()) {\n            String query = createdEvents.remove(0);\n            assertTrue(flattenedQueries.remove(query));\n        }\n        assertTrue(true);\n        assertTrue(flattenedQueries.isEmpty());\n    }\n\n    public static void launchTestThreads(final String apiUrl, final Dispatcher dispatcher, int threadCount, final int queryCount, final List<String> createdQueries) {\n        for (int i = 0; i < threadCount; i++) {\n            new Thread(() -> {\n                try {\n                    for (int j = 0; j < queryCount; j++) {\n                        TestHelper.sleep(new Random().nextInt(20));\n                        TrackMe trackMe = new TrackMe()\n                                .set(QueryParams.EVENT_ACTION, UUID.randomUUID().toString())\n                                .set(QueryParams.EVENT_CATEGORY, UUID.randomUUID().toString())\n                                .set(QueryParams.EVENT_NAME, UUID.randomUUID().toString())\n                                .set(QueryParams.EVENT_VALUE, j);\n                        dispatcher.submit(trackMe);\n                        createdQueries.add(apiUrl + new Event(trackMe.toMap()).getEncodedQuery());\n                    }\n                } catch (Exception e) {\n                    e.printStackTrace();\n                    fail();\n                }\n            }).start();\n        }\n    }\n\n    public static List<String> getFlattenedQueries(List<Packet> packets) throws Exception {\n        List<String> flattenedQueries = new ArrayList<>();\n        for (Packet request : packets) {\n            if (request.getPostData() != null) {\n                JSONArray batchedRequests = request.getPostData().getJSONArray(\"requests\");\n                for (int json = 0; json < batchedRequests.length(); json++) {\n                    String unbatchedRequest = request.getTargetURL() + batchedRequests.get(json).toString();\n                    flattenedQueries.add(unbatchedRequest);\n                }\n            } else {\n                flattenedQueries.add(request.getTargetURL());\n            }\n        }\n        return flattenedQueries;\n    }\n\n    public static TrackMe getTestEvent() {\n        TrackMe trackMe = new TrackMe();\n        trackMe.set(QueryParams.SESSION_START, 1);\n        return trackMe;\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/dispatcher/DefaultPacketSenderTest.java",
    "content": "package org.matomo.sdk.dispatcher;\n\nimport org.json.JSONObject;\nimport org.junit.After;\nimport org.junit.Before;\nimport org.junit.Test;\nimport org.junit.runner.RunWith;\nimport org.mockito.junit.MockitoJUnitRunner;\n\nimport okhttp3.mockwebserver.Dispatcher;\nimport okhttp3.mockwebserver.MockResponse;\nimport okhttp3.mockwebserver.MockWebServer;\nimport okhttp3.mockwebserver.RecordedRequest;\nimport testhelpers.BaseTest;\nimport testhelpers.TestHelper;\n\nimport static org.hamcrest.MatcherAssert.assertThat;\nimport static org.hamcrest.Matchers.not;\nimport static org.hamcrest.Matchers.nullValue;\nimport static org.hamcrest.core.Is.is;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport androidx.annotation.NonNull;\n\n@RunWith(MockitoJUnitRunner.class)\npublic class DefaultPacketSenderTest extends BaseTest {\n\n    DefaultPacketSender mDefaultPacketSender;\n    MockWebServer mMockWebServer;\n\n    @Before\n    public void setup() throws Exception {\n        super.setup();\n        mDefaultPacketSender = new DefaultPacketSender();\n        mMockWebServer = new MockWebServer();\n    }\n\n    @After\n    public void tearDown() throws Exception {\n        mMockWebServer.close();\n        super.tearDown();\n    }\n\n    @Test\n    public void testDispatch() throws Exception {\n        mMockWebServer.start();\n\n        Packet packet = mock(Packet.class);\n        when(packet.getTargetURL()).thenReturn(mMockWebServer.url(\"/\").toString());\n        JSONObject jsonObject = new JSONObject();\n        jsonObject.put(\"key\", \"value\");\n        when(packet.getPostData()).thenReturn(jsonObject);\n\n        mMockWebServer.enqueue(new MockResponse());\n        mDefaultPacketSender.send(packet);\n\n        final RecordedRequest recordedRequest = mMockWebServer.takeRequest();\n        assertThat(recordedRequest, is(not(nullValue())));\n\n        String body = recordedRequest.getBody().readUtf8();\n        assertThat(jsonObject.toString(), is(body));\n        assertThat(recordedRequest.getHeader(\"Content-Encoding\"), is((nullValue())));\n    }\n\n    @Test\n    public void testGzip() throws Exception {\n        mMockWebServer.start();\n\n        Packet packet = mock(Packet.class);\n        when(packet.getTargetURL()).thenReturn(mMockWebServer.url(\"/\").toString());\n        JSONObject jsonObject = new JSONObject();\n        jsonObject.put(\"key\", \"value\");\n        when(packet.getPostData()).thenReturn(jsonObject);\n\n        mMockWebServer.enqueue(new MockResponse());\n        mDefaultPacketSender.send(packet);\n        assertThat(mMockWebServer.takeRequest().getHeader(\"Content-Encoding\"), is((nullValue())));\n\n        mDefaultPacketSender.setGzipData(true);\n\n        mMockWebServer.enqueue(new MockResponse());\n        mDefaultPacketSender.send(packet);\n        assertThat(mMockWebServer.takeRequest().getHeader(\"Content-Encoding\"), is(\"gzip\"));\n    }\n\n    @Test\n    public void testTimeout() throws Exception {\n        mMockWebServer.start();\n\n        Packet packet = mock(Packet.class);\n        when(packet.getTargetURL()).thenReturn(mMockWebServer.url(\"/\").toString());\n\n        mDefaultPacketSender.setTimeout(50);\n        mMockWebServer.setDispatcher(new Dispatcher() {\n            @NonNull\n            @Override\n            public MockResponse dispatch(@NonNull RecordedRequest recordedRequest) {\n                TestHelper.sleep(100);\n                return new MockResponse();\n            }\n        });\n        assertThat(mDefaultPacketSender.send(packet), is(false));\n\n        mMockWebServer.setDispatcher(new Dispatcher() {\n            @NonNull\n            @Override\n            public MockResponse dispatch(@NonNull RecordedRequest recordedRequest) {\n                return new MockResponse();\n            }\n        });\n        assertThat(mDefaultPacketSender.send(packet), is(true));\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/dispatcher/EventCacheTest.java",
    "content": "package org.matomo.sdk.dispatcher;\n\n\nimport org.junit.Before;\nimport org.junit.Test;\nimport org.junit.runner.RunWith;\nimport org.mockito.ArgumentMatchers;\nimport org.mockito.Mock;\nimport org.mockito.junit.MockitoJUnitRunner;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.List;\n\nimport testhelpers.BaseTest;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertFalse;\nimport static org.junit.Assert.assertTrue;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.spy;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\n@RunWith(MockitoJUnitRunner.class)\npublic class EventCacheTest extends BaseTest {\n\n    @Mock EventDiskCache mEventDiskCache;\n    EventCache mEventCache;\n\n    @Before\n    public void setup() throws Exception {\n        super.setup();\n        when(mEventDiskCache.isEmpty()).thenReturn(true);\n        mEventCache = spy(new EventCache(mEventDiskCache));\n    }\n\n    @Test\n    public void testClear() {\n        mEventCache.add(new Event(\"test\"));\n        mEventCache.clear();\n        verify(mEventDiskCache).uncache();\n        assertTrue(mEventCache.isEmpty());\n    }\n\n    @Test\n    public void testDrain_simple() {\n        assertTrue(mEventCache.isEmpty());\n        mEventCache.add(new Event(\"test\"));\n        assertFalse(mEventCache.isEmpty());\n        List<Event> events = new ArrayList<>();\n        mEventCache.drainTo(events);\n        assertEquals(\"test\", events.get(0).getEncodedQuery());\n        assertTrue(mEventCache.isEmpty());\n    }\n\n    @Test\n    public void testDrain_empty() {\n        List<Event> events = new ArrayList<>();\n        mEventCache.drainTo(events);\n        assertTrue(events.isEmpty());\n    }\n\n    @Test\n    public void testDrain_diskCache_empty() {\n        List<Event> events = new ArrayList<>();\n        mEventCache.drainTo(events);\n        verify(mEventDiskCache, never()).uncache();\n        assertTrue(events.isEmpty());\n    }\n\n    @Test\n    public void testDrain_diskCache_nonempty() {\n        List<Event> events = new ArrayList<>();\n        when(mEventDiskCache.uncache()).thenReturn(Collections.singletonList(new Event(\"test\")));\n        mEventCache.updateState(true);\n        mEventCache.drainTo(events);\n        verify(mEventDiskCache).uncache();\n        assertFalse(events.isEmpty());\n    }\n\n    @Test\n    public void testDrain_diskCache_first() {\n        mEventCache.add(new Event(\"3\"));\n        List<Event> events = new ArrayList<>();\n        when(mEventDiskCache.uncache()).thenReturn(Arrays.asList(new Event(\"1\"), new Event(\"2\")));\n        mEventCache.updateState(true);\n        mEventCache.drainTo(events);\n        verify(mEventDiskCache).uncache();\n        assertFalse(events.isEmpty());\n        assertEquals(\"1\", events.get(0).getEncodedQuery());\n        assertEquals(\"2\", events.get(1).getEncodedQuery());\n        assertEquals(\"3\", events.get(2).getEncodedQuery());\n    }\n\n    @Test\n    public void testUpdateState_online() {\n        verify(mEventDiskCache, never()).uncache();\n        mEventCache.updateState(true);\n        mEventCache.updateState(true);\n        verify(mEventDiskCache, times(2)).uncache();\n    }\n\n    @Test\n    public void testUpdateState_offline() {\n        assertTrue(mEventCache.isEmpty());\n        mEventCache.add(new Event(\"test\"));\n        assertFalse(mEventCache.isEmpty());\n        mEventCache.updateState(false);\n        verify(mEventDiskCache).cache(ArgumentMatchers.anyList());\n\n        mEventCache.updateState(false);\n        verify(mEventDiskCache).cache(ArgumentMatchers.anyList());\n        mEventCache.add(new Event(\"test\"));\n        mEventCache.updateState(false);\n        verify(mEventDiskCache, times(2)).cache(ArgumentMatchers.anyList());\n    }\n\n    @Test\n    public void testUpdateState_offline_ordering() {\n        assertTrue(mEventCache.isEmpty());\n        mEventCache.add(new Event(\"test2\"));\n        when(mEventDiskCache.uncache()).thenReturn(Arrays.asList(new Event(\"test0\"), new Event(\"test1\")));\n        mEventCache.updateState(true);\n\n        List<Event> restoredEvents = new ArrayList<>();\n        mEventCache.drainTo(restoredEvents);\n\n        assertEquals(3, restoredEvents.size());\n        assertEquals(\"test0\", restoredEvents.get(0).getEncodedQuery());\n        assertEquals(\"test1\", restoredEvents.get(1).getEncodedQuery());\n        assertEquals(\"test2\", restoredEvents.get(2).getEncodedQuery());\n    }\n\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/dispatcher/EventDiskCacheTest.java",
    "content": "package org.matomo.sdk.dispatcher;\n\nimport android.content.Context;\n\nimport org.junit.After;\nimport org.junit.Before;\nimport org.junit.Test;\nimport org.junit.runner.RunWith;\nimport org.matomo.sdk.Matomo;\nimport org.matomo.sdk.Tracker;\nimport org.mockito.Mock;\nimport org.mockito.junit.MockitoJUnitRunner;\n\nimport java.io.File;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.concurrent.Semaphore;\n\nimport testhelpers.BaseTest;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertFalse;\nimport static org.junit.Assert.assertTrue;\nimport static org.mockito.Mockito.when;\n\n@RunWith(MockitoJUnitRunner.class)\npublic class EventDiskCacheTest extends BaseTest {\n    @Mock Matomo mMatomo;\n    @Mock Tracker mTracker;\n    @Mock Context mContext;\n    private EventDiskCache mDiskCache;\n    private File mHostFolder;\n    private File mBaseCacheDir;\n    private File mCacheFolder;\n\n    @Before\n    public void setup() throws Exception {\n        super.setup();\n        when(mTracker.getMatomo()).thenReturn(mMatomo);\n        when(mMatomo.getContext()).thenReturn(mContext);\n        mBaseCacheDir = new File(\"baseCacheDir\");\n        when(mContext.getCacheDir()).thenReturn(mBaseCacheDir);\n\n        when(mTracker.getAPIUrl()).thenReturn(\"http://testhost/matomo.php\");\n\n        when(mTracker.getOfflineCacheAge()).thenReturn(0L);\n\n        mCacheFolder = new File(mBaseCacheDir, \"piwik_cache\");\n        mHostFolder = new File(mCacheFolder, \"testhost\");\n\n        mDiskCache = new EventDiskCache(mTracker);\n    }\n\n    @SuppressWarnings(\"ResultOfMethodCallIgnored\")\n    @After\n    public void tearDown() throws Exception {\n        super.tearDown();\n        if (mHostFolder.exists()) {\n            for (File file : mHostFolder.listFiles())\n                file.delete();\n        }\n        mHostFolder.delete();\n        mCacheFolder.delete();\n        mBaseCacheDir.delete();\n    }\n\n    @Test\n    public void testIsEmpty() {\n        assertTrue(mDiskCache.isEmpty());\n        mDiskCache.cache(Collections.singletonList(new Event(\"test\")));\n        assertFalse(mDiskCache.isEmpty());\n    }\n\n    @Test\n    public void testCachePath() {\n        mDiskCache.cache(Collections.singletonList(new Event(1000, \"test\")));\n        File cacheFolder = new File(mBaseCacheDir, \"piwik_cache\");\n        File hostFolder = new File(cacheFolder, \"testhost\");\n        assertTrue(hostFolder.exists());\n        assertEquals(1, hostFolder.listFiles().length);\n    }\n\n    @Test\n    public void testCacheFileName() {\n        mDiskCache.cache(Arrays.asList(new Event(1234567890, \"test\"), new Event(987654321, \"test2\")));\n        File cacheFile = new File(mHostFolder, \"events_987654321\");\n        assertTrue(cacheFile.exists());\n        mDiskCache.uncache();\n        assertFalse(cacheFile.exists());\n    }\n\n    @Test\n    public void testCaching() {\n        Event event1 = new Event(1, \"test\");\n        Event event2 = new Event(2, \"test\");\n        mDiskCache.cache(Arrays.asList(event1, event2));\n        final List<Event> events = mDiskCache.uncache();\n        assertEquals(2, events.size());\n        assertEquals(event1, events.get(0));\n        assertEquals(event2, events.get(1));\n    }\n\n    @Test\n    public void testCaching_empty() {\n        mDiskCache.cache(Collections.emptyList());\n    }\n\n    @Test\n    public void testOrder() {\n        Event event1 = new Event(1, \"test\");\n        Event event2 = new Event(2, \"test\");\n        mDiskCache.cache(Arrays.asList(event1, event2));\n        Event event3 = new Event(3, \"test\");\n        Event event4 = new Event(4, \"test\");\n        mDiskCache.cache(Arrays.asList(event3, event4));\n        final List<Event> events = mDiskCache.uncache();\n        assertEquals(4, events.size());\n        assertEquals(event1, events.get(0));\n        assertEquals(event2, events.get(1));\n        assertEquals(event3, events.get(2));\n        assertEquals(event4, events.get(3));\n    }\n\n    @Test\n    public void testMaxAge_positive_allStale() {\n        when(mTracker.getOfflineCacheAge()).thenReturn(10 * 1000L);\n        mDiskCache = new EventDiskCache(mTracker);\n        Event event1 = new Event(1, \"test\");\n        Event event2 = new Event(2, \"test\");\n        mDiskCache.cache(Arrays.asList(event1, event2));\n        assertEquals(0, mHostFolder.listFiles().length);\n        final List<Event> events = mDiskCache.uncache();\n        assertEquals(0, events.size());\n    }\n\n    @Test\n    public void testMaxAge_positive_singleContainer() {\n        when(mTracker.getOfflineCacheAge()).thenReturn(10 * 1000L);\n        mDiskCache = new EventDiskCache(mTracker);\n        Event event1 = new Event(System.currentTimeMillis() - 60 * 1000, \"test\");\n        Event event2 = new Event(System.currentTimeMillis(), \"test\");\n        Event event3 = new Event(2 * System.currentTimeMillis(), \"test\");\n        mDiskCache.cache(Arrays.asList(event1, event2, event3));\n        final List<Event> events = mDiskCache.uncache();\n        assertEquals(2, events.size());\n        assertEquals(event2, events.get(0));\n        assertEquals(event3, events.get(1));\n    }\n\n    @Test\n    public void testMaxAge_positive_multipleContainer() {\n        when(mTracker.getOfflineCacheAge()).thenReturn(10 * 1000L);\n        mDiskCache = new EventDiskCache(mTracker);\n        Event event1 = new Event(System.currentTimeMillis() - 20 * 1000, \"test\");\n        Event event2 = new Event(System.currentTimeMillis() - 15 * 1000, \"test\");\n        mDiskCache.cache(Arrays.asList(event1, event2));\n        Event event3 = new Event(System.currentTimeMillis() - 5 * 1000, \"test\");\n        Event event4 = new Event(System.currentTimeMillis() - 2 * 1000, \"test\");\n        mDiskCache.cache(Arrays.asList(event3, event4));\n        final List<Event> events = mDiskCache.uncache();\n        assertEquals(2, events.size());\n        assertEquals(event3, events.get(0));\n        assertEquals(event4, events.get(1));\n    }\n\n    @Test\n    public void testMaxAge_unlimited() {\n        when(mTracker.getOfflineCacheAge()).thenReturn(0L);\n        mDiskCache = new EventDiskCache(mTracker);\n        Event event1 = new Event(-System.currentTimeMillis(), \"test1\");\n        Event event2 = new Event(0, \"test2\");\n        Event event3 = new Event(System.currentTimeMillis(), \"test3\");\n        Event event4 = new Event(2 * System.currentTimeMillis(), \"test3\");\n        mDiskCache.cache(Arrays.asList(event1, event2, event3, event4));\n        final List<Event> events = mDiskCache.uncache();\n        assertEquals(4, events.size());\n        assertEquals(event1, events.get(0));\n        assertEquals(event2, events.get(1));\n        assertEquals(event3, events.get(2));\n        assertEquals(event4, events.get(3));\n    }\n\n    @Test\n    public void testMaxAge_negative_cachingDisabled() {\n        when(mTracker.getOfflineCacheAge()).thenReturn(-1L);\n        mDiskCache = new EventDiskCache(mTracker);\n        Event event0 = new Event(-System.currentTimeMillis(), \"test\");\n        Event event1 = new Event(0, \"test\");\n        Event event2 = new Event(System.currentTimeMillis(), \"test\");\n        Event event3 = new Event(2 * System.currentTimeMillis(), \"test\");\n        mDiskCache.cache(Arrays.asList(event0, event1, event2, event3));\n        final List<Event> events = mDiskCache.uncache();\n        assertEquals(0, events.size());\n    }\n\n    @Test\n    public void testClearDataOnceEvenIfDisabled() {\n        Event event1 = new Event(0, \"test\");\n        Event event2 = new Event(System.currentTimeMillis(), \"test\");\n        mDiskCache.cache(Arrays.asList(event1, event2));\n        assertFalse(mDiskCache.isEmpty());\n        mDiskCache = new EventDiskCache(mTracker);\n        assertFalse(mDiskCache.isEmpty());\n        when(mTracker.getOfflineCacheAge()).thenReturn(-1L);\n        mDiskCache = new EventDiskCache(mTracker);\n        assertTrue(mDiskCache.isEmpty());\n    }\n\n    @Test\n    public void testMaxSize_limited() {\n        when(mTracker.getOfflineCacheSize()).thenReturn(1024L);\n        mDiskCache = new EventDiskCache(mTracker);\n        for (int j = 0; j < 4; j++) {\n            List<Event> events = new ArrayList<>();\n            for (int k = 0; k < 10; k++) {\n                events.add(new Event(System.nanoTime(), \"set:\" + j + \" \" + UUID.randomUUID().toString()));\n            }\n            // About ~512Byte\n            mDiskCache.cache(events);\n        }\n\n        assertEquals(2, mHostFolder.listFiles().length);\n        final List<Event> events = mDiskCache.uncache();\n        assertEquals(20, events.size());\n\n        for (Event e : events.subList(0, 10)) {\n            assertTrue(e.getEncodedQuery().startsWith(\"set:2\"));\n        }\n        for (Event e : events.subList(10, 20)) {\n            assertTrue(e.getEncodedQuery().startsWith(\"set:3\"));\n        }\n    }\n\n    @Test\n    public void testMaxSize_disabled() {\n        when(mTracker.getOfflineCacheSize()).thenReturn(0L);\n        mDiskCache = new EventDiskCache(mTracker);\n        for (int j = 0; j < 10; j++) {\n            List<Event> events = new ArrayList<>();\n            for (int k = 0; k < 1000; k++) {\n                events.add(new Event(System.nanoTime(), UUID.randomUUID().toString()));\n            }\n            mDiskCache.cache(events);\n        }\n\n        assertEquals(10, mHostFolder.listFiles().length);\n        final List<Event> events = mDiskCache.uncache();\n        assertEquals(10000, events.size());\n    }\n\n    @Test\n    public void stressTest_singles() throws Exception {\n        final Semaphore sem = new Semaphore(0);\n        for (int i = 0; i < 8; i++) {\n            new Thread(() -> {\n                for (int j = 0; j < 100; j++) {\n                    Event event1 = new Event(System.nanoTime(), UUID.randomUUID().toString());\n                    mDiskCache.cache(Collections.singletonList(event1));\n                }\n                sem.release(1);\n            }).start();\n        }\n        sem.acquire(8);\n        assertEquals(800, mHostFolder.listFiles().length);\n        final List<Event> events = mDiskCache.uncache();\n        assertEquals(800, events.size());\n        assertEquals(0, mHostFolder.listFiles().length);\n    }\n\n    @Test\n    public void stressTest_multi() throws Exception {\n        final Semaphore sem = new Semaphore(0);\n        for (int i = 0; i < 4; i++) {\n            new Thread(() -> {\n                for (int j = 0; j < 10; j++) {\n                    List<Event> events = new ArrayList<>();\n                    for (int k = 0; k < 1000; k++) {\n                        events.add(new Event(System.nanoTime(), UUID.randomUUID().toString()));\n                    }\n                    mDiskCache.cache(events);\n                }\n                sem.release(1);\n            }).start();\n        }\n        sem.acquire(4);\n        assertEquals(40, mHostFolder.listFiles().length);\n        final List<Event> events = mDiskCache.uncache();\n        assertEquals(40000, events.size());\n        assertEquals(0, mHostFolder.listFiles().length);\n    }\n\n    @Test\n    public void testOfflineMode_issue_271() {\n        when(mTracker.getOfflineCacheSize()).thenReturn(4096L);\n        mDiskCache = new EventDiskCache(mTracker);\n\n        // Hit limit\n        for (int i = 0; i < 2; i++) {\n            List<Event> batch1 = new ArrayList<>();\n            for (int k = 0; k < 100; k++) {\n                batch1.add(new Event(System.nanoTime(), UUID.randomUUID().toString()));\n            }\n            mDiskCache.cache(batch1);\n        }\n\n        final List<Event> events1 = mDiskCache.uncache();\n        assertEquals(100, events1.size());\n\n        // Hit limit again\n        List<Event> batch2 = new ArrayList<>();\n        for (int k = 0; k < 100; k++) {\n            batch2.add(new Event(System.nanoTime(), UUID.randomUUID().toString()));\n        }\n        mDiskCache.cache(batch2);\n\n        final List<Event> events2 = mDiskCache.uncache();\n        assertEquals(100, events2.size());\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/dispatcher/EventTest.java",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\npackage org.matomo.sdk.dispatcher;\n\nimport android.util.Pair;\n\nimport org.junit.Test;\nimport org.junit.runner.RunWith;\nimport org.matomo.sdk.QueryParams;\nimport org.matomo.sdk.TrackMe;\nimport org.matomo.sdk.tools.UrlHelper;\nimport org.mockito.junit.MockitoJUnitRunner;\n\nimport java.net.URI;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\n\nimport testhelpers.BaseTest;\n\nimport static org.junit.Assert.assertEquals;\n\n@RunWith(MockitoJUnitRunner.class)\npublic class EventTest extends BaseTest {\n    @Test\n    public void testhashCode() {\n        Event event = new Event(0, \"\");\n        assertEquals(0, event.hashCode());\n    }\n\n    @Test\n    public void testEncoding_escaping() {\n        Map<String, String> data = new HashMap<>();\n        data.put(QueryParams.VISIT_SCOPE_CUSTOM_VARIABLES.toString(), \"{\\\"1\\\":[\\\"2& ?\\\",\\\"3@#\\\"]}\");\n        Event event = new Event(data);\n        assertEquals(\"?_cvar=%7B%221%22%3A%5B%222%26%20%3F%22%2C%223%40%23%22%5D%7D\", event.getEncodedQuery());\n    }\n\n\n    @Test\n    public void testBncoding_empty() {\n        Map<String, String> data = new HashMap<>();\n        Event event = new Event(data);\n        assertEquals(\"\", event.getEncodedQuery());\n    }\n\n    @Test\n    public void testEncondingSingles() {\n        for (QueryParams param : QueryParams.values()) {\n            String testVal = UUID.randomUUID().toString();\n            TrackMe trackMe = new TrackMe();\n            trackMe.set(param, testVal);\n            assertEquals(\"?\" + param + \"=\" + testVal, new Event(trackMe.toMap()).getEncodedQuery());\n        }\n    }\n\n    @Test\n    public void testEncodingMultiples() throws Exception {\n        TrackMe trackMe = new TrackMe();\n        Map<String, String> testValues = new HashMap<>();\n        for (QueryParams param : QueryParams.values()) {\n            String testVal = UUID.randomUUID().toString();\n            trackMe.set(param, testVal);\n            testValues.put(param.toString(), testVal);\n        }\n        final Map<String, String> parsedParams = parseEncoding(new Event(trackMe.toMap()).getEncodedQuery());\n        for (Map.Entry<String, String> pair : parsedParams.entrySet()) {\n            assertEquals(testValues.get(pair.getKey()), pair.getValue());\n        }\n    }\n\n    private static Map<String, String> parseEncoding(String url) throws Exception {\n        Map<String, String> values = new HashMap<>();\n        List<Pair<String, String>> params = UrlHelper.parse(new URI(\"http://localhost/\" + url), \"UTF-8\");\n        for (Pair<String, String> param : params) values.put(param.first, param.second);\n        return values;\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/dispatcher/PacketFactoryTest.java",
    "content": "package org.matomo.sdk.dispatcher;\n\nimport org.junit.Test;\nimport org.junit.runner.RunWith;\nimport org.mockito.junit.MockitoJUnitRunner;\n\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.LinkedList;\nimport java.util.List;\n\nimport testhelpers.BaseTest;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertNotNull;\nimport static org.junit.Assert.assertNull;\nimport static org.junit.Assert.assertTrue;\n\n@RunWith(MockitoJUnitRunner.class)\npublic class PacketFactoryTest extends BaseTest {\n\n    @Test\n    public void testPOST_apiUrl() {\n        String url = \"http://example.com/\";\n        PacketFactory factory = new PacketFactory(url);\n        List<Packet> packets = factory.buildPackets(Arrays.asList(new Event(\"straw\"), new Event(\"berries\")));\n        for (Packet p : packets) {\n            assertEquals(url, p.getTargetURL());\n        }\n    }\n\n    @Test\n    public void testPOST_data() throws Exception {\n        PacketFactory factory = new PacketFactory(\"http://example.com/\");\n        List<Packet> packets = factory.buildPackets(Arrays.asList(new Event(\"straw\"), new Event(\"berries\")));\n        assertEquals(\"straw\", packets.get(0).getPostData().getJSONArray(\"requests\").get(0));\n        assertEquals(\"berries\", packets.get(0).getPostData().getJSONArray(\"requests\").get(1));\n    }\n\n    @Test\n    public void testGET_apiUrl() {\n        String url = \"http://example.com/\";\n        PacketFactory factory = new PacketFactory(url);\n        List<Packet> packets = factory.buildPackets(Collections.singletonList(new Event(\"strawberries\")));\n        assertTrue(packets.get(0).getTargetURL().startsWith(url));\n    }\n\n    @Test\n    public void testGET_badUrl() {\n        PacketFactory factory = new PacketFactory(\"http://example.com/\");\n        assertTrue(factory.buildPackets(Collections.singletonList(new Event(\"\"))).isEmpty());\n    }\n\n    @Test\n    public void testEmptyEvents() {\n        PacketFactory factory = new PacketFactory(\"http://example.com/\");\n        assertTrue(factory.buildPackets(Collections.emptyList()).isEmpty());\n    }\n\n    @Test\n    public void testPacking_rest() {\n        List<Event> events = new LinkedList<>();\n        for (int i = 1; i <= PacketFactory.PAGE_SIZE + 1; i++) {\n            events.add(new Event(\"?eve\" + i));\n        }\n        PacketFactory factory = new PacketFactory(\"http://example.com/\");\n        List<Packet> packets = factory.buildPackets(events);\n        Packet first = packets.get(0);\n        assertEquals(PacketFactory.PAGE_SIZE, first.getEventCount());\n        assertNotNull(first.getPostData());\n\n        Packet second = packets.get(1);\n        assertEquals(1, second.getEventCount());\n        assertNull(second.getPostData());\n        assertTrue(second.getTargetURL().endsWith(\"?eve\" + events.size()));\n    }\n\n    @Test\n    public void testPacking_notfull() throws Exception {\n        List<Event> events = new LinkedList<>();\n        for (int i = 0; i < PacketFactory.PAGE_SIZE * 2 - 2; i++) {\n            events.add(new Event(\"?eve\" + i));\n        }\n        PacketFactory factory = new PacketFactory(\"http://example.com/\");\n        List<Packet> packets = factory.buildPackets(events);\n        Packet first = packets.get(0);\n        assertEquals(PacketFactory.PAGE_SIZE, first.getEventCount());\n        assertNotNull(first.getPostData());\n        assertTrue(first.getPostData().getJSONArray(\"requests\").getString(0).endsWith(\"?eve0\"));\n\n        Packet second = packets.get(1);\n        assertEquals(PacketFactory.PAGE_SIZE - 2, second.getEventCount());\n        assertNotNull(second.getPostData());\n    }\n\n    @Test\n    public void testPacking_even() {\n        List<Event> events = new LinkedList<>();\n        for (int i = 0; i < PacketFactory.PAGE_SIZE * 3; i++) {\n            events.add(new Event(\"?eve\" + i));\n        }\n        PacketFactory factory = new PacketFactory(\"http://example.com/\");\n        List<Packet> packets = factory.buildPackets(events);\n        Packet first = packets.get(0);\n        assertEquals(PacketFactory.PAGE_SIZE, first.getEventCount());\n        assertNotNull(first.getPostData());\n\n        Packet second = packets.get(1);\n        assertEquals(PacketFactory.PAGE_SIZE, second.getEventCount());\n        assertNotNull(second.getPostData());\n\n        Packet third = packets.get(2);\n        assertEquals(PacketFactory.PAGE_SIZE, third.getEventCount());\n        assertNotNull(third.getPostData());\n    }\n\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/dispatcher/PacketTest.java",
    "content": "package org.matomo.sdk.dispatcher;\n\n\nimport org.junit.Test;\nimport org.junit.runner.RunWith;\nimport org.mockito.junit.MockitoJUnitRunner;\n\nimport testhelpers.BaseTest;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertTrue;\n\n@RunWith(MockitoJUnitRunner.class)\npublic class PacketTest extends BaseTest {\n\n    @Test\n    public void testEventCount() {\n        Packet testPacket = new Packet(\"\", null, 55);\n        assertEquals(55, testPacket.getEventCount());\n    }\n\n    @Test\n    public void testTimeStamp() {\n        Packet testPacket = new Packet(\"\");\n        long timeStamp = System.currentTimeMillis();\n        assertTrue(timeStamp - testPacket.getTimeStamp() < 5);\n    }\n\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/extra/CustomDimensionTest.java",
    "content": "package org.matomo.sdk.extra;\n\nimport org.junit.Test;\nimport org.junit.runner.RunWith;\nimport org.matomo.sdk.TrackMe;\nimport org.mockito.junit.MockitoJUnitRunner;\n\nimport java.util.UUID;\n\nimport testhelpers.BaseTest;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertFalse;\nimport static org.junit.Assert.assertNull;\nimport static org.junit.Assert.assertTrue;\n\n@RunWith(MockitoJUnitRunner.class)\npublic class CustomDimensionTest extends BaseTest {\n\n    @Test\n    public void testSetCustomDimensions() {\n        TrackMe trackMe = new TrackMe();\n        CustomDimension.setDimension(trackMe, 0, \"foo\");\n        CustomDimension.setDimension(trackMe, 1, \"foo\");\n        CustomDimension.setDimension(trackMe, 2, \"bar\");\n        CustomDimension.setDimension(trackMe, 3, \"empty\");\n        CustomDimension.setDimension(trackMe, 3, null);\n        CustomDimension.setDimension(trackMe, 4, \"\");\n\n\n        assertEquals(\"foo\", trackMe.get(\"dimension1\"));\n        assertEquals(\"bar\", trackMe.get(\"dimension2\"));\n        assertNull(trackMe.get(\"dimension0\"));\n        assertNull(trackMe.get(\"dimension3\"));\n        assertNull(trackMe.get(\"dimension4\"));\n    }\n\n    @Test\n    public void testSet_truncate() {\n        TrackMe trackMe = new TrackMe();\n        CustomDimension.setDimension(trackMe, 1, new String(new char[1000]));\n        assertEquals(255, trackMe.get(\"dimension1\").length());\n    }\n\n    @Test\n    public void testSet_badId() {\n        TrackMe trackMe = new TrackMe();\n        CustomDimension.setDimension(trackMe, 0, UUID.randomUUID().toString());\n        assertTrue(trackMe.isEmpty());\n    }\n\n    @Test\n    public void testSet_removal() {\n        TrackMe trackMe = new TrackMe();\n        CustomDimension.setDimension(trackMe, 1, UUID.randomUUID().toString());\n        assertFalse(trackMe.isEmpty());\n        CustomDimension.setDimension(trackMe, 1, null);\n        assertTrue(trackMe.isEmpty());\n    }\n\n    @Test\n    public void testSet_empty() {\n        TrackMe trackMe = new TrackMe();\n        CustomDimension.setDimension(trackMe, 1, UUID.randomUUID().toString());\n        assertFalse(trackMe.isEmpty());\n        CustomDimension.setDimension(trackMe, 1, \"\");\n        assertTrue(trackMe.isEmpty());\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/extra/CustomVariablesTest.java",
    "content": "package org.matomo.sdk.extra;\n\nimport org.json.JSONArray;\nimport org.json.JSONObject;\nimport org.junit.Test;\nimport org.junit.runner.RunWith;\nimport org.matomo.sdk.QueryParams;\nimport org.matomo.sdk.TrackMe;\nimport org.mockito.junit.MockitoJUnitRunner;\n\nimport java.util.Arrays;\nimport java.util.Collections;\n\nimport testhelpers.BaseTest;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertNull;\nimport static org.junit.Assert.assertTrue;\n\n@SuppressWarnings(\"deprecation\")\n@RunWith(MockitoJUnitRunner.class)\npublic class CustomVariablesTest extends BaseTest {\n\n    @Test\n    public void testPutAll() {\n        CustomVariables target = new CustomVariables();\n        target.put(1, \"name1\", \"value1\");\n        target.put(2, \"name2\", \"value2\");\n\n        CustomVariables toPut = new CustomVariables();\n        target.put(2, \"name2X\", \"value2X\");\n        target.put(3, \"name3\", \"value3\");\n\n        target.putAll(toPut);\n\n        assertTrue(target.toString().contains(\"\\\"1\\\":[\\\"name1\\\",\\\"value1\\\"]\"));\n        assertTrue(target.toString().contains(\"\\\"2\\\":[\\\"name2X\\\",\\\"value2X\\\"]\"));\n        assertTrue(target.toString().contains(\"\\\"3\\\":[\\\"name3\\\",\\\"value3\\\"]\"));\n    }\n\n    @Test\n    public void testInherit() throws Exception {\n        CustomVariables ancestor = new CustomVariables();\n        ancestor.put(1, \"name\", \"value\");\n        ancestor.put(2, \"name2\", \"uńicódę\");\n\n        CustomVariables cv = new CustomVariables(ancestor);\n        String cvJson = cv.toString();\n        new JSONObject(cvJson); //Will throw exception if not valid json\n        assertTrue(cvJson.contains(\"\\\"2\\\":[\\\"name2\\\",\\\"uńicódę\\\"]\"));\n        assertTrue(cvJson.contains(\"\\\"1\\\":[\\\"name\\\",\\\"value\\\"]\"));\n    }\n\n    @Test\n    public void testToString() throws Exception {\n        CustomVariables cv = new CustomVariables();\n        cv.put(1, \"name\", \"value\");\n        cv.put(2, \"name2\", \"uńicódę\");\n\n        String cvJson = cv.toString();\n        new JSONObject(cvJson); //Will throw exception if not valid json\n\n        assertTrue(cvJson.contains(\"\\\"2\\\":[\\\"name2\\\",\\\"uńicódę\\\"]\"));\n        assertTrue(cvJson.contains(\"\\\"1\\\":[\\\"name\\\",\\\"value\\\"]\"));\n    }\n\n    @Test\n    public void testToStringJSON() {\n        CustomVariables cv = new CustomVariables();\n        cv.put(5, \"name 1\", \"\\\"@<& '\");\n\n        assertEquals(\n                \"{\\\"5\\\":[\\\"name 1\\\",\\\"\\\\\\\"@<& '\\\"]}\",\n                cv.toString()\n        );\n    }\n\n    @Test\n    public void testTrimLongValue() {\n        CustomVariables cv = new CustomVariables();\n        String multipleA = String.join(\"\", Collections.nCopies(CustomVariables.MAX_LENGTH + 41, \"a\"));\n        String multipleB = String.join(\"\", Collections.nCopies(CustomVariables.MAX_LENGTH + 100, \"B\"));\n\n        cv.put(1, multipleA, multipleB);\n\n        assertEquals(cv.toString().length(), 13 + CustomVariables.MAX_LENGTH * 2); // 13 + 200x2\n    }\n\n    @Test\n    public void testWrongIndex() {\n        CustomVariables cv = new CustomVariables();\n        cv.put(1, \"name\", \"value\");\n        cv.put(-1, \"name-1\", \"value\");\n\n        assertEquals(\n                \"{\\\"1\\\":[\\\"name\\\",\\\"value\\\"]}\",\n                cv.toString()\n        );\n    }\n\n    @Test\n    public void testWrongValueSize() {\n        CustomVariables cv = new CustomVariables();\n        cv.put(\"test\", new JSONArray(Arrays.asList(\"1\", \"2\", \"3\")));\n        assertEquals(0, cv.size());\n        assertNull(cv.toString());\n        cv.put(\"test\", new JSONArray(Arrays.asList(\"1\", \"2\")));\n        assertEquals(\"{\\\"test\\\":[\\\"1\\\",\\\"2\\\"]}\", cv.toString());\n    }\n\n    @Test\n    public void testInject() {\n        CustomVariables cv = new CustomVariables();\n        cv.put(1, \"name\", \"value\");\n        TrackMe trackMe = new TrackMe();\n        cv.injectVisitVariables(trackMe);\n        assertEquals(cv.toString(), trackMe.get(QueryParams.VISIT_SCOPE_CUSTOM_VARIABLES));\n    }\n\n    @Test\n    public void testToTrackMe() {\n        CustomVariables cv = new CustomVariables();\n        cv.put(1, \"name\", \"value\");\n        TrackMe trackMe = cv.toVisitVariables();\n        assertEquals(cv.toString(), trackMe.get(QueryParams.VISIT_SCOPE_CUSTOM_VARIABLES));\n    }\n\n    @Test\n    public void testVisitCustomVariables() {\n        CustomVariables visitVars = new CustomVariables();\n        visitVars.put(1, \"visit\", \"valueX\");\n\n        CustomVariables _screen = new CustomVariables();\n        _screen.put(1, \"screen\", \"valueY\");\n\n        final TrackMe trackMe = TrackHelper.track(visitVars.toVisitVariables())\n                .screen(\"/path\")\n                .variable(1, \"screen\", \"valueY\")\n                .build();\n\n        assertEquals(visitVars.toString(), trackMe.get(QueryParams.VISIT_SCOPE_CUSTOM_VARIABLES));\n        assertEquals(_screen.toString(), trackMe.get(QueryParams.SCREEN_SCOPE_CUSTOM_VARIABLES));\n        assertEquals(\"/path\", trackMe.get(QueryParams.URL_PATH));\n    }\n\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/extra/DimensionQueueTest.java",
    "content": "package org.matomo.sdk.extra;\n\nimport org.junit.Test;\nimport org.junit.runner.RunWith;\nimport org.matomo.sdk.TrackMe;\nimport org.matomo.sdk.Tracker;\nimport org.mockito.ArgumentCaptor;\nimport org.mockito.junit.MockitoJUnitRunner;\n\nimport static org.hamcrest.CoreMatchers.is;\nimport static org.hamcrest.CoreMatchers.notNullValue;\nimport static org.hamcrest.CoreMatchers.nullValue;\nimport static org.hamcrest.MatcherAssert.assertThat;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\n\n@RunWith(MockitoJUnitRunner.class)\npublic class DimensionQueueTest {\n    Tracker mTracker = mock(Tracker.class);\n    ArgumentCaptor<Tracker.Callback> mCaptor = ArgumentCaptor.forClass(Tracker.Callback.class);\n\n    @Test\n    public void testEmpty() {\n        new DimensionQueue(mTracker);\n        verify(mTracker).addTrackingCallback(mCaptor.capture());\n\n        TrackMe pre = new TrackMe();\n        TrackMe post = mCaptor.getValue().onTrack(pre);\n        assertThat(post, notNullValue());\n        assertThat(pre, is(post));\n    }\n\n    @Test\n    public void testCallback() {\n        DimensionQueue queue = new DimensionQueue(mTracker);\n        verify(mTracker).addTrackingCallback(mCaptor.capture());\n\n        queue.add(1, \"test1\");\n        queue.add(2, \"test2\");\n        TrackMe pre = new TrackMe();\n        TrackMe post = mCaptor.getValue().onTrack(pre);\n        assertThat(post, notNullValue());\n        assertThat(pre, is(post));\n        assertThat(CustomDimension.getDimension(post, 1), is(\"test1\"));\n        assertThat(CustomDimension.getDimension(post, 2), is(\"test2\"));\n    }\n\n    @Test\n    public void testCollision() {\n        DimensionQueue queue = new DimensionQueue(mTracker);\n        verify(mTracker).addTrackingCallback(mCaptor.capture());\n\n        queue.add(1, \"test1\");\n        TrackMe pre = new TrackMe();\n        CustomDimension.setDimension(pre, 1, \"don't overwrite me\");\n        TrackMe post = mCaptor.getValue().onTrack(pre);\n        assertThat(post, notNullValue());\n        assertThat(pre, is(post));\n        assertThat(CustomDimension.getDimension(post, 1), is(\"don't overwrite me\"));\n    }\n\n    @Test\n    public void testOverwriting() {\n        DimensionQueue queue = new DimensionQueue(mTracker);\n        verify(mTracker).addTrackingCallback(mCaptor.capture());\n\n        queue.add(1, \"test1\");\n        queue.add(1, \"test3\");\n        queue.add(2, \"test2\");\n        {\n            TrackMe post = mCaptor.getValue().onTrack(new TrackMe());\n            assertThat(post, notNullValue());\n            assertThat(CustomDimension.getDimension(post, 1), is(\"test1\"));\n            assertThat(CustomDimension.getDimension(post, 2), is(\"test2\"));\n        }\n        {\n            TrackMe post = mCaptor.getValue().onTrack(new TrackMe());\n            assertThat(post, notNullValue());\n            assertThat(CustomDimension.getDimension(post, 1), is(\"test3\"));\n            assertThat(CustomDimension.getDimension(post, 2), nullValue());\n        }\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/extra/DownloadTrackerTest.java",
    "content": "package org.matomo.sdk.extra;\n\nimport android.content.Context;\nimport android.content.SharedPreferences;\nimport android.content.pm.ApplicationInfo;\nimport android.content.pm.PackageInfo;\nimport android.content.pm.PackageManager;\n\nimport org.junit.Before;\nimport org.junit.Test;\nimport org.junit.runner.RunWith;\nimport org.matomo.sdk.Matomo;\nimport org.matomo.sdk.QueryParams;\nimport org.matomo.sdk.TrackMe;\nimport org.matomo.sdk.Tracker;\nimport org.mockito.ArgumentCaptor;\nimport org.mockito.Mock;\nimport org.mockito.junit.MockitoJUnitRunner;\n\nimport java.io.File;\nimport java.io.FileOutputStream;\nimport java.util.UUID;\nimport java.util.regex.Matcher;\nimport java.util.regex.Pattern;\n\nimport testhelpers.BaseTest;\nimport testhelpers.TestHelper;\nimport testhelpers.TestPreferences;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertNull;\nimport static org.junit.Assert.assertTrue;\nimport static org.mockito.ArgumentMatchers.anyInt;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\n@RunWith(MockitoJUnitRunner.class)\npublic class DownloadTrackerTest extends BaseTest {\n    @Mock Tracker mTracker;\n    @Mock Matomo mMatomo;\n    @Mock Context mContext;\n    @Mock PackageManager mPackageManager;\n    ArgumentCaptor<TrackMe> mCaptor = ArgumentCaptor.forClass(TrackMe.class);\n    SharedPreferences mSharedPreferences = new TestPreferences();\n    private PackageInfo mPackageInfo;\n\n    @Before\n    public void setup() throws PackageManager.NameNotFoundException {\n        when(mTracker.getPreferences()).thenReturn(mSharedPreferences);\n        when(mTracker.getMatomo()).thenReturn(mMatomo);\n        when(mMatomo.getContext()).thenReturn(mContext);\n        when(mContext.getPackageManager()).thenReturn(mPackageManager);\n        when(mContext.getPackageName()).thenReturn(\"package\");\n\n        mPackageInfo = new PackageInfo();\n        mPackageInfo.versionCode = 123;\n        mPackageInfo.packageName = \"package\";\n        //noinspection WrongConstant\n        when(mPackageManager.getPackageInfo(anyString(), anyInt())).thenReturn(mPackageInfo);\n        when(mPackageManager.getInstallerPackageName(\"package\")).thenReturn(\"installer\");\n    }\n\n    @Test\n    public void testTrackAppDownload() {\n        DownloadTracker downloadTracker = new DownloadTracker(mTracker);\n        downloadTracker.trackOnce(new TrackMe(), new DownloadTracker.Extra.None());\n        verify(mTracker).track(mCaptor.capture());\n        checkNewAppDownload(mCaptor.getValue());\n\n        // track only once\n        downloadTracker.trackOnce(new TrackMe(), new DownloadTracker.Extra.None());\n        verify(mTracker, times(1)).track(mCaptor.capture());\n    }\n\n    @Test\n    public void testTrackIdentifier() {\n        ApplicationInfo applicationInfo = new ApplicationInfo();\n        mPackageInfo.applicationInfo = applicationInfo;\n        applicationInfo.sourceDir = UUID.randomUUID().toString();\n        final byte[] FAKE_APK_DATA = \"this is an apk, awesome right?\".getBytes();\n        final String FAKE_APK_DATA_MD5 = \"771BD8971508985852AF8F96170C52FB\";\n\n        try {\n            FileOutputStream out = new FileOutputStream(applicationInfo.sourceDir);\n            out.write(FAKE_APK_DATA);\n            out.close();\n        } catch (java.io.IOException e) {\n            e.printStackTrace();\n        }\n\n        DownloadTracker downloadTracker = new DownloadTracker(mTracker);\n        downloadTracker.trackNewAppDownload(new TrackMe(), new DownloadTracker.Extra.ApkChecksum(mContext));\n        TestHelper.sleep(100); // APK checksum happens off thread\n        verify(mTracker).track(mCaptor.capture());\n        checkNewAppDownload(mCaptor.getValue());\n        Matcher m = REGEX_DOWNLOADTRACK.matcher(mCaptor.getValue().get(QueryParams.DOWNLOAD));\n        assertTrue(m.matches());\n        assertEquals(\"package\", m.group(1));\n        assertEquals(123, Integer.parseInt(m.group(2)));\n        assertEquals(FAKE_APK_DATA_MD5, m.group(3));\n        assertEquals(\"http://installer\", mCaptor.getValue().get(QueryParams.REFERRER));\n\n        downloadTracker.trackNewAppDownload(new TrackMe(), new DownloadTracker.Extra.None());\n        verify(mTracker, times(2)).track(mCaptor.capture());\n        checkNewAppDownload(mCaptor.getValue());\n        String downloadParams = mCaptor.getValue().get(QueryParams.DOWNLOAD);\n        m = REGEX_DOWNLOADTRACK.matcher(downloadParams);\n        assertTrue(downloadParams, m.matches());\n        assertEquals(3, m.groupCount());\n        assertEquals(\"package\", m.group(1));\n        assertEquals(123, Integer.parseInt(m.group(2)));\n        assertNull(m.group(3));\n        assertEquals(\"http://installer\", mCaptor.getValue().get(QueryParams.REFERRER));\n        //noinspection ResultOfMethodCallIgnored\n        new File(applicationInfo.sourceDir).delete();\n    }\n\n    // http://org.matomo.sdk.test:1/some.package or http://org.matomo.sdk.test:1\n    private final Pattern REGEX_DOWNLOADTRACK = Pattern.compile(\"https?://([\\\\w.]+):([\\\\d]+)(?:/([\\\\W\\\\w]+))?\");\n\n    @Test\n    public void testTrackReferrer() {\n        DownloadTracker downloadTracker = new DownloadTracker(mTracker);\n        downloadTracker.trackNewAppDownload(new TrackMe(), new DownloadTracker.Extra.None());\n        verify(mTracker).track(mCaptor.capture());\n        checkNewAppDownload(mCaptor.getValue());\n        String downloadParams = mCaptor.getValue().get(QueryParams.DOWNLOAD);\n        Matcher m = REGEX_DOWNLOADTRACK.matcher(downloadParams);\n        assertTrue(downloadParams, m.matches());\n        assertEquals(3, m.groupCount());\n        assertEquals(\"package\", m.group(1));\n        assertEquals(123, Integer.parseInt(m.group(2)));\n        assertNull(m.group(3));\n        assertEquals(\"http://installer\", mCaptor.getValue().get(QueryParams.REFERRER));\n\n        when(mPackageManager.getInstallerPackageName(anyString())).thenReturn(null);\n        downloadTracker.trackNewAppDownload(new TrackMe(), new DownloadTracker.Extra.None());\n        verify(mTracker, times(2)).track(mCaptor.capture());\n        checkNewAppDownload(mCaptor.getValue());\n        m = REGEX_DOWNLOADTRACK.matcher(mCaptor.getValue().get(QueryParams.DOWNLOAD));\n        assertTrue(m.matches());\n        assertEquals(3, m.groupCount());\n        assertEquals(\"package\", m.group(1));\n        assertEquals(123, Integer.parseInt(m.group(2)));\n        assertNull(m.group(3));\n        assertNull(mCaptor.getValue().get(QueryParams.REFERRER));\n    }\n\n    @Test\n    public void testTrackNewAppDownloadWithVersion() {\n        DownloadTracker downloadTracker = new DownloadTracker(mTracker);\n        downloadTracker.setVersion(\"2\");\n        downloadTracker.trackOnce(new TrackMe(), new DownloadTracker.Extra.None());\n        verify(mTracker).track(mCaptor.capture());\n        checkNewAppDownload(mCaptor.getValue());\n        Matcher m = REGEX_DOWNLOADTRACK.matcher(mCaptor.getValue().get(QueryParams.DOWNLOAD));\n        assertTrue(m.matches());\n        assertEquals(\"package\", m.group(1));\n        assertEquals(\"2\", m.group(2));\n        assertEquals(\"2\", downloadTracker.getVersion());\n        assertEquals(\"http://installer\", mCaptor.getValue().get(QueryParams.REFERRER));\n\n        downloadTracker.trackOnce(new TrackMe(), new DownloadTracker.Extra.None());\n        verify(mTracker, times(1)).track(mCaptor.capture());\n\n        downloadTracker.setVersion(null);\n        downloadTracker.trackOnce(new TrackMe(), new DownloadTracker.Extra.None());\n        verify(mTracker, times(2)).track(mCaptor.capture());\n        checkNewAppDownload(mCaptor.getValue());\n        m = REGEX_DOWNLOADTRACK.matcher(mCaptor.getValue().get(QueryParams.DOWNLOAD));\n        assertTrue(m.matches());\n        assertEquals(\"package\", m.group(1));\n        assertEquals(123, Integer.parseInt(m.group(2)));\n        assertEquals(\"http://installer\", mCaptor.getValue().get(QueryParams.REFERRER));\n    }\n\n    private void checkNewAppDownload(TrackMe trackMe) {\n        assertTrue(trackMe.get(QueryParams.DOWNLOAD).length() > 0);\n        assertTrue(trackMe.get(QueryParams.URL_PATH).length() > 0);\n        assertEquals(trackMe.get(QueryParams.EVENT_CATEGORY), \"Application\");\n        assertEquals(trackMe.get(QueryParams.EVENT_ACTION), \"downloaded\");\n        assertEquals(trackMe.get(QueryParams.ACTION_NAME), \"application/downloaded\");\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/extra/EcommerceItemsTest.java",
    "content": "package org.matomo.sdk.extra;\n\nimport org.json.JSONArray;\nimport org.junit.Test;\nimport org.junit.runner.RunWith;\nimport org.mockito.junit.MockitoJUnitRunner;\n\nimport java.util.Locale;\n\nimport testhelpers.BaseTest;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertTrue;\n\n@RunWith(MockitoJUnitRunner.class)\npublic class EcommerceItemsTest extends BaseTest {\n\n    @Test\n    public void testEmptyItems() {\n        EcommerceItems items = new EcommerceItems();\n        assertEquals(\"[]\", items.toJson());\n    }\n\n    @Test\n    public void testAddItems() {\n        Locale.setDefault(Locale.US);\n        EcommerceItems items = new EcommerceItems();\n        items.addItem(new EcommerceItems.Item(\"fake_sku\").name(\"fake_product\").category(\"fake_category\").price(200).quantity(2));\n        items.addItem(new EcommerceItems.Item(\"fake_sku_2\").name(\"fake_product_2\").category(\"fake_category_2\").price(400).quantity(3));\n        items.addItem(new EcommerceItems.Item(\"fake_sku_3\"));\n\n        String itemsJson = items.toJson();\n        assertTrue(itemsJson.contains(\"[\\\"fake_sku\\\",\\\"fake_product\\\",\\\"fake_category\\\",\\\"2.00\\\",\\\"2\\\"]\"));\n        assertTrue(itemsJson.contains(\"[\\\"fake_sku_2\\\",\\\"fake_product_2\\\",\\\"fake_category_2\\\",\\\"4.00\\\",\\\"3\\\"]\"));\n        assertTrue(itemsJson.contains(\"[\\\"fake_sku_3\\\"]\"));\n    }\n\n    @Test\n    public void testRemoveItem() {\n        Locale.setDefault(Locale.US);\n        EcommerceItems items = new EcommerceItems();\n        items.addItem(new EcommerceItems.Item(\"fake_sku\").name(\"fake_product\").category(\"fake_category\").price(200).quantity(2));\n        final EcommerceItems.Item item2 = new EcommerceItems.Item(\"fake_sku_2\").name(\"fake_product_2\").category(\"fake_category_2\").price(400).quantity(3);\n        items.addItem(item2);\n        items.remove(\"fake_sku\");\n\n        assertEquals(\"[[\\\"fake_sku_2\\\",\\\"fake_product_2\\\",\\\"fake_category_2\\\",\\\"4.00\\\",\\\"3\\\"]]\", items.toJson());\n\n        items.remove(item2);\n        assertEquals(new JSONArray().toString(), items.toJson());\n    }\n\n    @Test\n    public void testRemoveAllItems() {\n        EcommerceItems items = new EcommerceItems();\n        items.addItem(new EcommerceItems.Item(\"fake_sku\").name(\"fake_product\").category(\"fake_category\").price(200).quantity(2));\n        items.addItem(new EcommerceItems.Item(\"fake_sku_2\").name(\"fake_product_2\").category(\"fake_category_2\").price(400).quantity(3));\n        items.clear();\n\n        assertEquals(\"[]\", items.toJson());\n    }\n\n    @Test\n    public void testItem() {\n        EcommerceItems.Item item = new EcommerceItems\n                .Item(\"fake_sku\")\n                .name(\"fake_product\")\n                .category(\"fake_category\")\n                .price(200)\n                .quantity(2);\n        assertEquals(\"fake_sku\", item.getSku());\n        assertEquals(\"fake_product\", item.getName());\n        assertEquals(\"fake_category\", item.getCategory());\n        assertEquals(200, (int) item.getPrice());\n        assertEquals(2, (int) item.getQuantity());\n    }\n\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/extra/InstallReferrerReceiverTest.java",
    "content": "package org.matomo.sdk.extra;\n\nimport android.content.Intent;\n\nimport org.junit.Test;\n\nimport androidx.test.core.app.ApplicationProvider;\nimport testhelpers.DefaultTestCase;\n\nimport static junit.framework.Assert.assertEquals;\nimport static junit.framework.Assert.assertNull;\nimport static junit.framework.Assert.assertTrue;\n\n\npublic class InstallReferrerReceiverTest extends DefaultTestCase {\n    // How to test on a live device:\n    // adb shell am broadcast -a com.android.vending.INSTALL_REFERRER -n org.matomo.demo/org.matomo.sdk.extra.InstallReferrerReceiver --es \"referrer\" \"utm_medium%3Dpartner%26utm_campaign%3Dpart\n    @Test\n    public void testReceiveGooglePlay() throws Exception {\n        InstallReferrerReceiver receiver = new InstallReferrerReceiver();\n        Intent testIntent = new Intent(\"com.android.vending.INSTALL_REFERRER\");\n        testIntent.setPackage(ApplicationProvider.getApplicationContext().getPackageName());\n\n        String testReferrerData1 = \"utm_source=test_source&utm_medium=test_medium&utm_term=test_term&utm_content=test_content&utm_campaign=test_name\";\n        testIntent.putExtra(InstallReferrerReceiver.ARG_KEY_GPLAY_REFERRER, testReferrerData1);\n        receiver.onReceive(ApplicationProvider.getApplicationContext().getApplicationContext(), testIntent);\n        Thread.sleep(250);\n        String referrerDataFromPreferences = getMatomo().getPreferences().getString(InstallReferrerReceiver.PREF_KEY_INSTALL_REFERRER_EXTRAS, null);\n        assertEquals(testReferrerData1, referrerDataFromPreferences);\n        assertTrue(testIntent.getBooleanExtra(\"forwarded\", false));\n\n\n        String testReferrerData2 = \"pk_campaign=Email-Nov2011&pk_kwd=OrderNow\";\n        testIntent.putExtra(InstallReferrerReceiver.ARG_KEY_GPLAY_REFERRER, testReferrerData2);\n\n        receiver.onReceive(ApplicationProvider.getApplicationContext().getApplicationContext(), testIntent);\n        Thread.sleep(250);\n        referrerDataFromPreferences = getMatomo().getPreferences().getString(InstallReferrerReceiver.PREF_KEY_INSTALL_REFERRER_EXTRAS, null);\n        assertEquals(testReferrerData1, referrerDataFromPreferences);\n\n\n        testIntent.putExtra(\"forwarded\", false);\n        receiver.onReceive(ApplicationProvider.getApplicationContext().getApplicationContext(), testIntent);\n        Thread.sleep(250);\n        referrerDataFromPreferences = getMatomo().getPreferences().getString(InstallReferrerReceiver.PREF_KEY_INSTALL_REFERRER_EXTRAS, null);\n        assertEquals(testReferrerData2, referrerDataFromPreferences);\n    }\n\n    @Test\n    public void testGracefulFailure() throws Exception {\n        InstallReferrerReceiver receiver = new InstallReferrerReceiver();\n        Intent badIntent = new Intent(\"bad.action\");\n        badIntent.setPackage(ApplicationProvider.getApplicationContext().getPackageName());\n\n        String testReferrerData1 = \"utm_source=test_source&utm_medium=test_medium&utm_term=test_term&utm_content=test_content&utm_campaign=test_name\";\n        badIntent.putExtra(InstallReferrerReceiver.ARG_KEY_GPLAY_REFERRER, testReferrerData1);\n        receiver.onReceive(ApplicationProvider.getApplicationContext().getApplicationContext(), badIntent);\n        Thread.sleep(250);\n        String referrerDataFromPreferences = getMatomo().getPreferences().getString(InstallReferrerReceiver.PREF_KEY_INSTALL_REFERRER_EXTRAS, null);\n        assertNull(referrerDataFromPreferences);\n\n\n        Intent nullIntent = new Intent();\n        nullIntent.setPackage(ApplicationProvider.getApplicationContext().getPackageName());\n\n        testReferrerData1 = \"utm_source=test_source&utm_medium=test_medium&utm_term=test_term&utm_content=test_content&utm_campaign=test_name\";\n        nullIntent.putExtra(InstallReferrerReceiver.ARG_KEY_GPLAY_REFERRER, testReferrerData1);\n        receiver.onReceive(ApplicationProvider.getApplicationContext().getApplicationContext(), nullIntent);\n        Thread.sleep(250);\n        referrerDataFromPreferences = getMatomo().getPreferences().getString(InstallReferrerReceiver.PREF_KEY_INSTALL_REFERRER_EXTRAS, null);\n        assertNull(referrerDataFromPreferences);\n    }\n\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/extra/MatomoApplicationTest.java",
    "content": "package org.matomo.sdk.extra;\n\nimport android.app.Application;\n\nimport org.junit.Assert;\nimport org.junit.Test;\nimport org.junit.runner.RunWith;\nimport org.matomo.sdk.Matomo;\nimport org.matomo.sdk.QueryParams;\nimport org.matomo.sdk.Tracker;\nimport org.robolectric.Robolectric;\nimport org.robolectric.annotation.Config;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\n\nimport androidx.test.core.app.ApplicationProvider;\nimport testhelpers.DefaultTestCase;\nimport testhelpers.FullEnvTestRunner;\nimport testhelpers.MatomoTestApplication;\nimport testhelpers.QueryHashMap;\nimport testhelpers.TestActivity;\n\nimport static org.junit.Assert.assertEquals;\n\n@Config(sdk = 28, manifest = Config.NONE, application = MatomoTestApplication.class)\n@RunWith(FullEnvTestRunner.class)\npublic class MatomoApplicationTest extends DefaultTestCase {\n\n    @Test\n    public void testAutoBindActivities() {\n        Application app = ApplicationProvider.getApplicationContext();\n        Tracker tracker = createTracker();\n        tracker.setDryRunTarget(Collections.synchronizedList(new ArrayList<>()));\n        //auto attach tracking screen view\n        TrackHelper.track().screens(app).with(tracker);\n\n        // emulate default trackScreenView\n        Robolectric.buildActivity(TestActivity.class).create().start().resume().visible().get();\n\n        assertEquals(TestActivity.getTestTitle(), new QueryHashMap(tracker.getLastEventX()).get(QueryParams.ACTION_NAME));\n    }\n\n    @Test\n    public void testApplicationGetTracker() {\n        MatomoApplication matomoApplication = ApplicationProvider.getApplicationContext();\n        assertEquals(matomoApplication.getTracker(), matomoApplication.getTracker());\n    }\n\n    @Test\n    public void testApplication() {\n        MatomoApplication matomoApplication = ApplicationProvider.getApplicationContext();\n        Assert.assertEquals(matomoApplication.getMatomo(), Matomo.getInstance(matomoApplication));\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/extra/TrackHelperTest.java",
    "content": "package org.matomo.sdk.extra;\n\nimport android.content.Context;\nimport android.content.pm.PackageInfo;\nimport android.content.pm.PackageManager;\n\nimport org.json.JSONArray;\nimport org.junit.Before;\nimport org.junit.Test;\nimport org.matomo.sdk.Matomo;\nimport org.matomo.sdk.QueryParams;\nimport org.matomo.sdk.TrackMe;\nimport org.matomo.sdk.Tracker;\nimport org.matomo.sdk.dispatcher.DispatchMode;\nimport org.mockito.ArgumentCaptor;\nimport org.mockito.Mock;\nimport org.mockito.MockitoAnnotations;\n\nimport java.net.MalformedURLException;\nimport java.net.URL;\nimport java.util.Locale;\nimport java.util.UUID;\n\nimport static org.hamcrest.MatcherAssert.assertThat;\nimport static org.hamcrest.core.Is.is;\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertFalse;\nimport static org.junit.Assert.assertNotEquals;\nimport static org.junit.Assert.assertNotNull;\nimport static org.junit.Assert.assertNull;\nimport static org.junit.Assert.assertTrue;\nimport static org.matomo.sdk.extra.TrackHelper.track;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyInt;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\n\n@SuppressWarnings(\"deprecation\")\npublic class TrackHelperTest {\n    ArgumentCaptor<TrackMe> mCaptor = ArgumentCaptor.forClass(TrackMe.class);\n    @Mock Tracker mTracker;\n    @Mock Matomo mMatomo;\n    @Mock Context mContext;\n    @Mock PackageManager mPackageManager;\n    @Mock MatomoApplication mMatomoApplication;\n\n    @Before\n    public void setup() throws PackageManager.NameNotFoundException {\n        MockitoAnnotations.initMocks(this);\n        when(mTracker.getMatomo()).thenReturn(mMatomo);\n        when(mMatomo.getContext()).thenReturn(mContext);\n        when(mContext.getPackageManager()).thenReturn(mPackageManager);\n        when(mContext.getPackageName()).thenReturn(\"packageName\");\n        when(mMatomoApplication.getTracker()).thenReturn(mTracker);\n        PackageInfo packageInfo = new PackageInfo();\n        packageInfo.versionCode = 123;\n        //noinspection WrongConstant\n        when(mPackageManager.getPackageInfo(anyString(), anyInt())).thenReturn(packageInfo);\n    }\n\n    @Test\n    public void testBaseEvent() {\n        track().screen(\"/path\").with(mMatomoApplication);\n        verify(mMatomoApplication).getTracker();\n        verify(mTracker).track(any(TrackMe.class));\n    }\n\n    @Test\n    public void testBaseEvent_track_safely() {\n        final TrackHelper.BaseEvent badTrackMe = new TrackHelper.BaseEvent(null) {\n            @Override\n            public TrackMe build() {\n                throw new IllegalArgumentException();\n            }\n        };\n        assertThat(badTrackMe.safelyWith(mTracker), is(false));\n        assertThat(badTrackMe.safelyWith(mMatomoApplication), is(false));\n        verify(mTracker, never()).track(any(TrackMe.class));\n\n        final TrackHelper.BaseEvent goodTrackMe = new TrackHelper.BaseEvent(null) {\n            @Override\n            public TrackMe build() {\n                return new TrackMe();\n            }\n        };\n        assertThat(goodTrackMe.safelyWith(mTracker), is(true));\n        verify(mTracker, times(1)).track(any(TrackMe.class));\n        assertThat(goodTrackMe.safelyWith(mMatomoApplication), is(true));\n        verify(mTracker, times(2)).track(any(TrackMe.class));\n    }\n\n    @Test\n    public void testOutlink() throws Exception {\n        URL valid = new URL(\"https://foo.bar\");\n        track().outlink(valid).with(mTracker);\n        verify(mTracker).track(mCaptor.capture());\n        assertEquals(valid.toExternalForm(), mCaptor.getValue().get(QueryParams.LINK));\n        assertEquals(valid.toExternalForm(), mCaptor.getValue().get(QueryParams.URL_PATH));\n\n        valid = new URL(\"https://foo.bar\");\n        track().outlink(valid).with(mTracker);\n        verify(mTracker, times(2)).track(mCaptor.capture());\n        assertEquals(valid.toExternalForm(), mCaptor.getValue().get(QueryParams.LINK));\n        assertEquals(valid.toExternalForm(), mCaptor.getValue().get(QueryParams.URL_PATH));\n\n        valid = new URL(\"ftp://foo.bar\");\n        track().outlink(valid).with(mTracker);\n        verify(mTracker, times(3)).track(mCaptor.capture());\n        assertEquals(valid.toExternalForm(), mCaptor.getValue().get(QueryParams.LINK));\n        assertEquals(valid.toExternalForm(), mCaptor.getValue().get(QueryParams.URL_PATH));\n    }\n\n    @Test(expected = IllegalArgumentException.class)\n    public void testOutlink_invalid_url() throws MalformedURLException {\n        track().outlink(new URL(\"file://mount/sdcard/something\")).build();\n    }\n\n    @Test\n    public void testDownloadTrackChecksum() {\n        DownloadTracker downloadTracker = mock(DownloadTracker.class);\n        track().download(downloadTracker).identifier(new DownloadTracker.Extra.ApkChecksum(mContext)).with(mTracker);\n        verify(downloadTracker).trackOnce(any(TrackMe.class), any(DownloadTracker.Extra.ApkChecksum.class));\n    }\n\n    @Test\n    public void testDownloadTrackForced() {\n        DownloadTracker downloadTracker = mock(DownloadTracker.class);\n        track().download(downloadTracker).force().with(mTracker);\n        verify(downloadTracker).trackNewAppDownload(any(TrackMe.class), any(DownloadTracker.Extra.None.class));\n    }\n\n    @Test\n    public void testDownloadCustomVersion() {\n        DownloadTracker downloadTracker = mock(DownloadTracker.class);\n        String version = UUID.randomUUID().toString();\n\n        track().download(downloadTracker).version(version).with(mTracker);\n        verify(downloadTracker).setVersion(version);\n        verify(downloadTracker).trackOnce(any(TrackMe.class), any(DownloadTracker.Extra.class));\n    }\n\n    @Test\n    public void testVisitCustomVariables_merge_base() {\n        CustomVariables varsA = new CustomVariables().put(1, \"visit1\", \"A\");\n        CustomVariables varsB = new CustomVariables().put(2, \"visit2\", \"B\");\n        CustomVariables combined = new CustomVariables().put(1, \"visit1\", \"A\").put(2, \"visit2\", \"B\");\n\n        TrackHelper.track(varsA.toVisitVariables())\n                .visitVariables(varsB)\n                .screen(\"/path\")\n                .with(mTracker);\n\n        verify(mTracker).track(mCaptor.capture());\n        assertEquals(combined.toString(), mCaptor.getValue().get(QueryParams.VISIT_SCOPE_CUSTOM_VARIABLES));\n        assertEquals(\"/path\", mCaptor.getValue().get(QueryParams.URL_PATH));\n    }\n\n    @Test\n    public void testVisitCustomVariables_merge_singles() {\n        CustomVariables varsA = new CustomVariables().put(1, \"visit1\", \"A\");\n        CustomVariables varsB = new CustomVariables().put(2, \"visit2\", \"B\");\n        CustomVariables combined = new CustomVariables().put(1, \"visit1\", \"A\").put(2, \"visit2\", \"B\");\n\n        TrackHelper.track()\n                .visitVariables(varsA)\n                .visitVariables(varsB)\n                .screen(\"/path\")\n                .with(mTracker);\n\n        verify(mTracker).track(mCaptor.capture());\n        assertEquals(combined.toString(), mCaptor.getValue().get(QueryParams.VISIT_SCOPE_CUSTOM_VARIABLES));\n        assertEquals(\"/path\", mCaptor.getValue().get(QueryParams.URL_PATH));\n    }\n\n    @Test\n    public void testVisitCustomVariables_add() {\n        CustomVariables _vars = new CustomVariables();\n        _vars.put(1, \"visit1\", \"A\");\n        _vars.put(2, \"visit2\", \"B\");\n\n        TrackHelper.track()\n                .visitVariables(1, \"visit1\", \"A\")\n                .visitVariables(2, \"visit2\", \"B\")\n                .screen(\"/path\")\n                .with(mTracker);\n\n        verify(mTracker).track(mCaptor.capture());\n        assertEquals(_vars.toString(), mCaptor.getValue().get(QueryParams.VISIT_SCOPE_CUSTOM_VARIABLES));\n        assertEquals(\"/path\", mCaptor.getValue().get(QueryParams.URL_PATH));\n    }\n\n    @Test\n    public void testSetScreenCustomVariable() {\n        track()\n                .screen(\"\")\n                .variable(1, \"2\", \"3\")\n                .with(mTracker);\n\n        verify(mTracker).track(mCaptor.capture());\n        assertEquals(\"{'1':['2','3']}\".replaceAll(\"'\", \"\\\"\"), mCaptor.getValue().get(QueryParams.SCREEN_SCOPE_CUSTOM_VARIABLES));\n    }\n\n    @Test\n    public void testSetScreenCustomDimension() {\n        track()\n                .screen(\"\")\n                .dimension(1, \"dim1\")\n                .dimension(2, \"dim2\")\n                .dimension(3, \"dim3\")\n                .dimension(3, null)\n                .dimension(4, null)\n                .with(mTracker);\n\n        verify(mTracker).track(mCaptor.capture());\n        assertEquals(\"dim1\", CustomDimension.getDimension(mCaptor.getValue(), 1));\n        assertEquals(\"dim2\", CustomDimension.getDimension(mCaptor.getValue(), 2));\n        assertNull(CustomDimension.getDimension(mCaptor.getValue(), 3));\n        assertNull(CustomDimension.getDimension(mCaptor.getValue(), 4));\n    }\n\n    @Test(expected = IllegalArgumentException.class)\n    public void testSetScreem_empty_path() {\n        TrackHelper.track().screen((String) null).build();\n    }\n\n    @Test\n    public void testCustomDimension_trackHelperAny() {\n        TrackHelper.track()\n                .dimension(1, \"visit\")\n                .dimension(2, \"screen\")\n                .event(\"category\", \"action\")\n                .with(mTracker);\n\n        verify(mTracker).track(mCaptor.capture());\n        assertEquals(\"visit\", CustomDimension.getDimension(mCaptor.getValue(), 1));\n        assertEquals(\"screen\", CustomDimension.getDimension(mCaptor.getValue(), 2));\n        assertEquals(\"category\", mCaptor.getValue().get(QueryParams.EVENT_CATEGORY));\n        assertEquals(\"action\", mCaptor.getValue().get(QueryParams.EVENT_ACTION));\n    }\n\n    @Test\n    public void testCustomDimension_override() {\n        TrackHelper.track()\n                .dimension(1, \"visit\")\n                .dimension(2, \"screen\")\n                .screen(\"/path\")\n                .dimension(1, null)\n                .with(mTracker);\n\n        verify(mTracker).track(mCaptor.capture());\n        assertNull(CustomDimension.getDimension(mCaptor.getValue(), 1));\n        assertEquals(\"screen\", CustomDimension.getDimension(mCaptor.getValue(), 2));\n        assertEquals(\"/path\", mCaptor.getValue().get(QueryParams.URL_PATH));\n    }\n\n    @Test\n    public void testTrackScreenView() {\n        track().screen(\"/test/test\").title(\"title\").with(mTracker);\n        verify(mTracker).track(mCaptor.capture());\n        assertTrue(mCaptor.getValue().get(QueryParams.URL_PATH).endsWith(\"/test/test\"));\n    }\n\n    @Test\n    public void testTrackScreenWithTitleView() {\n        track().screen(\"/test/test\").title(\"Test title\").with(mTracker);\n        verify(mTracker).track(mCaptor.capture());\n        assertTrue(mCaptor.getValue().get(QueryParams.URL_PATH).endsWith(\"/test/test\"));\n        assertEquals(mCaptor.getValue().get(QueryParams.ACTION_NAME), \"Test title\");\n    }\n\n    @Test\n    public void testTrackScreenWithCampaignView() {\n        track().screen(\"/test/test\").campaign(\"campaign_name\", \"campaign_keyword\").with(mTracker);\n        verify(mTracker).track(mCaptor.capture());\n        assertTrue(mCaptor.getValue().get(QueryParams.URL_PATH).endsWith(\"/test/test\"));\n        assertEquals(mCaptor.getValue().get(QueryParams.CAMPAIGN_NAME), \"campaign_name\");\n        assertEquals(mCaptor.getValue().get(QueryParams.CAMPAIGN_KEYWORD), \"campaign_keyword\");\n    }\n\n    @Test\n    public void testTrackEvent() {\n        track().event(\"category\", \"test action\").with(mTracker);\n        verify(mTracker).track(mCaptor.capture());\n        TrackMe tracked = mCaptor.getValue();\n        assertEquals(tracked.get(QueryParams.EVENT_CATEGORY), \"category\");\n        assertEquals(tracked.get(QueryParams.EVENT_ACTION), \"test action\");\n    }\n\n    @Test\n    public void testTrackEventName() {\n        String name = \"test name2\";\n        track().event(\"category\", \"test action\").name(name).with(mTracker);\n        verify(mTracker).track(mCaptor.capture());\n        TrackMe tracked = mCaptor.getValue();\n        assertEquals(tracked.get(QueryParams.EVENT_CATEGORY), \"category\");\n        assertEquals(tracked.get(QueryParams.EVENT_ACTION), \"test action\");\n        assertEquals(tracked.get(QueryParams.EVENT_NAME), name);\n    }\n\n    @Test\n    public void testTrackEventNameAndValue() {\n        String name = \"test name3\";\n        track().event(\"category\", \"test action\").name(name).value(1f).with(mTracker);\n        verify(mTracker).track(mCaptor.capture());\n        TrackMe tracked = mCaptor.getValue();\n        assertEquals(tracked.get(QueryParams.EVENT_CATEGORY), \"category\");\n        assertEquals(tracked.get(QueryParams.EVENT_ACTION), \"test action\");\n        assertEquals(tracked.get(QueryParams.EVENT_NAME), name);\n        assertEquals(String.valueOf(tracked.get(QueryParams.EVENT_VALUE)), String.valueOf(1f));\n    }\n\n    @Test\n    public void testTrackEventNameAndValueWithpath() {\n        track().event(\"category\", \"test action\").name(\"test name3\").path(\"/path\").value(1f).with(mTracker);\n        verify(mTracker).track(mCaptor.capture());\n        TrackMe tracked = mCaptor.getValue();\n        assertEquals(tracked.get(QueryParams.EVENT_CATEGORY), \"category\");\n        assertEquals(tracked.get(QueryParams.EVENT_ACTION), \"test action\");\n        assertEquals(tracked.get(QueryParams.EVENT_NAME), \"test name3\");\n        assertEquals(tracked.get(QueryParams.URL_PATH), \"/path\");\n        assertEquals(String.valueOf(tracked.get(QueryParams.EVENT_VALUE)), String.valueOf(1f));\n    }\n\n    @Test\n    public void testTrackGoal() {\n        track().goal(1).with(mTracker);\n        verify(mTracker).track(mCaptor.capture());\n\n        assertNull(mCaptor.getValue().get(QueryParams.REVENUE));\n        assertEquals(mCaptor.getValue().get(QueryParams.GOAL_ID), \"1\");\n    }\n\n    @Test(expected = IllegalArgumentException.class)\n    public void testTrackGoal_invalid_id() {\n        track().goal(-1).revenue(100f).build();\n    }\n\n    @Test\n    public void testTrackSiteSearch() {\n        track().search(\"keyword\").category(\"category\").count(1337).with(mTracker);\n        verify(mTracker).track(mCaptor.capture());\n\n        assertEquals(mCaptor.getValue().get(QueryParams.SEARCH_KEYWORD), \"keyword\");\n        assertEquals(mCaptor.getValue().get(QueryParams.SEARCH_CATEGORY), \"category\");\n        assertEquals(mCaptor.getValue().get(QueryParams.SEARCH_NUMBER_OF_HITS), String.valueOf(1337));\n\n        track().search(\"keyword2\").with(mTracker);\n        verify(mTracker, times(2)).track(mCaptor.capture());\n\n        assertEquals(mCaptor.getValue().get(QueryParams.SEARCH_KEYWORD), \"keyword2\");\n        assertNull(mCaptor.getValue().get(QueryParams.SEARCH_CATEGORY));\n        assertNull(mCaptor.getValue().get(QueryParams.SEARCH_NUMBER_OF_HITS));\n    }\n\n    @Test\n    public void testTrackGoalRevenue() {\n        track().goal(1).revenue(100f).with(mTracker);\n        verify(mTracker).track(mCaptor.capture());\n\n        assertEquals(\"1\", mCaptor.getValue().get(QueryParams.GOAL_ID));\n        assertEquals(100f, Float.parseFloat(mCaptor.getValue().get(QueryParams.REVENUE)), 0.0);\n    }\n\n    @Test\n    public void testTrackContentImpression() {\n        String name = \"test name2\";\n        track().impression(name).piece(\"test\").target(\"test2\").with(mTracker);\n        verify(mTracker).track(mCaptor.capture());\n\n        assertEquals(mCaptor.getValue().get(QueryParams.CONTENT_NAME), name);\n        assertEquals(mCaptor.getValue().get(QueryParams.CONTENT_PIECE), \"test\");\n        assertEquals(mCaptor.getValue().get(QueryParams.CONTENT_TARGET), \"test2\");\n    }\n\n    @Test(expected = IllegalArgumentException.class)\n    public void testTrackContentImpression_invalid_name_empty() {\n        track().impression(\"\").build();\n    }\n\n    @Test(expected = IllegalArgumentException.class)\n    public void testTrackContentImpression_invalid_name_null() {\n        track().impression(null).build();\n    }\n\n    @Test\n    public void testTrackContentInteraction_invalid_name_empty() {\n        int errorCount = 0;\n        try {\n            track().interaction(\"\", \"test\").piece(\"test\").target(\"test2\").build();\n        } catch (IllegalArgumentException e) { errorCount++; }\n        try {\n            track().interaction(\"test\", \"\").piece(\"test\").target(\"test2\").build();\n        } catch (IllegalArgumentException e) { errorCount++; }\n        try {\n            track().interaction(\"\", \"\").piece(\"test\").target(\"test2\").build();\n        } catch (IllegalArgumentException e) { errorCount++; }\n        assertThat(errorCount, is(3));\n    }\n\n    @Test\n    public void testTrackContentInteraction_invalid_name_null() {\n        int errorCount = 0;\n        try {\n            track().interaction(null, \"test\").piece(\"test\").target(\"test2\").build();\n        } catch (IllegalArgumentException e) { errorCount++; }\n        try {\n            track().interaction(\"test\", null).piece(\"test\").target(\"test2\").build();\n        } catch (IllegalArgumentException e) { errorCount++; }\n        try {\n            track().interaction(null, null).piece(\"test\").target(\"test2\").build();\n        } catch (IllegalArgumentException e) { errorCount++; }\n        assertThat(errorCount, is(3));\n    }\n\n    @Test\n    public void testTrackEcommerceCartUpdate() throws Exception {\n        Locale.setDefault(Locale.US);\n        EcommerceItems items = new EcommerceItems();\n        items.addItem(new EcommerceItems.Item(\"fake_sku\").name(\"fake_product\").category(\"fake_category\").price(200).quantity(2));\n        items.addItem(new EcommerceItems.Item(\"fake_sku_2\").name(\"fake_product_2\").category(\"fake_category_2\").price(400).quantity(3));\n        track().cartUpdate(50000).items(items).with(mTracker);\n        verify(mTracker).track(mCaptor.capture());\n\n        assertEquals(mCaptor.getValue().get(QueryParams.GOAL_ID), \"0\");\n        assertEquals(mCaptor.getValue().get(QueryParams.REVENUE), \"500.00\");\n\n        String ecommerceItemsJson = mCaptor.getValue().get(QueryParams.ECOMMERCE_ITEMS);\n\n        new JSONArray(ecommerceItemsJson); // will throw exception if not valid json\n\n        assertTrue(ecommerceItemsJson.contains(\"[\\\"fake_sku\\\",\\\"fake_product\\\",\\\"fake_category\\\",\\\"2.00\\\",\\\"2\\\"]\"));\n        assertTrue(ecommerceItemsJson.contains(\"[\\\"fake_sku_2\\\",\\\"fake_product_2\\\",\\\"fake_category_2\\\",\\\"4.00\\\",\\\"3\\\"]\"));\n    }\n\n    @Test\n    public void testTrackEcommerceOrder() throws Exception {\n        Locale.setDefault(Locale.US);\n        EcommerceItems items = new EcommerceItems();\n        items.addItem(new EcommerceItems.Item(\"fake_sku\").name(\"fake_product\").category(\"fake_category\").price(200).quantity(2));\n        items.addItem(new EcommerceItems.Item(\"fake_sku_2\").name(\"fake_product_2\").category(\"fake_category_2\").price(400).quantity(3));\n        track().order(\"orderId\", 10020).subTotal(7002).tax(2000).shipping(1000).discount(0).items(items).with(mTracker);\n        verify(mTracker).track(mCaptor.capture());\n        TrackMe tracked = mCaptor.getValue();\n        assertEquals(tracked.get(QueryParams.GOAL_ID), \"0\");\n        assertEquals(tracked.get(QueryParams.ORDER_ID), \"orderId\");\n        assertEquals(tracked.get(QueryParams.REVENUE), \"100.20\");\n        assertEquals(tracked.get(QueryParams.SUBTOTAL), \"70.02\");\n        assertEquals(tracked.get(QueryParams.TAX), \"20.00\");\n        assertEquals(tracked.get(QueryParams.SHIPPING), \"10.00\");\n        assertEquals(tracked.get(QueryParams.DISCOUNT), \"0.00\");\n\n        String ecommerceItemsJson = tracked.get(QueryParams.ECOMMERCE_ITEMS);\n\n        new JSONArray(ecommerceItemsJson); // will throw exception if not valid json\n\n        assertTrue(ecommerceItemsJson.contains(\"[\\\"fake_sku\\\",\\\"fake_product\\\",\\\"fake_category\\\",\\\"2.00\\\",\\\"2\\\"]\"));\n        assertTrue(ecommerceItemsJson.contains(\"[\\\"fake_sku_2\\\",\\\"fake_product_2\\\",\\\"fake_category_2\\\",\\\"4.00\\\",\\\"3\\\"]\"));\n    }\n\n    @Test\n    public void testTrackException() {\n        Exception catchedException;\n        try {\n            throw new Exception(\"Test\");\n        } catch (Exception e) {\n            catchedException = e;\n        }\n        assertNotNull(catchedException);\n        track().exception(catchedException).description(\"<Null> exception\").fatal(false).with(mTracker);\n        verify(mTracker).track(mCaptor.capture());\n        assertEquals(mCaptor.getValue().get(QueryParams.EVENT_CATEGORY), \"Exception\");\n        StackTraceElement traceElement = catchedException.getStackTrace()[0];\n        assertNotNull(traceElement);\n        assertEquals(mCaptor.getValue().get(QueryParams.EVENT_ACTION), \"org.matomo.sdk.extra.TrackHelperTest\" + \"/\" + \"testTrackException\" + \":\" + traceElement.getLineNumber());\n        assertEquals(mCaptor.getValue().get(QueryParams.EVENT_NAME), \"<Null> exception\");\n    }\n\n    @SuppressWarnings({\"divzero\", \"NumericOverflow\"})\n    @Test\n    public void testExceptionHandler() {\n        assertFalse(Thread.getDefaultUncaughtExceptionHandler() instanceof MatomoExceptionHandler);\n        track().uncaughtExceptions().with(mTracker);\n        assertTrue(Thread.getDefaultUncaughtExceptionHandler() instanceof MatomoExceptionHandler);\n        try {\n            int i = 1 / 0;\n            assertNotEquals(i, 0);\n        } catch (Exception e) {\n            (Thread.getDefaultUncaughtExceptionHandler()).uncaughtException(Thread.currentThread(), e);\n        }\n        verify(mTracker).track(mCaptor.capture());\n        TrackMe tracked = mCaptor.getValue();\n        assertEquals(tracked.get(QueryParams.EVENT_CATEGORY), \"Exception\");\n        assertTrue(tracked.get(QueryParams.EVENT_ACTION).startsWith(\"org.matomo.sdk.extra.TrackHelperTest/testExceptionHandler:\"));\n        assertEquals(tracked.get(QueryParams.EVENT_NAME), \"/ by zero\");\n        assertEquals(tracked.get(QueryParams.EVENT_VALUE), \"1\");\n\n        verify(mTracker).setDispatchMode(DispatchMode.EXCEPTION);\n        verify(mTracker).dispatchBlocking();\n\n        boolean exception = false;\n        try {\n            track().uncaughtExceptions().with(mTracker);\n        } catch (RuntimeException e) {\n            exception = true;\n        }\n        assertTrue(exception);\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/tools/BuildInfoTest.kt",
    "content": "package org.matomo.sdk.tools\n\nimport android.os.Build\nimport org.junit.Assert\nimport org.junit.Before\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.mockito.junit.MockitoJUnitRunner\nimport testhelpers.BaseTest\n\n@RunWith(MockitoJUnitRunner::class)\nclass BuildInfoTest : BaseTest() {\n    private var buildInfo: BuildInfo? = null\n\n    @Before\n    @Throws(Exception::class)\n    override fun setup() {\n        super.setup()\n        buildInfo = BuildInfo()\n    }\n\n    @Test\n    fun testGetRelease() {\n        Assert.assertEquals(Build.VERSION.RELEASE, buildInfo!!.release)\n    }\n\n    @Test\n    fun testGetModel() {\n        Assert.assertEquals(Build.MODEL, buildInfo!!.model)\n    }\n\n    @Test\n    fun testGetBuildId() {\n        Assert.assertEquals(Build.ID, buildInfo!!.buildId)\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/tools/ChecksumTest.kt",
    "content": "package org.matomo.sdk.tools\n\nimport org.junit.Assert\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.mockito.junit.MockitoJUnitRunner\nimport testhelpers.BaseTest\nimport java.io.File\n\n@RunWith(MockitoJUnitRunner::class)\nclass ChecksumTest : BaseTest() {\n    @Test\n    @Throws(Exception::class)\n    fun testgetMD5Checksum() {\n        val md5 = Checksum.getMD5Checksum(\"foo\")\n        Assert.assertEquals(md5, \"ACBD18DB4CC2F85CEDEF654FCCC4A4D8\")\n    }\n\n    @Test\n    fun testHex() {\n        Assert.assertNull(Checksum.getHex(null))\n    }\n\n    @Test\n    @Throws(Exception::class)\n    fun testgetMD5ChecksumDir() {\n        val directory = File(\".\", \"\")\n        val md5 = Checksum.getMD5Checksum(directory)\n        Assert.assertNull(md5)\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/tools/ConnectivityTest.kt",
    "content": "package org.matomo.sdk.tools\n\nimport android.content.Context\nimport android.net.ConnectivityManager\nimport android.net.NetworkInfo\nimport org.junit.Assert\nimport org.junit.Before\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.mockito.Mock\nimport org.mockito.Mockito\nimport org.mockito.junit.MockitoJUnitRunner\nimport testhelpers.BaseTest\n\n@RunWith(MockitoJUnitRunner::class)\nclass ConnectivityTest : BaseTest() {\n    @Mock\n    var context: Context? = null\n\n    @Mock\n    var connectivityManager: ConnectivityManager? = null\n\n    @Mock\n    var networkInfo: NetworkInfo? = null\n\n    @Before\n    override fun setup() {\n        Mockito.`when`(context!!.getSystemService(Context.CONNECTIVITY_SERVICE)).thenReturn(connectivityManager)\n    }\n\n    @Test\n    fun testGetType_none() {\n        val connectivity = Connectivity(context)\n        Assert.assertEquals(Connectivity.Type.NONE, connectivity.type)\n        Mockito.verify(connectivityManager)?.activeNetworkInfo\n    }\n\n    @Test\n    fun testGetType_wifi() {\n        val connectivity = Connectivity(context)\n        Mockito.`when`(connectivityManager!!.activeNetworkInfo).thenReturn(networkInfo)\n        Mockito.`when`(networkInfo!!.type).thenReturn(ConnectivityManager.TYPE_WIFI)\n        Assert.assertEquals(Connectivity.Type.WIFI, connectivity.type)\n        Mockito.verify(connectivityManager)?.activeNetworkInfo\n    }\n\n    @Test\n    fun testGetType_else() {\n        val connectivity = Connectivity(context)\n        Mockito.`when`(connectivityManager!!.activeNetworkInfo).thenReturn(networkInfo)\n        Mockito.`when`(networkInfo!!.type).thenReturn(ConnectivityManager.TYPE_WIMAX)\n        Assert.assertEquals(Connectivity.Type.MOBILE, connectivity.type)\n        Mockito.verify(connectivityManager)?.activeNetworkInfo\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/tools/CurrencyFormatterTest.kt",
    "content": "package org.matomo.sdk.tools\n\nimport org.junit.Assert\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.mockito.junit.MockitoJUnitRunner\nimport testhelpers.BaseTest\n\n@RunWith(MockitoJUnitRunner::class)\nclass CurrencyFormatterTest : BaseTest() {\n    @Test\n    fun testCurrencyFormat() {\n        Assert.assertEquals(\"10.00\", CurrencyFormatter.priceString(1000))\n        Assert.assertEquals(\"39.50\", CurrencyFormatter.priceString(3950))\n        Assert.assertEquals(\"0.01\", CurrencyFormatter.priceString(1))\n        Assert.assertEquals(\"250.34\", CurrencyFormatter.priceString(25034))\n        Assert.assertEquals(\"1747.20\", CurrencyFormatter.priceString(174720))\n        Assert.assertEquals(\"1234567.89\", CurrencyFormatter.priceString(123456789))\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/tools/DeviceHelperTest.kt",
    "content": "package org.matomo.sdk.tools\n\nimport android.content.Context\nimport org.junit.Assert\nimport org.junit.Before\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.mockito.Mock\nimport org.mockito.Mockito\nimport org.mockito.junit.MockitoJUnitRunner\nimport testhelpers.BaseTest\n\n\n@RunWith(MockitoJUnitRunner::class)\nclass DeviceHelperTest : BaseTest() {\n    @Mock\n    var propertySource: PropertySource? = null\n\n    @Mock\n    var buildInfo: BuildInfo? = null\n\n    @Mock\n    var context: Context? = null\n    private var deviceHelper: DeviceHelper? = null\n\n    @Before\n    @Throws(Exception::class)\n    override fun setup() {\n        super.setup()\n        Mockito.`when`(buildInfo!!.buildId).thenReturn(\"ABCDEF\")\n        Mockito.`when`(buildInfo!!.model).thenReturn(\"UnitTest\")\n        Mockito.`when`(buildInfo!!.release).thenReturn(\"8.0.0\")\n        Mockito.`when`(propertySource!!.jvmVersion).thenReturn(\"2.2.0\")\n        deviceHelper = DeviceHelper(context, propertySource, buildInfo)\n    }\n\n    @Test\n    fun testGetHttpAgent_normal() {\n        Mockito.`when`(propertySource!!.httpAgent).thenReturn(\"testagent\")\n        Assert.assertEquals(\"testagent\", deviceHelper!!.userAgent)\n    }\n\n    @Test\n    fun testGetHttpAgent_badAgent() {\n        Mockito.`when`(propertySource!!.httpAgent).thenReturn(\"Apache-HttpClient/UNAVAILABLE (java 1.4)\")\n        Assert.assertEquals(\"Dalvik/2.2.0 (Linux; U; Android 8.0.0; UnitTest Build/ABCDEF)\", deviceHelper!!.userAgent)\n        Mockito.verify(buildInfo)?.buildId\n        Mockito.verify(buildInfo)?.model\n        Mockito.verify(buildInfo)?.release\n        Mockito.verify(propertySource)?.jvmVersion\n\n        Mockito.`when`(propertySource!!.jvmVersion).thenReturn(null)\n        Assert.assertEquals(\"Dalvik/0.0.0 (Linux; U; Android 8.0.0; UnitTest Build/ABCDEF)\", deviceHelper!!.userAgent)\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/org/matomo/sdk/tools/PropertySourceTest.kt",
    "content": "package org.matomo.sdk.tools\n\nimport org.junit.Assert\nimport org.junit.Test\nimport org.mockito.Mockito\nimport testhelpers.BaseTest\n\nclass PropertySourceTest : BaseTest() {\n    @Test\n    fun testGetHttpAgent() {\n        val propertySource = Mockito.spy(PropertySource())\n        propertySource.httpAgent\n        Mockito.verify(propertySource).getSystemProperty(\"http.agent\")\n\n        Assert.assertEquals(propertySource.httpAgent, propertySource.getSystemProperty(\"http.agent\"))\n    }\n\n    @Test\n    fun testGetJVMVersion() {\n        val propertySource = Mockito.spy(PropertySource())\n        propertySource.jvmVersion\n        Mockito.verify(propertySource).getSystemProperty(\"java.vm.version\")\n\n        Assert.assertEquals(propertySource.jvmVersion, propertySource.getSystemProperty(\"java.vm.version\"))\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/testhelpers/BaseTest.kt",
    "content": "package testhelpers\n\nimport org.junit.After\nimport org.junit.Before\nimport timber.log.Timber\nimport timber.log.Timber.Forest.plant\n\nopen class BaseTest {\n    @Before\n    @Throws(Exception::class)\n    open fun setup() {\n        plant(JUnitTree())\n    }\n\n    @After\n    @Throws(Exception::class)\n    open fun tearDown() {\n        Timber.uprootAll()\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/testhelpers/DefaultTestCase.kt",
    "content": "package testhelpers\n\nimport androidx.test.core.app.ApplicationProvider\nimport org.junit.runner.RunWith\nimport org.matomo.sdk.Matomo\nimport org.matomo.sdk.Matomo.Companion.getInstance\nimport org.matomo.sdk.Tracker\nimport org.robolectric.annotation.Config\n\n@Config(sdk = [21], manifest = Config.NONE)\n@RunWith(FullEnvTestRunner::class)\nabstract class DefaultTestCase : BaseTest() {\n    fun createTracker(): Tracker {\n        val app = ApplicationProvider.getApplicationContext<MatomoTestApplication>()\n        val tracker = app.onCreateTrackerConfig().build(getInstance(ApplicationProvider.getApplicationContext()))\n        tracker.preferences.edit().clear().apply()\n        return tracker\n    }\n\n    val matomo: Matomo\n        get() = getInstance(ApplicationProvider.getApplicationContext())\n}\n"
  },
  {
    "path": "tracker/src/test/java/testhelpers/FullEnvTestLifeCycle.kt",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\npackage testhelpers\n\nimport org.robolectric.DefaultTestLifecycle\n\n/**\n * Tries to emulate a full app environment to satisfy more in-depth tests\n */\nclass FullEnvTestLifeCycle : DefaultTestLifecycle()\n"
  },
  {
    "path": "tracker/src/test/java/testhelpers/FullEnvTestRunner.kt",
    "content": "/*\n * Android SDK for Matomo\n *\n * @link https://github.com/matomo-org/matomo-android-sdk\n * @license https://github.com/matomo-org/matomo-sdk-android/blob/master/LICENSE BSD-3 Clause\n */\npackage testhelpers\n\nimport org.robolectric.RobolectricTestRunner\nimport org.robolectric.TestLifecycle\nimport org.robolectric.util.inject.Injector\n\n/**\n * Tries to emulate a full app environment to satisfy more in-depth tests\n */\n@Suppress(\"unused\")\nopen class FullEnvTestRunner : RobolectricTestRunner {\n    constructor(testClass: Class<*>?) : super(testClass)\n\n    protected constructor(testClass: Class<*>?, injector: Injector?) : super(testClass, injector)\n\n    override fun getTestLifecycleClass(): Class<out TestLifecycle> {\n        return FullEnvTestLifeCycle::class.java\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/testhelpers/JUnitTree.kt",
    "content": "package testhelpers\n\nimport android.util.Log\nimport timber.log.Timber\n\nclass JUnitTree : Timber.DebugTree() {\n    private val minLogLevel = Log.VERBOSE\n\n    override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {\n        if (priority < minLogLevel) return\n        println(System.currentTimeMillis().toString() + \" \" + priorityToString(priority) + \"/\" + tag + \": \" + message)\n    }\n\n    companion object {\n        private fun priorityToString(priority: Int): String {\n            return when (priority) {\n                Log.ERROR -> \"E\"\n                Log.WARN -> \"W\"\n                Log.INFO -> \"I\"\n                Log.DEBUG -> \"D\"\n                Log.VERBOSE -> \"V\"\n                else -> priority.toString()\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/testhelpers/MatomoTestApplication.kt",
    "content": "package testhelpers\n\nimport org.matomo.sdk.TrackerBuilder\nimport org.matomo.sdk.extra.MatomoApplication\nimport org.robolectric.TestLifecycleApplication\nimport org.robolectric.shadows.ShadowLog\nimport java.lang.reflect.Method\n\n\nclass MatomoTestApplication : MatomoApplication(), TestLifecycleApplication {\n    override fun onCreate() {\n        ShadowLog.stream = System.out\n        super.onCreate()\n    }\n\n    override fun beforeTest(method: Method) {\n    }\n\n    override fun prepareTest(test: Any) {\n    }\n\n    override fun afterTest(method: Method) {\n    }\n\n    override fun getPackageName(): String {\n        return \"11\"\n    }\n\n\n    override fun onCreateTrackerConfig(): TrackerBuilder {\n        return TrackerBuilder.createDefault(\"http://example.com\", 1)\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/testhelpers/QueryHashMap.kt",
    "content": "package testhelpers\n\nimport org.matomo.sdk.QueryParams\nimport org.matomo.sdk.TrackMe\n\nclass QueryHashMap(trackMe: TrackMe) : HashMap<String?, String?>(trackMe.toMap()) {\n    fun get(key: QueryParams): String? {\n        return get(key.toString())\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/testhelpers/TestActivity.kt",
    "content": "package testhelpers\n\nimport android.app.Activity\nimport android.os.Bundle\n\nclass TestActivity : Activity() {\n    public override fun onCreate(savedInstanceState: Bundle?) {\n        super.onCreate(savedInstanceState)\n        title = testTitle\n    }\n\n    companion object {\n        @JvmStatic\n        val testTitle: String\n            get() = \"Test Activity\"\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/testhelpers/TestHelper.kt",
    "content": "package testhelpers\n\nimport timber.log.Timber\n\nobject TestHelper {\n    @JvmStatic\n    fun sleep(millis: Long) {\n        try {\n            Thread.sleep(millis)\n        } catch (e: InterruptedException) {\n            Timber.e(e)\n        }\n    }\n}\n"
  },
  {
    "path": "tracker/src/test/java/testhelpers/TestPreferences.kt",
    "content": "package testhelpers\n\nimport android.content.SharedPreferences\nimport android.content.SharedPreferences.OnSharedPreferenceChangeListener\n\nclass TestPreferences : SharedPreferences {\n    var map: MutableMap<String, Any?> = HashMap()\n    var editor: SharedPreferences.Editor = TestEditor()\n\n    override fun getAll(): Map<String, *> {\n        return map\n    }\n\n    override fun getString(key: String, defValue: String?): String? {\n        if (!map.containsKey(key)) return defValue\n        return map[key] as String?\n    }\n\n    override fun getStringSet(key: String, defValues: Set<String>?): Set<String>? {\n        if (!map.containsKey(key)) return defValues\n        return map[key] as Set<String>?\n    }\n\n    override fun getInt(key: String, defValue: Int): Int {\n        if (!map.containsKey(key)) return defValue\n        return map[key] as Int\n    }\n\n    override fun getLong(key: String, defValue: Long): Long {\n        if (!map.containsKey(key)) return defValue\n        return map[key] as Long\n    }\n\n    override fun getFloat(key: String, defValue: Float): Float {\n        if (!map.containsKey(key)) return defValue\n        return map[key] as Float\n    }\n\n    override fun getBoolean(key: String, defValue: Boolean): Boolean {\n        if (!map.containsKey(key)) return defValue\n        return map[key] as Boolean\n    }\n\n    override fun contains(key: String): Boolean {\n        return map.containsKey(key)\n    }\n\n    override fun edit(): SharedPreferences.Editor {\n        return editor\n    }\n\n    override fun registerOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener) {\n    }\n\n    override fun unregisterOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener) {\n    }\n\n    inner class TestEditor : SharedPreferences.Editor {\n        override fun putString(key: String, value: String?): SharedPreferences.Editor {\n            map[key] = value\n            return editor\n        }\n\n        override fun putStringSet(key: String, values: Set<String>?): SharedPreferences.Editor {\n            map[key] = values\n            return editor\n        }\n\n        override fun putInt(key: String, value: Int): SharedPreferences.Editor {\n            map[key] = value\n            return editor\n        }\n\n        override fun putLong(key: String, value: Long): SharedPreferences.Editor {\n            map[key] = value\n            return editor\n        }\n\n        override fun putFloat(key: String, value: Float): SharedPreferences.Editor {\n            map[key] = value\n            return editor\n        }\n\n        override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor {\n            map[key] = value\n            return editor\n        }\n\n        override fun remove(key: String): SharedPreferences.Editor {\n            map.remove(key)\n            return editor\n        }\n\n        override fun clear(): SharedPreferences.Editor {\n            map.clear()\n            return editor\n        }\n\n        override fun commit(): Boolean {\n            return true\n        }\n\n        override fun apply() {\n        }\n    }\n}\n"
  }
]