diff --git a/.github/workflows/cross-platform-tests.yml b/.github/workflows/cross-platform-tests.yml deleted file mode 100644 index ba7fdb936..000000000 --- a/.github/workflows/cross-platform-tests.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: "Cross Platform Tests" -on: pull_request - -permissions: - contents: read - pull-requests: read -jobs: - cross-platform-tests: - timeout-minutes: 30 - runs-on: ubuntu-latest - steps: - - name: "Checkout Cross Platform Tests Repo" - uses: actions/checkout@v6 - with: - repository: mParticle/crossplatform-sdk-tests - - - name: "Checkout Android SDK Branch" - uses: actions/checkout@v6 - with: - repository: ${{github.event.pull_request.head.repo.full_name}} - ref: ${{github.head_ref}} - path: .sdks/android - - - name: Gradle cache - uses: gradle/gradle-build-action@842c587ad8aa4c68eeba24c396e15af4c2e9f30a #v2.9.0 - - - name: AVD cache - uses: actions/cache@v5 - id: avd-cache - with: - path: | - ~/.android/avd/* - ~/.android/adb* - key: avd-cache - - - name: create AVD and generate snapshot for caching - if: steps.avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b #v2.35.0 - with: - api-level: 29 - force-avd-creation: false - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: false - script: echo "Generated AVD snapshot for caching." - - name: Enable KVM group perms - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - name: "Install JDK 17" - uses: actions/setup-java@v5 - with: - distribution: "zulu" - java-version: "17" - - name: "Run Crossplatform Tests" - uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b #v2.35.0 - with: - api-level: 29 - force-avd-creation: false - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: true - script: | - adb uninstall com.mparticle.kittests || true - adb uninstall com.mparticle.kits.test || true - adb uninstall com.mparticle.legacyTest || true - adb uninstall com.mparticle.test || true - adb uninstall com.mparticle.cpt.testing.test || true - ./gradlew runAndroid - - name: "Archive Test Results" - if: ${{ always() }} - uses: actions/upload-artifact@v7 - with: - name: "test-results" - path: Tests/build/reports/androidTests/connected/** diff --git a/.github/workflows/daily.yml b/.github/workflows/daily.yml index 2e9ca0c8c..eb7d41c8b 100644 --- a/.github/workflows/daily.yml +++ b/.github/workflows/daily.yml @@ -45,7 +45,7 @@ jobs: GIT_COMMITTER_EMAIL: developers@mparticle.com steps: - name: "Clone branch" - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: token: ${{ secrets.MP_SEMANTIC_RELEASE_BOT }} repository: mparticle/mparticle-android-sdk @@ -69,7 +69,7 @@ jobs: # needs: create-regression-branch # steps: # - name: "Checkout future release branch" - # uses: actions/checkout@v6 + # uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # with: # repository: mparticle/mparticle-android-sdk # ref: regression/${{ github.run_number }} @@ -102,7 +102,7 @@ jobs: # needs: create-regression-branch # steps: # - name: "Checkout Branch" - # uses: actions/checkout@v6 + # uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # with: # repository: mparticle/mparticle-android-sdk # ref: regression/${{ github.run_number }} @@ -192,7 +192,7 @@ jobs: needs: create-regression-branch steps: - name: "Checkout future release branch" - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: mparticle/mparticle-android-sdk ref: regression/${{ github.run_number }} @@ -221,7 +221,7 @@ jobs: needs: create-regression-branch steps: - name: "Checkout Branch" - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: regression/${{ github.run_number }} - run: echo "ORG_GRADLE_PROJECT_VERSION=$(head -n 1 VERSION)" >> $GITHUB_ENV @@ -264,7 +264,7 @@ jobs: needs: create-regression-branch steps: - name: "Checkout Branch" - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: regression/${{ github.run_number }} - run: echo "ORG_GRADLE_PROJECT_VERSION=$(head -n 1 VERSION)" >> $GITHUB_ENV @@ -311,7 +311,7 @@ jobs: GIT_COMMITTER_EMAIL: developers@mparticle.com steps: - name: "Checkout future release branch" - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: mparticle/mparticle-android-sdk ref: regression/${{ github.run_number }} @@ -346,7 +346,7 @@ jobs: GIT_COMMITTER_EMAIL: developers@mparticle.com steps: - name: "Checkout public main branch" - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 ref: main @@ -377,7 +377,7 @@ jobs: GIT_COMMITTER_EMAIL: developers@mparticle.com steps: - name: "Checkout repo" - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: token: ${{ secrets.MP_SEMANTIC_RELEASE_BOT }} fetch-depth: 0 diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index ff8d482f8..4a97ad0ea 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -19,20 +19,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 #v5.0.0 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v5.0.0 - name: Trunk Check - uses: trunk-io/trunk-action@75699af9e26881e564e9d832ef7dc3af25ec031b # v1.2.4 + uses: trunk-io/trunk-action@04ba50e7658c81db7356da96657e6e77f220bfa3 # v1.3.1 with: check-mode: pull_request pr-check-hadcoded-secrets: name: "Check PR for hardcoded secrets" uses: mParticle/mparticle-workflows/.github/workflows/security-hardcoded-secrets.yml@main - pr-branch-check-name: - name: "Check PR for semantic branch name" - uses: mParticle/mparticle-workflows/.github/workflows/pr-branch-check-name.yml@main - pr-title-check: - name: "Check PR for semantic title" - uses: mParticle/mparticle-workflows/.github/workflows/pr-title-check.yml@main instrumented-core: uses: ./.github/workflows/instrumented-tests.yml @@ -75,7 +69,7 @@ jobs: runs-on: ubuntu-latest steps: - name: "Checkout Branch" - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: "Install JDK 17" uses: actions/setup-java@v5 with: @@ -99,7 +93,7 @@ jobs: runs-on: macos-latest steps: - name: "Checkout Branch" - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - run: echo "ORG_GRADLE_PROJECT_VERSION=$(head -n 1 VERSION)" >> $GITHUB_ENV - run: echo "ORG_GRADLE_PROJECT_version=$(head -n 1 VERSION)" >> $GITHUB_ENV - name: "Install JDK 17" @@ -126,7 +120,7 @@ jobs: runs-on: macos-latest steps: - name: "Checkout Branch" - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - run: echo "ORG_GRADLE_PROJECT_VERSION=$(head -n 1 VERSION)" >> $GITHUB_ENV - run: echo "ORG_GRADLE_PROJECT_version=$(head -n 1 VERSION)" >> $GITHUB_ENV - name: "Install JDK 17" @@ -162,7 +156,7 @@ jobs: if: github.event_name == 'pull_request' steps: - name: "Checkout Branch" - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - run: echo "ORG_GRADLE_PROJECT_VERSION=$(head -n 1 VERSION)" >> $GITHUB_ENV - run: echo "ORG_GRADLE_PROJECT_version=$(head -n 1 VERSION)" >> $GITHUB_ENV - name: "Install JDK 17" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cca1644f..75756d319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,6 +120,65 @@ - Migrate Rokt contracts and facade ownership to rokt-kit ([#700](https://github.com/mParticle/mparticle-android-sdk/pull/700)) +## [5.80.0](https://github.com/mParticle/mparticle-android-sdk/compare/v5.79.2...v5.80.0) (2026-06-25) + +### Features + +- add device-based consent to override MPID-scoped consent ([#726](https://github.com/mParticle/mparticle-android-sdk/issues/726)) ([e92d352](https://github.com/mParticle/mparticle-android-sdk/commit/e92d3522a350be90a1f15eb3b81d5e6f089f1aed)) + +## [5.79.2](https://github.com/mParticle/mparticle-android-sdk/compare/v5.79.1...v5.79.2) (2026-06-22) + +### Updates & Maintenance + +- bump actions/checkout from 6.0.2 to 6.0.3 ([#720](https://github.com/mParticle/mparticle-android-sdk/issues/720)) ([d904edb](https://github.com/mParticle/mparticle-android-sdk/commit/d904edb0a944a2ce813e7dc31c026f0b2f7ccecb)) +- remove cross-platform-tests and semantic PR/branch checks ([#712](https://github.com/mParticle/mparticle-android-sdk/issues/712)) ([6cdacf2](https://github.com/mParticle/mparticle-android-sdk/commit/6cdacf2926e02e2359352dbcf12b4e4bb66236fa)) +- Update submodules ([976dd0d](https://github.com/mParticle/mparticle-android-sdk/commit/976dd0d13d6aa27f805a6b06a73d923f1a207e80)) + +## [5.79.1](https://github.com/mParticle/mparticle-android-sdk/compare/v5.79.0...v5.79.1) (2026-05-27) + +### Bug Fixes + +- **ci:** pin isolated-kit gradle version to prevent 6.0.0-rc.1 pull ([#714](https://github.com/mParticle/mparticle-android-sdk/issues/714)) ([eff10ea](https://github.com/mParticle/mparticle-android-sdk/commit/eff10ead69167f0ea3df248b2bbed089397600cd)) + +### Updates & Maintenance + +- bump trunk-io/trunk-action from 1.2.4 to 1.3.1 ([#705](https://github.com/mParticle/mparticle-android-sdk/issues/705)) ([f4ceca7](https://github.com/mParticle/mparticle-android-sdk/commit/f4ceca7de2c2ba01ea87c98698ef7bb84991b4ab)) +- Update submodules ([e435979](https://github.com/mParticle/mparticle-android-sdk/commit/e435979e1e966ef2b318bb29135852f9d55df55a)) + +## [5.79.0](https://github.com/mParticle/mparticle-android-sdk/compare/v5.78.5...v5.79.0) (2026-05-14) + +### Features + +- add customBaseURL CNAME support to NetworkOptions ([#701](https://github.com/mParticle/mparticle-android-sdk/issues/701)) ([5285149](https://github.com/mParticle/mparticle-android-sdk/commit/5285149118b58cbd5cf4fb90cb58627deb7538b9)), closes [mparticle-apple-sdk#760](https://github.com/mParticle/mparticle-apple-sdk/issues/760) +- Add max persistence age override option [TRIAGE-608] ([#699](https://github.com/mParticle/mparticle-android-sdk/issues/699)) ([ca88322](https://github.com/mParticle/mparticle-android-sdk/commit/ca88322ac7ef8649ed111cf28afb81d99cdb271a)) + +### Bug Fixes + +- increase MPLatch timeout from 5s to 30s ([#695](https://github.com/mParticle/mparticle-android-sdk/issues/695)) ([20f723f](https://github.com/mParticle/mparticle-android-sdk/commit/20f723f27cf296c879ceebc9d234edca039ab371)) + +### Updates & Maintenance + +- Update submodules ([59a4a9a](https://github.com/mParticle/mparticle-android-sdk/commit/59a4a9a68e3addfaf5c4eea1afd396889721819d)) + +## [5.78.5](https://github.com/mParticle/mparticle-android-sdk/compare/v5.78.4...v5.78.5) (2026-03-25) + +### Updates & Maintenance + +- Update submodules ([86d8d97](https://github.com/mParticle/mparticle-android-sdk/commit/86d8d9748a0c7a796dd0b0be6722f92acef301ea)) + +## [5.78.4](https://github.com/mParticle/mparticle-android-sdk/compare/v5.78.3...v5.78.4) (2026-03-23) + +### Updates & Maintenance + +- Update submodules ([08dab75](https://github.com/mParticle/mparticle-android-sdk/commit/08dab75e3715733f6f5655fbf39fbce16a2e01a2)) + +## [5.78.3](https://github.com/mParticle/mparticle-android-sdk/compare/v5.78.2...v5.78.3) (2026-03-11) + +### Updates & Maintenance + +- bump actions/upload-artifact from 6 to 7 ([#649](https://github.com/mParticle/mparticle-android-sdk/issues/649)) ([0a188b1](https://github.com/mParticle/mparticle-android-sdk/commit/0a188b1f88efde471f8d6bff2aa8611b1b4bf956)) +- Update submodules ([cbb17d7](https://github.com/mParticle/mparticle-android-sdk/commit/cbb17d7e359d41d8d7577a9516cf7788c7288442)) + ## [5.78.2](https://github.com/mParticle/mparticle-android-sdk/compare/v5.78.1...v5.78.2) (2026-02-27) ### Bug Fixes diff --git a/README.md b/README.md index 5ffdcfe48..f6be482de 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ You can grab the Core SDK via Maven Central. Please see the badge above and foll ```groovy dependencies { - implementation 'com.mparticle:android-core:5.78.2' + implementation 'com.mparticle:android-core:5.80.0' } ``` @@ -31,8 +31,8 @@ Several integrations require additional client-side add-on libraries called "kit ```groovy dependencies { implementation ( - 'com.mparticle:android-example-kit:5.78.2', - 'com.mparticle:android-another-kit:5.78.2' + 'com.mparticle:android-example-kit:5.80.0', + 'com.mparticle:android-another-kit:5.80.0' ) } ``` diff --git a/android-core/src/androidTest/java/com/mparticle/internal/MParticleJSInterfaceITest.java b/android-core/src/androidTest/java/com/mparticle/internal/MParticleJSInterfaceITest.java index 0a1832b0a..55d40222b 100644 --- a/android-core/src/androidTest/java/com/mparticle/internal/MParticleJSInterfaceITest.java +++ b/android-core/src/androidTest/java/com/mparticle/internal/MParticleJSInterfaceITest.java @@ -33,6 +33,8 @@ import com.mparticle.testutils.BaseCleanStartedEachTest; import com.mparticle.testutils.BuildConfig; import com.mparticle.testutils.MPLatch; + +import java.util.concurrent.TimeUnit; import com.mparticle.testutils.RandomUtils; import org.json.JSONArray; @@ -68,6 +70,7 @@ public class MParticleJSInterfaceITest extends BaseCleanStartedEachTest { private static boolean sdkFetchedSuccessfully = false; private static String bridgeToken = new RandomUtils().getAlphaString(5); private static String bridgeVersion = "2"; + private static final int WEBVIEW_TIMEOUT_SECONDS = 30; private static final String jsStartupMParticle = "window.mParticle = {\n" + " config: {\n" + @@ -238,7 +241,7 @@ public void setUserAttribute(String json) { } }); - latch.await(); + latch.await(WEBVIEW_TIMEOUT_SECONDS, TimeUnit.SECONDS); assertTrue(called.value); } @@ -263,7 +266,7 @@ public void removeUserAttribute(String json) { } } }); - latch.await(); + latch.await(WEBVIEW_TIMEOUT_SECONDS, TimeUnit.SECONDS); assertTrue(called.value); } @@ -304,7 +307,7 @@ public void setUserAttribute(String json) { } } }); - latch.await(); + latch.await(WEBVIEW_TIMEOUT_SECONDS, TimeUnit.SECONDS); assertTrue(called.value); } @@ -361,7 +364,7 @@ public void logEvent(String json) { } } }); - latch.await(); + latch.await(WEBVIEW_TIMEOUT_SECONDS, TimeUnit.SECONDS); assertTrue(called.value); } @@ -443,7 +446,7 @@ public void logEvent(String json) { } }); assertNull(error.value); - latch.await(); + latch.await(WEBVIEW_TIMEOUT_SECONDS, TimeUnit.SECONDS); assertTrue(called.value); } @@ -470,7 +473,7 @@ public void logout(String json) { } } }); - latch.await(); + latch.await(WEBVIEW_TIMEOUT_SECONDS, TimeUnit.SECONDS); assertTrue(called.value); } @@ -496,7 +499,7 @@ public void logout() { latch.countDown(); } }); - latch.await(); + latch.await(WEBVIEW_TIMEOUT_SECONDS, TimeUnit.SECONDS); assertTrue(called.value); } @@ -522,7 +525,7 @@ public void login(String json) { } } }); - latch.await(); + latch.await(WEBVIEW_TIMEOUT_SECONDS, TimeUnit.SECONDS); assertTrue(called.value); } @@ -548,7 +551,7 @@ public void login() { latch.countDown(); } }); - latch.await(); + latch.await(WEBVIEW_TIMEOUT_SECONDS, TimeUnit.SECONDS); assertTrue(called.value); } @@ -574,7 +577,7 @@ public void modify(String json) { } } }); - latch.await(); + latch.await(WEBVIEW_TIMEOUT_SECONDS, TimeUnit.SECONDS); assertTrue(called.value); } diff --git a/android-core/src/androidTest/kotlin/com.mparticle/MParticleOptionsTest.kt b/android-core/src/androidTest/kotlin/com.mparticle/MParticleOptionsTest.kt index c72065db1..dfa3e6591 100644 --- a/android-core/src/androidTest/kotlin/com.mparticle/MParticleOptionsTest.kt +++ b/android-core/src/androidTest/kotlin/com.mparticle/MParticleOptionsTest.kt @@ -670,6 +670,46 @@ class MParticleOptionsTest : BaseAbstractTest() { Assert.assertNull(options.configMaxAge) } + @Test + fun testPersistenceMaxAgeSeconds() { + // nothing set, should return null (SDK will fall back to the 90-day default) + var options = + MParticleOptions + .builder(mContext) + .credentials("key", "secret") + .build() + Assert.assertNull(options.persistenceMaxAgeSeconds) + + // positive number should be preserved + val testValue = Math.abs(ran.nextInt()) + 1 + options = + MParticleOptions + .builder(mContext) + .credentials("key", "secret") + .persistenceMaxAgeSeconds(testValue) + .build() + Assert.assertEquals(testValue, options.persistenceMaxAgeSeconds) + + // zero is non-positive and should be rejected (differs from configMaxAgeSeconds which + // accepts zero as "always stale") - mirrors iOS SDK behaviour + options = + MParticleOptions + .builder(mContext) + .credentials("key", "secret") + .persistenceMaxAgeSeconds(0) + .build() + Assert.assertNull(options.persistenceMaxAgeSeconds) + + // negative numbers should be rejected + options = + MParticleOptions + .builder(mContext) + .credentials("key", "secret") + .persistenceMaxAgeSeconds(-5) + .build() + Assert.assertNull(options.persistenceMaxAgeSeconds) + } + @Test fun testAndroidIdLogMessage() { val infoLogs = ArrayList() diff --git a/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/MessageServiceTest.kt b/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/MessageServiceTest.kt index 9b1db3a01..290e4b95d 100644 --- a/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/MessageServiceTest.kt +++ b/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/MessageServiceTest.kt @@ -400,6 +400,72 @@ class MessageServiceTest : BaseMPServiceTest() { Assert.assertEquals(MessageService.getMessagesForUpload(database).size.toLong(), 20) } + @Test + @Throws(JSONException::class) + fun testDeleteMessagesOlderThan() { + val sessionId = UUID.randomUUID().toString() + val now = System.currentTimeMillis() + val oneDayMillis = 24L * 60L * 60L * 1000L + // Insert 5 "old" messages dated 10 days ago and 5 "recent" messages dated 1 hour ago. + for (i in 0 until 5) { + val oldMessage = + BaseMPMessage + .Builder("custom_event") + .timestamp(now - 10L * oneDayMillis) + .build( + InternalSession().apply { mSessionID = sessionId }, + null, + 1L, + ) + MessageService.insertMessage(database, "apiKey", oldMessage, 1L, null, null) + } + for (i in 0 until 5) { + val recentMessage = + BaseMPMessage + .Builder("custom_event") + .timestamp(now - 60L * 60L * 1000L) + .build( + InternalSession().apply { mSessionID = sessionId }, + null, + 1L, + ) + MessageService.insertMessage(database, "apiKey", recentMessage, 1L, null, null) + } + Assert.assertEquals( + 10L, + MessageService.getMessagesForUpload(database).size.toLong(), + ) + + // Cut off at 7 days ago - the 5 old messages should be removed and the 5 recent kept. + val cutoffMillis = now - 7L * oneDayMillis + val deleted = MessageService.deleteMessagesOlderThan(database, cutoffMillis) + Assert.assertEquals(5, deleted.toLong()) + Assert.assertEquals( + 5L, + MessageService.getMessagesForUpload(database).size.toLong(), + ) + + // Rows exactly at the cutoff must not be removed (strict `<` predicate). + val exactlyAtCutoffMessage = + BaseMPMessage + .Builder("custom_event") + .timestamp(cutoffMillis) + .build( + InternalSession().apply { mSessionID = sessionId }, + null, + 1L, + ) + MessageService.insertMessage(database, "apiKey", exactlyAtCutoffMessage, 1L, null, null) + Assert.assertEquals( + 0, + MessageService.deleteMessagesOlderThan(database, cutoffMillis).toLong(), + ) + Assert.assertEquals( + 6L, + MessageService.getMessagesForUpload(database).size.toLong(), + ) + } + private fun getMaxId(messages: List): Int { var max = 0 for (message in messages) { diff --git a/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/SessionServiceTest.kt b/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/SessionServiceTest.kt index 80ab0de18..eeb790001 100644 --- a/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/SessionServiceTest.kt +++ b/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/SessionServiceTest.kt @@ -4,6 +4,7 @@ import android.database.Cursor import com.mparticle.internal.BatchId import com.mparticle.internal.MessageBatch import com.mparticle.internal.database.tables.SessionTable +import org.json.JSONException import org.json.JSONObject import org.junit.Assert import org.junit.Assert.assertEquals @@ -76,6 +77,59 @@ class SessionServiceTest : BaseMPServiceTest() { } } + @Test + @Throws(JSONException::class) + fun testDeleteSessionsOlderThan() { + val now = System.currentTimeMillis() + val oneDayMillis = 24L * 60L * 60L * 1000L + val oldEndTime = now - 10L * oneDayMillis + val recentEndTime = now - 60L * 60L * 1000L + + // Insert 5 sessions whose END_TIME is 10 days ago and 5 whose END_TIME is 1 hour ago. + // insertSession seeds END_TIME = START_TIME, so we call updateSessionEndTime to model + // the production flow where subsequent events advance END_TIME independently. + for (i in 0 until 5) { + val oldSessionId = UUID.randomUUID().toString() + SessionService.insertSession(database, getMpMessage(oldSessionId), "apiKey", "{}", "{}", 1L) + SessionService.updateSessionEndTime(database, oldSessionId, oldEndTime, 0) + } + for (i in 0 until 5) { + val recentSessionId = UUID.randomUUID().toString() + SessionService.insertSession(database, getMpMessage(recentSessionId), "apiKey", "{}", "{}", 1L) + SessionService.updateSessionEndTime(database, recentSessionId, recentEndTime, 0) + } + assertEquals(10, countSessions()) + + // Cut off at 7 days ago - the 5 old sessions should be removed and the 5 recent kept. + val cutoffMillis = now - 7L * oneDayMillis + val deleted = SessionService.deleteSessionsOlderThan(database, cutoffMillis) + assertEquals(5, deleted) + assertEquals(5, countSessions()) + + // Rows whose END_TIME is exactly at the cutoff must not be removed (strict `<` predicate). + val boundarySessionId = UUID.randomUUID().toString() + SessionService.insertSession(database, getMpMessage(boundarySessionId), "apiKey", "{}", "{}", 1L) + SessionService.updateSessionEndTime(database, boundarySessionId, cutoffMillis, 0) + assertEquals(0, SessionService.deleteSessionsOlderThan(database, cutoffMillis)) + assertEquals(6, countSessions()) + } + + private fun countSessions(): Int { + var count = 0 + var cursor: Cursor? = null + try { + cursor = SessionService.getSessions(database) + while (cursor.moveToNext()) { + count++ + } + } finally { + if (cursor != null && !cursor.isClosed) { + cursor.close() + } + } + return count + } + internal inner class MockMessageBatch( var id: Int, ) : MessageBatch() { diff --git a/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/UploadServiceTest.kt b/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/UploadServiceTest.kt new file mode 100644 index 000000000..c0d3e5e38 --- /dev/null +++ b/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/UploadServiceTest.kt @@ -0,0 +1,73 @@ +package com.mparticle.internal.database.services + +import com.mparticle.internal.Constants +import com.mparticle.internal.database.UploadSettings +import com.mparticle.networking.NetworkOptions +import org.json.JSONException +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Test + +class UploadServiceTest : BaseMPServiceTest() { + + @Test + @Throws(JSONException::class) + fun testDeleteUploadsOlderThan() { + val now = System.currentTimeMillis() + val oneDayMillis = 24L * 60L * 60L * 1000L + val uploadSettings = UploadSettings( + "apiKey", + "secret", + NetworkOptions.builder().build(), + "", + "", + ) + + // Insert 5 uploads dated 10 days ago and 5 uploads dated 1 hour ago. + // insertUpload reads CREATED_AT from the message's TIMESTAMP ("ct") key. + for (i in 0 until 5) { + UploadService.insertUpload(database, uploadJson(now - 10L * oneDayMillis), uploadSettings) + } + for (i in 0 until 5) { + UploadService.insertUpload(database, uploadJson(now - 60L * 60L * 1000L), uploadSettings) + } + assertEquals(10, UploadService.getReadyUploads(database).size) + + // Cut off at 7 days ago - the 5 old uploads should be removed and the 5 recent kept. + val cutoffMillis = now - 7L * oneDayMillis + val deleted = UploadService.deleteUploadsOlderThan(database, cutoffMillis) + assertEquals(5, deleted) + assertEquals(5, UploadService.getReadyUploads(database).size) + + // Rows whose CREATED_AT is exactly at the cutoff must not be removed (strict `<` predicate). + UploadService.insertUpload(database, uploadJson(cutoffMillis), uploadSettings) + assertEquals(0, UploadService.deleteUploadsOlderThan(database, cutoffMillis)) + assertEquals(6, UploadService.getReadyUploads(database).size) + } + + @Test + @Throws(JSONException::class) + fun testUploadSettingsPreserveCustomBaseURL() { + val uploadSettings = UploadSettings( + "apiKey", + "secret", + NetworkOptions + .builder() + .setCustomBaseURL("https://rkt.example.com:8443") + .build(), + "", + "", + ) + UploadService.insertUpload(database, uploadJson(System.currentTimeMillis()), uploadSettings) + + val readyUploads = UploadService.getReadyUploads(database) + + assertEquals(1, readyUploads.size) + assertEquals("rkt.example.com:8443", readyUploads[0].uploadSettings.networkOptions.customBaseURL) + } + + @Throws(JSONException::class) + private fun uploadJson(timestampMillis: Long): JSONObject = JSONObject() + .put(Constants.MessageKey.TIMESTAMP, timestampMillis) + .put("payload", "test") +} diff --git a/android-core/src/main/java/com/mparticle/MParticle.java b/android-core/src/main/java/com/mparticle/MParticle.java index a74cf7260..76cf029ed 100644 --- a/android-core/src/main/java/com/mparticle/MParticle.java +++ b/android-core/src/main/java/com/mparticle/MParticle.java @@ -20,6 +20,7 @@ import androidx.annotation.RequiresApi; import com.mparticle.commerce.CommerceEvent; +import com.mparticle.consent.ConsentState; import com.mparticle.identity.IdentityApi; import com.mparticle.identity.IdentityApiRequest; import com.mparticle.identity.IdentityApiResult; @@ -892,6 +893,48 @@ public void setOptOut(@NonNull Boolean optOutStatus) { } } + /** + * Query the device-level consent state. + *

