diff --git a/core/src/androidTest/java/com/emarsys/core/crypto/SharedPreferenceCryptoTest.kt b/core/src/androidTest/java/com/emarsys/core/crypto/SharedPreferenceCryptoTest.kt new file mode 100644 index 00000000..665d9ecc --- /dev/null +++ b/core/src/androidTest/java/com/emarsys/core/crypto/SharedPreferenceCryptoTest.kt @@ -0,0 +1,153 @@ +import android.security.keystore.KeyGenParameterSpec +import android.util.Base64 +import com.emarsys.core.crypto.SharedPreferenceCrypto +import com.emarsys.testUtil.AnnotationSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import java.security.GeneralSecurityException +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +class SharedPreferenceCryptoTest : AnnotationSpec() { + + private lateinit var sharedPreferenceCrypto: SharedPreferenceCrypto + private lateinit var mockKeyStore: KeyStore + private lateinit var mockKeyGenerator: KeyGenerator + private lateinit var mockSecretKey: SecretKey + private lateinit var mockCipher: Cipher + + @BeforeEach + fun setup() { + mockkStatic(KeyStore::class) + mockkStatic(KeyGenerator::class) + mockkStatic(Cipher::class) + mockkStatic(Base64::class) + + mockKeyStore = mockk() + mockKeyGenerator = mockk() + mockSecretKey = mockk() + mockCipher = mockk() + + every { KeyStore.getInstance(any()) } returns mockKeyStore + every { KeyGenerator.getInstance(any(), any()) } returns mockKeyGenerator + every { Cipher.getInstance(any()) } returns mockCipher + + sharedPreferenceCrypto = SharedPreferenceCrypto() + } + + @AfterEach + fun tearDown() { + unmockkAll() + } + + @Test + fun testGetOrCreateSecretKey_KeyExists() { + every { mockKeyStore.load(null) } just Runs + every { mockKeyStore.containsAlias(any()) } returns true + every { mockKeyStore.getKey(any(), null) } returns mockSecretKey + + val result = sharedPreferenceCrypto.getOrCreateSecretKey() + + result shouldBe mockSecretKey + verify { mockKeyStore.getKey(any(), null) } + } + + @Test + fun testGetOrCreateSecretKey_KeyDoesNotExist() { + every { mockKeyStore.load(null) } just Runs + every { mockKeyStore.containsAlias(any()) } returns false + every { mockKeyGenerator.init(any()) } just Runs + every { mockKeyGenerator.generateKey() } returns mockSecretKey + every { mockKeyStore.setEntry(any(), any(), null) } just Runs + + val result = sharedPreferenceCrypto.getOrCreateSecretKey() + + result shouldBe mockSecretKey + verify { mockKeyGenerator.generateKey() } + verify { mockKeyStore.setEntry(any(), any(), null) } + } + + @Test + fun testEncrypt_Success() { + val value = "test_value" + val encryptedBytes = byteArrayOf(1, 2, 3, 4) + val iv = byteArrayOf(5, 6, 7, 8) + + every { mockCipher.init(Cipher.ENCRYPT_MODE, mockSecretKey) } just Runs + every { mockCipher.doFinal(any()) } returns encryptedBytes + every { mockCipher.iv } returns iv + every { Base64.encodeToString(any(), Base64.DEFAULT) } returns "encodedString" + + val result = sharedPreferenceCrypto.encrypt(value, mockSecretKey) + + result shouldNotBe value + result shouldBe "encodedStringencodedString" + } + + @Test + fun testEncrypt_Exception() { + val value = "test_value" + + every { + mockCipher.init( + Cipher.ENCRYPT_MODE, + mockSecretKey + ) + } throws GeneralSecurityException("Encryption failed") + + val result = sharedPreferenceCrypto.encrypt(value, mockSecretKey) + + result shouldBe value + } + + @Test + fun testDecrypt_Success() { + val value = "IVBase64EncryptedBase64" + val ivBytes = byteArrayOf(1, 2, 3, 4) + val encryptedBytes = byteArrayOf(5, 6, 7, 8) + val decryptedBytes = "decrypted".toByteArray() + + every { Base64.decode(any(), Base64.DEFAULT) } returnsMany listOf( + ivBytes, + encryptedBytes + ) + every { + mockCipher.init( + Cipher.DECRYPT_MODE, + mockSecretKey, + any() + ) + } just Runs + every { mockCipher.doFinal(encryptedBytes) } returns decryptedBytes + + val result = sharedPreferenceCrypto.decrypt(value, mockSecretKey) + + result shouldBe "decrypted" + } + + @Test + fun testDecrypt_Exception() { + val value = "IVBase64EncryptedBase64" + + every { + Base64.decode( + any(), + Base64.DEFAULT + ) + } throws GeneralSecurityException("Decryption failed") + + val result = sharedPreferenceCrypto.decrypt(value, mockSecretKey) + + result shouldBe value + } +} \ No newline at end of file diff --git a/core/src/androidTest/java/com/emarsys/core/di/FakeCoreDependencyContainer.kt b/core/src/androidTest/java/com/emarsys/core/di/FakeCoreDependencyContainer.kt index 4ffb597a..5c784ed4 100644 --- a/core/src/androidTest/java/com/emarsys/core/di/FakeCoreDependencyContainer.kt +++ b/core/src/androidTest/java/com/emarsys/core/di/FakeCoreDependencyContainer.kt @@ -42,6 +42,7 @@ class FakeCoreDependencyContainer( override val fileDownloader: FileDownloader = mock(), override val keyValueStore: KeyValueStore = mock(), override val sharedPreferences: SharedPreferences = mock(), + override val sharedPreferencesV3: SharedPreferences = mock(), override val hardwareIdProvider: HardwareIdProvider = mock(), override val coreDbHelper: CoreDbHelper = mock(), override val hardwareIdStorage: Storage = mock(), diff --git a/core/src/androidTest/java/com/emarsys/core/storage/EmarsysEncryptedSharedPreferencesV3Test.kt b/core/src/androidTest/java/com/emarsys/core/storage/EmarsysEncryptedSharedPreferencesV3Test.kt new file mode 100644 index 00000000..5822c509 --- /dev/null +++ b/core/src/androidTest/java/com/emarsys/core/storage/EmarsysEncryptedSharedPreferencesV3Test.kt @@ -0,0 +1,202 @@ +package com.emarsys.core.storage + +import android.content.Context +import android.content.SharedPreferences +import com.emarsys.core.crypto.SharedPreferenceCrypto +import com.emarsys.testUtil.AnnotationSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import javax.crypto.SecretKey + +class EmarsysEncryptedSharedPreferencesV3Test : AnnotationSpec() { + + private lateinit var mockContext: Context + private lateinit var mockSharedPreferenceCrypto: SharedPreferenceCrypto + private lateinit var mockRealPreferences: SharedPreferences + private lateinit var mockSecretKey: SecretKey + private lateinit var emarsysEncryptedSharedPreferencesV3: EmarsysEncryptedSharedPreferencesV3 + private lateinit var mockInternalEditor: SharedPreferences.Editor + + @BeforeEach + fun setup() { + mockContext = mockk() + mockSharedPreferenceCrypto = mockk() + mockRealPreferences = mockk(relaxed = true) + mockSecretKey = mockk() + + every { mockContext.getSharedPreferences(any(), any()) } returns mockRealPreferences + every { mockSharedPreferenceCrypto.getOrCreateSecretKey() } returns mockSecretKey + mockInternalEditor = mockk(relaxed = true) + + every { mockRealPreferences.edit() } returns mockInternalEditor + every { mockSharedPreferenceCrypto.encrypt(any(), any()) } returns "encryptedValue" + + emarsysEncryptedSharedPreferencesV3 = EmarsysEncryptedSharedPreferencesV3( + mockContext, + "test_file", + mockSharedPreferenceCrypto + ) + } + + @Test + fun testGetAll() { + val encryptedMap = mapOf( + "key1" to "encryptedValue1", + "key2" to 42, + "key3" to true, + "key4" to 3.14f, + "key5" to 1234L, + "key6" to setOf("encryptedValue2", "encryptedValue3") + ) + every { mockRealPreferences.all } returns encryptedMap + every { + mockSharedPreferenceCrypto.decrypt( + any(), + any() + ) + } returnsMany listOf("decryptedValue1", "decryptedValue2", "decryptedValue3") + + val result = emarsysEncryptedSharedPreferencesV3.getAll() + + result shouldBe mapOf( + "key1" to "decryptedValue1", + "key2" to 42, + "key3" to true, + "key4" to 3.14f, + "key5" to 1234L, + "key6" to setOf("decryptedValue2", "decryptedValue3") + ) + } + + @Test + fun testGetString() { + every { mockRealPreferences.getString("testKey", null) } returns "encryptedValue" + every { + mockSharedPreferenceCrypto.decrypt( + "encryptedValue", + mockSecretKey + ) + } returns "decryptedValue" + + val result = emarsysEncryptedSharedPreferencesV3.getString("testKey", "defaultValue") + + result shouldBe "decryptedValue" + } + + @Test + fun testGetStringSet() { + val encryptedSet = setOf("encryptedValue1", "encryptedValue2") + every { mockRealPreferences.getStringSet("testKey", null) } returns encryptedSet + every { + mockSharedPreferenceCrypto.decrypt( + "encryptedValue1", + mockSecretKey + ) + } returns "decryptedValue1" + every { + mockSharedPreferenceCrypto.decrypt( + "encryptedValue2", + mockSecretKey + ) + } returns "decryptedValue2" + + val result = emarsysEncryptedSharedPreferencesV3.getStringSet("testKey", mutableSetOf()) + + result shouldBe mutableSetOf("decryptedValue1", "decryptedValue2") + } + + @Test + fun testGetInt() { + every { mockRealPreferences.getInt("testKey", 0) } returns 42 + + val result = emarsysEncryptedSharedPreferencesV3.getInt("testKey", 0) + + result shouldBe 42 + } + + @Test + fun testGetLong() { + every { mockRealPreferences.getLong("testKey", 0L) } returns 1234L + + val result = emarsysEncryptedSharedPreferencesV3.getLong("testKey", 0L) + + result shouldBe 1234L + } + + @Test + fun testGetFloat() { + every { mockRealPreferences.getFloat("testKey", 0f) } returns 3.14f + + val result = emarsysEncryptedSharedPreferencesV3.getFloat("testKey", 0f) + + result shouldBe 3.14f + } + + @Test + fun testGetBoolean() { + every { mockRealPreferences.getBoolean("testKey", false) } returns true + + val result = emarsysEncryptedSharedPreferencesV3.getBoolean("testKey", false) + + result shouldBe true + } + + @Test + fun testContains() { + every { mockRealPreferences.contains("testKey") } returns true + + val result = emarsysEncryptedSharedPreferencesV3.contains("testKey") + + result shouldBe true + } + + @Test + fun testEdit() { + every { mockRealPreferences.edit() } returns mockInternalEditor + + val editor = emarsysEncryptedSharedPreferencesV3.edit() + + editor.putString("testKey", "testValue") + editor.putInt("testIntKey", 42) + editor.putBoolean("testBoolKey", true) + editor.putFloat("testFloatKey", 3.14f) + editor.putLong("testLongKey", 1234L) + editor.putStringSet("testSetKey", mutableSetOf("value1", "value2")) + + editor.commit() + + verify(exactly = 1) { mockSharedPreferenceCrypto.encrypt("testValue", mockSecretKey) } + verify(exactly = 1) { mockSharedPreferenceCrypto.encrypt("value1", mockSecretKey) } + verify(exactly = 1) { mockSharedPreferenceCrypto.encrypt("value2", mockSecretKey) } + + verify(exactly = 1) { mockInternalEditor.putString("testKey", "encryptedValue") } + verify(exactly = 1) { mockInternalEditor.putInt("testIntKey", 42) } + verify(exactly = 1) { mockInternalEditor.putBoolean("testBoolKey", true) } + verify(exactly = 1) { mockInternalEditor.putFloat("testFloatKey", 3.14f) } + verify(exactly = 1) { mockInternalEditor.putLong("testLongKey", 1234L) } + verify(exactly = 1) { + mockInternalEditor.putStringSet( + "testSetKey", + setOf("encryptedValue", "encryptedValue") + ) + } + verify(exactly = 1) { mockInternalEditor.commit() } + } + + @Test + fun testRegisterAndUnregisterOnSharedPreferenceChangeListener() { + val listener: SharedPreferences.OnSharedPreferenceChangeListener = mockk() + + emarsysEncryptedSharedPreferencesV3.registerOnSharedPreferenceChangeListener(listener) + verify(exactly = 1) { mockRealPreferences.registerOnSharedPreferenceChangeListener(listener) } + + emarsysEncryptedSharedPreferencesV3.unregisterOnSharedPreferenceChangeListener(listener) + verify(exactly = 1) { + mockRealPreferences.unregisterOnSharedPreferenceChangeListener( + listener + ) + } + } +} \ No newline at end of file diff --git a/core/src/androidTest/java/com/emarsys/core/storage/EncryptedSharedPreferencesToSharedPreferencesMigrationTest.kt b/core/src/androidTest/java/com/emarsys/core/storage/EncryptedSharedPreferencesToSharedPreferencesMigrationTest.kt new file mode 100644 index 00000000..f1b9eaba --- /dev/null +++ b/core/src/androidTest/java/com/emarsys/core/storage/EncryptedSharedPreferencesToSharedPreferencesMigrationTest.kt @@ -0,0 +1,98 @@ +package com.emarsys.core.storage + +import android.content.SharedPreferences +import com.emarsys.testUtil.AnnotationSpec +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import java.security.GeneralSecurityException + +class EncryptedSharedPreferencesToSharedPreferencesMigrationTest : AnnotationSpec() { + + private val mockOldSharedPreferences = mockk() + private val mockNewSharedPreferences = mockk() + private val mockEditor = mockk() + + @Test + fun shouldMigrateData_from_oldSharedPreferences_to_newSharedPreferences() { + every { mockOldSharedPreferences.all } returns mapOf( + "string_key" to "value", + "int_key" to 42, + "boolean_key" to true, + "float_key" to 3.14f, + "long_key" to 1234L, + "set_key" to setOf("item1", "item2") + ) + every { mockNewSharedPreferences.edit() } returns mockEditor + every { mockEditor.putString(any(), any()) } returns mockEditor + every { mockEditor.putInt(any(), any()) } returns mockEditor + every { mockEditor.putBoolean(any(), any()) } returns mockEditor + every { mockEditor.putFloat(any(), any()) } returns mockEditor + every { mockEditor.putLong(any(), any()) } returns mockEditor + every { mockEditor.putStringSet(any(), any()) } returns mockEditor + every { mockEditor.apply() } just Runs + every { mockOldSharedPreferences.edit() } returns mockEditor + every { mockEditor.clear() } returns mockEditor + + val encryptedSharedPreferencesToSharedPreferencesMigration = + EncryptedSharedPreferencesToSharedPreferencesMigration() + encryptedSharedPreferencesToSharedPreferencesMigration.migrate( + mockOldSharedPreferences, + mockNewSharedPreferences + ) + + verify { mockNewSharedPreferences.edit() } + verify { mockEditor.putString("string_key", "value") } + verify { mockEditor.putInt("int_key", 42) } + verify { mockEditor.putBoolean("boolean_key", true) } + verify { mockEditor.putFloat("float_key", 3.14f) } + verify { mockEditor.putLong("long_key", 1234L) } + verify { mockEditor.putStringSet("set_key", setOf("item1", "item2")) } + verify { mockEditor.apply() } + verify { mockOldSharedPreferences.edit() } + verify { mockEditor.clear() } + verify { mockEditor.apply() } + } + + @Test + fun shouldHandleGeneralSecurityExceptionDuringMigration() { + every { mockOldSharedPreferences.all } returns mapOf("key" to "value") + every { mockNewSharedPreferences.edit() } returns mockEditor + every { + mockEditor.putString( + any(), + any() + ) + } throws GeneralSecurityException("Encryption error") + + val migration = EncryptedSharedPreferencesToSharedPreferencesMigration() + + migration.migrate(mockOldSharedPreferences, mockNewSharedPreferences) + + verify(exactly = 0) { mockEditor.apply() } + verify(exactly = 0) { mockOldSharedPreferences.edit().clear() } + } + + @Test + fun shouldNotThrowAnyExceptionsDuringSuccessfulMigration() { + every { mockOldSharedPreferences.all } returns mapOf("key" to "value") + every { mockNewSharedPreferences.edit() } returns mockEditor + every { mockEditor.putString(any(), any()) } returns mockEditor + every { mockEditor.apply() } just Runs + every { mockOldSharedPreferences.edit() } returns mockEditor + every { mockEditor.clear() } returns mockEditor + every { mockEditor.apply() } just Runs + + val encryptedSharedPreferencesToSharedPreferencesMigration = + EncryptedSharedPreferencesToSharedPreferencesMigration() + shouldNotThrowAny { + encryptedSharedPreferencesToSharedPreferencesMigration.migrate( + mockOldSharedPreferences, + mockNewSharedPreferences + ) + } + } +} \ No newline at end of file diff --git a/core/src/androidTest/java/com/emarsys/core/storage/SharedPreferencesV3ProviderTest.kt b/core/src/androidTest/java/com/emarsys/core/storage/SharedPreferencesV3ProviderTest.kt new file mode 100644 index 00000000..2372936f --- /dev/null +++ b/core/src/androidTest/java/com/emarsys/core/storage/SharedPreferencesV3ProviderTest.kt @@ -0,0 +1,94 @@ +import android.content.Context +import android.content.SharedPreferences +import com.emarsys.core.crypto.SharedPreferenceCrypto +import com.emarsys.core.storage.EmarsysEncryptedSharedPreferencesV3 +import com.emarsys.core.storage.EncryptedSharedPreferencesToSharedPreferencesMigration +import com.emarsys.core.storage.SharedPreferencesV3Provider +import com.emarsys.testUtil.AnnotationSpec +import com.emarsys.testUtil.ReflectionTestUtils +import io.kotest.matchers.shouldBe +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify + +class SharedPreferencesV3ProviderTest : AnnotationSpec() { + + private lateinit var mockContext: Context + private lateinit var mockOldSharedPreferences: SharedPreferences + private lateinit var mockCrypto: SharedPreferenceCrypto + private lateinit var mockMigration: EncryptedSharedPreferencesToSharedPreferencesMigration + private lateinit var mockEmarsysEncryptedSharedPreferencesV3: EmarsysEncryptedSharedPreferencesV3 + + @BeforeEach + fun setup() { + mockContext = mockk() + mockOldSharedPreferences = mockk() + mockCrypto = mockk() + mockMigration = mockk() + mockEmarsysEncryptedSharedPreferencesV3 = mockk(relaxed = true) + val mockRealSharedPrefs: SharedPreferences = mockk(relaxed = true) + every { + mockContext.getSharedPreferences( + any(), + any() + ) + } returns mockRealSharedPrefs + every { mockCrypto.getOrCreateSecretKey() } returns mockk() + every { mockMigration.migrate(any(), any()) } just Runs + + } + + @Test + fun testInitialization() { + val provider = SharedPreferencesV3Provider( + mockContext, + "test_file", + mockOldSharedPreferences, + mockCrypto, + mockMigration + ) + verify { + mockMigration.migrate( + mockOldSharedPreferences, + any() + ) + } + } + + @Test + fun testProvide() { + val provider = SharedPreferencesV3Provider( + mockContext, + "test_file", + mockOldSharedPreferences, + mockCrypto, + mockMigration + ) + ReflectionTestUtils.setInstanceField( + provider, + "sharedPreferences", + mockEmarsysEncryptedSharedPreferencesV3 + ) + val result = provider.provide() + + result shouldBe mockEmarsysEncryptedSharedPreferencesV3 + } + + @Test + fun testMigrationIsCalledOnlyOnce() { + val provider = SharedPreferencesV3Provider( + mockContext, + "test_file", + mockOldSharedPreferences, + mockCrypto, + mockMigration + ) + + provider.provide() + provider.provide() + + verify(exactly = 1) { mockMigration.migrate(any(), any()) } + } +} \ No newline at end of file diff --git a/core/src/main/java/com/emarsys/core/crypto/SharedPreferenceCrypto.kt b/core/src/main/java/com/emarsys/core/crypto/SharedPreferenceCrypto.kt new file mode 100644 index 00000000..a3f4b851 --- /dev/null +++ b/core/src/main/java/com/emarsys/core/crypto/SharedPreferenceCrypto.kt @@ -0,0 +1,76 @@ +package com.emarsys.core.crypto + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.GeneralSecurityException +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +class SharedPreferenceCrypto { + companion object { + private const val KEYSTORE_ALIAS = "emarsys_sdk_key_shared_pref_key_v3" + } + + fun getOrCreateSecretKey(): SecretKey { + val keyStore = KeyStore.getInstance("AndroidKeyStore") + keyStore.load(null) + + if (!keyStore.containsAlias(KEYSTORE_ALIAS)) { + val keyGenerator = + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + val keyGenParameterSpec = KeyGenParameterSpec.Builder( + KEYSTORE_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build() + keyGenerator.init(keyGenParameterSpec) + val secretKey = keyGenerator.generateKey() + keyStore.setEntry( + KEYSTORE_ALIAS, + KeyStore.SecretKeyEntry(secretKey), + null + ) + return secretKey + } + + return keyStore.getKey(KEYSTORE_ALIAS, null) as SecretKey + } + + fun encrypt(value: String, secretKey: SecretKey): String { + return try { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + val encrypted = cipher.doFinal(value.toByteArray()) + val iv = cipher.iv + val ivBase64 = Base64.encodeToString(iv, Base64.DEFAULT) + val encryptedBase64 = Base64.encodeToString(encrypted, Base64.DEFAULT) + "$ivBase64$encryptedBase64" + } catch (e: GeneralSecurityException) { + e.printStackTrace() + value + } + } + + fun decrypt(value: String, secretKey: SecretKey): String { + return try { + val ivBase64 = value.substring(0, 16) + val encryptedBase64 = value.substring(16) + val ivBytes = Base64.decode(ivBase64, Base64.DEFAULT) + val encryptedBytes = Base64.decode(encryptedBase64, Base64.DEFAULT) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, ivBytes)) + val decrypted = cipher.doFinal(encryptedBytes) + String(decrypted) + } catch (e: GeneralSecurityException) { + e.printStackTrace() + value + } + } +} \ No newline at end of file diff --git a/core/src/main/java/com/emarsys/core/di/CoreComponent.kt b/core/src/main/java/com/emarsys/core/di/CoreComponent.kt index 3cc28d95..893f91f0 100644 --- a/core/src/main/java/com/emarsys/core/di/CoreComponent.kt +++ b/core/src/main/java/com/emarsys/core/di/CoreComponent.kt @@ -76,6 +76,8 @@ interface CoreComponent { val sharedPreferences: SharedPreferences + val sharedPreferencesV3: SharedPreferences + val hardwareIdProvider: HardwareIdProvider val coreDbHelper: CoreDbHelper diff --git a/core/src/main/java/com/emarsys/core/storage/EmarsysEncryptedSharedPreferencesV3.kt b/core/src/main/java/com/emarsys/core/storage/EmarsysEncryptedSharedPreferencesV3.kt new file mode 100644 index 00000000..f14a26a5 --- /dev/null +++ b/core/src/main/java/com/emarsys/core/storage/EmarsysEncryptedSharedPreferencesV3.kt @@ -0,0 +1,150 @@ +package com.emarsys.core.storage + +import android.content.Context +import android.content.SharedPreferences +import com.emarsys.core.crypto.SharedPreferenceCrypto +import javax.crypto.SecretKey + +class EmarsysEncryptedSharedPreferencesV3( + context: Context, + fileName: String, + private val sharedPreferenceCrypto: SharedPreferenceCrypto +) : SharedPreferences { + + private val secretKey: SecretKey = sharedPreferenceCrypto.getOrCreateSecretKey() + private val realPreferences: SharedPreferences = + context.getSharedPreferences(fileName, Context.MODE_PRIVATE) + private val editor: SharedPreferences.Editor = Editor( + realPreferences, + sharedPreferenceCrypto, + secretKey + ) + + override fun getAll(): Map { + val encryptedData = realPreferences.all + val decryptedData = mutableMapOf() + for ((key, value) in encryptedData) { + when (value) { + is String -> decryptedData[key] = decryptString(value) + is Int -> decryptedData[key] = value + is Boolean -> decryptedData[key] = value + is Float -> decryptedData[key] = value + is Long -> decryptedData[key] = value + is Set<*> -> decryptedData[key] = + (value as Set).map { decryptString(it) }.toSet() + } + } + return decryptedData + } + + override fun getString(key: String, defValue: String?): String? { + val encryptedValue = realPreferences.getString(key, null) + return encryptedValue?.let { decryptString(it) } ?: defValue + } + + override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? { + val encryptedValue = realPreferences.getStringSet(key, null) + return encryptedValue?.map { decryptString(it) }?.toMutableSet() ?: defValues + } + + override fun getInt(key: String?, defValue: Int): Int { + return realPreferences.getInt(key, defValue) + } + + override fun getLong(key: String?, defValue: Long): Long { + return realPreferences.getLong(key, defValue) + } + + override fun getFloat(key: String?, defValue: Float): Float { + return realPreferences.getFloat(key, defValue) + } + + override fun getBoolean(key: String?, defValue: Boolean): Boolean { + return realPreferences.getBoolean(key, defValue) + } + + override fun contains(key: String?): Boolean { + return realPreferences.contains(key) + } + + override fun edit(): SharedPreferences.Editor { + return editor + } + + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) { + realPreferences.registerOnSharedPreferenceChangeListener(listener) + } + + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) { + realPreferences.unregisterOnSharedPreferenceChangeListener(listener) + } + + private fun decryptString(value: String): String { + return sharedPreferenceCrypto.decrypt(value, secretKey) + } + + private class Editor( + realPreferences: SharedPreferences, + private val sharedPreferenceCrypto: SharedPreferenceCrypto, + private val secretKey: SecretKey + ) : SharedPreferences.Editor { + private val editor: SharedPreferences.Editor = realPreferences.edit() + + override fun putString(key: String, value: String?): SharedPreferences.Editor { + editor.putString(key, encryptString(value ?: "")) + return this + } + + override fun putInt(key: String, value: Int): SharedPreferences.Editor { + editor.putInt(key, value) + return this + } + + override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor { + editor.putBoolean(key, value) + return this + } + + override fun remove(key: String): SharedPreferences.Editor { + editor.remove(key) + return this + } + + override fun clear(): SharedPreferences.Editor { + editor.clear() + return this + } + + override fun commit(): Boolean { + return editor.commit() + } + + override fun putFloat(key: String, value: Float): SharedPreferences.Editor { + editor.putFloat(key, value) + return this + } + + override fun putLong(key: String, value: Long): SharedPreferences.Editor { + editor.putLong(key, value) + return this + } + + override fun putStringSet( + key: String, + values: MutableSet? + ): SharedPreferences.Editor { + editor + .putStringSet(key, values?.map { encryptString(it) }?.toMutableSet()) + + return this + } + + override fun apply() { + editor + } + + private fun encryptString(value: String): String { + return sharedPreferenceCrypto.encrypt(value, secretKey) + } + } +} diff --git a/core/src/main/java/com/emarsys/core/storage/EncryptedSharedPreferencesToSharedPreferencesMigration.kt b/core/src/main/java/com/emarsys/core/storage/EncryptedSharedPreferencesToSharedPreferencesMigration.kt new file mode 100644 index 00000000..7d2cf6a0 --- /dev/null +++ b/core/src/main/java/com/emarsys/core/storage/EncryptedSharedPreferencesToSharedPreferencesMigration.kt @@ -0,0 +1,30 @@ +package com.emarsys.core.storage + +import android.content.SharedPreferences + +class EncryptedSharedPreferencesToSharedPreferencesMigration { + + fun migrate( + oldSharedPreferences: SharedPreferences, + newSharedPreferences: SharedPreferences + ) { + try { + val encryptedData = oldSharedPreferences.all + val editor = newSharedPreferences.edit() + for ((key, value) in encryptedData) { + when (value) { + is String -> editor.putString(key, value) + is Int -> editor.putInt(key, value) + is Boolean -> editor.putBoolean(key, value) + is Float -> editor.putFloat(key, value) + is Long -> editor.putLong(key, value) + is Set<*> -> editor.putStringSet(key, value as Set) + } + } + editor.apply() + oldSharedPreferences.edit().clear().apply() + } catch (e: Exception) { + e.printStackTrace() + } + } +} \ No newline at end of file diff --git a/core/src/main/java/com/emarsys/core/storage/SharedPreferencesV3Provider.kt b/core/src/main/java/com/emarsys/core/storage/SharedPreferencesV3Provider.kt new file mode 100644 index 00000000..88ecc6c4 --- /dev/null +++ b/core/src/main/java/com/emarsys/core/storage/SharedPreferencesV3Provider.kt @@ -0,0 +1,25 @@ +package com.emarsys.core.storage + +import android.content.Context +import android.content.SharedPreferences +import com.emarsys.core.crypto.SharedPreferenceCrypto + +class SharedPreferencesV3Provider( + context: Context, + fileName: String, + oldSharedPreferences: SharedPreferences, + crypto: SharedPreferenceCrypto, + migration: EncryptedSharedPreferencesToSharedPreferencesMigration +) { + + private var sharedPreferences: SharedPreferences = + EmarsysEncryptedSharedPreferencesV3(context, fileName, crypto) + + init { + migration.migrate(oldSharedPreferences, sharedPreferences) + } + + fun provide(): SharedPreferences { + return sharedPreferences + } +} \ No newline at end of file diff --git a/emarsys-firebase/src/androidTest/java/com/emarsys/fake/FakeFirebaseDependencyContainer.kt b/emarsys-firebase/src/androidTest/java/com/emarsys/fake/FakeFirebaseDependencyContainer.kt index bfab5050..5a6cf7a3 100644 --- a/emarsys-firebase/src/androidTest/java/com/emarsys/fake/FakeFirebaseDependencyContainer.kt +++ b/emarsys-firebase/src/androidTest/java/com/emarsys/fake/FakeFirebaseDependencyContainer.kt @@ -155,4 +155,5 @@ class FakeFirebaseDependencyContainer( override val jsOnAppEventListener: OnAppEventListener = mock(), override val remoteMessageMapperFactory: RemoteMessageMapperFactory = mock(), override val transitionSafeCurrentActivityWatchdog: TransitionSafeCurrentActivityWatchdog = mock(), + override val sharedPreferencesV3: SharedPreferences = mock(), ) : MobileEngageComponent \ No newline at end of file diff --git a/emarsys-huawei/src/androidTest/java/com/emarsys/fake/FakeHuaweiDependencyContainer.kt b/emarsys-huawei/src/androidTest/java/com/emarsys/fake/FakeHuaweiDependencyContainer.kt index cc8be62b..550320c1 100644 --- a/emarsys-huawei/src/androidTest/java/com/emarsys/fake/FakeHuaweiDependencyContainer.kt +++ b/emarsys-huawei/src/androidTest/java/com/emarsys/fake/FakeHuaweiDependencyContainer.kt @@ -155,4 +155,5 @@ class FakeHuaweiDependencyContainer( override val jsOnAppEventListener: OnAppEventListener = mock(), override val remoteMessageMapperFactory: RemoteMessageMapperFactory = mock(), override val transitionSafeCurrentActivityWatchdog: TransitionSafeCurrentActivityWatchdog = mock(), + override val sharedPreferencesV3: SharedPreferences = mock(), ) : MobileEngageComponent \ No newline at end of file diff --git a/emarsys-sdk/src/androidTest/java/com/emarsys/di/FakeDependencyContainer.kt b/emarsys-sdk/src/androidTest/java/com/emarsys/di/FakeDependencyContainer.kt index e79e6b4f..f8574b32 100644 --- a/emarsys-sdk/src/androidTest/java/com/emarsys/di/FakeDependencyContainer.kt +++ b/emarsys-sdk/src/androidTest/java/com/emarsys/di/FakeDependencyContainer.kt @@ -6,6 +6,7 @@ import android.content.SharedPreferences import com.emarsys.clientservice.ClientServiceApi import com.emarsys.config.ConfigApi import com.emarsys.config.ConfigInternal +import com.emarsys.config.EmarsysConfig import com.emarsys.core.CoreCompletionHandler import com.emarsys.core.activity.ActivityLifecycleActionRegistry import com.emarsys.core.activity.ActivityLifecycleWatchdog @@ -200,5 +201,10 @@ class FakeDependencyContainer( override val jsOnCloseListener: OnCloseListener = mock(), override val jsOnAppEventListener: OnAppEventListener = mock(), override val remoteMessageMapperFactory: RemoteMessageMapperFactory = mock(), - override val transitionSafeCurrentActivityWatchdog: TransitionSafeCurrentActivityWatchdog = mock() -) : EmarsysComponent \ No newline at end of file + override val transitionSafeCurrentActivityWatchdog: TransitionSafeCurrentActivityWatchdog = mock(), + override val sharedPreferencesV3: SharedPreferences = mock(), +) : EmarsysComponent { + override fun logInitialSetup(emarsysConfig: EmarsysConfig) { + // no-op + } +} \ No newline at end of file diff --git a/emarsys-sdk/src/main/java/com/emarsys/Emarsys.kt b/emarsys-sdk/src/main/java/com/emarsys/Emarsys.kt index 0288e3b8..bf8706ee 100644 --- a/emarsys-sdk/src/main/java/com/emarsys/Emarsys.kt +++ b/emarsys-sdk/src/main/java/com/emarsys/Emarsys.kt @@ -99,6 +99,7 @@ object Emarsys { } refreshRemoteConfig(emarsysConfig.applicationCode) + emarsys().logInitialSetup(emarsysConfig) } private fun registerLifecycleObservers() { diff --git a/emarsys-sdk/src/main/java/com/emarsys/di/DefaultEmarsysComponent.kt b/emarsys-sdk/src/main/java/com/emarsys/di/DefaultEmarsysComponent.kt index c79a80d9..37c95633 100644 --- a/emarsys-sdk/src/main/java/com/emarsys/di/DefaultEmarsysComponent.kt +++ b/emarsys-sdk/src/main/java/com/emarsys/di/DefaultEmarsysComponent.kt @@ -35,6 +35,7 @@ import com.emarsys.core.contentresolver.EmarsysContentResolver import com.emarsys.core.contentresolver.hardwareid.HardwareIdContentResolver import com.emarsys.core.crypto.Crypto import com.emarsys.core.crypto.HardwareIdentificationCrypto +import com.emarsys.core.crypto.SharedPreferenceCrypto import com.emarsys.core.database.CoreSQLiteDatabase import com.emarsys.core.database.helper.CoreDbHelper import com.emarsys.core.database.repository.Repository @@ -68,8 +69,10 @@ import com.emarsys.core.shard.specification.FilterByShardType import com.emarsys.core.storage.BooleanStorage import com.emarsys.core.storage.CoreStorageKey import com.emarsys.core.storage.DefaultKeyValueStore +import com.emarsys.core.storage.EncryptedSharedPreferencesToSharedPreferencesMigration import com.emarsys.core.storage.KeyValueStore import com.emarsys.core.storage.SecureSharedPreferencesProvider +import com.emarsys.core.storage.SharedPreferencesV3Provider import com.emarsys.core.storage.Storage import com.emarsys.core.storage.StringStorage import com.emarsys.core.util.FileDownloader @@ -200,6 +203,8 @@ open class DefaultEmarsysComponent(config: EmarsysConfig) : EmarsysComponent { private const val EMARSYS_SHARED_PREFERENCES_NAME = "emarsys_shared_preferences" private const val EMARSYS_SECURE_SHARED_PREFERENCES_NAME = "emarsys_secure_shared_preferences" + private const val EMARSYS_SECURE_SHARED_PREFERENCES_V3_NAME = + "emarsys_secure_shared_preferences_v3" private const val GEOFENCE_LIMIT = 99 private const val PUBLIC_KEY = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAELjWEUIBX9zlm1OI4gF1hMCBLzpaBwgs9HlmSIBAqP4MDGy4ibOOV3FVDrnAY0Q34LZTbPBlp3gRNZJ19UoSy2Q==" @@ -420,23 +425,31 @@ open class DefaultEmarsysComponent(config: EmarsysConfig) : EmarsysComponent { Context.MODE_PRIVATE ) - override val sharedPreferences: SharedPreferences = + final override val sharedPreferences: SharedPreferences = SecureSharedPreferencesProvider( config.application, EMARSYS_SECURE_SHARED_PREFERENCES_NAME, oldSharedPrefs ).provide() + override val sharedPreferencesV3: SharedPreferences = + SharedPreferencesV3Provider( + config.application, EMARSYS_SECURE_SHARED_PREFERENCES_V3_NAME, sharedPreferences, + SharedPreferenceCrypto(), + EncryptedSharedPreferencesToSharedPreferencesMigration() + ).provide() + + override val contactTokenStorage: Storage by lazy { - StringStorage(MobileEngageStorageKey.CONTACT_TOKEN, sharedPreferences) + StringStorage(MobileEngageStorageKey.CONTACT_TOKEN, sharedPreferencesV3) } override val clientStateStorage: Storage by lazy { - StringStorage(MobileEngageStorageKey.CLIENT_STATE, sharedPreferences) + StringStorage(MobileEngageStorageKey.CLIENT_STATE, sharedPreferencesV3) } override val pushTokenStorage: Storage by lazy { - StringStorage(MobileEngageStorageKey.PUSH_TOKEN, sharedPreferences) + StringStorage(MobileEngageStorageKey.PUSH_TOKEN, sharedPreferencesV3) } override val uuidProvider: UUIDProvider by lazy { @@ -444,7 +457,7 @@ open class DefaultEmarsysComponent(config: EmarsysConfig) : EmarsysComponent { } override val hardwareIdStorage: Storage by lazy { - StringStorage(CoreStorageKey.HARDWARE_ID, sharedPreferences) + StringStorage(CoreStorageKey.HARDWARE_ID, sharedPreferencesV3) } override val coreDbHelper: CoreDbHelper by lazy { @@ -497,11 +510,11 @@ open class DefaultEmarsysComponent(config: EmarsysConfig) : EmarsysComponent { } override val refreshTokenStorage: Storage by lazy { - StringStorage(MobileEngageStorageKey.REFRESH_TOKEN, sharedPreferences) + StringStorage(MobileEngageStorageKey.REFRESH_TOKEN, sharedPreferencesV3) } override val contactFieldValueStorage: Storage by lazy { - StringStorage(MobileEngageStorageKey.CONTACT_FIELD_VALUE, sharedPreferences) + StringStorage(MobileEngageStorageKey.CONTACT_FIELD_VALUE, sharedPreferencesV3) } override val sessionIdHolder: SessionIdHolder by lazy { @@ -554,27 +567,27 @@ open class DefaultEmarsysComponent(config: EmarsysConfig) : EmarsysComponent { } override val clientServiceStorage: Storage by lazy { - StringStorage(MobileEngageStorageKey.CLIENT_SERVICE_URL, sharedPreferences) + StringStorage(MobileEngageStorageKey.CLIENT_SERVICE_URL, sharedPreferencesV3) } override val eventServiceStorage: Storage by lazy { - StringStorage(MobileEngageStorageKey.EVENT_SERVICE_URL, sharedPreferences) + StringStorage(MobileEngageStorageKey.EVENT_SERVICE_URL, sharedPreferencesV3) } override val deepLinkServiceStorage: Storage by lazy { - StringStorage(MobileEngageStorageKey.DEEPLINK_SERVICE_URL, sharedPreferences) + StringStorage(MobileEngageStorageKey.DEEPLINK_SERVICE_URL, sharedPreferencesV3) } override val messageInboxServiceStorage: Storage by lazy { - StringStorage(MobileEngageStorageKey.MESSAGE_INBOX_SERVICE_URL, sharedPreferences) + StringStorage(MobileEngageStorageKey.MESSAGE_INBOX_SERVICE_URL, sharedPreferencesV3) } override val deviceEventStateStorage: Storage by lazy { - StringStorage(MobileEngageStorageKey.DEVICE_EVENT_STATE, sharedPreferences) + StringStorage(MobileEngageStorageKey.DEVICE_EVENT_STATE, sharedPreferencesV3) } override val geofenceInitialEnterTriggerEnabledStorage: Storage by lazy { - BooleanStorage(MobileEngageStorageKey.GEOFENCE_INITIAL_ENTER_TRIGGER, sharedPreferences) + BooleanStorage(MobileEngageStorageKey.GEOFENCE_INITIAL_ENTER_TRIGGER, sharedPreferencesV3) } override val clientServiceEndpointProvider: ServiceEndpointProvider by lazy { @@ -808,11 +821,11 @@ open class DefaultEmarsysComponent(config: EmarsysConfig) : EmarsysComponent { } override val deviceInfoPayloadStorage: Storage by lazy { - StringStorage(MobileEngageStorageKey.DEVICE_INFO_HASH, sharedPreferences) + StringStorage(MobileEngageStorageKey.DEVICE_INFO_HASH, sharedPreferencesV3) } override val logLevelStorage: Storage by lazy { - StringStorage(CoreStorageKey.LOG_LEVEL, sharedPreferences) + StringStorage(CoreStorageKey.LOG_LEVEL, sharedPreferencesV3) } override val pushTokenProvider: PushTokenProvider by lazy { @@ -865,7 +878,7 @@ open class DefaultEmarsysComponent(config: EmarsysConfig) : EmarsysComponent { LocationServices.getGeofencingClient(config.application), geofenceActionCommandFactory, geofenceCacheableEventHandler, - BooleanStorage(MobileEngageStorageKey.GEOFENCE_ENABLED, sharedPreferences), + BooleanStorage(MobileEngageStorageKey.GEOFENCE_ENABLED, sharedPreferencesV3), GeofencePendingIntentProvider(config.application), concurrentHandlerHolder, geofenceInitialEnterTriggerEnabledStorage @@ -897,7 +910,7 @@ open class DefaultEmarsysComponent(config: EmarsysConfig) : EmarsysComponent { } override val keyValueStore: KeyValueStore by lazy { - DefaultKeyValueStore(sharedPreferences) + DefaultKeyValueStore(sharedPreferencesV3) } override val predictRequestContext: PredictRequestContext by lazy { @@ -1013,7 +1026,7 @@ open class DefaultEmarsysComponent(config: EmarsysConfig) : EmarsysComponent { } override val predictServiceStorage: Storage by lazy { - StringStorage(PredictStorageKey.PREDICT_SERVICE_URL, sharedPreferences) + StringStorage(PredictStorageKey.PREDICT_SERVICE_URL, sharedPreferencesV3) } private fun createRequestModelRepository( @@ -1055,7 +1068,7 @@ open class DefaultEmarsysComponent(config: EmarsysConfig) : EmarsysComponent { ) } - private fun logInitialSetup(emarsysConfig: EmarsysConfig) { + override fun logInitialSetup(emarsysConfig: EmarsysConfig) { if (!emarsysConfig.verboseConsoleLoggingEnabled) { return } @@ -1120,9 +1133,7 @@ open class DefaultEmarsysComponent(config: EmarsysConfig) : EmarsysComponent { Log.d( "EMARSYS_SDK", "${MobileEngageStorageKey.DEVICE_INFO_HASH} : ${ - JSONObject(deviceInfoPayloadStorage.get() ?: "{}").toString( - 4 - ) + deviceInfoPayloadStorage.get() ?: "-" }" ) Log.d("EMARSYS_SDK", "${CoreStorageKey.LOG_LEVEL} : ${logLevelStorage.get()}") diff --git a/emarsys-sdk/src/main/java/com/emarsys/di/EmarsysComponent.kt b/emarsys-sdk/src/main/java/com/emarsys/di/EmarsysComponent.kt index 6e1d868f..4145cebd 100644 --- a/emarsys-sdk/src/main/java/com/emarsys/di/EmarsysComponent.kt +++ b/emarsys-sdk/src/main/java/com/emarsys/di/EmarsysComponent.kt @@ -3,6 +3,7 @@ package com.emarsys.di import com.emarsys.clientservice.ClientServiceApi import com.emarsys.config.ConfigApi import com.emarsys.config.ConfigInternal +import com.emarsys.config.EmarsysConfig import com.emarsys.core.di.CoreComponent import com.emarsys.deeplink.DeepLinkApi import com.emarsys.eventservice.EventServiceApi @@ -18,7 +19,7 @@ import com.emarsys.predict.di.PredictComponent import com.emarsys.push.PushApi fun emarsys() = EmarsysComponent.instance - ?: throw IllegalStateException("DependencyContainer has to be setup first!") + ?: throw IllegalStateException("DependencyContainer has to be setup first!") fun setupEmarsysComponent(emarsysComponent: EmarsysComponent) { EmarsysComponent.instance = emarsysComponent @@ -35,10 +36,10 @@ fun tearDownEmarsysComponent() { } fun isEmarsysComponentSetup() = - EmarsysComponent.instance != null && - MobileEngageComponent.instance != null && - PredictComponent.instance != null && - CoreComponent.instance != null + EmarsysComponent.instance != null && + MobileEngageComponent.instance != null && + PredictComponent.instance != null && + CoreComponent.instance != null interface EmarsysComponent : MobileEngageComponent, PredictComponent { companion object { @@ -50,7 +51,7 @@ interface EmarsysComponent : MobileEngageComponent, PredictComponent { val loggingMessageInbox: MessageInboxApi val deepLink: DeepLinkApi - + val inApp: InAppApi val loggingInApp: InAppApi @@ -92,4 +93,6 @@ interface EmarsysComponent : MobileEngageComponent, PredictComponent { val loggingEventService: EventServiceApi val isGooglePlayServiceAvailable: Boolean + + fun logInitialSetup(emarsysConfig: EmarsysConfig) } diff --git a/emarsys/src/androidTest/java/com/emarsys/fake/FakeEmarsysDependencyContainer.kt b/emarsys/src/androidTest/java/com/emarsys/fake/FakeEmarsysDependencyContainer.kt index 2f63cc6e..95f6d437 100644 --- a/emarsys/src/androidTest/java/com/emarsys/fake/FakeEmarsysDependencyContainer.kt +++ b/emarsys/src/androidTest/java/com/emarsys/fake/FakeEmarsysDependencyContainer.kt @@ -156,4 +156,5 @@ class FakeEmarsysDependencyContainer( override val jsOnAppEventListener: OnAppEventListener = mock(), override val remoteMessageMapperFactory: RemoteMessageMapperFactory = mock(), override val transitionSafeCurrentActivityWatchdog: TransitionSafeCurrentActivityWatchdog = mock(), + override val sharedPreferencesV3: SharedPreferences = mock(), ) : MobileEngageComponent \ No newline at end of file diff --git a/mobile-engage/src/androidTest/java/com/emarsys/mobileengage/fake/FakeMobileEngageDependencyContainer.kt b/mobile-engage/src/androidTest/java/com/emarsys/mobileengage/fake/FakeMobileEngageDependencyContainer.kt index cf422a49..c3a1b960 100644 --- a/mobile-engage/src/androidTest/java/com/emarsys/mobileengage/fake/FakeMobileEngageDependencyContainer.kt +++ b/mobile-engage/src/androidTest/java/com/emarsys/mobileengage/fake/FakeMobileEngageDependencyContainer.kt @@ -155,4 +155,5 @@ class FakeMobileEngageDependencyContainer( override val jsOnAppEventListener: OnAppEventListener = mock(), override val remoteMessageMapperFactory: RemoteMessageMapperFactory = mock(), override val transitionSafeCurrentActivityWatchdog: TransitionSafeCurrentActivityWatchdog = mock(), + override val sharedPreferencesV3: SharedPreferences = mock(), ): MobileEngageComponent \ No newline at end of file