diff --git a/.github/workflows/cross-platform-tests.yml b/.github/workflows/cross-platform-tests.yml index 0f57f8b9e..348a909c0 100644 --- a/.github/workflows/cross-platform-tests.yml +++ b/.github/workflows/cross-platform-tests.yml @@ -35,7 +35,7 @@ jobs: - 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 + uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a #v2.37.0 with: api-level: 29 force-avd-creation: false @@ -53,7 +53,7 @@ jobs: distribution: "zulu" java-version: "17" - name: "Run Crossplatform Tests" - uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b #v2.35.0 + uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a #v2.37.0 with: api-level: 29 force-avd-creation: false diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 81b0da6d0..0103df279 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -21,7 +21,7 @@ jobs: - name: Checkout uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 #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: @@ -58,7 +58,7 @@ jobs: key: avd-29 - 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 + uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a #v2.37.0 with: api-level: 28 force-avd-creation: false @@ -71,7 +71,7 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - name: "Run Instrumented Tests" - uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b #v2.35.0 + uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a #v2.37.0 with: api-level: 28 force-avd-creation: false @@ -120,7 +120,7 @@ jobs: - 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 + uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a #v2.37.0 with: api-level: 28 force-avd-creation: false @@ -133,7 +133,7 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - name: "Run Instrumented Orchestrator Tests" - uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b #v2.35.0 + uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a #v2.37.0 with: api-level: 28 force-avd-creation: false diff --git a/CHANGELOG.md b/CHANGELOG.md index c2f5b46a9..f039bb5b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,43 @@ +## [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) diff --git a/README.md b/README.md index 5ffdcfe48..6321b413a 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.79.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.79.0', + 'com.mparticle:android-another-kit:5.79.0' ) } ``` diff --git a/android-core/proguard.pro b/android-core/proguard.pro index 400222184..956b9a715 100644 --- a/android-core/proguard.pro +++ b/android-core/proguard.pro @@ -84,6 +84,10 @@ -keep class com.mparticle.MPEvent$* { *; } -keep class com.mparticle.MParticle { *; } +-keep class com.mparticle.MParticle$Internal { *; } +-keep class com.mparticle.internal.ConfigManager { + public com.mparticle.networking.NetworkOptions getNetworkOptions(); +} -keep class com.mparticle.MParticle$EventType { *; } -keep class com.mparticle.MParticle$InstallType { *; } -keep class com.mparticle.MParticle$IdentityType { *; } 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 826b3139b..6249078c0 100644 --- a/android-core/src/androidTest/kotlin/com.mparticle/MParticleOptionsTest.kt +++ b/android-core/src/androidTest/kotlin/com.mparticle/MParticleOptionsTest.kt @@ -694,6 +694,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/androidTest/kotlin/com.mparticle/networking/MParticleBaseClientImplTest.kt b/android-core/src/androidTest/kotlin/com.mparticle/networking/MParticleBaseClientImplTest.kt index 349a4faa4..d74f9f768 100644 --- a/android-core/src/androidTest/kotlin/com.mparticle/networking/MParticleBaseClientImplTest.kt +++ b/android-core/src/androidTest/kotlin/com.mparticle/networking/MParticleBaseClientImplTest.kt @@ -422,4 +422,177 @@ class MParticleBaseClientImplTest : BaseCleanInstallEachTest() { ) assertEquals(null, result) } + + @Test + @Throws(MalformedURLException::class) + fun testCustomBaseURLConfigEndpoint() { + val options = + MParticleOptions + .builder(mContext) + .credentials(apiKey, "secret") + .networkOptions( + NetworkOptions + .builder() + .setCustomBaseURL("https://rkt.example.com") + .build(), + ).build() + MParticle.start(options) + val baseClientImpl = AccessUtils.getApiClient() as MParticleBaseClientImpl + val configUrl = baseClientImpl.getUrl(MParticleBaseClientImpl.Endpoint.CONFIG) + Assert.assertTrue(configUrl.toString().contains("rkt.example.com/config/v4/")) + Assert.assertFalse(configUrl.toString().contains("config2.mparticle.com")) + } + + @Test + @Throws(MalformedURLException::class) + fun testCustomBaseURLEventsEndpoint() { + val options = + MParticleOptions + .builder(mContext) + .credentials(apiKey, "secret") + .networkOptions( + NetworkOptions + .builder() + .setCustomBaseURL("https://rkt.example.com") + .build(), + ).build() + MParticle.start(options) + val uploadSettings = UploadSettings(apiKey, "secret", options.networkOptions, "", "") + val baseClientImpl = AccessUtils.getApiClient() as MParticleBaseClientImpl + val eventsUrl = + baseClientImpl.getUrl(MParticleBaseClientImpl.Endpoint.EVENTS, null, null, uploadSettings) + Assert.assertTrue(eventsUrl.toString().contains("rkt.example.com/nativeevents/v2/")) + Assert.assertFalse(eventsUrl.toString().contains("nativesdks")) + } + + @Test + @Throws(MalformedURLException::class) + fun testCustomBaseURLIdentityEndpoint() { + val options = + MParticleOptions + .builder(mContext) + .credentials(apiKey, "secret") + .networkOptions( + NetworkOptions + .builder() + .setCustomBaseURL("https://rkt.example.com") + .build(), + ).build() + MParticle.start(options) + val baseClientImpl = AccessUtils.getApiClient() as MParticleBaseClientImpl + val identityUrl = + baseClientImpl.getUrl(MParticleBaseClientImpl.Endpoint.IDENTITY, "login") + Assert.assertTrue(identityUrl.toString().contains("rkt.example.com/identity/v1/login")) + Assert.assertFalse(identityUrl.toString().contains("identity.us1.mparticle.com")) + } + + @Test + @Throws(MalformedURLException::class) + fun testCustomBaseURLAliasEndpoint() { + val options = + MParticleOptions + .builder(mContext) + .credentials(apiKey, "secret") + .networkOptions( + NetworkOptions + .builder() + .setCustomBaseURL("https://rkt.example.com") + .build(), + ).build() + MParticle.start(options) + val uploadSettings = UploadSettings(apiKey, "secret", options.networkOptions, "", "") + val baseClientImpl = AccessUtils.getApiClient() as MParticleBaseClientImpl + val aliasUrl = + baseClientImpl.getUrl(MParticleBaseClientImpl.Endpoint.ALIAS, null, null, uploadSettings) + Assert.assertTrue(aliasUrl.toString().contains("rkt.example.com/nativeevents/v1/identity/")) + Assert.assertTrue(aliasUrl.toString().endsWith("/alias")) + Assert.assertFalse(aliasUrl.toString().contains("nativesdks")) + } + + @Test + @Throws(MalformedURLException::class) + fun testCustomBaseURLAudienceEndpoint() { + val options = + MParticleOptions + .builder(mContext) + .credentials(apiKey, "secret") + .networkOptions( + NetworkOptions + .builder() + .setCustomBaseURL("https://rkt.example.com") + .build(), + ).build() + MParticle.start(options) + val baseClientImpl = AccessUtils.getApiClient() as MParticleBaseClientImpl + val audienceUrl = + baseClientImpl.getUrl(MParticleBaseClientImpl.Endpoint.AUDIENCE, 12345L) + Assert.assertTrue(audienceUrl.toString().contains("rkt.example.com/nativeevents/v1/")) + Assert.assertTrue(audienceUrl.toString().contains("/audience")) + Assert.assertFalse(audienceUrl.toString().contains("nativesdks")) + } + + @Test + fun testCustomBaseURLRejectsNonHTTPS() { + val opts = + NetworkOptions + .builder() + .setCustomBaseURL("http://rkt.example.com") + .build() + assertEquals(null, opts.customBaseURL) + } + + @Test + fun testCustomBaseURLStripsPath() { + val opts = + NetworkOptions + .builder() + .setCustomBaseURL("https://rkt.example.com/some/path?q=1") + .build() + assertEquals("rkt.example.com", opts.customBaseURL) + } + + @Test + fun testCustomBaseURLPreservesPort() { + val opts = + NetworkOptions + .builder() + .setCustomBaseURL("https://rkt.example.com:8443") + .build() + assertEquals("rkt.example.com:8443", opts.customBaseURL) + } + + @Test + fun testCustomBaseURLRejectsMalformed() { + val opts = + NetworkOptions + .builder() + .setCustomBaseURL("not a url") + .build() + assertEquals(null, opts.customBaseURL) + } + + @Test + fun testCustomBaseURLSurvivesJsonRoundTrip() { + val original = + NetworkOptions + .builder() + .setCustomBaseURL("https://rkt.example.com:8443") + .build() + Assert.assertEquals("rkt.example.com:8443", original.customBaseURL) + + val json = original.toJson().toString() + val restored = NetworkOptions.withNetworkOptions(json) + Assert.assertNotNull(restored) + Assert.assertEquals("rkt.example.com:8443", restored!!.customBaseURL) + } + + @Test + fun testCustomBaseURLOmittedFromJsonWhenUnset() { + val opts = NetworkOptions.builder().build() + val json = opts.toJson() + Assert.assertFalse(json.has("customBaseURL")) + + val restored = NetworkOptions.withNetworkOptions(json.toString()) + Assert.assertNull(restored!!.customBaseURL) + } } diff --git a/android-core/src/main/java/com/mparticle/MParticleOptions.java b/android-core/src/main/java/com/mparticle/MParticleOptions.java index e6fd4c123..3b4002224 100644 --- a/android-core/src/main/java/com/mparticle/MParticleOptions.java +++ b/android-core/src/main/java/com/mparticle/MParticleOptions.java @@ -42,6 +42,7 @@ public class MParticleOptions { 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; @@ -118,6 +119,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; } @@ -290,6 +298,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; @@ -403,6 +427,7 @@ public static class Builder { 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; @@ -622,6 +647,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/internal/ConfigManager.java b/android-core/src/main/java/com/mparticle/internal/ConfigManager.java index 8b7d61569..105dd8d54 100644 --- a/android-core/src/main/java/com/mparticle/internal/ConfigManager.java +++ b/android-core/src/main/java/com/mparticle/internal/ConfigManager.java @@ -106,6 +106,8 @@ public class ConfigManager { private String mDataplanId; private Integer mDataplanVersion; private Integer mMaxConfigAge; + @Nullable + private Integer mPersistenceMaxAgeSeconds; public static final int DEFAULT_CONNECTION_TIMEOUT_SECONDS = 30; public static final int MINIMUM_CONNECTION_TIMEOUT_SECONDS = 1; public static final int DEFAULT_SESSION_TIMEOUT_SECONDS = 60; @@ -136,6 +138,17 @@ public ConfigManager(Context context) { public ConfigManager(@NonNull MParticleOptions options) { this(options.getContext(), options.getEnvironment(), options.getApiKey(), options.getApiSecret(), options.getDataplanOptions(), options.getDataplanId(), options.getDataplanVersion(), options.getConfigMaxAge(), options.getConfigurationsForTarget(ConfigManager.class), options.getSideloadedKits()); + mPersistenceMaxAgeSeconds = options.getPersistenceMaxAgeSeconds(); + } + + /** + * @return the configured maximum persistence age in seconds, or {@code null} when the SDK + * should fall back to its 90-day default. + * @see MParticleOptions.Builder#persistenceMaxAgeSeconds(int) + */ + @Nullable + public Integer getPersistenceMaxAgeSeconds() { + return mPersistenceMaxAgeSeconds; } public ConfigManager(@NonNull Context context, @Nullable MParticle.Environment environment, @Nullable String apiKey, @Nullable String apiSecret, @Nullable MParticleOptions.DataplanOptions dataplanOptions, @Nullable String dataplanId, @Nullable Integer dataplanVersion, @Nullable Integer configMaxAge, @Nullable List> configurations, @Nullable List sideloadedKits) { 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/MParticleBaseClientImpl.java b/android-core/src/main/java/com/mparticle/networking/MParticleBaseClientImpl.java index 2ed7817ba..cbb2153e5 100644 --- a/android-core/src/main/java/com/mparticle/networking/MParticleBaseClientImpl.java +++ b/android-core/src/main/java/com/mparticle/networking/MParticleBaseClientImpl.java @@ -114,38 +114,26 @@ protected MPUrl getUrl(Endpoint endpoint, @Nullable String identityPath, HashMap protected MPUrl getUrl(Endpoint endpoint, @Nullable String identityPath,HashMap audienceQueryParams, @Nullable UploadSettings uploadSettings) throws MalformedURLException { NetworkOptions networkOptions = uploadSettings == null ? mConfigManager.getNetworkOptions() : uploadSettings.getNetworkOptions(); DomainMapping domainMapping = networkOptions.getDomain(endpoint); - String url = NetworkOptionsManager.getDefaultUrl(endpoint); String apiKey = uploadSettings == null ? mApiKey : uploadSettings.getApiKey(); + final boolean usingCustomBaseURL = !MPUtility.isEmpty(networkOptions.getCustomBaseURL()); - // `defaultDomain` variable is for URL generation when domain mapping is specified. - String defaultDomain = url; - boolean isDefaultDomain = true; - - // Check if domain mapping is specified and update the URL based on domain mapping - String domainMappingUrl = domainMapping != null ? domainMapping.getUrl() : null; - if (!MPUtility.isEmpty(domainMappingUrl)) { - isDefaultDomain = url.equals(domainMappingUrl); - url = domainMappingUrl; - } - - if (endpoint != Endpoint.CONFIG) { - // Set URL with pod prefix if it’s the default domain and endpoint is not CONFIG - if (isDefaultDomain) { - url = getPodUrl(url, mConfigManager.getPodPrefix(apiKey), mConfigManager.isDirectUrlRoutingEnabled()); - } else { - // When domain mapping is specified, generate the default domain. Whether podRedirection is enabled or not, always use the original URL. - defaultDomain = getPodUrl(defaultDomain, null, false); - } - } + ResolvedHost host = resolveHost(endpoint, networkOptions, domainMapping, apiKey); + String url = host.url; + String defaultDomain = host.defaultDomain; + boolean isDefaultDomain = host.isDefaultDomain; Uri uri; String subdirectory; String pathPrefix; String pathPostfix; boolean overridesSubdirectory = domainMapping != null && domainMapping.isOverridesSubdirectory(); + if (usingCustomBaseURL && overridesSubdirectory) { + Logger.warning("NetworkOptions: customBaseURL with overridesSubdirectory is unsupported for CDN routing; overridesSubdirectory will be ignored for " + endpoint.name() + "."); + overridesSubdirectory = false; + } switch (endpoint) { case CONFIG: - pathPrefix = SERVICE_VERSION_4 + "/"; + pathPrefix = usingCustomBaseURL ? "/config/v4/" : SERVICE_VERSION_4 + "/"; subdirectory = overridesSubdirectory ? "" : pathPrefix; pathPostfix = mApiKey + "/config"; Uri.Builder builder = new Uri.Builder() @@ -165,9 +153,9 @@ protected MPUrl getUrl(Endpoint endpoint, @Nullable String identityPath,HashMap< } } } - return MPUrl.getUrl(builder.build().toString(), generateDefaultURL(isDefaultDomain, builder.build(), defaultDomain, (pathPrefix + pathPostfix))); + return MPUrl.getUrl(builder.build().toString(), generateDefaultURL(isDefaultDomain, builder.build(), defaultDomain, (SERVICE_VERSION_4 + "/" + pathPostfix))); case EVENTS: - pathPrefix = SERVICE_VERSION_2 + "/"; + pathPrefix = usingCustomBaseURL ? "/nativeevents/v2/" : SERVICE_VERSION_2 + "/"; subdirectory = overridesSubdirectory ? "" : pathPrefix; pathPostfix = apiKey + "/events"; uri = new Uri.Builder() @@ -176,9 +164,9 @@ protected MPUrl getUrl(Endpoint endpoint, @Nullable String identityPath,HashMap< .path(subdirectory + pathPostfix) .build(); - return MPUrl.getUrl(uri.toString(), generateDefaultURL(isDefaultDomain, uri, defaultDomain, (pathPrefix + pathPostfix))); + return MPUrl.getUrl(uri.toString(), generateDefaultURL(isDefaultDomain, uri, defaultDomain, (SERVICE_VERSION_2 + "/" + pathPostfix))); case ALIAS: - pathPrefix = SERVICE_VERSION_1 + "/identity/"; + pathPrefix = usingCustomBaseURL ? "/nativeevents/v1/identity/" : SERVICE_VERSION_1 + "/identity/"; subdirectory = overridesSubdirectory ? "" : pathPrefix; pathPostfix = apiKey + "/alias"; uri = new Uri.Builder() @@ -186,26 +174,28 @@ protected MPUrl getUrl(Endpoint endpoint, @Nullable String identityPath,HashMap< .encodedAuthority(url) .path(subdirectory + pathPostfix) .build(); - return MPUrl.getUrl(uri.toString(), generateDefaultURL(isDefaultDomain, uri, defaultDomain, (pathPrefix + pathPostfix))); + return MPUrl.getUrl(uri.toString(), generateDefaultURL(isDefaultDomain, uri, defaultDomain, (SERVICE_VERSION_1 + "/identity/" + pathPostfix))); case IDENTITY: - pathPrefix = SERVICE_VERSION_1 + "/"; - subdirectory = overridesSubdirectory ? "" : SERVICE_VERSION_1 + "/"; + pathPrefix = usingCustomBaseURL ? "/identity/v1/" : SERVICE_VERSION_1 + "/"; + subdirectory = overridesSubdirectory ? "" : pathPrefix; pathPostfix = identityPath; uri = new Uri.Builder() .scheme(BuildConfig.SCHEME) .encodedAuthority(url) .path(subdirectory + pathPostfix) .build(); - return MPUrl.getUrl(uri.toString(), generateDefaultURL(isDefaultDomain, uri, defaultDomain, (pathPrefix + pathPostfix))); + return MPUrl.getUrl(uri.toString(), generateDefaultURL(isDefaultDomain, uri, defaultDomain, (SERVICE_VERSION_1 + "/" + pathPostfix))); case AUDIENCE: - pathPostfix = SERVICE_VERSION_1 + "/" + mApiKey + "/audience"; + pathPostfix = usingCustomBaseURL + ? "/nativeevents/v1/" + mApiKey + "/audience" + : SERVICE_VERSION_1 + "/" + mApiKey + "/audience"; uri = new Uri.Builder() .scheme(BuildConfig.SCHEME) .encodedAuthority(url) .path(pathPostfix) .appendQueryParameter("mpid", String.valueOf(mConfigManager.getMpid())) .build(); - return MPUrl.getUrl(uri.toString(), generateDefaultURL(isDefaultDomain, uri, defaultDomain, pathPostfix)); + return MPUrl.getUrl(uri.toString(), generateDefaultURL(isDefaultDomain, uri, defaultDomain, (SERVICE_VERSION_1 + "/" + mApiKey + "/audience"))); default: return null; } @@ -246,6 +236,57 @@ String getPodUrl(String URLPrefix, String pod, boolean enablePodRedirection) { return null; } + /** + * Resolves the host(s) used to build the endpoint URL. Returns three values: + * {@code url} — host used for the request, {@code defaultDomain} — host used as the + * fallback for {@link #generateDefaultURL}, and {@code isDefaultDomain} — true when + * {@code url} is the unmodified mParticle default. + * + *