+ * Device-level consent, when set, overrides MPID-based consent when applying consent forwarding + * rules and uploading events. + * + * @return the device-level consent state, or an empty state if none has been set + */ + @NonNull + public ConsentState getDeviceConsentState() { + return mConfigManager.getDeviceConsentState(); + } + + /** + * Set the device-level consent state. + *

+ * Device-level consent overrides MPID-based consent when applying consent forwarding rules + * and uploading events. Pass {@code null} to clear the device-level override and fall back to + * MPID-based consent. + * + * @param state the device-level consent state, or {@code null} to clear the override + */ + public void setDeviceConsentState(@Nullable ConsentState state) { + ConsentState oldState = mConfigManager.getEffectiveConsentState(mConfigManager.getMpid()); + mConfigManager.setDeviceConsentState(state); + ConsentState newState = mConfigManager.getEffectiveConsentState(mConfigManager.getMpid()); + mKitManager.onConsentStateUpdated(oldState, newState, mConfigManager.getMpid()); + } + + /** + * Query whether device-based consent is enabled. + *

+ * When enabled, {@link com.mparticle.identity.MParticleUser#setConsentState(ConsentState)} + * also persists consent at the device level. + * + * @return true if device-based consent is enabled + */ + @NonNull + public Boolean isDeviceBasedConsentEnabled() { + return mConfigManager.isDeviceBasedConsentEnabled(); + } + /** * Retrieve a URL to be loaded within a {@link WebView} to show the user a survey * or feedback form. diff --git a/android-core/src/main/java/com/mparticle/MParticleOptions.java b/android-core/src/main/java/com/mparticle/MParticleOptions.java index 712d15122..c5e421e8b 100644 --- a/android-core/src/main/java/com/mparticle/MParticleOptions.java +++ b/android-core/src/main/java/com/mparticle/MParticleOptions.java @@ -38,10 +38,12 @@ public class MParticleOptions { private String mApiSecret; private IdentityApiRequest mIdentifyRequest; private Boolean mDevicePerformanceMetricsDisabled = false; + private Boolean mDeviceBasedConsentEnabled = false; private Boolean mAndroidIdEnabled = false; private Integer mUploadInterval = ConfigManager.DEFAULT_UPLOAD_INTERVAL; //seconds private Integer mSessionTimeout = ConfigManager.DEFAULT_SESSION_TIMEOUT_SECONDS; //seconds private Integer mConfigMaxAge = null; + private Integer mPersistenceMaxAgeSeconds = null; private Boolean mUnCaughtExceptionLogging = false; private MParticle.LogLevel mLogLevel = MParticle.LogLevel.DEBUG; private AttributionListener mAttributionListener; @@ -92,6 +94,9 @@ public MParticleOptions(@NonNull Builder builder) { if (builder.devicePerformanceMetricsDisabled != null) { this.mDevicePerformanceMetricsDisabled = builder.devicePerformanceMetricsDisabled; } + if (builder.deviceBasedConsentEnabled != null) { + this.mDeviceBasedConsentEnabled = builder.deviceBasedConsentEnabled; + } if (builder.androidIdEnabled != null) { this.mAndroidIdEnabled = builder.androidIdEnabled; } @@ -118,6 +123,13 @@ public MParticleOptions(@NonNull Builder builder) { this.mConfigMaxAge = builder.configMaxAge; } } + if (builder.persistenceMaxAgeSeconds != null) { + if (builder.persistenceMaxAgeSeconds <= 0) { + Logger.warning("Persistence Max Age must be a positive number, disregarding value."); + } else { + this.mPersistenceMaxAgeSeconds = builder.persistenceMaxAgeSeconds; + } + } if (builder.unCaughtExceptionLogging != null) { this.mUnCaughtExceptionLogging = builder.unCaughtExceptionLogging; } @@ -246,6 +258,20 @@ public Boolean isDevicePerformanceMetricsDisabled() { return mDevicePerformanceMetricsDisabled; } + /** + * Query whether device-based consent is enabled. + *

+ * When enabled, {@link com.mparticle.identity.MParticleUser#setConsentState(ConsentState)} + * will persist consent at the device level in addition to the current MPID. Device-level + * consent overrides MPID-based consent when applying consent forwarding rules and uploading events. + * + * @return true if device-based consent is enabled + */ + @NonNull + public Boolean isDeviceBasedConsentEnabled() { + return mDeviceBasedConsentEnabled; + } + /** * Query whether Android Id collection is enabled or disabled. * @@ -277,6 +303,22 @@ public Integer getConfigMaxAge() { return mConfigMaxAge; } + /** + * The maximum threshold (in seconds) for locally persisted events, batches, and sessions. + *

+ * When {@code null} (the default), records are retained for 90 days before being deleted. + * Values less than or equal to zero are rejected at build time and result in the default + * being used. + * + * @return the configured maximum persistence age in seconds, or {@code null} when the default + * (90 days) applies + * @see Builder#persistenceMaxAgeSeconds(int) + */ + @Nullable + public Integer getPersistenceMaxAgeSeconds() { + return mPersistenceMaxAgeSeconds; + } + @NonNull public Boolean isUncaughtExceptionLoggingEnabled() { return mUnCaughtExceptionLogging; @@ -386,10 +428,12 @@ public static class Builder { private MParticle.Environment environment; private IdentityApiRequest identifyRequest; private Boolean devicePerformanceMetricsDisabled = null; + private Boolean deviceBasedConsentEnabled = null; private Boolean androidIdEnabled = null; private Integer uploadInterval = null; private Integer sessionTimeout = null; private Integer configMaxAge = null; + private Integer persistenceMaxAgeSeconds = null; private Boolean unCaughtExceptionLogging = null; MParticle.LogLevel logLevel = null; BaseIdentityTask identityTask; @@ -532,6 +576,23 @@ public Builder devicePerformanceMetricsDisabled(boolean disabled) { return this; } + /** + * Enable device-based consent. + *

+ * When enabled, consent set via {@link com.mparticle.identity.MParticleUser#setConsentState(ConsentState)} + * is stored at the device level and overrides MPID-based consent when applying consent forwarding + * rules and uploading events. This is useful when consent is collected before the user's MPID is known + * or when the MPID changes during a flow such as checkout. + * + * @param enabled true to enable device-based consent + * @return the instance of the builder, for chaining calls + */ + @NonNull + public Builder deviceBasedConsentEnabled(boolean enabled) { + this.deviceBasedConsentEnabled = enabled; + return this; + } + /** * By default, the SDK will NOT collect Android Id for the purpose * of anonymous analytics. If you're not using an mParticle integration that consumes Android ID and you would like to collect it, use this API to enable collection @@ -592,6 +653,32 @@ public Builder configMaxAgeSeconds(int configMaxAge) { return this; } + /** + * Set a maximum threshold for locally persisted events, batches, and sessions, in seconds. + *

+ * By default, data is persisted for 90 days before being deleted to minimize data loss; + * however, this can lead to excessive storage usage on some users' devices. This is + * exacerbated if your app logs a large number of events, or events carrying a lot of data + * (attributes, etc.). + *

+ * Set a lower value (for example, 48 hours or 1 week) if you have storage usage concerns. + * Alternatively, if you have data loss concerns, set a longer value than the default. + *

+ * This is the Android equivalent of the iOS SDK's + * {@code MParticleOptions.persistenceMaxAgeSeconds} option. + * + * @param persistenceMaxAgeSeconds the upper limit, in seconds, for how long persisted + * data may live on disk. Must be greater than zero; + * non-positive values are rejected and the default + * (90 days) is used instead + * @return the instance of the builder, for chaining calls + */ + @NonNull + public Builder persistenceMaxAgeSeconds(int persistenceMaxAgeSeconds) { + this.persistenceMaxAgeSeconds = persistenceMaxAgeSeconds; + return this; + } + /** * Enable or disable mParticle exception handling to automatically log events on uncaught exceptions. * diff --git a/android-core/src/main/java/com/mparticle/identity/MParticleUserDelegate.java b/android-core/src/main/java/com/mparticle/identity/MParticleUserDelegate.java index 172051a45..1c20186a5 100644 --- a/android-core/src/main/java/com/mparticle/identity/MParticleUserDelegate.java +++ b/android-core/src/main/java/com/mparticle/identity/MParticleUserDelegate.java @@ -265,13 +265,17 @@ boolean setUser(Context context, long previousMpid, long newMpid, Map> configurations, @Nullable List sideloadedKits) { @@ -164,6 +180,7 @@ public ConfigManager(@NonNull Context context, @Nullable MParticle.Environment e configuration.apply(this); } } + mDeviceBasedConsentEnabled = sPreferences.getBoolean(Constants.PrefKeys.DEVICE_BASED_CONSENT_ENABLED, false); } public void onMParticleStarted() { @@ -1322,6 +1339,50 @@ public ConsentState getConsentState(long mpid) { return ConsentState.withConsentState(serializedConsent).build(); } + public boolean isDeviceBasedConsentEnabled() { + return mDeviceBasedConsentEnabled; + } + + public void setDeviceBasedConsentEnabled(boolean deviceBasedConsentEnabled) { + mDeviceBasedConsentEnabled = deviceBasedConsentEnabled; + sPreferences.edit() + .putBoolean(Constants.PrefKeys.DEVICE_BASED_CONSENT_ENABLED, deviceBasedConsentEnabled) + .apply(); + } + + public boolean hasDeviceConsentOverride() { + return sPreferences.contains(Constants.PrefKeys.DEVICE_CONSENT_STATE); + } + + @NonNull + public ConsentState getDeviceConsentState() { + if (!hasDeviceConsentOverride()) { + return ConsentState.withConsentState((String) null).build(); + } + String serializedConsent = sPreferences.getString(Constants.PrefKeys.DEVICE_CONSENT_STATE, null); + return ConsentState.withConsentState(serializedConsent).build(); + } + + public void setDeviceConsentState(@Nullable ConsentState state) { + if (state != null) { + sPreferences.edit() + .putString(Constants.PrefKeys.DEVICE_CONSENT_STATE, state.toString()) + .apply(); + } else { + sPreferences.edit() + .remove(Constants.PrefKeys.DEVICE_CONSENT_STATE) + .apply(); + } + } + + @NonNull + public ConsentState getEffectiveConsentState(long mpid) { + if (hasDeviceConsentOverride()) { + return getDeviceConsentState(); + } + return getConsentState(mpid); + } + public boolean isDirectUrlRoutingEnabled() { return directUrlRouting; } diff --git a/android-core/src/main/java/com/mparticle/internal/MessageBatch.java b/android-core/src/main/java/com/mparticle/internal/MessageBatch.java index b12c4d312..d271c32b4 100644 --- a/android-core/src/main/java/com/mparticle/internal/MessageBatch.java +++ b/android-core/src/main/java/com/mparticle/internal/MessageBatch.java @@ -54,7 +54,7 @@ public static MessageBatch create(boolean history, ConfigManager configManager, uploadMessage.put(Constants.MessageKey.COOKIES, cookies); uploadMessage.put(Constants.MessageKey.PROVIDER_PERSISTENCE, configManager.getProviderPersistence()); uploadMessage.put(Constants.MessageKey.INTEGRATION_ATTRIBUTES, configManager.getIntegrationAttributes()); - uploadMessage.addConsentState(configManager.getConsentState(batchId.getMpid())); + uploadMessage.addConsentState(configManager.getEffectiveConsentState(batchId.getMpid())); uploadMessage.addDataplanContext(batchId.getDataplanId(), batchId.getDataplanVersion()); return uploadMessage; } diff --git a/android-core/src/main/java/com/mparticle/internal/UploadHandler.java b/android-core/src/main/java/com/mparticle/internal/UploadHandler.java index 38e68d7db..2ce074c28 100644 --- a/android-core/src/main/java/com/mparticle/internal/UploadHandler.java +++ b/android-core/src/main/java/com/mparticle/internal/UploadHandler.java @@ -8,6 +8,7 @@ import android.os.Message; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.mparticle.MParticle; import com.mparticle.audience.AudienceResponse; @@ -56,6 +57,27 @@ public class UploadHandler extends BaseHandler { */ public static final int INIT_CONFIG = 6; + /** + * Default retention window for persisted events, batches, and sessions when + * {@link com.mparticle.MParticleOptions.Builder#persistenceMaxAgeSeconds(int)} is not set. + * Matches the iOS SDK's 90-day default. + */ + @VisibleForTesting + static final long DEFAULT_PERSISTENCE_MAX_AGE_MILLIS = 90L * 24L * 60L * 60L * 1000L; + + /** + * Minimum interval between age-based persistence sweeps, matching the iOS SDK's 24 hour + * throttle on {@code cleanUp}. + */ + @VisibleForTesting + static final long PERSISTENCE_CLEANUP_INTERVAL_MILLIS = 24L * 60L * 60L * 1000L; + + /** + * Unix-epoch millisecond timestamp of the last successful age-based sweep. Zero means + * "never run in this process". + */ + private long mLastPersistenceCleanupMillis = 0L; + private final SharedPreferences mPreferences; private final SegmentDatabase audienceDB; @@ -186,6 +208,7 @@ public void prepareMessageUploads(UploadSettings uploadSettings) throws Exceptio * This method is responsible for looking for batches that are ready to be uploaded, and uploading them. */ protected void upload() { + maybePrunePersistedRecords(System.currentTimeMillis()); mParticleDBManager.cleanupUploadMessages(); try { List readyUploads = mParticleDBManager.getReadyUploads(); @@ -211,6 +234,34 @@ protected void upload() { } } + /** + * Run an age-based retention sweep across persisted events, batches, and sessions at most + * once every {@link #PERSISTENCE_CLEANUP_INTERVAL_MILLIS}. When the consumer has not + * configured {@link com.mparticle.MParticleOptions.Builder#persistenceMaxAgeSeconds(int)}, + * the default 90-day window is used. The throttle timestamp is only advanced on a + * successful sweep so that transient failures (for example a locked database) can be + * retried on the next upload cycle rather than deferred for 24 hours. + * + * @param nowMillis current time in unix-epoch milliseconds + */ + @VisibleForTesting + void maybePrunePersistedRecords(long nowMillis) { + if (nowMillis - mLastPersistenceCleanupMillis < PERSISTENCE_CLEANUP_INTERVAL_MILLIS) { + return; + } + if (mParticleDBManager == null) { + return; + } + Integer configured = mConfigManager == null ? null : mConfigManager.getPersistenceMaxAgeSeconds(); + long maxAgeMillis = (configured == null) + ? DEFAULT_PERSISTENCE_MAX_AGE_MILLIS + : configured.longValue() * 1000L; + long cutoffMillis = nowMillis - maxAgeMillis; + if (mParticleDBManager.deleteRecordsOlderThan(cutoffMillis)) { + mLastPersistenceCleanupMillis = nowMillis; + } + } + void uploadMessage(int id, String message, UploadSettings uploadSettings) throws IOException, MParticleApiClientImpl.MPThrottleException { int responseCode = -1; boolean sampling = false; diff --git a/android-core/src/main/java/com/mparticle/internal/database/services/MParticleDBManager.java b/android-core/src/main/java/com/mparticle/internal/database/services/MParticleDBManager.java index 9e9015946..9f46908d9 100644 --- a/android-core/src/main/java/com/mparticle/internal/database/services/MParticleDBManager.java +++ b/android-core/src/main/java/com/mparticle/internal/database/services/MParticleDBManager.java @@ -7,6 +7,7 @@ import android.os.Handler; import android.os.Looper; +import androidx.annotation.CheckResult; import androidx.annotation.Nullable; import com.mparticle.MParticle; @@ -206,6 +207,37 @@ public void deleteMessagesAndSessions(String currentSessionId) { } } + /** + * Age-based retention sweep across the three persistence tables. + *

+ * Deletes any messages and uploads whose {@code CREATED_AT} is strictly less than + * {@code cutoffMillis}, and any sessions whose {@code END_TIME} is strictly less than + * {@code cutoffMillis}. + * {@code MPPersistenceController.deleteRecordsOlderThan:}, but reports success so + * callers can decide whether to arm retry/throttle state. + * + * @param cutoffMillis the unix-epoch millisecond cutoff; rows older than this are removed + * @return {@code true} if the transaction committed successfully, {@code false} if any + * exception was caught (and logged) during the sweep + */ + @CheckResult + public boolean deleteRecordsOlderThan(long cutoffMillis) { + MPDatabase db = getDatabase(); + try { + db.beginTransaction(); + MessageService.deleteMessagesOlderThan(db, cutoffMillis); + UploadService.deleteUploadsOlderThan(db, cutoffMillis); + SessionService.deleteSessionsOlderThan(db, cutoffMillis); + db.setTransactionSuccessful(); + return true; + } catch (Exception e) { + Logger.warning(e, "Error pruning persisted records older than " + cutoffMillis + " ms."); + return false; + } finally { + db.endTransaction(); + } + } + private HashMap getUploadMessageByBatchIdMap(List readyMessages, MPDatabase db, ConfigManager configManager) throws JSONException { return getUploadMessageByBatchIdMap(readyMessages, db, configManager, false); } diff --git a/android-core/src/main/java/com/mparticle/internal/database/services/MessageService.java b/android-core/src/main/java/com/mparticle/internal/database/services/MessageService.java index 135352ef8..38814cf34 100644 --- a/android-core/src/main/java/com/mparticle/internal/database/services/MessageService.java +++ b/android-core/src/main/java/com/mparticle/internal/database/services/MessageService.java @@ -105,6 +105,22 @@ public static int deleteOldMessages(MPDatabase database, String currentSessionId selectionArgs); } + /** + * Delete messages whose {@link MessageTableColumns#CREATED_AT} is strictly less than + * {@code cutoffMillis}. + * + * @param database the message database + * @param cutoffMillis the unix-epoch millisecond cutoff; rows older than this are removed + * @return the number of rows deleted + */ + public static int deleteMessagesOlderThan(MPDatabase database, long cutoffMillis) { + String[] whereArgs = new String[]{Long.toString(cutoffMillis)}; + return database.delete( + MessageTableColumns.TABLE_NAME, + MessageTableColumns.CREATED_AT + " < ?", + whereArgs); + } + public static boolean hasMessagesForUpload(MPDatabase database) { Cursor messageIds = null; try { diff --git a/android-core/src/main/java/com/mparticle/internal/database/services/SessionService.java b/android-core/src/main/java/com/mparticle/internal/database/services/SessionService.java index 142cf11fb..7b33fd6fd 100644 --- a/android-core/src/main/java/com/mparticle/internal/database/services/SessionService.java +++ b/android-core/src/main/java/com/mparticle/internal/database/services/SessionService.java @@ -33,6 +33,23 @@ public static int deleteSessions(MPDatabase database, String currentSessionId) { return database.delete(TABLE_NAME, SessionTableColumns.SESSION_ID + "!=? ", selectionArgs); } + /** + * Delete sessions whose {@link SessionTableColumns#END_TIME} is strictly less than + * {@code cutoffMillis}. + * + * @param database the session database + * @param cutoffMillis the unix-epoch millisecond cutoff; sessions that ended before this are + * removed + * @return the number of rows deleted + */ + public static int deleteSessionsOlderThan(MPDatabase database, long cutoffMillis) { + String[] whereArgs = new String[]{Long.toString(cutoffMillis)}; + return database.delete( + TABLE_NAME, + SessionTableColumns.END_TIME + " < ?", + whereArgs); + } + /** * delete Session entries with session_id that are not a part of the Set * diff --git a/android-core/src/main/java/com/mparticle/internal/database/services/UploadService.java b/android-core/src/main/java/com/mparticle/internal/database/services/UploadService.java index 912c40aeb..90e4809d3 100644 --- a/android-core/src/main/java/com/mparticle/internal/database/services/UploadService.java +++ b/android-core/src/main/java/com/mparticle/internal/database/services/UploadService.java @@ -22,6 +22,22 @@ public static int cleanupUploadMessages(MPDatabase database) { return database.delete(UploadTableColumns.TABLE_NAME, "length(" + UploadTableColumns.MESSAGE + ") > " + Constants.LIMIT_MAX_UPLOAD_SIZE, null); } + /** + * Delete uploads whose {@link UploadTableColumns#CREATED_AT} is strictly less than + * {@code cutoffMillis}. + * + * @param database the upload database + * @param cutoffMillis the unix-epoch millisecond cutoff; rows older than this are removed + * @return the number of rows deleted + */ + public static int deleteUploadsOlderThan(MPDatabase database, long cutoffMillis) { + String[] whereArgs = new String[]{Long.toString(cutoffMillis)}; + return database.delete( + UploadTableColumns.TABLE_NAME, + UploadTableColumns.CREATED_AT + " < ?", + whereArgs); + } + /** * Generic method to insert a new upload, * either a regular message batch, or a session history. diff --git a/android-core/src/main/java/com/mparticle/networking/DomainMapping.java b/android-core/src/main/java/com/mparticle/networking/DomainMapping.java index af908cd43..80ac6a42a 100644 --- a/android-core/src/main/java/com/mparticle/networking/DomainMapping.java +++ b/android-core/src/main/java/com/mparticle/networking/DomainMapping.java @@ -326,7 +326,7 @@ private static Builder withJson(String jsonString) { JSONObject jsonObject = new JSONObject(jsonString); int type = jsonObject.getInt("mType"); String newUrl = jsonObject.getString("url"); - boolean overridesSubdirectory = jsonObject.getBoolean("overridesSubdirectory"); + boolean overridesSubdirectory = jsonObject.optBoolean("overridesSubdirectory", false); Builder builder = new Builder(Endpoint.parseInt(type), newUrl, overridesSubdirectory); JSONArray certificatesJsonArray = jsonObject.getJSONArray("mCertificates"); for (int i = 0; i < certificatesJsonArray.length(); i++) { @@ -339,4 +339,4 @@ private static Builder withJson(String jsonString) { return null; } } -} \ No newline at end of file +} diff --git a/android-core/src/main/java/com/mparticle/networking/NetworkOptions.java b/android-core/src/main/java/com/mparticle/networking/NetworkOptions.java index 708aae363..678f66529 100644 --- a/android-core/src/main/java/com/mparticle/networking/NetworkOptions.java +++ b/android-core/src/main/java/com/mparticle/networking/NetworkOptions.java @@ -77,9 +77,12 @@ public static NetworkOptions withNetworkOptions(@Nullable String jsonString) { } JSONArray domainMappingsJson = jsonObject.getJSONArray("domainMappings"); for (int i = 0; i < domainMappingsJson.length(); i++) { - builder.addDomainMapping(DomainMapping - .withDomainMapping(domainMappingsJson.getString(i)) - .build()); + DomainMapping.Builder domainMappingBuilder = DomainMapping.withDomainMapping(domainMappingsJson.getString(i)); + if (domainMappingBuilder == null) { + Logger.warning("NetworkOptions: skipping invalid persisted domain mapping at index " + i + "."); + continue; + } + builder.addDomainMapping(domainMappingBuilder.build()); } } catch (Exception e) { Logger.error(e); diff --git a/android-core/src/main/kotlin/com/mparticle/internal/Constants.kt b/android-core/src/main/kotlin/com/mparticle/internal/Constants.kt index 14aade988..409a92867 100644 --- a/android-core/src/main/kotlin/com/mparticle/internal/Constants.kt +++ b/android-core/src/main/kotlin/com/mparticle/internal/Constants.kt @@ -608,6 +608,8 @@ object Constants { const val IF_MODIFIED: String = "mp::ifmodified" const val IDENTITY_API_CONTEXT: String = "mp::identity::api::context" const val DEVICE_APPLICATION_STAMP: String = "mp::device-app-stamp" + const val DEVICE_CONSENT_STATE: String = "mp::device::consent" + const val DEVICE_BASED_CONSENT_ENABLED: String = "mp::device::consent::enabled" const val PREVIOUS_ANDROID_ID: String = "mp::previous::android::id" const val DISPLAY_PUSH_NOTIFICATIONS: String = "mp::displaypushnotifications" const val IDENTITY_CONNECTION_TIMEOUT: String = "mp::connection:timeout:identity" diff --git a/android-core/src/test/kotlin/com/mparticle/external/ApiVisibilityTest.kt b/android-core/src/test/kotlin/com/mparticle/external/ApiVisibilityTest.kt index d796e4de2..3017d6bbf 100644 --- a/android-core/src/test/kotlin/com/mparticle/external/ApiVisibilityTest.kt +++ b/android-core/src/test/kotlin/com/mparticle/external/ApiVisibilityTest.kt @@ -17,7 +17,7 @@ class ApiVisibilityTest { publicMethodCount++ } } - Assert.assertEquals(62, publicMethodCount) + Assert.assertEquals(65, publicMethodCount) } @Test diff --git a/android-core/src/test/kotlin/com/mparticle/internal/ConfigManagerTest.kt b/android-core/src/test/kotlin/com/mparticle/internal/ConfigManagerTest.kt index 92e252bc2..d53657bdf 100644 --- a/android-core/src/test/kotlin/com/mparticle/internal/ConfigManagerTest.kt +++ b/android-core/src/test/kotlin/com/mparticle/internal/ConfigManagerTest.kt @@ -2,6 +2,8 @@ package com.mparticle.internal import com.mparticle.MParticle import com.mparticle.MockMParticle +import com.mparticle.consent.ConsentState +import com.mparticle.consent.GDPRConsent import com.mparticle.internal.KitManager.KitStatus import com.mparticle.internal.PushRegistrationHelper.PushRegistration import com.mparticle.internal.messages.BaseMPMessage @@ -729,6 +731,40 @@ class ConfigManagerTest { Assert.assertNotNull(manager.configTimestamp) } + @Test + fun testDeviceConsentOverridesMpidConsent() { + val mpid = ran.nextLong() + val mpidConsent = ConsentState.builder() + .addGDPRConsentState("mpid-purpose", GDPRConsent.builder(false).build()) + .build() + val deviceConsent = ConsentState.builder() + .addGDPRConsentState("device-purpose", GDPRConsent.builder(true).build()) + .build() + + manager.setConsentState(mpidConsent, mpid) + Assert.assertFalse(manager.hasDeviceConsentOverride()) + Assert.assertTrue(manager.getEffectiveConsentState(mpid).gdprConsentState.containsKey("mpid-purpose")) + + manager.setDeviceConsentState(deviceConsent) + Assert.assertTrue(manager.hasDeviceConsentOverride()) + Assert.assertTrue(manager.getEffectiveConsentState(mpid).gdprConsentState.containsKey("device-purpose")) + Assert.assertFalse(manager.getEffectiveConsentState(mpid).gdprConsentState.containsKey("mpid-purpose")) + + manager.setDeviceConsentState(null) + Assert.assertFalse(manager.hasDeviceConsentOverride()) + Assert.assertTrue(manager.getEffectiveConsentState(mpid).gdprConsentState.containsKey("mpid-purpose")) + } + + @Test + fun testDeviceBasedConsentEnabledPersists() { + Assert.assertFalse(manager.isDeviceBasedConsentEnabled()) + manager.setDeviceBasedConsentEnabled(true) + Assert.assertTrue(manager.isDeviceBasedConsentEnabled()) + + val reloadedManager = ConfigManager(context) + Assert.assertTrue(reloadedManager.isDeviceBasedConsentEnabled()) + } + companion object { private const val SAMPLE_CONFIG = "{ \"dt\":\"ac\", \"id\":\"5b7b8073-852b-47c2-9b89-c4bc66e3bd55\", \"ct\":1428030730685, \"dbg\":false, \"cue\":\"appdefined\", \"pmk\":[ \"mp_message\", \"com.urbanairship.push.ALERT\", \"alert\", \"a\", \"message\" ], \"cnp\":\"appdefined\", \"soc\":0, \"oo\":false, \"tri\" : { \"mm\" : [{ \"dt\" : \"x\", \"eh\" : true } ], \"evts\" : [1217787541, 2, 3] }, \"eks\":[ { \"id\":64, \"as\":{ \"clientId\":\"8FMBElARYl9ZtgwYIN5sZA==\", \"surveyId\":\"android_app\", \"sendAppVersion\":\"True\", \"rootUrl\":\"http://survey.foreseeresults.com/survey/display\" }, \"hs\":{ \"et\":{ \"57\":0, \"49\":0, \"55\":0, \"52\":0, \"53\":0, \"50\":0, \"56\":0, \"51\":0, \"54\":0, \"48\":0 }, \"ec\":{ \"609391310\":0, \"-1282670145\":0, \"2138942058\":0, \"-1262630649\":0, \"-877324321\":0, \"1700497048\":0, \"1611158813\":0, \"1900204162\":0, \"-998867355\":0, \"-1758179958\":0, \"-994832826\":0, \"1598473606\":0, \"-2106320589\":0 }, \"ea\":{ \"343635109\":0, \"1162787110\":0, \"-427055400\":0, \"-1285822129\":0, \"1699530232\":0 }, \"svec\":{ \"-725356351\":0, \"-1992427723\":0, \"751512662\":0, \"-118381281\":0, \"-171137512\":0, \"-2036479142\":0, \"-1338304551\":0, \"1003167705\":0, \"1046650497\":0, \"1919407518\":0, \"-1326325184\":0, \"480870493\":0, \"-1087232483\":0, \"-725540438\":0, \"-461793000\":0, \"1935019626\":0, \"76381608\":0, \"273797382\":0, \"-948909976\":0, \"-348193740\":0, \"-685370074\":0, \"-849874419\":0, \"2074021738\":0, \"-767572488\":0, \"-1091433459\":0, \"1671688881\":0, \"1304651793\":0, \"1299738196\":0, \"326063875\":0, \"296835202\":0, \"268236000\":0, \"1708308839\":0, \"101093345\":0, \"-652558691\":0, \"-1613021771\":0, \"1106318256\":0, \"-473874363\":0, \"-1267780435\":0, \"486732621\":0, \"1855792002\":0, \"-881258627\":0, \"698731249\":0, \"1510155838\":0, \"1119638805\":0, \"479337352\":0, \"1312099430\":0, \"1712783405\":0, \"-459721027\":0, \"-214402990\":0, \"617910950\":0, \"428901717\":0, \"-201124647\":0, \"940674176\":0, \"1632668193\":0, \"338835860\":0, \"879890181\":0, \"1667730064\":0 } } } ], \"lsv\":\"2.1.4\", \"pio\":30 }" diff --git a/android-core/src/test/kotlin/com/mparticle/internal/UploadHandlerTest.kt b/android-core/src/test/kotlin/com/mparticle/internal/UploadHandlerTest.kt index ff4640404..3c64cadb8 100644 --- a/android-core/src/test/kotlin/com/mparticle/internal/UploadHandlerTest.kt +++ b/android-core/src/test/kotlin/com/mparticle/internal/UploadHandlerTest.kt @@ -130,6 +130,113 @@ class UploadHandlerTest { handler.uploadMessage(522, "", mConfigManager.uploadSettings) } + @Test + fun testMaybePrunePersistedRecordsUsesDefaultMaxAgeWhenUnconfigured() { + val db = Mockito.mock(MParticleDBManager::class.java) + val config = Mockito.mock(ConfigManager::class.java) + // Mockito's default Answer returns Integer.valueOf(0) for Integer wrapper return + // types, so stub null explicitly to model the "consumer did not configure a value" + // path. The production code must then fall back to DEFAULT_PERSISTENCE_MAX_AGE_MILLIS. + Mockito.doReturn(null).`when`(config).persistenceMaxAgeSeconds + val uploadHandler = + UploadHandler( + MockContext(), + config, + Mockito.mock(AppStateManager::class.java), + Mockito.mock(MessageManager::class.java), + db, + Mockito.mock(KitFrameworkWrapper::class.java), + ) + val capturedCutoff = java.util.concurrent.atomic.AtomicLong(Long.MIN_VALUE) + Mockito.doAnswer { invocation -> + capturedCutoff.set(invocation.getArgument(0)) + true + }.`when`(db).deleteRecordsOlderThan(Mockito.anyLong()) + + val now = 1_700_000_000_000L + uploadHandler.maybePrunePersistedRecords(now) + + Mockito.verify(db, Mockito.times(1)).deleteRecordsOlderThan(Mockito.anyLong()) + Assert.assertEquals( + now - UploadHandler.DEFAULT_PERSISTENCE_MAX_AGE_MILLIS, + capturedCutoff.get(), + ) + } + + @Test + fun testMaybePrunePersistedRecordsUsesConfiguredMaxAge() { + val db = Mockito.mock(MParticleDBManager::class.java) + val config = Mockito.mock(ConfigManager::class.java) + // 1 hour retention window -> cutoff should be exactly now - 3_600_000 ms. + // Guards the seconds-to-millis conversion in maybePrunePersistedRecords. + Mockito.`when`(config.persistenceMaxAgeSeconds).thenReturn(3600) + val uploadHandler = + UploadHandler( + MockContext(), + config, + Mockito.mock(AppStateManager::class.java), + Mockito.mock(MessageManager::class.java), + db, + Mockito.mock(KitFrameworkWrapper::class.java), + ) + val capturedCutoff = java.util.concurrent.atomic.AtomicLong(Long.MIN_VALUE) + Mockito.doAnswer { invocation -> + capturedCutoff.set(invocation.getArgument(0)) + true + }.`when`(db).deleteRecordsOlderThan(Mockito.anyLong()) + + val now = 1_700_000_000_000L + uploadHandler.maybePrunePersistedRecords(now) + + Assert.assertEquals(now - 3_600_000L, capturedCutoff.get()) + } + + @Test + fun testMaybePrunePersistedRecordsHonorsTwentyFourHourThrottle() { + val db = handler.mParticleDBManager + // The sweep must succeed so the throttle arms; otherwise every call retries. + Mockito.`when`(db.deleteRecordsOlderThan(Mockito.anyLong())).thenReturn(true) + val t0 = 1_000_000_000_000L + + // First call always runs the sweep. + handler.maybePrunePersistedRecords(t0) + Mockito.verify(db, Mockito.times(1)).deleteRecordsOlderThan(Mockito.anyLong()) + + // One millisecond before the throttle interval expires - still a no-op. + handler.maybePrunePersistedRecords(t0 + UploadHandler.PERSISTENCE_CLEANUP_INTERVAL_MILLIS - 1L) + Mockito.verify(db, Mockito.times(1)).deleteRecordsOlderThan(Mockito.anyLong()) + + // Exactly at the throttle boundary - the sweep runs again. + handler.maybePrunePersistedRecords(t0 + UploadHandler.PERSISTENCE_CLEANUP_INTERVAL_MILLIS) + Mockito.verify(db, Mockito.times(2)).deleteRecordsOlderThan(Mockito.anyLong()) + } + + @Test + fun testMaybePrunePersistedRecordsRetriesAfterFailure() { + val db = handler.mParticleDBManager + + // Phase 1 - deleteRecordsOlderThan reports failure (false); throttle must NOT be armed. + // This models the real contract: MParticleDBManager.deleteRecordsOlderThan catches + // SQL exceptions internally and signals failure via its return value. + Mockito.`when`(db.deleteRecordsOlderThan(Mockito.anyLong())).thenReturn(false) + val t1 = 1_000_000_000_000L + handler.maybePrunePersistedRecords(t1) + Mockito.verify(db, Mockito.times(1)).deleteRecordsOlderThan(Mockito.anyLong()) + + // Phase 2 - a minute later the sweep retries because the throttle was not armed. + handler.maybePrunePersistedRecords(t1 + 60_000L) + Mockito.verify(db, Mockito.times(2)).deleteRecordsOlderThan(Mockito.anyLong()) + + // Phase 3 - successful sweep arms the throttle. + Mockito.`when`(db.deleteRecordsOlderThan(Mockito.anyLong())).thenReturn(true) + handler.maybePrunePersistedRecords(t1 + 120_000L) + Mockito.verify(db, Mockito.times(3)).deleteRecordsOlderThan(Mockito.anyLong()) + + // Phase 4 - a minute after the successful sweep the throttle short-circuits the call. + handler.maybePrunePersistedRecords(t1 + 180_000L) + Mockito.verify(db, Mockito.times(3)).deleteRecordsOlderThan(Mockito.anyLong()) + } + @Test @Throws(Exception::class) fun testGetDeviceInfo() { diff --git a/android-core/src/test/kotlin/com/mparticle/networking/NetworkOptionsTest.kt b/android-core/src/test/kotlin/com/mparticle/networking/NetworkOptionsTest.kt index 5f0f688dd..4b0039ad2 100644 --- a/android-core/src/test/kotlin/com/mparticle/networking/NetworkOptionsTest.kt +++ b/android-core/src/test/kotlin/com/mparticle/networking/NetworkOptionsTest.kt @@ -47,6 +47,50 @@ class NetworkOptionsTest { Assert.assertTrue(equals(options, optionsDeserialized)) } + @Test + fun testLegacyDomainMappingWithoutOverridesSubdirectoryParses() { + val options = + NetworkOptions.withNetworkOptions( + """ + { + "disableDevPinning": false, + "disablePinning": false, + "domainMappings": [ + "{\"mType\":1,\"url\":\"www.configUrl.com\",\"mCertificates\":[]}" + ] + } + """.trimIndent(), + ) + + Assert.assertNotNull(options) + Assert.assertNotNull(options!!.configDomain) + Assert.assertFalse(options.configDomain!!.isOverridesSubdirectory) + Assert.assertEquals("www.configUrl.com", options.configDomain!!.url) + } + + @Test + fun testNetworkOptionsSkipsInvalidPersistedDomainMappings() { + val options = + NetworkOptions.withNetworkOptions( + """ + { + "disableDevPinning": false, + "disablePinning": false, + "domainMappings": [ + "{\"mType\":1,\"url\":\"www.configUrl.com\",\"overridesSubdirectory\":true,\"mCertificates\":[]}", + "{\"mType\":1,\"url\":\"www.invalid.com\"" + ] + } + """.trimIndent(), + ) + + Assert.assertNotNull(options) + Assert.assertNotNull(options!!.configDomain) + Assert.assertEquals(1, options.domainMappings.size) + Assert.assertTrue(options.configDomain!!.isOverridesSubdirectory) + Assert.assertEquals("www.configUrl.com", options.configDomain!!.url) + } + companion object { fun equals( networkOptions1: NetworkOptions, diff --git a/build.gradle b/build.gradle index 271dc2957..d6bab31e2 100644 --- a/build.gradle +++ b/build.gradle @@ -64,7 +64,6 @@ subprojects { allprojects { group = 'com.mparticle' - repositories { mavenLocal() google()