diff --git a/CHANGELOG.md b/CHANGELOG.md index 74b8a921b..bb2b4f02f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## CHANGE LOG. +### January 21, 2025 +* [CleverTap Android SDK v7.2.2](docs/CTCORECHANGELOG.md) + ### January 16, 2025 * [CleverTap Android SDK v7.2.1](docs/CTCORECHANGELOG.md) diff --git a/README.md b/README.md index 21dd05b9e..68e9962db 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ We publish the SDK to `mavenCentral` as an `AAR` file. Just declare it as depend ```groovy dependencies { - implementation "com.clevertap.android:clevertap-android-sdk:7.2.1" + implementation "com.clevertap.android:clevertap-android-sdk:7.2.2" } ``` @@ -34,7 +34,7 @@ Alternatively, you can download and add the AAR file included in this repo in yo ```groovy dependencies { - implementation (name: "clevertap-android-sdk-7.2.1", ext: 'aar') + implementation (name: "clevertap-android-sdk-7.2.2", ext: 'aar') } ``` @@ -46,7 +46,7 @@ Add the Firebase Messaging library and Android Support Library v4 as dependencie ```groovy dependencies { - implementation "com.clevertap.android:clevertap-android-sdk:7.2.1" + implementation "com.clevertap.android:clevertap-android-sdk:7.2.2" implementation "androidx.core:core:1.13.0" implementation "com.google.firebase:firebase-messaging:24.0.0" implementation "com.google.android.gms:play-services-ads:23.6.0" // Required only if you enable Google ADID collection in the SDK (turned off by default). diff --git a/clevertap-core/src/main/java/com/clevertap/android/sdk/AnalyticsManager.java b/clevertap-core/src/main/java/com/clevertap/android/sdk/AnalyticsManager.java index 4d60a96d7..51659eff6 100644 --- a/clevertap-core/src/main/java/com/clevertap/android/sdk/AnalyticsManager.java +++ b/clevertap-core/src/main/java/com/clevertap/android/sdk/AnalyticsManager.java @@ -35,8 +35,6 @@ import org.json.JSONException; import org.json.JSONObject; -import kotlin.jvm.functions.Function0; - public class AnalyticsManager extends BaseAnalyticsManager { private final CTLockManager ctLockManager; @@ -496,14 +494,14 @@ public void pushNotificationClickedEvent(final Bundle extras) { ); if (isDuplicate) { config.getLogger().debug(config.getAccountId(), - "Already processed Notification Clicked event for " + extras.toString() + "Already processed Notification Clicked event for " + extras + ", dropping duplicate."); return; } try { // convert bundle to json - JSONObject event = AnalyticsManagerBundler.notificationViewedJson(extras); + JSONObject event = AnalyticsManagerBundler.notificationClickedJson(extras); baseEventQueueManager.queueEvent(context, event, Constants.RAISED_EVENT); coreMetaData.setWzrkParams(AnalyticsManagerBundler.wzrkBundleToJson(extras)); @@ -1075,7 +1073,7 @@ private void _push(Map profile) { } config.getLogger() - .verbose(config.getAccountId(), "Constructed custom profile: " + customProfile.toString()); + .verbose(config.getAccountId(), "Constructed custom profile: " + customProfile); baseEventQueueManager.pushBasicProfile(customProfile, false); @@ -1142,7 +1140,7 @@ private void _pushMultiValue(ArrayList originalValues, String key, Strin baseEventQueueManager.pushBasicProfile(fields, false); config.getLogger() - .verbose(config.getAccountId(), "Constructed multi-value profile push: " + fields.toString()); + .verbose(config.getAccountId(), "Constructed multi-value profile push: " + fields); } catch (Throwable t) { config.getLogger().verbose(config.getAccountId(), "Error pushing multiValue for key " + key, t); diff --git a/clevertap-core/src/main/java/com/clevertap/android/sdk/AnalyticsManagerBundler.kt b/clevertap-core/src/main/java/com/clevertap/android/sdk/AnalyticsManagerBundler.kt index 45586483e..bf1a412c4 100644 --- a/clevertap-core/src/main/java/com/clevertap/android/sdk/AnalyticsManagerBundler.kt +++ b/clevertap-core/src/main/java/com/clevertap/android/sdk/AnalyticsManagerBundler.kt @@ -39,4 +39,17 @@ object AnalyticsManagerBundler { } return event } + + @JvmStatic + fun notificationClickedJson(root: Bundle): JSONObject { + val event = JSONObject() + try { + val notif = wzrkBundleToJson(root) + event.put("evtName", Constants.NOTIFICATION_CLICKED_EVENT_NAME) + event.put("evtData", notif) + } catch (ignored: Throwable) { + //no-op + } + return event + } } \ No newline at end of file diff --git a/clevertap-core/src/main/java/com/clevertap/android/sdk/InAppNotificationActivity.java b/clevertap-core/src/main/java/com/clevertap/android/sdk/InAppNotificationActivity.java index 4f87c0143..afc6d5d88 100644 --- a/clevertap-core/src/main/java/com/clevertap/android/sdk/InAppNotificationActivity.java +++ b/clevertap-core/src/main/java/com/clevertap/android/sdk/InAppNotificationActivity.java @@ -54,6 +54,8 @@ public final class InAppNotificationActivity extends FragmentActivity implements private PushPermissionManager pushPermissionManager; + private boolean invokedCallbacks = false; + public interface PushPermissionResultCallback { void onPushPermissionAccept(); @@ -264,13 +266,23 @@ public void onRequestPermissionsResult( } void didDismiss(Bundle data) { + didDismiss(data, true); + } + + void didDismiss(Bundle data, boolean killActivity) { if (isAlertVisible) { isAlertVisible = false; } - finish(); - InAppListener listener = getListener(); - if (listener != null && inAppNotification != null) { - listener.inAppNotificationDidDismiss(inAppNotification, data); + + if (!invokedCallbacks) { + InAppListener listener = getListener(); + if (listener != null && inAppNotification != null) { + listener.inAppNotificationDidDismiss(inAppNotification, data); + } + invokedCallbacks = true; + } + if (killActivity) { + finish(); } } @@ -449,4 +461,12 @@ private void onAlertButtonClick(CTInAppNotificationButton button, boolean isPosi didDismiss(clickData); } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (!isChangingConfigurations()) { + didDismiss(null, false); + } + } } \ No newline at end of file diff --git a/clevertap-core/src/test/java/com/clevertap/android/sdk/AnalyticsManagerTest.kt b/clevertap-core/src/test/java/com/clevertap/android/sdk/AnalyticsManagerTest.kt index cc58b99fa..9da554f8c 100644 --- a/clevertap-core/src/test/java/com/clevertap/android/sdk/AnalyticsManagerTest.kt +++ b/clevertap-core/src/test/java/com/clevertap/android/sdk/AnalyticsManagerTest.kt @@ -2,6 +2,7 @@ package com.clevertap.android.sdk import android.content.Context import android.os.Bundle +import com.clevertap.android.sdk.AnalyticsManagerBundler.notificationClickedJson import com.clevertap.android.sdk.AnalyticsManagerBundler.notificationViewedJson import com.clevertap.android.sdk.events.BaseEventQueueManager import com.clevertap.android.sdk.response.InAppResponse @@ -173,7 +174,7 @@ class AnalyticsManagerTest { @Test fun `clevertap processes PN viewed for same wzrk_id if separated by a span of greater than 2 seconds`() { - val json = notificationViewedJson(bundleIdCheck); + val json = notificationViewedJson(bundleIdCheck) every { timeProvider.currentTimeMillis() } returns 10000 @@ -225,7 +226,7 @@ class AnalyticsManagerTest { @Test fun `clevertap does not process duplicate (same wzrk_id) PN clicked within 2 seconds - case 2nd click happens in 200ms`() { - val json = notificationViewedJson(bundleIdCheck) + val json = notificationClickedJson(bundleIdCheck) every { timeProvider.currentTimeMillis() } returns 0 // send PN first time @@ -263,7 +264,7 @@ class AnalyticsManagerTest { @Test fun `clevertap processes PN clicked for same wzrk_id if separated by a span of greater than 5 seconds`() { - val json = notificationViewedJson(bundleIdCheck); + val json = notificationClickedJson(bundleIdCheck) every { timeProvider.currentTimeMillis() } returns 10000 // send PN first time @@ -354,8 +355,7 @@ class AnalyticsManagerTest { putString("wzrk_pid", "same_pid") } - val json1 = notificationViewedJson(notif1) - val json2 = notificationViewedJson(notif1) + val json1 = notificationClickedJson(notif1) every { timeProvider.currentTimeMillis() } returns 0 diff --git a/clevertap-core/src/test/java/com/clevertap/android/sdk/cryption/CryptMigratorTest.kt b/clevertap-core/src/test/java/com/clevertap/android/sdk/cryption/CryptMigratorTest.kt new file mode 100644 index 000000000..62a32f7aa --- /dev/null +++ b/clevertap-core/src/test/java/com/clevertap/android/sdk/cryption/CryptMigratorTest.kt @@ -0,0 +1,529 @@ +package com.clevertap.android.sdk.cryption + +import com.clevertap.android.sdk.ILogger +import io.mockk.* +import io.mockk.impl.annotations.MockK +import org.json.JSONObject +import org.junit.Before +import org.junit.Test +import org.skyscreamer.jsonassert.JSONAssert + +class CryptMigratorTest { + + @MockK(relaxed = true) + private lateinit var logger: ILogger + + @MockK(relaxed = true) + private lateinit var cryptHandler: CryptHandler + + @MockK(relaxed = true) + private lateinit var cryptRepository: CryptRepository + + @MockK(relaxed = true) + private lateinit var dataMigrationRepository: DataMigrationRepository + + private lateinit var cryptMigratorMedium: CryptMigrator + + private lateinit var cryptMigratorNone: CryptMigrator + + @Before + fun setUp() { + MockKAnnotations.init(this) + + cryptMigratorMedium = CryptMigrator( + logPrefix = "[CryptMigratorTest]", + configEncryptionLevel = EncryptionLevel.MEDIUM.intValue(), + logger = logger, + cryptHandler = cryptHandler, + cryptRepository = cryptRepository, + dataMigrationRepository = dataMigrationRepository + ) + + cryptMigratorNone = CryptMigrator( + logPrefix = "[CryptMigratorTest]", + configEncryptionLevel = EncryptionLevel.NONE.intValue(), + logger = logger, + cryptHandler = cryptHandler, + cryptRepository = cryptRepository, + dataMigrationRepository = dataMigrationRepository + ) + } + + @Test + fun `migrateEncryption should not migrate when stored and configured encryption levels match and no migration failure`() { + every { cryptRepository.storedEncryptionLevel() } returns EncryptionLevel.MEDIUM.intValue() + every { cryptRepository.migrationFailureCount() } returns CryptMigrator.MIGRATION_NOT_NEEDED + + cryptMigratorMedium.migrateEncryption() + + verify(exactly = 0) { cryptRepository.updateMigrationFailureCount(any()) } + } + + @Test + fun `migrateEncryption should migrate when encryption levels differ and no migration failure`() { + every { cryptRepository.storedEncryptionLevel() } returns EncryptionLevel.NONE.intValue() + every { cryptRepository.migrationFailureCount() } returns CryptMigrator.MIGRATION_NOT_NEEDED + + mockkObject(CryptHandler) + every { CryptHandler.isTextAESGCMEncrypted(any()) } returns false + every { CryptHandler.isTextAESEncrypted(any()) } returns true + + cryptMigratorMedium.migrateEncryption() + + verify { cryptRepository.updateEncryptionLevel(EncryptionLevel.MEDIUM.intValue()) } + verify { cryptRepository.updateMigrationFailureCount(true) } + } + + @Test + fun `migrateEncryption should migrate when levels are the same and no migration failure`() { + every { cryptRepository.storedEncryptionLevel() } returns EncryptionLevel.MEDIUM.intValue() + every { cryptRepository.migrationFailureCount() } returns 2 + + mockkObject(CryptHandler) + every { CryptHandler.isTextAESGCMEncrypted(any()) } returns false + every { CryptHandler.isTextAESEncrypted(any()) } returns true + + cryptMigratorMedium.migrateEncryption() + + verify { cryptRepository.updateEncryptionLevel(EncryptionLevel.MEDIUM.intValue()) } + verify { cryptRepository.updateMigrationFailureCount(true) } + } + + @Test + fun `migrateEncryption should migrate when it's the first upgrade`() { + every { cryptRepository.storedEncryptionLevel() } returns EncryptionLevel.MEDIUM.intValue() + every { cryptRepository.migrationFailureCount() } returns CryptMigrator.MIGRATION_FIRST_UPGRADE + + mockkObject(CryptHandler) + every { CryptHandler.isTextAESGCMEncrypted(any()) } returns false + every { CryptHandler.isTextAESEncrypted(any()) } returns true + + cryptMigratorMedium.migrateEncryption() + + verify { cryptRepository.updateMigrationFailureCount(true) } + } + + + // ------------------------------------------------------------------------------------------------------------ + //. --------------------------------- CGK related migration -----------------------------------------------------' + // ------------------------------------------------------------------------------------------------------------ + @Test + fun `migrateCachedGuidsKeyPref should migrate encrypted data when encryption level changes from none to medium`() { + val encryptedText = "encrypted_data" + val decryptedText = "decrypted_data" + every { cryptRepository.storedEncryptionLevel() } returns EncryptionLevel.NONE.intValue() + every { cryptRepository.migrationFailureCount() } returns 0 + + every { dataMigrationRepository.cachedGuidString() } returns decryptedText + + mockkObject(CryptHandler) + every { CryptHandler.isTextAESEncrypted(any()) } returns false + every { CryptHandler.isTextAESGCMEncrypted(any()) } returns false + every { cryptHandler.encrypt(decryptedText, CryptHandler.EncryptionAlgorithm.AES_GCM)} returns encryptedText + + cryptMigratorMedium.migrateEncryption() + + verify { dataMigrationRepository.saveCachedGuidJson(encryptedText) } + verify { cryptRepository.updateMigrationFailureCount(true) } + } + + @Test + fun `migrateCachedGuidsKeyPref should save data in plain-text when encryption fails and level changes from none to medium`() { + val decryptedText = "decrypted_data" + every { cryptRepository.storedEncryptionLevel() } returns EncryptionLevel.NONE.intValue() + every { cryptRepository.migrationFailureCount() } returns 0 + + every { dataMigrationRepository.cachedGuidString() } returns decryptedText + + mockkObject(CryptHandler) + every { CryptHandler.isTextAESEncrypted(any()) } returns false + every { CryptHandler.isTextAESGCMEncrypted(any()) } returns false + every { cryptHandler.encrypt(decryptedText, CryptHandler.EncryptionAlgorithm.AES_GCM)} returns null + + cryptMigratorMedium.migrateEncryption() + + verify { dataMigrationRepository.saveCachedGuidJson(decryptedText) } + verify { cryptRepository.updateMigrationFailureCount(false) } + } + + @Test + fun `migrateCachedGuidsKeyPref should migrate when encryption level changes from medium to none`() { + val encryptedText = "encrypted_data" + val decryptedText = "decrypted_data" + every { cryptRepository.storedEncryptionLevel() } returns EncryptionLevel.MEDIUM.intValue() + every { cryptRepository.migrationFailureCount() } returns 0 + + every { dataMigrationRepository.cachedGuidString() } returns encryptedText + + mockkObject(CryptHandler) + every { CryptHandler.isTextAESEncrypted(any()) } returns false + every { CryptHandler.isTextAESGCMEncrypted(any()) } returns true + every { cryptHandler.decrypt(encryptedText, CryptHandler.EncryptionAlgorithm.AES_GCM)} returns decryptedText + + cryptMigratorNone.migrateEncryption() + + verify { dataMigrationRepository.saveCachedGuidJson(decryptedText) } + verify { cryptRepository.updateMigrationFailureCount(true) } + } + + @Test + fun `migrateCachedGuidsKeyPref should save encrypted data when decryption fails and level changes from medium to none`() { + val encryptedText = "encrypted_data" + every { cryptRepository.storedEncryptionLevel() } returns EncryptionLevel.MEDIUM.intValue() + every { cryptRepository.migrationFailureCount() } returns 0 + + every { dataMigrationRepository.cachedGuidString() } returns encryptedText + + mockkObject(CryptHandler) + every { CryptHandler.isTextAESEncrypted(any()) } returns false + every { CryptHandler.isTextAESGCMEncrypted(any()) } returns true + every { cryptHandler.decrypt(encryptedText, CryptHandler.EncryptionAlgorithm.AES_GCM)} returns null + + + cryptMigratorNone.migrateEncryption() + + verify { dataMigrationRepository.saveCachedGuidJson(encryptedText) } + verify { cryptRepository.updateMigrationFailureCount(false) } + } + + @Test + fun `migrateCachedGuidsKeyPref should not migrate if data is already migrated`() { + val encryptedText = "encrypted_data" + every { cryptRepository.storedEncryptionLevel() } returns EncryptionLevel.MEDIUM.intValue() + every { cryptRepository.migrationFailureCount() } returns 0 + + every { dataMigrationRepository.cachedGuidString() } returns encryptedText + + mockkObject(CryptHandler) + every { CryptHandler.isTextAESEncrypted(any()) } returns false + every { CryptHandler.isTextAESGCMEncrypted(any()) } returns true + + cryptMigratorMedium.migrateEncryption() + + verify(exactly = 0) { cryptHandler.encrypt(any(), CryptHandler.EncryptionAlgorithm.AES_GCM) } + verify(exactly = 0) { cryptHandler.decrypt(any(), CryptHandler.EncryptionAlgorithm.AES_GCM) } + } + + @Test + fun `migrateCachedGuidsKeyPref should migrate format and then encrypt for first upgrade`() { + val encryptedJson = JSONObject().apply { + put("Email_[encrypted1]", "_i123") + put("Phone_[encrypted2]", "_i456") + } + + val migratedFormatJson = "migratedFormatJson" + val encryptedText = "encrypted_data" + + every { cryptRepository.storedEncryptionLevel() } returns EncryptionLevel.MEDIUM.intValue() + every { cryptRepository.migrationFailureCount() } returns CryptMigrator.MIGRATION_FIRST_UPGRADE + + every { dataMigrationRepository.cachedGuidJsonObject() } returns encryptedJson + + every { cryptHandler.decrypt(any(), CryptHandler.EncryptionAlgorithm.AES)} returns migratedFormatJson + every { cryptHandler.encrypt(any(), CryptHandler.EncryptionAlgorithm.AES_GCM)} returns encryptedText + + mockkObject(CryptHandler) + + cryptMigratorMedium.migrateEncryption() + + verify { dataMigrationRepository.saveCachedGuidJson(encryptedText) } + verify { dataMigrationRepository.saveCachedGuidJsonLength(encryptedJson.length()) } + verify { cryptRepository.updateMigrationFailureCount(true) } + } + + @Test + fun `migrateCachedGuidsKeyPref should remove cached data and save length 0 when decryption fails from AES during first upgrade`() { + val encryptedJson = JSONObject().apply { + put("Email_[encrypted1]", "_i123") + put("Phone_[encrypted2]", "_i456") + } + + every { cryptRepository.storedEncryptionLevel() } returns EncryptionLevel.MEDIUM.intValue() + every { cryptRepository.migrationFailureCount() } returns CryptMigrator.MIGRATION_FIRST_UPGRADE + + every { dataMigrationRepository.cachedGuidJsonObject() } returns encryptedJson + every { cryptHandler.decrypt(any(), CryptHandler.EncryptionAlgorithm.AES)} returns null + + mockkObject(CryptHandler) + + cryptMigratorMedium.migrateEncryption() + + verify { dataMigrationRepository.removeCachedGuidJson() } + verify { dataMigrationRepository.saveCachedGuidJsonLength(0) } + verify { cryptRepository.updateMigrationFailureCount(true) } + } + + @Test + fun `migrateCachedGuidsKeyPref should save data as plaintext when encryption to AES_GCM fails during first upgrade`() { + val encryptedJson = JSONObject().apply { + put("Email_[encrypted1]", "_i123") + put("Phone_[encrypted2]", "_i456") + } + + val migratedFormat = "decrypted" + + val migratedJson = JSONObject().apply { + put("Email_$migratedFormat", "_i123") + put("Phone_$migratedFormat", "_i456") + } + + every { cryptRepository.storedEncryptionLevel() } returns EncryptionLevel.MEDIUM.intValue() + every { cryptRepository.migrationFailureCount() } returns CryptMigrator.MIGRATION_FIRST_UPGRADE + + every { dataMigrationRepository.cachedGuidJsonObject() } returns encryptedJson + + every { cryptHandler.decrypt(any(), CryptHandler.EncryptionAlgorithm.AES)} returns migratedFormat + every { cryptHandler.encrypt(any(), CryptHandler.EncryptionAlgorithm.AES_GCM)} returns null + + mockkObject(CryptHandler) + + cryptMigratorMedium.migrateEncryption() + + verify { dataMigrationRepository.saveCachedGuidJson(migratedJson.toString()) } + verify { dataMigrationRepository.saveCachedGuidJsonLength(2) } + verify { cryptRepository.updateMigrationFailureCount(false) } + } + + // ------------------------------------------------------------------------------------------------------------ + //. --------------------------------- DB related migration -----------------------------------------------------' + // ------------------------------------------------------------------------------------------------------------ + @Test + fun `migrateDBProfile should migrate encrypted data when encryption level changes from none to medium`() { + val encryptedText = "encrypted_data" + val decryptedText = "decrypted_data" + + val encryptedJSONObject = JSONObject().apply { + put("Email", encryptedText) + put("Custom", "no_encrypt") + } + val decryptedJSONObject = JSONObject().apply { + put("Email", decryptedText) + put("Custom", "no_encrypt") + } + + val decryptedMap = mapOf("deviceId" to decryptedJSONObject) + + every { cryptRepository.storedEncryptionLevel() } returns EncryptionLevel.NONE.intValue() + every { cryptRepository.migrationFailureCount() } returns 0 + + every { dataMigrationRepository.userProfilesInAccount() } returns decryptedMap + + mockkObject(CryptHandler) + every { CryptHandler.isTextAESEncrypted(any()) } returns false + every { CryptHandler.isTextAESGCMEncrypted(any()) } returns false + every { cryptHandler.encrypt(any(), CryptHandler.EncryptionAlgorithm.AES_GCM)} returns encryptedText + every { dataMigrationRepository.saveUserProfile(any(), any()) } returns 1 + + cryptMigratorMedium.migrateEncryption() + + val jsonObjectSlot = slot() + + verify { dataMigrationRepository.saveUserProfile(eq("deviceId"), capture(jsonObjectSlot)) } + verify { cryptRepository.updateMigrationFailureCount(true) } + JSONAssert.assertEquals(encryptedJSONObject, jsonObjectSlot.captured, false) + } + + @Test + fun `migrateDBProfile should save data in plain-text when encryption fails and level changes from none to medium`() { + val decryptedJSONObject = JSONObject().apply { + put("Email", "value1") + put("Custom", "no_encrypt") + } + + val decryptedMap = mapOf("deviceId" to decryptedJSONObject) + + every { cryptRepository.storedEncryptionLevel() } returns EncryptionLevel.NONE.intValue() + every { cryptRepository.migrationFailureCount() } returns 0 + + every { dataMigrationRepository.userProfilesInAccount() } returns decryptedMap + + mockkObject(CryptHandler) + every { CryptHandler.isTextAESEncrypted(any()) } returns false + every { CryptHandler.isTextAESGCMEncrypted(any()) } returns false + every { cryptHandler.encrypt(any(), CryptHandler.EncryptionAlgorithm.AES_GCM)} returns null + every { dataMigrationRepository.saveUserProfile(any(), any()) } returns 1 + + cryptMigratorMedium.migrateEncryption() + + val jsonObjectSlot = slot() + + verify { dataMigrationRepository.saveUserProfile(eq("deviceId"), capture(jsonObjectSlot)) } + verify { cryptRepository.updateMigrationFailureCount(false) } + JSONAssert.assertEquals(decryptedJSONObject, jsonObjectSlot.captured, false) + } + + @Test + fun `migrateDBProfile should migrate when encryption level changes from medium to none`() { + val encryptedText = "encrypted_data" + val decryptedText = "decrypted_data" + val encryptedJSONObject = JSONObject().apply { + put("Email", encryptedText) + put("Custom", "no_encrypt") + } + + val decryptedJSONObject = JSONObject().apply { + put("Email", decryptedText) + put("Custom", "no_encrypt") + } + + val encryptedMap = mapOf("deviceId" to encryptedJSONObject) + + every { cryptRepository.storedEncryptionLevel() } returns EncryptionLevel.MEDIUM.intValue() + every { cryptRepository.migrationFailureCount() } returns 0 + + every { dataMigrationRepository.userProfilesInAccount() } returns encryptedMap + + mockkObject(CryptHandler) + every { CryptHandler.isTextAESEncrypted(any()) } returns false + every { CryptHandler.isTextAESGCMEncrypted(any()) } returns true + + every { cryptHandler.decrypt(encryptedText, CryptHandler.EncryptionAlgorithm.AES_GCM)} returns decryptedText + every { dataMigrationRepository.saveUserProfile(any(), any()) } returns 1 + + cryptMigratorNone.migrateEncryption() + + val jsonObjectSlot = slot() + + verify { dataMigrationRepository.saveUserProfile(eq("deviceId"), capture(jsonObjectSlot)) } + verify { cryptRepository.updateMigrationFailureCount(true) } + + JSONAssert.assertEquals(decryptedJSONObject, jsonObjectSlot.captured, false) + } + + @Test + fun `migrateDBProfile should save encrypted data when decryption fails and level changes from medium to none`() { + val encryptedText = "encrypted_data" + val encryptedJSONObject = JSONObject().apply { + put("Email", encryptedText) + put("Custom", "no_encrypt") + } + + val encryptedMap = mapOf("deviceId" to encryptedJSONObject) + + every { cryptRepository.storedEncryptionLevel() } returns EncryptionLevel.MEDIUM.intValue() + every { cryptRepository.migrationFailureCount() } returns 0 + + every { dataMigrationRepository.userProfilesInAccount() } returns encryptedMap + + mockkObject(CryptHandler) + every { CryptHandler.isTextAESEncrypted(any()) } returns false + every { CryptHandler.isTextAESGCMEncrypted(any()) } returns true + + every { cryptHandler.decrypt(encryptedText, CryptHandler.EncryptionAlgorithm.AES_GCM)} returns null + every { dataMigrationRepository.saveUserProfile(any(), any()) } returns 1 + + cryptMigratorNone.migrateEncryption() + + val jsonObjectSlot = slot() + + verify { dataMigrationRepository.saveUserProfile(eq("deviceId"), capture(jsonObjectSlot)) } + verify { cryptRepository.updateMigrationFailureCount(false) } + JSONAssert.assertEquals(encryptedJSONObject, jsonObjectSlot.captured, false) + } + + @Test + fun `migrateDBProfile should migrate for first upgrade`() { + val encryptedTextV1 = "[encrypted_data]" + val decryptedText = "decrypted_data" + val encryptedTextV2 = "ct>" + val encryptedJSONObject = JSONObject().apply { + put("Email", encryptedTextV1) + put("Custom", "no_encrypt") + } + + val resultJSONObject = JSONObject().apply { + put("Email", encryptedTextV2) + put("Custom", "no_encrypt") + } + + val encryptedMap = mapOf("deviceId" to encryptedJSONObject) + + every { cryptRepository.storedEncryptionLevel() } returns EncryptionLevel.MEDIUM.intValue() + every { cryptRepository.migrationFailureCount() } returns CryptMigrator.MIGRATION_FIRST_UPGRADE + + every { dataMigrationRepository.userProfilesInAccount() } returns encryptedMap + + mockkObject(CryptHandler) + + every { cryptHandler.decrypt(encryptedTextV1, CryptHandler.EncryptionAlgorithm.AES)} returns decryptedText + every { cryptHandler.encrypt(decryptedText, CryptHandler.EncryptionAlgorithm.AES_GCM)} returns encryptedTextV2 + every { dataMigrationRepository.saveUserProfile(any(), any()) } returns 1 + + cryptMigratorMedium.migrateEncryption() + + val jsonObjectSlot = slot() + + verify { dataMigrationRepository.saveUserProfile(eq("deviceId"), capture(jsonObjectSlot)) } + verify { cryptRepository.updateMigrationFailureCount(true) } + JSONAssert.assertEquals(resultJSONObject, jsonObjectSlot.captured, false) + } + + @Test + fun `migrateDBProfile should store field in plain text when encryption fails to AES_GCM during first upgrade`() { + val encryptedText = "[encrypted_data]" + val encryptedJSONObject = JSONObject().apply { + put("Email", encryptedText) + put("Custom", "no_encrypt") + } + + val droppedJSONObject = JSONObject().apply { + put("Custom", "no_encrypt") + } + + val encryptedMap = mapOf("deviceId" to encryptedJSONObject) + + every { cryptRepository.storedEncryptionLevel() } returns EncryptionLevel.MEDIUM.intValue() + every { cryptRepository.migrationFailureCount() } returns CryptMigrator.MIGRATION_FIRST_UPGRADE + + every { dataMigrationRepository.userProfilesInAccount() } returns encryptedMap + + mockkObject(CryptHandler) + + every { cryptHandler.decrypt(encryptedText, CryptHandler.EncryptionAlgorithm.AES)} returns null + every { dataMigrationRepository.saveUserProfile(any(), any()) } returns 1 + + cryptMigratorMedium.migrateEncryption() + + val jsonObjectSlot = slot() + + verify { dataMigrationRepository.saveUserProfile(eq("deviceId"), capture(jsonObjectSlot)) } + verify { cryptRepository.updateMigrationFailureCount(true) } + JSONAssert.assertEquals(droppedJSONObject, jsonObjectSlot.captured, false) + } + + @Test + fun `migrateDBProfile should remove field from profile when decryption fails from AES during first upgrade`() { + val encryptedText = "[encrypted_data]" + val decryptedText = "decrypted_data" + val encryptedJSONObject = JSONObject().apply { + put("Email", encryptedText) + put("Custom", "no_encrypt") + } + + val decryptedJSONObject = JSONObject().apply { + put("Email", decryptedText) + put("Custom", "no_encrypt") + } + + val encryptedMap = mapOf("deviceId" to encryptedJSONObject) + + every { cryptRepository.storedEncryptionLevel() } returns EncryptionLevel.MEDIUM.intValue() + every { cryptRepository.migrationFailureCount() } returns CryptMigrator.MIGRATION_FIRST_UPGRADE + + every { dataMigrationRepository.userProfilesInAccount() } returns encryptedMap + + mockkObject(CryptHandler) + + every { cryptHandler.decrypt(any(), CryptHandler.EncryptionAlgorithm.AES)} returns decryptedText + every { cryptHandler.encrypt(any(), CryptHandler.EncryptionAlgorithm.AES_GCM)} returns null + every { dataMigrationRepository.saveUserProfile(any(), any()) } returns 1 + + cryptMigratorMedium.migrateEncryption() + + val jsonObjectSlot = slot() + + verify { dataMigrationRepository.saveUserProfile(eq("deviceId"), capture(jsonObjectSlot)) } + verify { cryptRepository.updateMigrationFailureCount(false) } + JSONAssert.assertEquals(decryptedJSONObject, jsonObjectSlot.captured, false) + } +} diff --git a/docs/CTCORECHANGELOG.md b/docs/CTCORECHANGELOG.md index 12841733c..c942905c5 100644 --- a/docs/CTCORECHANGELOG.md +++ b/docs/CTCORECHANGELOG.md @@ -1,7 +1,16 @@ ## CleverTap Android SDK CHANGE LOG +### Version 7.2.2 (January 21, 2025) + +This hotfix release addresses a critical issue from `v7.1.0` onwards: + +#### Bug Fixes +* Fixes an issue where `Notification Clicked` event was not being raised. + + ### Version 7.2.1 (January 16, 2025) -This hotfix release addresses the following issue in `v7.2.0`: +> ‼️ **NOTE** +A critical issue was identified in 7.2.1, please update to 7.2.2 and above #### Bug Fixes * Fixes `ClassCastException` from `Integer` to `Long` for server side in-apps delivery. A bug occurs when the network is turned off, and the following steps are performed: @@ -11,7 +20,7 @@ This hotfix release addresses the following issue in `v7.2.0`: ### Version 7.2.0 (January 7, 2025) > ‼️ **NOTE** -If you are using server side in-apps please use `7.2.1` instead. `7.2.0` has a bug related to server side in-apps. +A critical issue was identified in 7.2.0, please update to 7.2.2 and above #### New Features @@ -23,6 +32,8 @@ If you are using server side in-apps please use `7.2.1` instead. `7.2.0` has a b After upgrading the SDK to v7.2.0, don't downgrade in subsequent app releases. If you encounter any issues, please contact the CleverTap support team for assistance. ### Version 7.1.0 (December 24, 2024) +> ‼️ **NOTE** +A critical issue was identified in 7.1.0, please update to 7.2.2 and above #### New Features diff --git a/docs/CTGEOFENCE.md b/docs/CTGEOFENCE.md index 92da62905..9aa44732d 100644 --- a/docs/CTGEOFENCE.md +++ b/docs/CTGEOFENCE.md @@ -17,7 +17,7 @@ Add the following dependencies to the `build.gradle` ```Groovy implementation "com.clevertap.android:clevertap-geofence-sdk:1.4.0" -implementation "com.clevertap.android:clevertap-android-sdk:7.2.1" // 3.9.0 and above +implementation "com.clevertap.android:clevertap-android-sdk:7.2.2" // 3.9.0 and above implementation "com.google.android.gms:play-services-location:21.3.0" implementation "androidx.work:work-runtime:2.9.1" // required for FETCH_LAST_LOCATION_PERIODIC implementation "androidx.concurrent:concurrent-futures:1.2.0" // required for FETCH_LAST_LOCATION_PERIODIC diff --git a/docs/CTPUSHTEMPLATES.md b/docs/CTPUSHTEMPLATES.md index 2584033f8..ffcd0e6c5 100644 --- a/docs/CTPUSHTEMPLATES.md +++ b/docs/CTPUSHTEMPLATES.md @@ -21,7 +21,7 @@ CleverTap Push Templates SDK helps you engage with your users using fancy push n ```groovy implementation "com.clevertap.android:push-templates:1.3.0" -implementation "com.clevertap.android:clevertap-android-sdk:7.2.1" // 4.4.0 and above +implementation "com.clevertap.android:clevertap-android-sdk:7.2.2" // 4.4.0 and above ``` 2. Add the following line to your Application class before the `onCreate()` diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 69589ee77..25a1e98e1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,7 +54,7 @@ coroutines_test = "1.7.3" installreferrer = "2.2" #SDK Versions -clevertap_android_sdk = "7.2.1" +clevertap_android_sdk = "7.2.2" clevertap_rendermax_sdk = "1.0.3" clevertap_geofence_sdk = "1.4.0" clevertap_hms_sdk = "1.4.0" diff --git a/sample/build.gradle b/sample/build.gradle index 6bbea723c..de42b31b3 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -18,8 +18,8 @@ android { applicationId "com.clevertap.demo" minSdkVersion 21 targetSdkVersion 35 - versionCode 7000021 - versionName "7.2.1" + versionCode 7020200 + versionName "7.2.2" multiDexEnabled true testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } @@ -159,15 +159,15 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.72" implementation "org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:3.1.1"*/ - remoteImplementation("com.clevertap.android:clevertap-android-sdk:7.2.1") - remoteImplementation("com.clevertap.android:clevertap-geofence-sdk:1.3.0") - remoteImplementation("com.clevertap.android:push-templates:1.2.4") - remoteImplementation("com.clevertap.android:clevertap-hms-sdk:1.3.4") + remoteImplementation("com.clevertap.android:clevertap-android-sdk:7.2.2") + remoteImplementation("com.clevertap.android:clevertap-geofence-sdk:1.4.0") + remoteImplementation("com.clevertap.android:push-templates:1.3.0") + remoteImplementation("com.clevertap.android:clevertap-hms-sdk:1.4.0") - stagingImplementation("com.clevertap.android:clevertap-android-sdk:7.2.1") - stagingImplementation("com.clevertap.android:clevertap-geofence-sdk:1.3.0") - stagingImplementation("com.clevertap.android:push-templates:1.2.4") - stagingImplementation("com.clevertap.android:clevertap-hms-sdk:1.3.4") + stagingImplementation("com.clevertap.android:clevertap-android-sdk:7.2.2") + stagingImplementation("com.clevertap.android:clevertap-geofence-sdk:1.4.0") + stagingImplementation("com.clevertap.android:push-templates:1.3.0") + stagingImplementation("com.clevertap.android:clevertap-hms-sdk:1.4.0") } Properties loadLocalProperties() { diff --git a/templates/CTCORECHANGELOG.md b/templates/CTCORECHANGELOG.md index 12841733c..c942905c5 100644 --- a/templates/CTCORECHANGELOG.md +++ b/templates/CTCORECHANGELOG.md @@ -1,7 +1,16 @@ ## CleverTap Android SDK CHANGE LOG +### Version 7.2.2 (January 21, 2025) + +This hotfix release addresses a critical issue from `v7.1.0` onwards: + +#### Bug Fixes +* Fixes an issue where `Notification Clicked` event was not being raised. + + ### Version 7.2.1 (January 16, 2025) -This hotfix release addresses the following issue in `v7.2.0`: +> ‼️ **NOTE** +A critical issue was identified in 7.2.1, please update to 7.2.2 and above #### Bug Fixes * Fixes `ClassCastException` from `Integer` to `Long` for server side in-apps delivery. A bug occurs when the network is turned off, and the following steps are performed: @@ -11,7 +20,7 @@ This hotfix release addresses the following issue in `v7.2.0`: ### Version 7.2.0 (January 7, 2025) > ‼️ **NOTE** -If you are using server side in-apps please use `7.2.1` instead. `7.2.0` has a bug related to server side in-apps. +A critical issue was identified in 7.2.0, please update to 7.2.2 and above #### New Features @@ -23,6 +32,8 @@ If you are using server side in-apps please use `7.2.1` instead. `7.2.0` has a b After upgrading the SDK to v7.2.0, don't downgrade in subsequent app releases. If you encounter any issues, please contact the CleverTap support team for assistance. ### Version 7.1.0 (December 24, 2024) +> ‼️ **NOTE** +A critical issue was identified in 7.1.0, please update to 7.2.2 and above #### New Features