From cbb17d7e359d41d8d7577a9516cf7788c7288442 Mon Sep 17 00:00:00 2001 From: mparticle-automation Date: Wed, 11 Mar 2026 18:09:14 +0000 Subject: [PATCH 01/14] chore: Update submodules --- kits/rokt-kit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kits/rokt-kit b/kits/rokt-kit index cfd8174db..b96f92867 160000 --- a/kits/rokt-kit +++ b/kits/rokt-kit @@ -1 +1 @@ -Subproject commit cfd8174db257484693247752bc094835916d4108 +Subproject commit b96f92867ab538673a648b2ecf863598441d66aa From 892a77834cc0e58a076659f18ae40015610ead82 Mon Sep 17 00:00:00 2001 From: mparticle-automation Date: Wed, 11 Mar 2026 18:58:47 +0000 Subject: [PATCH 02/14] chore: 5.78.3 (release) ## [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)) --- CHANGELOG.md | 8 ++++++++ README.md | 6 +++--- build.gradle | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2f5b46a9..028bd5d4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [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..6304e7651 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.78.3' } ``` @@ -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.78.3', + 'com.mparticle:android-another-kit:5.78.3' ) } ``` diff --git a/build.gradle b/build.gradle index ae38d7a0d..8f4ba8e5a 100644 --- a/build.gradle +++ b/build.gradle @@ -63,7 +63,7 @@ subprojects { allprojects { group = 'com.mparticle' - version = '5.78.2-SNAPSHOT' + version = '5.78.3-SNAPSHOT' if (project.hasProperty('isRelease') && project.isRelease) { version = version.toString().replace("-SNAPSHOT", "") } From 08dab75e3715733f6f5655fbf39fbce16a2e01a2 Mon Sep 17 00:00:00 2001 From: mparticle-automation Date: Mon, 23 Mar 2026 14:39:14 +0000 Subject: [PATCH 03/14] chore: Update submodules --- kits/appsflyer-kit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kits/appsflyer-kit b/kits/appsflyer-kit index 541195a3c..ff3596bc2 160000 --- a/kits/appsflyer-kit +++ b/kits/appsflyer-kit @@ -1 +1 @@ -Subproject commit 541195a3c0b4a3f3a712486080211564589ac986 +Subproject commit ff3596bc2cc6b3ec85f4ecce29f4f419c8beb005 From 6bfb6af793b8fba6146fc26c5829b18207095ad0 Mon Sep 17 00:00:00 2001 From: mparticle-automation Date: Mon, 23 Mar 2026 15:45:38 +0000 Subject: [PATCH 04/14] chore: 5.78.4 (release) ## [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)) --- CHANGELOG.md | 7 +++++++ README.md | 6 +++--- build.gradle | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 028bd5d4a..eff0c7072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [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) diff --git a/README.md b/README.md index 6304e7651..a908cf80e 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.3' + implementation 'com.mparticle:android-core:5.78.4' } ``` @@ -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.3', - 'com.mparticle:android-another-kit:5.78.3' + 'com.mparticle:android-example-kit:5.78.4', + 'com.mparticle:android-another-kit:5.78.4' ) } ``` diff --git a/build.gradle b/build.gradle index 8f4ba8e5a..ca31bd288 100644 --- a/build.gradle +++ b/build.gradle @@ -63,7 +63,7 @@ subprojects { allprojects { group = 'com.mparticle' - version = '5.78.3-SNAPSHOT' + version = '5.78.4-SNAPSHOT' if (project.hasProperty('isRelease') && project.isRelease) { version = version.toString().replace("-SNAPSHOT", "") } From 86d8d9748a0c7a796dd0b0be6722f92acef301ea Mon Sep 17 00:00:00 2001 From: mparticle-automation Date: Wed, 25 Mar 2026 16:10:11 +0000 Subject: [PATCH 05/14] chore: Update submodules --- kits/appsflyer-kit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kits/appsflyer-kit b/kits/appsflyer-kit index ff3596bc2..e58e6e00b 160000 --- a/kits/appsflyer-kit +++ b/kits/appsflyer-kit @@ -1 +1 @@ -Subproject commit ff3596bc2cc6b3ec85f4ecce29f4f419c8beb005 +Subproject commit e58e6e00baae4ce863e2ef38da46a2746addbdc1 From 83b24bfbffab6eccc235a3fd1f6089f57462e446 Mon Sep 17 00:00:00 2001 From: mparticle-automation Date: Wed, 25 Mar 2026 16:26:15 +0000 Subject: [PATCH 06/14] chore: 5.78.5 (release) ## [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)) --- CHANGELOG.md | 7 +++++++ README.md | 6 +++--- build.gradle | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eff0c7072..bd0d2cbce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [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) diff --git a/README.md b/README.md index a908cf80e..a0a16d657 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.4' + implementation 'com.mparticle:android-core:5.78.5' } ``` @@ -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.4', - 'com.mparticle:android-another-kit:5.78.4' + 'com.mparticle:android-example-kit:5.78.5', + 'com.mparticle:android-another-kit:5.78.5' ) } ``` diff --git a/build.gradle b/build.gradle index ca31bd288..74315ca09 100644 --- a/build.gradle +++ b/build.gradle @@ -63,7 +63,7 @@ subprojects { allprojects { group = 'com.mparticle' - version = '5.78.4-SNAPSHOT' + version = '5.78.5-SNAPSHOT' if (project.hasProperty('isRelease') && project.isRelease) { version = version.toString().replace("-SNAPSHOT", "") } From 20f723f27cf296c879ceebc9d234edca039ab371 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Wed, 8 Apr 2026 11:41:09 -0400 Subject: [PATCH 07/14] fix: increase MPLatch timeout from 5s to 30s (#695) * fix: increase MPLatch timeout from 5s to 30s The 5-second timeout is too short for WebView JS bridge tests running on newer GitHub Actions runner images (ubuntu24/20260309.50+). The MParticleJSInterfaceITest tests consistently fail because JS execution in the emulator WebView doesn't complete within 5 seconds on these environments. Increasing to 30 seconds provides sufficient margin while still catching genuine hangs via the workflow-level timeout-minutes: 15. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use explicit 30s timeout in JS bridge tests only Instead of increasing the global MPLatch timeout (used by 20+ test files), use an explicit 30-second await in MParticleJSInterfaceITest only. WebView JS execution needs more time on newer CI runner images, but other tests don't need the longer timeout. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../internal/MParticleJSInterfaceITest.java | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) 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); } From ca88322ac7ef8649ed111cf28afb81d99cdb271a Mon Sep 17 00:00:00 2001 From: junias-rokt Date: Thu, 23 Apr 2026 13:18:47 -0400 Subject: [PATCH 08/14] feat: Add max persistence age override option [TRIAGE-608] (#699) * TRIAGE-608: Add max persistence age override option * TRIAGE-608: Update throttl ts on success + tests * TRIAGE-608: Make properties @VisibleForTesting * TRIAGE-608: Fix retry-on-failure throttle logic --- .../com.mparticle/MParticleOptionsTest.kt | 40 +++++++ .../database/services/MessageServiceTest.kt | 66 +++++++++++ .../database/services/SessionServiceTest.kt | 54 +++++++++ .../database/services/UploadServiceTest.kt | 52 +++++++++ .../java/com/mparticle/MParticleOptions.java | 51 +++++++++ .../com/mparticle/internal/ConfigManager.java | 13 +++ .../com/mparticle/internal/UploadHandler.java | 51 +++++++++ .../database/services/MParticleDBManager.java | 32 ++++++ .../database/services/MessageService.java | 16 +++ .../database/services/SessionService.java | 17 +++ .../database/services/UploadService.java | 16 +++ .../mparticle/internal/UploadHandlerTest.kt | 107 ++++++++++++++++++ 12 files changed, 515 insertions(+) create mode 100644 android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/UploadServiceTest.kt 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..5b2ed9895 --- /dev/null +++ b/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/UploadServiceTest.kt @@ -0,0 +1,52 @@ +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) + } + + @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/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/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() { From 5285149118b58cbd5cf4fb90cb58627deb7538b9 Mon Sep 17 00:00:00 2001 From: James Newman Date: Thu, 14 May 2026 11:20:57 -0400 Subject: [PATCH 09/14] feat: add customBaseURL CNAME support to NetworkOptions (#701) * feat: add customBaseURL CNAME support to NetworkOptions Adds NetworkOptions.Builder.setCustomBaseURL(String) which routes all mParticle endpoint traffic (config, events, identity, alias, audience) through a single HTTPS CNAME host. When set, customBaseURL takes priority over individual domain mappings and rewrites paths to match CDN routing: /config/v4/, /nativeevents/v2/, /identity/v1/, /nativeevents/v1/identity/, /nativeevents/v1//audience. Also adds R8 keep rules for MParticle$Internal and ConfigManager.getNetworkOptions() so kits can read customBaseURL after minification. The Rokt kit reads NetworkOptions.customBaseURL and forwards it to the Rokt SDK: https://github.com/mparticle-integrations/mparticle-android-integration-rokt/pull/143 Mirrors iOS work from mparticle-apple-sdk#760. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: persist customBaseURL across NetworkOptions JSON round-trip NetworkOptions.toJson() and withNetworkOptions(String) did not include customBaseURL, so any value was silently dropped when UploadSettings serialized NetworkOptions to the upload database. Events and alias uploads read back NetworkOptions without customBaseURL and routed to the default mParticle endpoints instead of the partner CNAME. Also: - Extract the customBaseURL/DomainMapping host-resolution branch out of getUrl() into a private resolveHost() helper plus a small ResolvedHost value type, lowering getUrl()'s cyclomatic complexity. - Switch java.net.URL / java.net.MalformedURLException to imports. - Add two androidTest cases covering the round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) * test(android-core): cover custom base URL upload storage --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Thomson Thomas --- android-core/proguard.pro | 4 + .../database/services/UploadServiceTest.kt | 21 +++ .../networking/MParticleBaseClientImplTest.kt | 173 ++++++++++++++++++ .../networking/MParticleBaseClientImpl.java | 105 +++++++---- .../mparticle/networking/NetworkOptions.java | 64 ++++++- 5 files changed, 334 insertions(+), 33 deletions(-) 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/kotlin/com.mparticle/internal/database/services/UploadServiceTest.kt b/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/UploadServiceTest.kt index 5b2ed9895..c0d3e5e38 100644 --- 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 @@ -45,6 +45,27 @@ class UploadServiceTest : BaseMPServiceTest() { 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) 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/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..708aae363 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,6 +68,13 @@ 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 @@ -106,6 +120,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 +147,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 +164,7 @@ public static class Builder { private Map domainMappings = new HashMap(); private Boolean pinningDisabledInDevelopment; private Boolean pinningDisabled; + private String customBaseURL; private Builder() { } @@ -187,6 +215,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); From 59a4a9a68e3addfaf5c4eea1afd396889721819d Mon Sep 17 00:00:00 2001 From: mparticle-automation Date: Thu, 14 May 2026 15:43:01 +0000 Subject: [PATCH 10/14] chore: Update submodules --- kits/appboy-kit | 2 +- kits/rokt-kit | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/rokt-kit b/kits/rokt-kit index b96f92867..ff28ccdc3 160000 --- a/kits/rokt-kit +++ b/kits/rokt-kit @@ -1 +1 @@ -Subproject commit b96f92867ab538673a648b2ecf863598441d66aa +Subproject commit ff28ccdc3d513044663d9c7b407e8e5ea3ed9338 From 3593cdcbbf1910be124f9e327eeff46177fb2ce0 Mon Sep 17 00:00:00 2001 From: mparticle-automation Date: Thu, 14 May 2026 15:53:43 +0000 Subject: [PATCH 11/14] chore: 5.79.0 (release) ## [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)) --- CHANGELOG.md | 18 ++++++++++++++++++ README.md | 6 +++--- build.gradle | 2 +- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd0d2cbce..f039bb5b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +## [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) diff --git a/README.md b/README.md index a0a16d657..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.5' + 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.5', - 'com.mparticle:android-another-kit:5.78.5' + 'com.mparticle:android-example-kit:5.79.0', + 'com.mparticle:android-another-kit:5.79.0' ) } ``` diff --git a/build.gradle b/build.gradle index 74315ca09..bef6ec38f 100644 --- a/build.gradle +++ b/build.gradle @@ -63,7 +63,7 @@ subprojects { allprojects { group = 'com.mparticle' - version = '5.78.5-SNAPSHOT' + version = '5.79.0-SNAPSHOT' if (project.hasProperty('isRelease') && project.isRelease) { version = version.toString().replace("-SNAPSHOT", "") } From f4ceca7de2c2ba01ea87c98698ef7bb84991b4ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 09:26:13 -0400 Subject: [PATCH 12/14] chore: bump trunk-io/trunk-action from 1.2.4 to 1.3.1 (#705) Bumps [trunk-io/trunk-action](https://github.com/trunk-io/trunk-action) from 1.2.4 to 1.3.1. - [Release notes](https://github.com/trunk-io/trunk-action/releases) - [Commits](https://github.com/trunk-io/trunk-action/compare/75699af9e26881e564e9d832ef7dc3af25ec031b...04ba50e7658c81db7356da96657e6e77f220bfa3) --- updated-dependencies: - dependency-name: trunk-io/trunk-action dependency-version: 1.3.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pull-request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 81b0da6d0..fe063da8e 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: From 491f169d8cbb39d8f1828ff71309bdec4918c354 Mon Sep 17 00:00:00 2001 From: ankitsingh08 Date: Wed, 20 May 2026 14:15:48 +0100 Subject: [PATCH 13/14] Handle legacy persisted domain mappings safely (#703) Co-authored-by: James Newman --- .../mparticle/networking/DomainMapping.java | 4 +- .../mparticle/networking/NetworkOptions.java | 9 ++-- .../networking/NetworkOptionsTest.kt | 44 +++++++++++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) 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/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, From 0f19d0be0cd8a671abd6e174475de4da315d8901 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 14:19:17 +0000 Subject: [PATCH 14/14] chore: bump reactivecircus/android-emulator-runner from 2.35.0 to 2.37.0 Bumps [reactivecircus/android-emulator-runner](https://github.com/reactivecircus/android-emulator-runner) from 2.35.0 to 2.37.0. - [Release notes](https://github.com/reactivecircus/android-emulator-runner/releases) - [Changelog](https://github.com/ReactiveCircus/android-emulator-runner/blob/main/CHANGELOG.md) - [Commits](https://github.com/reactivecircus/android-emulator-runner/compare/b530d96654c385303d652368551fb075bc2f0b6b...e89f39f1abbbd05b1113a29cf4db69e7540cae5a) --- updated-dependencies: - dependency-name: reactivecircus/android-emulator-runner dependency-version: 2.37.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/cross-platform-tests.yml | 4 ++-- .github/workflows/pull-request.yml | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) 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 fe063da8e..0103df279 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -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