Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String?>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReadyMessage>): Int {
var max = 0
for (message in messages) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
51 changes: 51 additions & 0 deletions android-core/src/main/java/com/mparticle/MParticleOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Comment on lines +123 to +127
Copy link
Copy Markdown
Collaborator

@thomson-t thomson-t Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we check for the upper bound? Something like

if (builder.persistenceMaxAgeSeconds <= 0) {
    Logger.warning("Persistence Max Age must be a positive number, disregarding value.");
} else if (builder.persistenceMaxAgeSeconds > MAX_PERSISTENCE_MAX_AGE_SECONDS) {
    Logger.warning("Persistence Max Age is too high");
    this.mPersistenceMaxAgeSeconds = MAX_PERSISTENCE_MAX_AGE_SECONDS;
} else {
    this.mPersistenceMaxAgeSeconds = builder.persistenceMaxAgeSeconds;
}

or not set it at all of not in the range?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey good callout! This is intentional as the main aim for these changes was to have feature parity with the other SDKs. Changing this would diverge from the other ones. This also matches all the other validators in the file and large values basically just disable the sweep.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, fair enough

}
if (builder.unCaughtExceptionLogging != null) {
this.mUnCaughtExceptionLogging = builder.unCaughtExceptionLogging;
}
Expand Down Expand Up @@ -290,6 +298,22 @@ public Integer getConfigMaxAge() {
return mConfigMaxAge;
}

/**
* The maximum threshold (in seconds) for locally persisted events, batches, and sessions.
* <p>
* 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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -622,6 +647,32 @@ public Builder configMaxAgeSeconds(int configMaxAge) {
return this;
}

/**
* Set a maximum threshold for locally persisted events, batches, and sessions, in seconds.
* <p>
* 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.).
* <p>
* 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.
* <p>
* 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Configuration<ConfigManager>> configurations, @Nullable List<SideloadedKit> sideloadedKits) {
Expand Down
Loading
Loading