Priority: {@code customBaseURL} → per-endpoint {@code DomainMapping} → default. + */ + private ResolvedHost resolveHost(Endpoint endpoint, NetworkOptions networkOptions, DomainMapping domainMapping, String apiKey) { + String defaultUrl = NetworkOptionsManager.getDefaultUrl(endpoint); + String customBaseURL = networkOptions.getCustomBaseURL(); + + if (!MPUtility.isEmpty(customBaseURL)) { + if (domainMapping != null && !MPUtility.isEmpty(domainMapping.getUrl())) { + Logger.warning("NetworkOptions: customBaseURL is set; domain mapping for " + endpoint.name() + " is ignored."); + } + // When custom CNAME is used, the default-domain URL still needs the pod prefix + // so MPConnectionTest matching and pinning fallbacks continue to work. + String defaultDomain = endpoint == Endpoint.CONFIG ? defaultUrl : getPodUrl(defaultUrl, null, false); + return new ResolvedHost(customBaseURL, defaultDomain, false); + } + + String domainMappingUrl = domainMapping != null ? domainMapping.getUrl() : null; + boolean isDefaultDomain = MPUtility.isEmpty(domainMappingUrl) || defaultUrl.equals(domainMappingUrl); + String url = isDefaultDomain ? defaultUrl : domainMappingUrl; + String defaultDomain = defaultUrl; + + if (endpoint != Endpoint.CONFIG) { + if (isDefaultDomain) { + // Default domain gets the pod prefix. + url = getPodUrl(url, mConfigManager.getPodPrefix(apiKey), mConfigManager.isDirectUrlRoutingEnabled()); + } else { + // Domain-mapped: always generate the default with the original (un-pod-prefixed) host. + defaultDomain = getPodUrl(defaultDomain, null, false); + } + } + return new ResolvedHost(url, defaultDomain, isDefaultDomain); + } + + private static final class ResolvedHost { + final String url; + final String defaultDomain; + final boolean isDefaultDomain; + + ResolvedHost(String url, String defaultDomain, boolean isDefaultDomain) { + this.url = url; + this.defaultDomain = defaultDomain; + this.isDefaultDomain = isDefaultDomain; + } + } + public enum Endpoint { CONFIG(1), IDENTITY(2), 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 6faf9d521..678f66529 100644 --- a/android-core/src/main/java/com/mparticle/networking/NetworkOptions.java +++ b/android-core/src/main/java/com/mparticle/networking/NetworkOptions.java @@ -16,11 +16,13 @@ import org.json.JSONException; import org.json.JSONObject; +import java.net.MalformedURLException; +import java.net.URL; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.HashSet; import java.util.Set; public class NetworkOptions { @@ -28,6 +30,7 @@ public class NetworkOptions { Map domainMappings = new HashMap(); boolean pinningDisabledInDevelopment = false; boolean pinningDisabled = false; + private String customBaseURL = null; private static Set loggedDomainTypes = new HashSet<>(); private NetworkOptions() { @@ -44,6 +47,10 @@ private NetworkOptions(Builder builder) { if (builder.pinningDisabled != null) { pinningDisabled = builder.pinningDisabled; } + + if (builder.customBaseURL != null) { + customBaseURL = builder.customBaseURL; + } } @NonNull @@ -61,11 +68,21 @@ public static NetworkOptions withNetworkOptions(@Nullable String jsonString) { JSONObject jsonObject = new JSONObject(jsonString); builder.setPinningDisabledInDevelopment(jsonObject.optBoolean("disableDevPinning", false)); builder.setPinningDisabled(jsonObject.optBoolean("disablePinning", false)); + String storedCustomBaseURL = jsonObject.optString("customBaseURL", null); + if (!MPUtility.isEmpty(storedCustomBaseURL)) { + // Stored value is already host(:port). Skip the Builder setter (which expects a full + // HTTPS URL with scheme) and assign directly so a previously-validated value survives + // the JSON round-trip. + builder.customBaseURL = storedCustomBaseURL; + } 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); @@ -106,6 +123,16 @@ public boolean isPinningDisabled() { return pinningDisabled; } + /** + * Returns the configured custom CNAME host (without scheme), or {@code null} + * if not set. When non-null, this host overrides individual domain mappings + * for all endpoints. + */ + @Nullable + public String getCustomBaseURL() { + return customBaseURL; + } + DomainMapping getDomain(Endpoint endpoint) { return domainMappings.get(endpoint); } @@ -123,6 +150,9 @@ public JSONObject toJson() { JSONArray domainMappingsJson = new JSONArray(); networkOptions.put("disableDevPinning", pinningDisabledInDevelopment); networkOptions.put("disablePinning", pinningDisabled); + if (!MPUtility.isEmpty(customBaseURL)) { + networkOptions.put("customBaseURL", customBaseURL); + } networkOptions.put("domainMappings", domainMappingsJson); for (DomainMapping domainMapping : domainMappings.values()) { domainMappingsJson.put(domainMapping.toString()); @@ -137,6 +167,7 @@ public static class Builder { private Map domainMappings = new HashMap(); private Boolean pinningDisabledInDevelopment; private Boolean pinningDisabled; + private String customBaseURL; private Builder() { } @@ -187,6 +218,40 @@ public Builder setPinningDisabled(boolean disabled) { return this; } + /** + * Routes all mParticle endpoint traffic (config, events, identity, alias, audience) + * through a single CNAME host. Must be an HTTPS URL (e.g. https://rkt.example.com). + * Non-HTTPS values are rejected with a warning log and the property is left unset. + * + *

When set, this property takes priority over any per-endpoint domain mapping. + * Any path, query, or fragment on the URL is ignored — only the scheme, host, and + * port are used. + * + *

Certificate pinning: if pinning is enabled (default), supply certificates for + * the CNAME domain via the relevant {@link DomainMapping}, or disable pinning via + * {@link #setPinningDisabled(boolean)} / {@link #setPinningDisabledInDevelopment(boolean)}. + * + * @param customBaseURL HTTPS URL containing the CNAME host + */ + @NonNull + public Builder setCustomBaseURL(@NonNull String customBaseURL) { + try { + URL parsed = new URL(customBaseURL); + if (!"https".equalsIgnoreCase(parsed.getProtocol()) || MPUtility.isEmpty(parsed.getHost())) { + Logger.warning("NetworkOptions: customBaseURL must use HTTPS and include a valid host — value ignored."); + return this; + } + StringBuilder host = new StringBuilder(parsed.getHost()); + if (parsed.getPort() != -1) { + host.append(":").append(parsed.getPort()); + } + this.customBaseURL = host.toString(); + } catch (MalformedURLException e) { + Logger.warning("NetworkOptions: customBaseURL is malformed — value ignored."); + } + return this; + } + @NonNull public NetworkOptions build() { return new NetworkOptions(this); 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 ae38d7a0d..bef6ec38f 100644 --- a/build.gradle +++ b/build.gradle @@ -63,7 +63,7 @@ subprojects { allprojects { group = 'com.mparticle' - version = '5.78.2-SNAPSHOT' + version = '5.79.0-SNAPSHOT' if (project.hasProperty('isRelease') && project.isRelease) { version = version.toString().replace("-SNAPSHOT", "") } diff --git a/kits/appboy-kit b/kits/appboy-kit index 84f7f64fb..f44d2b3a5 160000 --- a/kits/appboy-kit +++ b/kits/appboy-kit @@ -1 +1 @@ -Subproject commit 84f7f64fb24902ee0717053d0cdb1ca4e31599d6 +Subproject commit f44d2b3a5fb29266e2a72b5ed0d32c95ced1c62f diff --git a/kits/appsflyer-kit b/kits/appsflyer-kit index 541195a3c..e58e6e00b 160000 --- a/kits/appsflyer-kit +++ b/kits/appsflyer-kit @@ -1 +1 @@ -Subproject commit 541195a3c0b4a3f3a712486080211564589ac986 +Subproject commit e58e6e00baae4ce863e2ef38da46a2746addbdc1 diff --git a/kits/rokt-kit b/kits/rokt-kit index cfd8174db..ff28ccdc3 160000 --- a/kits/rokt-kit +++ b/kits/rokt-kit @@ -1 +1 @@ -Subproject commit cfd8174db257484693247752bc094835916d4108 +Subproject commit ff28ccdc3d513044663d9c7b407e8e5ea3ed9338