diff --git a/id/src/main/java/com/simprints/id/Application.kt b/id/src/main/java/com/simprints/id/Application.kt index 41b90c472b..48915db62c 100644 --- a/id/src/main/java/com/simprints/id/Application.kt +++ b/id/src/main/java/com/simprints/id/Application.kt @@ -12,8 +12,10 @@ import com.simprints.infra.enrolment.records.repository.local.migration.RealmToR import com.simprints.infra.eventsync.BuildConfig.DB_ENCRYPTION import com.simprints.infra.logging.LoggingConstants.CrashReportTag.APPLICATION import com.simprints.infra.logging.LoggingConstants.CrashReportingCustomKeys.DEVICE_ID +import com.simprints.infra.logging.LoggingConstants.CrashReportingCustomKeys.VERSION_HISTORY import com.simprints.infra.logging.Simber import com.simprints.infra.logging.SimberBuilder +import com.simprints.infra.logging.usecases.UpdateAndGetVersionHistoryUseCase import com.simprints.infra.sync.SyncOrchestrator import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.CoroutineScope @@ -35,6 +37,8 @@ open class Application : @Inject lateinit var realmToRoomMigrationScheduler: RealmToRoomMigrationScheduler + private val getVersionNames = UpdateAndGetVersionHistoryUseCase() + @AppScope @Inject lateinit var appScope: CoroutineScope @@ -76,6 +80,8 @@ open class Application : open fun initApplication() { SimberBuilder.initialize(this) Simber.setUserProperty(DEVICE_ID, deviceHardwareId) + Simber.setUserProperty(VERSION_HISTORY, getVersionNames(this, BuildConfig.VERSION_NAME)) + appScope.launch { realmToRoomMigrationScheduler.scheduleMigrationWorkerIfNeeded() syncOrchestrator.cleanupWorkers() diff --git a/infra/logging/build.gradle.kts b/infra/logging/build.gradle.kts index 28a49c9dfc..2ba6701363 100644 --- a/infra/logging/build.gradle.kts +++ b/infra/logging/build.gradle.kts @@ -41,6 +41,7 @@ dependencies { implementation(libs.kermit.io) testImplementation(libs.testing.junit) + testImplementation(libs.testing.truth) testImplementation(libs.testing.mockk.core) testImplementation(libs.testing.robolectric.core) testImplementation(libs.testing.coroutines) diff --git a/infra/logging/src/main/java/com/simprints/infra/logging/LoggingConstants.kt b/infra/logging/src/main/java/com/simprints/infra/logging/LoggingConstants.kt index 56f0fc2903..623c28acdc 100644 --- a/infra/logging/src/main/java/com/simprints/infra/logging/LoggingConstants.kt +++ b/infra/logging/src/main/java/com/simprints/infra/logging/LoggingConstants.kt @@ -14,6 +14,8 @@ object LoggingConstants { const val DEVICE_ID = "Device_ID" const val SUBJECTS_DOWN_SYNC_TRIGGERS = "Down_sync_triggers" const val SESSION_ID = "Session_ID" + + const val VERSION_HISTORY = "Version_History" } object AnalyticsUserProperties { diff --git a/infra/logging/src/main/java/com/simprints/infra/logging/usecases/UpdateAndGetVersionHistoryUseCase.kt b/infra/logging/src/main/java/com/simprints/infra/logging/usecases/UpdateAndGetVersionHistoryUseCase.kt new file mode 100644 index 0000000000..59f35d1523 --- /dev/null +++ b/infra/logging/src/main/java/com/simprints/infra/logging/usecases/UpdateAndGetVersionHistoryUseCase.kt @@ -0,0 +1,37 @@ +package com.simprints.infra.logging.usecases + +import android.content.Context +import android.content.Context.MODE_PRIVATE +import androidx.core.content.edit + +class UpdateAndGetVersionHistoryUseCase { + operator fun invoke( + context: Context, + currentVersion: String, + ): String { + // Keeping the preferences initialised only in the scope of the function call + val preference = context.getSharedPreferences(VERSION_CACHE_NAME, MODE_PRIVATE) + + val versions = preference.getString(VERSIONS_KEY, null).orEmpty() + if (versions.startsWith(currentVersion)) return versions + + val newVersions = + if (versions.isEmpty()) currentVersion else "$currentVersion$VERSION_DELIMITER$versions" + return newVersions + .let { + // Keep the total length within custom key constrains + if (it.length > MAX_VALUE_LENGTH) it.substringBeforeLast(VERSION_DELIMITER) else it + }.also { preference.edit { putString(VERSIONS_KEY, it) } } + } + + companion object Companion { + private const val VERSION_CACHE_NAME = "574b2793-7087-42ff-a80f-f9ad7cc3e54e" + private const val VERSIONS_KEY = "versions" + + private const val VERSION_DELIMITER = ";" + + // This limit is based on the firebase/crashlytics message limit and + // gives us ~6 version names or ~10 version codes of tracking + private const val MAX_VALUE_LENGTH = 95 + } +} diff --git a/infra/logging/src/test/java/com/simprints/infra/logging/usecases/UpdateAndGetVersionHistoryTest.kt b/infra/logging/src/test/java/com/simprints/infra/logging/usecases/UpdateAndGetVersionHistoryTest.kt new file mode 100644 index 0000000000..3ea23120e2 --- /dev/null +++ b/infra/logging/src/test/java/com/simprints/infra/logging/usecases/UpdateAndGetVersionHistoryTest.kt @@ -0,0 +1,88 @@ +package com.simprints.infra.logging.usecases + +import android.content.Context +import android.content.SharedPreferences +import com.google.common.truth.Truth.* +import io.mockk.* +import io.mockk.impl.annotations.MockK +import org.junit.Before +import org.junit.Test + +class UpdateAndGetVersionHistoryTest { + @MockK + lateinit var mockContext: Context + + @MockK + private lateinit var mockSharedPreferences: SharedPreferences + + @MockK + private lateinit var mockEditor: SharedPreferences.Editor + private lateinit var updateAndGetVersionHistoryUseCase: UpdateAndGetVersionHistoryUseCase + + private lateinit var slot: CapturingSlot + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true, relaxUnitFun = true) + + every { mockContext.getSharedPreferences(any(), Context.MODE_PRIVATE) } returns mockSharedPreferences + every { mockSharedPreferences.edit() } returns mockEditor + + slot = slot() + every { mockEditor.putString(any(), capture(slot)) } returns mockEditor + + updateAndGetVersionHistoryUseCase = UpdateAndGetVersionHistoryUseCase() + } + + @Test + fun `adds current version and returns it when cache is empty `() { + verifyCorrectCachedVersions( + currentVersion = "10010701", + cachedVersions = null, + expectedVersions = "10010701", + ) + } + + @Test + fun `returns existing versions without change when current version is already cached and is the first`() { + verifyCorrectCachedVersions( + currentVersion = "10010701", + cachedVersions = "10010701;10010601", + expectedVersions = "10010701;10010601", + shouldWriteCache = false, + ) + } + + @Test + fun `prepends current version when cache is not empty and current version is new`() { + verifyCorrectCachedVersions( + currentVersion = "10010701", + cachedVersions = "10010601;10010501", + expectedVersions = "10010701;10010601;10010501", + ) + } + + @Test + fun `truncates oldest versions when current version is new and adding it exceeds MAX_VALUE_LENGTH `() { + verifyCorrectCachedVersions( + currentVersion = "2025.3.0+107.1", + // Cached is already at the limit of the analytics keys + cachedVersions = "2025.2.0+107.1;2025.1.0+107.1;2024.4.0+107.1;2024.3.0+107.1;2024.2.0+107.1;2024.1.0+107.1", + expectedVersions = "2025.3.0+107.1;2025.2.0+107.1;2025.1.0+107.1;2024.4.0+107.1;2024.3.0+107.1;2024.2.0+107.1", + ) + } + + private fun verifyCorrectCachedVersions( + currentVersion: String, + cachedVersions: String?, + expectedVersions: String, + shouldWriteCache: Boolean = true, + ) { + every { mockSharedPreferences.getString(any(), any()) } returns cachedVersions + + val result = updateAndGetVersionHistoryUseCase(mockContext, currentVersion) + + assertThat(result).isEqualTo(expectedVersions) + verify(exactly = if (shouldWriteCache) 1 else 0) { mockEditor.putString(any(), eq(expectedVersions)) } + } +}