diff --git a/CHANGELOG.md b/CHANGELOG.md index 4feb35655..36c472cf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,13 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] #### Added -- nothing yet +- - Added `IterableDecryptionFailureHandler` interface to handle decryption failures of PII information. #### Removed -- nothing yet +- Removed `encryptionEnforced` parameter from `IterableConfig` as data is now always encoded for security #### Changed -- nothing yet +- Migrated from EncryptedSharedPreferences to regular SharedPreferences to prevent ANRs while EncryptedSharedPreferences was created on the main thread. We are now using our own encryption library to encrypt PII information before storing it in SharedPreferences. ## [3.5.4] #### Fixed diff --git a/iterableapi/build.gradle b/iterableapi/build.gradle index 98003f97b..7503c782d 100644 --- a/iterableapi/build.gradle +++ b/iterableapi/build.gradle @@ -65,7 +65,7 @@ dependencies { testImplementation 'androidx.test.ext:junit:1.1.5' testImplementation 'androidx.test:rules:1.5.0' testImplementation 'org.mockito:mockito-core:3.3.3' - testImplementation 'org.mockito:mockito-inline:2.8.47' + testImplementation 'org.mockito:mockito-inline:5.2.0' testImplementation 'org.robolectric:robolectric:4.9.2' testImplementation 'org.robolectric:shadows-playservices:4.9.2' testImplementation 'org.khronos:opengl-api:gl1.1-android-2.1_r1' diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index f12797511..49cfd3c6a 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -143,7 +143,7 @@ IterableKeychain getKeychain() { } if (keychain == null) { try { - keychain = new IterableKeychain(getMainActivityContext(), config.encryptionEnforced); + keychain = new IterableKeychain(getMainActivityContext(), config.decryptionFailureHandler); } catch (Exception e) { IterableLogger.e(TAG, "Failed to create IterableKeychain", e); } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java index 598e9a216..ebc640b9a 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java @@ -86,14 +86,17 @@ public class IterableConfig { * By default, the SDK will save in-apps to disk. */ final boolean useInMemoryStorageForInApps; - - final boolean encryptionEnforced; - /** * Allows for fetching embedded messages. */ final boolean enableEmbeddedMessaging; + /** + * Handler for decryption failures of PII information. + * Before calling this handler, the SDK will clear the PII information and create new encryption keys + */ + final IterableDecryptionFailureHandler decryptionFailureHandler; + private IterableConfig(Builder builder) { pushIntegrationName = builder.pushIntegrationName; urlHandler = builder.urlHandler; @@ -109,8 +112,8 @@ private IterableConfig(Builder builder) { allowedProtocols = builder.allowedProtocols; dataRegion = builder.dataRegion; useInMemoryStorageForInApps = builder.useInMemoryStorageForInApps; - encryptionEnforced = builder.encryptionEnforced; enableEmbeddedMessaging = builder.enableEmbeddedMessaging; + decryptionFailureHandler = builder.decryptionFailureHandler; } public static class Builder { @@ -128,8 +131,8 @@ public static class Builder { private String[] allowedProtocols = new String[0]; private IterableDataRegion dataRegion = IterableDataRegion.US; private boolean useInMemoryStorageForInApps = false; - private boolean encryptionEnforced = false; private boolean enableEmbeddedMessaging = false; + private IterableDecryptionFailureHandler decryptionFailureHandler; public Builder() {} @@ -261,17 +264,6 @@ public Builder setAllowedProtocols(@NonNull String[] allowedProtocols) { return this; } - /** - * Set whether the SDK should enforce encryption. If set to `true`, the SDK will not use fallback mechanism - * of storing data in un-encrypted shared preferences if encrypted database is not available. Set this to `true` - * if PII confidentiality is a concern for your app. - * @param encryptionEnforced `true` will have the SDK enforce encryption. - */ - public Builder setEncryptionEnforced(boolean encryptionEnforced) { - this.encryptionEnforced = encryptionEnforced; - return this; - } - /** * Set the data region used by the SDK * @param dataRegion enum value that determines which endpoint to use, defaults to IterableDataRegion.US @@ -302,6 +294,16 @@ public Builder setEnableEmbeddedMessaging(boolean enableEmbeddedMessaging) { return this; } + /** + * Set a handler for decryption failures that can be used to handle data recovery + * @param handler Decryption failure handler provided by the app + */ + @NonNull + public Builder setDecryptionFailureHandler(@NonNull IterableDecryptionFailureHandler handler) { + this.decryptionFailureHandler = handler; + return this; + } + @NonNull public IterableConfig build() { return new IterableConfig(this); diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt new file mode 100644 index 000000000..1597639cf --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt @@ -0,0 +1,173 @@ +package com.iterable.iterableapi + +import android.util.Base64 +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import android.os.Build +import java.security.KeyStore.PasswordProtection +import androidx.annotation.VisibleForTesting + +class IterableDataEncryptor { + companion object { + private const val TAG = "IterableDataEncryptor" + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val ITERABLE_KEY_ALIAS = "iterable_encryption_key" + private const val GCM_IV_LENGTH = 12 + private const val GCM_TAG_LENGTH = 128 + private val TEST_KEYSTORE_PASSWORD = "test_password".toCharArray() + + // Make keyStore static so it's shared across instances + private val keyStore: KeyStore by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + try { + KeyStore.getInstance(ANDROID_KEYSTORE).apply { + load(null) + } + } catch (e: Exception) { + IterableLogger.e(TAG, "Failed to initialize AndroidKeyStore", e) + KeyStore.getInstance("PKCS12").apply { + load(null, TEST_KEYSTORE_PASSWORD) + } + } + } else { + KeyStore.getInstance("PKCS12").apply { + load(null, TEST_KEYSTORE_PASSWORD) + } + } + } + } + + init { + if (!keyStore.containsAlias(ITERABLE_KEY_ALIAS)) { + generateKey() + } + } + + private fun generateKey() { + try { + if (canUseAndroidKeyStore()) { + generateAndroidKeyStoreKey()?.let { return } + } + generateFallbackKey() + } catch (e: Exception) { + IterableLogger.e(TAG, "Failed to generate key", e) + throw e + } + } + + private fun canUseAndroidKeyStore(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 && + keyStore.type == ANDROID_KEYSTORE + } + + private fun generateAndroidKeyStoreKey(): Unit? { + return try { + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + ANDROID_KEYSTORE + ) + + val keySpec = KeyGenParameterSpec.Builder( + ITERABLE_KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build() + + keyGenerator.init(keySpec) + keyGenerator.generateKey() + Unit + } catch (e: Exception) { + IterableLogger.e(TAG, "Failed to generate key using AndroidKeyStore", e) + null + } + } + + private fun generateFallbackKey() { + val keyGenerator = KeyGenerator.getInstance("AES") + keyGenerator.init(256) // 256-bit AES key + val secretKey = keyGenerator.generateKey() + + val keyEntry = KeyStore.SecretKeyEntry(secretKey) + val protParam = if (keyStore.type == "PKCS12") { + PasswordProtection(TEST_KEYSTORE_PASSWORD) + } else { + null + } + keyStore.setEntry(ITERABLE_KEY_ALIAS, keyEntry, protParam) + } + + private fun getKey(): SecretKey { + val protParam = if (keyStore.type == "PKCS12") { + PasswordProtection(TEST_KEYSTORE_PASSWORD) + } else { + null + } + return (keyStore.getEntry(ITERABLE_KEY_ALIAS, protParam) as KeyStore.SecretKeyEntry).secretKey + } + + class DecryptionException(message: String, cause: Throwable? = null) : Exception(message, cause) + + fun resetKeys() { + try { + keyStore.deleteEntry(ITERABLE_KEY_ALIAS) + generateKey() + } catch (e: Exception) { + IterableLogger.e(TAG, "Failed to regenerate key", e) + } + } + + fun encrypt(value: String?): String? { + if (value == null) return null + + try { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getKey()) + + val iv = cipher.iv + val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + + // Combine IV and encrypted data + val combined = ByteArray(iv.size + encrypted.size) + System.arraycopy(iv, 0, combined, 0, iv.size) + System.arraycopy(encrypted, 0, combined, iv.size, encrypted.size) + + return Base64.encodeToString(combined, Base64.NO_WRAP) + } catch (e: Exception) { + IterableLogger.e(TAG, "Encryption failed", e) + throw e + } + } + + fun decrypt(value: String?): String? { + if (value == null) return null + + try { + val combined = Base64.decode(value, Base64.NO_WRAP) + + // Extract IV + val iv = combined.copyOfRange(0, GCM_IV_LENGTH) + val encrypted = combined.copyOfRange(GCM_IV_LENGTH, combined.size) + + val cipher = Cipher.getInstance(TRANSFORMATION) + val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv) + cipher.init(Cipher.DECRYPT_MODE, getKey(), spec) + + return String(cipher.doFinal(encrypted), Charsets.UTF_8) + } catch (e: Exception) { + IterableLogger.e(TAG, "Decryption failed", e) + throw DecryptionException("Failed to decrypt data", e) + } + } + + // Add this method for testing purposes + @VisibleForTesting + fun getKeyStore(): KeyStore = keyStore +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDecryptionFailureHandler.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDecryptionFailureHandler.java new file mode 100644 index 000000000..3edadb5eb --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDecryptionFailureHandler.java @@ -0,0 +1,12 @@ +package com.iterable.iterableapi; + +/** + * Interface for handling decryption failures + */ +public interface IterableDecryptionFailureHandler { + /** + * Called when a decryption failure occurs + * @param exception The exception that caused the decryption failure + */ + void onDecryptionFailed(Exception exception); +} \ No newline at end of file diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt index df9aae23a..80f2aff74 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt @@ -2,154 +2,99 @@ package com.iterable.iterableapi import android.content.Context import android.content.SharedPreferences -import android.os.Build -import androidx.annotation.RequiresApi -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey class IterableKeychain { + companion object { + private const val TAG = "IterableKeychain" + const val KEY_EMAIL = "iterable-email" + const val KEY_USER_ID = "iterable-user-id" + const val KEY_AUTH_TOKEN = "iterable-auth-token" + } - private val TAG = "IterableKeychain" private var sharedPrefs: SharedPreferences - - private val encryptedSharedPrefsFileName = "iterable-encrypted-shared-preferences" - - private val emailKey = "iterable-email" - private val userIdKey = "iterable-user-id" - private val authTokenKey = "iterable-auth-token" - - private var encryptionEnabled = false - - constructor(context: Context, encryptionEnforced: Boolean) { - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - encryptionEnabled = false - sharedPrefs = context.getSharedPreferences( - IterableConstants.SHARED_PREFS_FILE, - Context.MODE_PRIVATE - ) - IterableLogger.v(TAG, "SharedPreferences being used") - } else { - // See if EncryptedSharedPreferences can be created successfully - try { - val masterKeyAlias = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - sharedPrefs = EncryptedSharedPreferences.create( - context, - encryptedSharedPrefsFileName, - masterKeyAlias, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) - encryptionEnabled = true - } catch (e: Throwable) { - if (e is Error) { - IterableLogger.e( - TAG, - "EncryptionSharedPreference creation failed with Error. Attempting to continue" - ) - } - - if (encryptionEnforced) { - //TODO: In-memory or similar solution needs to be implemented in the future. - IterableLogger.w( - TAG, - "Encryption is enforced. PII will not be persisted due to EncryptionSharedPreference failure. Email/UserId and Auth token will have to be passed for every app session.", - e - ) - throw e.fillInStackTrace() - } else { - sharedPrefs = context.getSharedPreferences( - IterableConstants.SHARED_PREFS_FILE, - Context.MODE_PRIVATE - ) - IterableLogger.w( - TAG, - "Using SharedPreference as EncryptionSharedPreference creation failed." - ) - encryptionEnabled = false + internal var encryptor: IterableDataEncryptor + private val decryptionFailureHandler: IterableDecryptionFailureHandler? + + @JvmOverloads + constructor( + context: Context, + decryptionFailureHandler: IterableDecryptionFailureHandler? = null, + migrator: IterableKeychainEncryptedDataMigrator? = null + ) { + this.decryptionFailureHandler = decryptionFailureHandler + sharedPrefs = context.getSharedPreferences( + IterableConstants.SHARED_PREFS_FILE, + Context.MODE_PRIVATE + ) + encryptor = IterableDataEncryptor() + IterableLogger.v(TAG, "SharedPreferences being used with encryption") + + try { + val dataMigrator = migrator ?: IterableKeychainEncryptedDataMigrator(context, sharedPrefs, this) + if (!dataMigrator.isMigrationCompleted()) { + dataMigrator.setMigrationCompletionCallback { error -> + error?.let { + IterableLogger.w(TAG, "Migration failed", it) + handleDecryptionError(Exception(it)) + } } + dataMigrator.attemptMigration() } - - //Try to migrate data from SharedPreferences to EncryptedSharedPreferences - if (encryptionEnabled) { - migrateAuthDataFromSharedPrefsToKeychain(context) - } + } catch (e: Exception) { + IterableLogger.w(TAG, "Migration failed, clearing data", e) + handleDecryptionError(e) } - } - fun getEmail(): String? { - return sharedPrefs.getString(emailKey, null) + IterableLogger.v(TAG, "Migration completed") } - fun saveEmail(email: String?) { + private fun handleDecryptionError(e: Exception? = null) { + IterableLogger.w(TAG, "Decryption failed, clearing all data and regenerating key") sharedPrefs.edit() - .putString(emailKey, email) + .remove(KEY_EMAIL) + .remove(KEY_USER_ID) + .remove(KEY_AUTH_TOKEN) .apply() + + encryptor.resetKeys() + decryptionFailureHandler?.let { handler -> + val exception = e ?: Exception("Unknown decryption error") + try { + val mainLooper = android.os.Looper.getMainLooper() + if (mainLooper != null) { + android.os.Handler(mainLooper).post { + handler.onDecryptionFailed(exception) + } + } else { + throw IllegalStateException("MainLooper is unavailable") + } + } catch (ex: Exception) { + handler.onDecryptionFailed(exception) + } + } } - fun getUserId(): String? { - return sharedPrefs.getString(userIdKey, null) + private fun secureGet(key: String): String? { + return try { + sharedPrefs.getString(key, null)?.let { encryptor.decrypt(it) } + } catch (e: Exception) { + handleDecryptionError(e) + null + } } - fun saveUserId(userId: String?) { + private fun secureSave(key: String, value: String?) { sharedPrefs.edit() - .putString(userIdKey, userId) + .putString(key, value?.let { encryptor.encrypt(it) }) .apply() } - fun getAuthToken(): String? { - return sharedPrefs.getString(authTokenKey, null) - } + fun getEmail() = secureGet(KEY_EMAIL) + fun saveEmail(email: String?) = secureSave(KEY_EMAIL, email) - fun saveAuthToken(authToken: String?) { - sharedPrefs.edit() - .putString(authTokenKey, authToken) - .apply() - } + fun getUserId() = secureGet(KEY_USER_ID) + fun saveUserId(userId: String?) = secureSave(KEY_USER_ID, userId) - @RequiresApi(api = Build.VERSION_CODES.M) - private fun migrateAuthDataFromSharedPrefsToKeychain(context: Context) { - val oldPrefs: SharedPreferences = context.getSharedPreferences( - IterableConstants.SHARED_PREFS_FILE, - Context.MODE_PRIVATE - ) - val sharedPrefsEmail = oldPrefs.getString(IterableConstants.SHARED_PREFS_EMAIL_KEY, null) - val sharedPrefsUserId = oldPrefs.getString(IterableConstants.SHARED_PREFS_USERID_KEY, null) - val sharedPrefsAuthToken = - oldPrefs.getString(IterableConstants.SHARED_PREFS_AUTH_TOKEN_KEY, null) - val editor: SharedPreferences.Editor = oldPrefs.edit() - if (getEmail() == null && sharedPrefsEmail != null) { - saveEmail(sharedPrefsEmail) - editor.remove(IterableConstants.SHARED_PREFS_EMAIL_KEY) - IterableLogger.v( - TAG, - "UPDATED: migrated email from SharedPreferences to IterableKeychain" - ) - } else if (sharedPrefsEmail != null) { - editor.remove(IterableConstants.SHARED_PREFS_EMAIL_KEY) - } - if (getUserId() == null && sharedPrefsUserId != null) { - saveUserId(sharedPrefsUserId) - editor.remove(IterableConstants.SHARED_PREFS_USERID_KEY) - IterableLogger.v( - TAG, - "UPDATED: migrated userId from SharedPreferences to IterableKeychain" - ) - } else if (sharedPrefsUserId != null) { - editor.remove(IterableConstants.SHARED_PREFS_USERID_KEY) - } - if (getAuthToken() == null && sharedPrefsAuthToken != null) { - saveAuthToken(sharedPrefsAuthToken) - editor.remove(IterableConstants.SHARED_PREFS_AUTH_TOKEN_KEY) - IterableLogger.v( - TAG, - "UPDATED: migrated authToken from SharedPreferences to IterableKeychain" - ) - } else if (sharedPrefsAuthToken != null) { - editor.remove(IterableConstants.SHARED_PREFS_AUTH_TOKEN_KEY) - } - editor.apply() - } -} \ No newline at end of file + fun getAuthToken() = secureGet(KEY_AUTH_TOKEN) + fun saveAuthToken(authToken: String?) = secureSave(KEY_AUTH_TOKEN, authToken) +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychainEncryptedDataMigrator.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychainEncryptedDataMigrator.kt new file mode 100644 index 000000000..0e7f0fd3f --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychainEncryptedDataMigrator.kt @@ -0,0 +1,188 @@ +package com.iterable.iterableapi + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import androidx.annotation.VisibleForTesting +import java.util.concurrent.TimeUnit +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeoutException + +class IterableKeychainEncryptedDataMigrator( + private val context: Context, + private val sharedPrefs: SharedPreferences, + private val keychain: IterableKeychain +) { + private val TAG = "IterableKeychainMigrator" + + private val encryptedSharedPrefsFileName = "iterable-encrypted-shared-preferences" + private val migrationStartedKey = "iterable-encrypted-migration-started" + private val migrationCompletedKey = "iterable-encrypted-migration-completed" + + private var migrationCompletionCallback: ((Throwable?) -> Unit)? = null + private val migrationLock = Object() + + class MigrationException(message: String, cause: Throwable? = null) : Exception(message, cause) + + private var migrationTimeoutMs = 5000L // Default 5 seconds + + @VisibleForTesting + fun setMigrationTimeout(timeoutMs: Long) { + migrationTimeoutMs = timeoutMs + } + + fun isMigrationCompleted(): Boolean { + return sharedPrefs.getBoolean(migrationCompletedKey, false) + } + + fun attemptMigration() { + synchronized(migrationLock) { + // Skip if running in JVM (for tests) unless mockEncryptedPrefs is present + if (isRunningInJVM() && mockEncryptedPrefs == null) { + IterableLogger.v(TAG, "Running in JVM, skipping migration of encrypted shared preferences") + markMigrationCompleted() + migrationCompletionCallback?.invoke(null) + return + } + + // Skip if migration was already completed + if (sharedPrefs.getBoolean(migrationCompletedKey, false)) { + IterableLogger.v(TAG, "Migration was already completed, skipping") + migrationCompletionCallback?.invoke(null) + return + } + + // Check for interrupted migration + if (sharedPrefs.getBoolean(migrationStartedKey, false)) { + IterableLogger.w(TAG, "Previous migration attempt was interrupted") + markMigrationCompleted() + val exception = MigrationException("Previous migration attempt was interrupted") + migrationCompletionCallback?.invoke(exception) + return + } + + // Mark migration as started + sharedPrefs.edit() + .putBoolean(migrationStartedKey, true) + .apply() + + // Create a single thread executor + val executor = Executors.newSingleThreadExecutor() + + try { + val future: Future<*> = executor.submit { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // For Android M and above, use EncryptedSharedPreferences + val prefs = mockEncryptedPrefs ?: run { + try { + val masterKeyAlias = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context, + encryptedSharedPrefsFileName, + masterKeyAlias, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } catch (e: Exception) { + null + } + } + + if (prefs == null) { + markMigrationCompleted() + val migrationException = MigrationException("Failed to load EncryptedSharedPreferences") + migrationCompletionCallback?.invoke(migrationException) + return@submit + } + migrateData(prefs) + } else { + // For versions below Android M, migrate directly from sharedPrefs + migrateData(sharedPrefs) + } + + markMigrationCompleted() + migrationCompletionCallback?.invoke(null) + } + + try { + future.get(migrationTimeoutMs, TimeUnit.MILLISECONDS) + } catch (e: TimeoutException) { + IterableLogger.w(TAG, "Migration timed out after ${migrationTimeoutMs}ms") + future.cancel(true) + if (!sharedPrefs.getBoolean(migrationCompletedKey, false)) { + markMigrationCompleted() + migrationCompletionCallback?.invoke(MigrationException("Migration timed out")) + } else { + // Migration was already completed, so we don't need to do anything + } + } catch (e: Exception) { + IterableLogger.w(TAG, "Migration failed", e) + markMigrationCompleted() + migrationCompletionCallback?.invoke(MigrationException("Migration failed", e)) + } + } finally { + executor.shutdown() + } + } + } + + private fun migrateData(encryptedPrefs: SharedPreferences) { + val editor = encryptedPrefs.edit() + + // Fetch and migrate email + val email = encryptedPrefs.getString(IterableKeychain.KEY_EMAIL, null) + if (email != null) { + keychain.saveEmail(email) + editor.remove(IterableKeychain.KEY_EMAIL) + IterableLogger.d(TAG, "Email migrated: $email") + } else { + IterableLogger.d(TAG, "No email found to migrate.") + } + + // Fetch and migrate user ID + val userId = encryptedPrefs.getString(IterableKeychain.KEY_USER_ID, null) + if (userId != null) { + keychain.saveUserId(userId) + editor.remove(IterableKeychain.KEY_USER_ID) + IterableLogger.d(TAG, "User ID migrated: $userId") + } else { + IterableLogger.w(TAG, "No user ID found to migrate.") + } + + // Fetch and migrate auth token + val authToken = encryptedPrefs.getString(IterableKeychain.KEY_AUTH_TOKEN, null) + if (authToken != null) { + keychain.saveAuthToken(authToken) + editor.remove(IterableKeychain.KEY_AUTH_TOKEN) + IterableLogger.d(TAG, "Auth token migrated: $authToken") + } else { + IterableLogger.d(TAG, "No auth token found to migrate.") + } + + editor.apply() + } + + private fun markMigrationCompleted() { + sharedPrefs.edit() + .putBoolean(migrationStartedKey, false) + .putBoolean(migrationCompletedKey, true) + .apply() + } + + fun setMigrationCompletionCallback(callback: (Throwable?) -> Unit) { + migrationCompletionCallback = callback + } + + // Add a property for tests to inject mock encrypted preferences + @VisibleForTesting + var mockEncryptedPrefs: SharedPreferences? = null + + private fun isRunningInJVM(): Boolean { + return System.getProperty("java.vendor")?.contains("Android") != true + } +} diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java new file mode 100644 index 000000000..896c28fdc --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java @@ -0,0 +1,299 @@ +package com.iterable.iterableapi; + +import android.content.SharedPreferences; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.fail; + +import java.security.KeyStore; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +public class IterableDataEncryptorTest extends BaseTest { + + private IterableDataEncryptor encryptor; + + @Mock + private SharedPreferences sharedPreferences; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + encryptor = new IterableDataEncryptor(); + } + + @Test + public void testConstructor() { + // Simply creating a new instance should not throw any exceptions + IterableDataEncryptor encryptor = new IterableDataEncryptor(); + assertNotNull("Encryptor should be created successfully", encryptor); + } + + @Test + public void testEncryptDecryptSuccess() { + String originalText = "test data to encrypt"; + String encrypted = encryptor.encrypt(originalText); + String decrypted = encryptor.decrypt(encrypted); + + assertNotNull("Encrypted text should not be null", encrypted); + assertNotEquals("Encrypted text should not match original", originalText, encrypted); + assertEquals("Decrypted text should match original", originalText, decrypted); + } + + @Test + public void testEncryptNullInput() { + String encrypted = encryptor.encrypt(null); + assertNull("Encrypting null should return null", encrypted); + } + + @Test + public void testDecryptNullInput() { + String decrypted = encryptor.decrypt(null); + assertNull("Decrypting null should return null", decrypted); + } + + @Test + public void testEncryptEmptyString() { + String encrypted = encryptor.encrypt(""); + String decrypted = encryptor.decrypt(encrypted); + + assertNotNull("Encrypted text should not be null", encrypted); + assertEquals("Decrypted text should be empty string", "", decrypted); + } + + @Test + public void testEncryptLongString() { + StringBuilder longString = new StringBuilder(); + for (int i = 0; i < 1000; i++) { + longString.append("test"); + } + String originalText = longString.toString(); + + String encrypted = encryptor.encrypt(originalText); + String decrypted = encryptor.decrypt(encrypted); + + assertNotNull("Encrypted text should not be null", encrypted); + assertEquals("Decrypted long text should match original", originalText, decrypted); + } + + @Test + public void testMultipleEncryptions() { + String text1 = "first text"; + String text2 = "second text"; + + String encrypted1 = encryptor.encrypt(text1); + String encrypted2 = encryptor.encrypt(text2); + + assertNotEquals("Different texts should have different encryptions", encrypted1, encrypted2); + assertEquals("First text should decrypt correctly", text1, encryptor.decrypt(encrypted1)); + assertEquals("Second text should decrypt correctly", text2, encryptor.decrypt(encrypted2)); + } + + @Test(expected = IterableDataEncryptor.DecryptionException.class) + public void testDecryptInvalidData() { + encryptor.decrypt("invalid encrypted data"); + } + + @Test + public void testDecryptionExceptionDetails() { + try { + encryptor.decrypt("invalid_base64_data!!!"); + fail("Should throw DecryptionException"); + } catch (Exception e) { + assertTrue("Should be instance of DecryptionException", e instanceof IterableDataEncryptor.DecryptionException); + assertNotNull("Exception should have a cause", e.getCause()); + assertEquals("Exception should have correct message", "Failed to decrypt data", e.getMessage()); + } + } + + @Test + public void testDecryptTamperedData() { + String originalText = "test data"; + String encrypted = encryptor.encrypt(originalText); + // Tamper with the encrypted data while maintaining valid base64 + String tamperedData = encrypted.substring(0, encrypted.length() - 4) + "AAAA"; + + try { + encryptor.decrypt(tamperedData); + fail("Should throw DecryptionException for tampered data"); + } catch (Exception e) { + assertTrue("Should be instance of DecryptionException", e instanceof IterableDataEncryptor.DecryptionException); + assertNotNull("Exception should have a cause", e.getCause()); + } + } + + @Test + public void testResetKeys() { + String originalText = "test data"; + String encrypted = encryptor.encrypt(originalText); + + // Clear the key + encryptor.resetKeys(); + + // Try to decrypt the data encrypted with the old key + try { + encryptor.decrypt(encrypted); + fail("Should not be able to decrypt data with cleared key"); + } catch (Exception e) { + // Expected behavior - old encrypted data should not be decryptable + assertNotNull(e); + } + + // Verify new encryption/decryption works after clearing + String newEncrypted = encryptor.encrypt(originalText); + String newDecrypted = encryptor.decrypt(newEncrypted); + assertEquals("New encryption/decryption should work after clearing", originalText, newDecrypted); + } + + @Test + public void testKeyGeneration() throws Exception { + // Create new encryptor which should trigger key generation + IterableDataEncryptor encryptor = new IterableDataEncryptor(); + + // Get the keystore from the encryptor (we'll need to add a method to expose this) + KeyStore keyStore = encryptor.getKeyStore(); + + // Verify the key exists in keystore + assertTrue("Key should exist in keystore", keyStore.containsAlias("iterable_encryption_key")); + + // Rest of the test remains the same + String testData = "test data"; + String encrypted = encryptor.encrypt(testData); + String decrypted = encryptor.decrypt(encrypted); + assertEquals("Data should be correctly encrypted and decrypted", testData, decrypted); + } + + @Test + public void testKeyRegeneration() throws Exception { + // Create first encryptor and encrypt data + IterableDataEncryptor encryptor1 = new IterableDataEncryptor(); + KeyStore keyStore = encryptor1.getKeyStore(); + + String testData = "test data"; + String encrypted1 = encryptor1.encrypt(testData); + + // Delete the key + encryptor1.resetKeys(); + + // Create second encryptor which should generate a new key + IterableDataEncryptor encryptor2 = new IterableDataEncryptor(); + + // Rest of the test remains the same + assertTrue("Key should be regenerated", keyStore.containsAlias("iterable_encryption_key")); + + // Verify old encrypted data can't be decrypted with new key + try { + encryptor2.decrypt(encrypted1); + fail("Should not be able to decrypt data encrypted with old key"); + } catch (Exception e) { + // Expected + } + + // Verify new encryption/decryption works + String encrypted2 = encryptor2.encrypt(testData); + String decrypted2 = encryptor2.decrypt(encrypted2); + assertEquals("New key should work for encryption/decryption", testData, decrypted2); + } + + @Test + public void testMultipleEncryptorInstances() throws Exception { + // Create two encryptor instances + IterableDataEncryptor encryptor1 = new IterableDataEncryptor(); + IterableDataEncryptor encryptor2 = new IterableDataEncryptor(); + + // Test that they can decrypt each other's encrypted data + String testData = "test data"; + String encrypted1 = encryptor1.encrypt(testData); + String encrypted2 = encryptor2.encrypt(testData); + + assertEquals("Encryptor 2 should decrypt Encryptor 1's data", testData, encryptor2.decrypt(encrypted1)); + assertEquals("Encryptor 1 should decrypt Encryptor 2's data", testData, encryptor1.decrypt(encrypted2)); + } + + @Test + public void testConcurrentAccess() throws InterruptedException { + int threadCount = 5; + int operationsPerThread = 20; + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicBoolean hasErrors = new AtomicBoolean(false); + List threads = new ArrayList<>(); + + // Create and start multiple threads + for (int i = 0; i < threadCount; i++) { + final int threadId = i; + Thread thread = new Thread(() -> { + try { + for (int j = 0; j < operationsPerThread; j++) { + String originalText = "Thread-" + threadId + "-Data-" + j; + String encrypted = encryptor.encrypt(originalText); + String decrypted = encryptor.decrypt(encrypted); + + if (!originalText.equals(decrypted)) { + hasErrors.set(true); + IterableLogger.e("TestConcurrent", "Encryption/Decryption mismatch: " + originalText + " != " + decrypted); + } + } + } catch (Exception e) { + hasErrors.set(true); + IterableLogger.e("TestConcurrent", "Thread " + threadId + " failed: " + e.getMessage()); + } finally { + latch.countDown(); + } + }); + threads.add(thread); + thread.start(); + } + + // Wait for all threads to complete with a shorter timeout + boolean completed = latch.await(5, TimeUnit.SECONDS); + + assertTrue("All threads should complete within timeout", completed); + assertFalse("No errors should occur", hasErrors.get()); + } + + @Test + public void testSpecialCharacters() { + String specialChars = "!@#$%^&*()_+{}[]|\"':;?/>.<,~`"; + String encrypted = encryptor.encrypt(specialChars); + assertEquals(specialChars, encryptor.decrypt(encrypted)); + } + + @Test + public void testUnicodeStrings() { + String unicodeText = "Hello δΈ–η•Œ 🌍"; + String encrypted = encryptor.encrypt(unicodeText); + assertEquals(unicodeText, encryptor.decrypt(encrypted)); + } + + @Test + public void testDecryptionAfterKeyLoss() { + // Create data with original key + String testData = "test data"; + String encrypted = encryptor.encrypt(testData); + + // Clear the key and generate a new one + encryptor.resetKeys(); + + try { + encryptor.decrypt(encrypted); + fail("Should throw DecryptionException when decrypting with new key"); + } catch (Exception e) { + assertTrue("Should be instance of DecryptionException", e instanceof IterableDataEncryptor.DecryptionException); + assertNotNull("Exception should have a cause", e.getCause()); + assertEquals("Exception should have correct message", "Failed to decrypt data", e.getMessage()); + } + } +} \ No newline at end of file diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainEncryptedDataMigratorTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainEncryptedDataMigratorTest.java new file mode 100644 index 000000000..8108df1bc --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainEncryptedDataMigratorTest.java @@ -0,0 +1,233 @@ +package com.iterable.iterableapi; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Build; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.util.ReflectionHelpers; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; + +public class IterableKeychainEncryptedDataMigratorTest extends BaseTest { + + private static final String MIGRATION_STARTED_KEY = "iterable-encrypted-migration-started"; + private static final String MIGRATION_COMPLETED_KEY = "iterable-encrypted-migration-completed"; + private static final String OLD_EMAIL_KEY = IterableKeychain.KEY_EMAIL; + private static final String OLD_USER_ID_KEY = IterableKeychain.KEY_USER_ID; + private static final String OLD_AUTH_TOKEN_KEY = IterableKeychain.KEY_AUTH_TOKEN; + + @Mock private Context mockContext; + @Mock private SharedPreferences mockSharedPrefs; + @Mock private SharedPreferences mockEncryptedPrefs; + @Mock private SharedPreferences.Editor mockEditor; + @Mock private SharedPreferences.Editor mockEncryptedEditor; + @Mock private IterableKeychain mockKeychain; + + private IterableKeychainEncryptedDataMigrator migrator; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + // Setup SharedPreferences mocks + when(mockSharedPrefs.edit()).thenReturn(mockEditor); + when(mockEncryptedPrefs.edit()).thenReturn(mockEncryptedEditor); + + // Setup editor method chaining + when(mockEditor.putBoolean(anyString(), anyBoolean())).thenReturn(mockEditor); + when(mockEncryptedEditor.clear()).thenReturn(mockEncryptedEditor); + + // No need to mock void methods (apply) + + migrator = new IterableKeychainEncryptedDataMigrator( + mockContext, + mockSharedPrefs, + mockKeychain + ); + migrator.setMockEncryptedPrefs(mockEncryptedPrefs); + } + + @Test + public void testSkipIfAlreadyCompleted() { + when(mockSharedPrefs.getBoolean(MIGRATION_COMPLETED_KEY, false)) + .thenReturn(true); + + migrator.attemptMigration(); + + verify(mockSharedPrefs, never()).edit(); + } + + @Test + public void testThrowsExceptionIfPreviouslyInterrupted() throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + + when(mockSharedPrefs.getBoolean(MIGRATION_STARTED_KEY, false)) + .thenReturn(true); + + migrator.setMigrationCompletionCallback(throwable -> { + error.set(throwable); + latch.countDown(); + return null; + }); + + migrator.attemptMigration(); + + assertTrue("Migration timed out", latch.await(5, TimeUnit.SECONDS)); + assertNotNull("Should have received an error", error.get()); + assertTrue("Exception should be MigrationException", + error.get() instanceof IterableKeychainEncryptedDataMigrator.MigrationException); + assertEquals("Previous migration attempt was interrupted", error.get().getMessage()); + + verify(mockEditor).putBoolean(MIGRATION_COMPLETED_KEY, true); + verify(mockEditor).apply(); + } + + @Test + public void testSuccessfulMigration() { + String testEmail = "test@example.com"; + String testUserId = "user123"; + String testAuthToken = "auth-token-123"; + + when(mockEncryptedPrefs.getString(eq(OLD_EMAIL_KEY), eq(null))).thenReturn(testEmail); + when(mockEncryptedPrefs.getString(eq(OLD_USER_ID_KEY), eq(null))).thenReturn(testUserId); + when(mockEncryptedPrefs.getString(eq(OLD_AUTH_TOKEN_KEY), eq(null))).thenReturn(testAuthToken); + + migrator.attemptMigration(); + + verify(mockEditor).putBoolean(MIGRATION_STARTED_KEY, true); + verify(mockEditor).putBoolean(MIGRATION_COMPLETED_KEY, true); + + verify(mockKeychain).saveEmail(testEmail); + verify(mockKeychain).saveUserId(testUserId); + verify(mockKeychain).saveAuthToken(testAuthToken); + + verify(mockEncryptedEditor).remove(OLD_EMAIL_KEY); + verify(mockEncryptedEditor).remove(OLD_USER_ID_KEY); + verify(mockEncryptedEditor).remove(OLD_AUTH_TOKEN_KEY); + verify(mockEncryptedEditor).apply(); + } + + @Test + public void testPartialDataMigration() { + String testEmail = "test@example.com"; + when(mockEncryptedPrefs.getString(eq(OLD_EMAIL_KEY), eq(null))).thenReturn(testEmail); + when(mockEncryptedPrefs.getString(eq(OLD_USER_ID_KEY), eq(null))).thenReturn(null); + when(mockEncryptedPrefs.getString(eq(OLD_AUTH_TOKEN_KEY), eq(null))).thenReturn(null); + + migrator.attemptMigration(); + + verify(mockKeychain).saveEmail(testEmail); + verify(mockKeychain, never()).saveUserId(anyString()); + verify(mockKeychain, never()).saveAuthToken(anyString()); + } + + @Test + public void testMigrationWithEmptyData() throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + + when(mockEncryptedPrefs.getString(anyString(), eq(null))).thenReturn(null); + + migrator.setMigrationCompletionCallback(throwable -> { + error.set(throwable); + latch.countDown(); + return null; + }); + + migrator.attemptMigration(); + + assertTrue("Migration timed out", latch.await(5, TimeUnit.SECONDS)); + assertNull("Migration failed with error: " + error.get(), error.get()); + + verify(mockKeychain, never()).saveEmail(anyString()); + verify(mockKeychain, never()).saveUserId(anyString()); + verify(mockKeychain, never()).saveAuthToken(anyString()); + + verify(mockEncryptedEditor).apply(); + } + + @Test + public void testMigrationError() throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + + when(mockEncryptedPrefs.getString(anyString(), eq(null))) + .thenThrow(new SecurityException("Encryption error")); + + migrator.setMigrationCompletionCallback(throwable -> { + error.set(throwable); + latch.countDown(); + return null; + }); + + migrator.attemptMigration(); + + assertTrue("Migration timed out", latch.await(5, TimeUnit.SECONDS)); + assertNotNull("Should have received an error", error.get()); + assertTrue("Exception should be MigrationException", + error.get() instanceof IterableKeychainEncryptedDataMigrator.MigrationException); + assertEquals("Migration failed", error.get().getMessage()); + } + + @Test + public void testMigrationTimeout() { + // Set a very short timeout (50ms) + migrator.setMigrationTimeout(50L); + + // Make the migration hang by making getString block + when(mockEncryptedPrefs.getString(anyString(), eq(null))).thenAnswer(invocation -> { + Thread.sleep(1000); // Sleep longer than timeout + return "test@example.com"; + }); + + migrator.attemptMigration(); + + // Verify both calls to apply(): + // 1. Setting migration started flag + verify(mockEditor).putBoolean(MIGRATION_STARTED_KEY, true); + // 2. Setting migration completed flag during timeout + verify(mockEditor).putBoolean(MIGRATION_COMPLETED_KEY, true); + verify(mockEditor, times(2)).apply(); + } + + @Test + public void testMigrationBehaviorBelowAndroidM() { + // Set Android version to Lollipop (below M) + ReflectionHelpers.setStaticField(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.LOLLIPOP); + + String testEmail = "test@example.com"; + // Use mockSharedPrefs instead of mockEncryptedPrefs since below Android M + when(mockSharedPrefs.getString(eq(OLD_EMAIL_KEY), eq(null))).thenReturn(testEmail); + + migrator.attemptMigration(); + + // Should migrate using regular SharedPreferences + verify(mockKeychain).saveEmail(testEmail); + verify(mockEditor).putBoolean(MIGRATION_STARTED_KEY, true); + verify(mockEditor).putBoolean(MIGRATION_COMPLETED_KEY, true); + verify(mockEditor, times(3)).apply(); // Called three times during the migration process + + // Verify that mockEncryptedPrefs was never used + verify(mockEncryptedPrefs, never()).getString(anyString(), eq(null)); + } +} \ No newline at end of file diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt new file mode 100644 index 000000000..14bc49e9e --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt @@ -0,0 +1,288 @@ +package com.iterable.iterableapi + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import android.util.Base64 +import org.junit.Before +import org.junit.After +import org.junit.Test +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.`when` +import org.mockito.Mockito.verify +import org.mockito.Mockito.times +import org.mockito.MockitoAnnotations +import org.junit.Assert.* +import org.mockito.ArgumentMatchers.eq +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.isNull +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.mock + +class IterableKeychainTest { + + @Mock private lateinit var mockContext: Context + @Mock private lateinit var mockSharedPrefs: SharedPreferences + @Mock private lateinit var mockEditor: SharedPreferences.Editor + @Mock private lateinit var mockDecryptionFailureHandler: IterableDecryptionFailureHandler + @Mock private lateinit var mockEncryptor: IterableDataEncryptor + + private lateinit var keychain: IterableKeychain + private lateinit var mockedLog: MockedStatic + private lateinit var mockedBase64: MockedStatic + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + + // Mock Android Log + mockedLog = mockStatic(Log::class.java) + + // Mock Android Base64 + mockedBase64 = mockStatic(Base64::class.java) + `when`(Base64.encodeToString(any(), anyInt())).thenReturn("mocked_base64_string") + + `when`(mockContext.getSharedPreferences( + any(), + eq(Context.MODE_PRIVATE) + )).thenReturn(mockSharedPrefs) + + `when`(mockSharedPrefs.edit()).thenReturn(mockEditor) + `when`(mockEditor.putString(any(), any())).thenReturn(mockEditor) + `when`(mockEditor.remove(any())).thenReturn(mockEditor) + `when`(mockEditor.putBoolean(any(), anyBoolean())).thenReturn(mockEditor) + + // Mock migration-related SharedPreferences calls + `when`(mockSharedPrefs.contains(any())).thenReturn(false) + `when`(mockSharedPrefs.getBoolean(any(), anyBoolean())).thenReturn(false) + `when`(mockSharedPrefs.getString(any(), any())).thenReturn(null) + + // Mock editor.apply() to do nothing + Mockito.doNothing().`when`(mockEditor).apply() + + keychain = IterableKeychain( + mockContext, + mockDecryptionFailureHandler + ) + // Directly set the mock encryptor + keychain.encryptor = mockEncryptor + + // Setup encrypt/decrypt behavior + `when`(mockEncryptor.encrypt(any())).thenAnswer { invocation -> + val input = invocation.arguments[0] as String? + input?.let { "encrypted_$it" } + } + + `when`(mockEncryptor.decrypt(any())).thenAnswer { invocation -> + val encrypted = invocation.arguments[0] as String? + if (encrypted == null) { + null + } else if (encrypted.startsWith("encrypted_")) { + encrypted.substring("encrypted_".length) + } else { + throw IterableDataEncryptor.DecryptionException("Invalid encrypted value") + } + } + + // Reset the verification count after setup + Mockito.clearInvocations(mockEditor) + } + + @After + fun tearDown() { + mockedLog.close() + mockedBase64.close() + } + + @Test + fun testSaveAndGetEmail() { + val testEmail = "test@example.com" + + // Update mock to return the encrypted value + `when`(mockSharedPrefs.getString(eq("iterable-email"), isNull())) + .thenReturn("encrypted_$testEmail") + + keychain.saveEmail(testEmail) + + verify(mockEditor).putString(eq("iterable-email"), eq("encrypted_$testEmail")) + verify(mockEditor).apply() + + val retrievedEmail = keychain.getEmail() + assertEquals(testEmail, retrievedEmail) + } + + @Test + fun testSaveAndGetUserId() { + val testUserId = "user123" + + // Update mock to return the encrypted value + `when`(mockSharedPrefs.getString(eq("iterable-user-id"), isNull())) + .thenReturn("encrypted_$testUserId") + + keychain.saveUserId(testUserId) + + verify(mockEditor).putString(eq("iterable-user-id"), eq("encrypted_$testUserId")) + verify(mockEditor).apply() + + val retrievedUserId = keychain.getUserId() + assertEquals(testUserId, retrievedUserId) + } + + @Test + fun testSaveAndGetAuthToken() { + val testToken = "auth-token-123" + + // Update mock to return the encrypted value + `when`(mockSharedPrefs.getString(eq("iterable-auth-token"), isNull())) + .thenReturn("encrypted_$testToken") + + keychain.saveAuthToken(testToken) + + verify(mockEditor).putString(eq("iterable-auth-token"), eq("encrypted_$testToken")) + verify(mockEditor).apply() + + val retrievedToken = keychain.getAuthToken() + assertEquals(testToken, retrievedToken) + } + + @Test + fun testDecryptionFailure() { + // Setup mock to throw runtime exception instead + `when`(mockEncryptor.decrypt(any())).thenAnswer { + throw RuntimeException("Test decryption failed") + } + `when`(mockSharedPrefs.getString(eq("iterable-email"), isNull())) + .thenReturn("any_encrypted_value") + + val result = keychain.getEmail() + + // Verify data was cleared + verify(mockEditor).remove("iterable-email") + verify(mockEditor).remove("iterable-user-id") + verify(mockEditor).remove("iterable-auth-token") + verify(mockEditor).apply() + + // Verify failure handler was called with any exception + verify(mockDecryptionFailureHandler).onDecryptionFailed(any()) + + // Verify encryptor keys were reset + verify(mockEncryptor).resetKeys() + + assertNull(result) + } + + @Test + fun testDecryptionFailureForAllOperations() { + // Setup mock to throw runtime exception + `when`(mockEncryptor.decrypt(any())).thenAnswer { + throw RuntimeException("Test decryption failed") + } + `when`(mockSharedPrefs.getString(any(), isNull())).thenReturn("any_encrypted_value") + + // Test all getter methods + assertNull(keychain.getEmail()) + assertNull(keychain.getUserId()) + assertNull(keychain.getAuthToken()) + + // Verify failure handler was called exactly once for each operation + verify(mockDecryptionFailureHandler, times(3)).onDecryptionFailed(any()) + + // Verify keys were reset for each failure + verify(mockEncryptor, times(3)).resetKeys() + } + + @Test + fun testSaveNullValues() { + keychain.saveEmail(null) + keychain.saveUserId(null) + keychain.saveAuthToken(null) + + // Verify exactly one putString call for each save operation + verify(mockEditor, times(3)).putString(any(), isNull()) + // Verify exactly one apply call for each save operation + verify(mockEditor, times(3)).apply() + } + + @Test + fun testConcurrentAccess() { + val testEmail = "test@example.com" + val threads = mutableListOf() + val exceptions = mutableListOf() + + // Simulate multiple threads accessing keychain + for (i in 1..5) { + threads.add(Thread { + try { + keychain.saveEmail(testEmail) + assertEquals(testEmail, keychain.getEmail()) + } catch (e: Exception) { + exceptions.add(e) + } + }) + } + + threads.forEach { it.start() } + threads.forEach { it.join() } + + assertTrue("Concurrent access caused exceptions: $exceptions", exceptions.isEmpty()) + } + + @Test + fun testMigrationFailure() { + // Mock migration to throw exception + val mockMigrator = mock(IterableKeychainEncryptedDataMigrator::class.java) + val migrationException = RuntimeException("Test migration failed") + + doThrow(migrationException).`when`(mockMigrator).attemptMigration() + + // Create new keychain with mock migrator + keychain = IterableKeychain( + mockContext, + mockDecryptionFailureHandler, + mockMigrator + ) + + // Verify data was cleared + verify(mockEditor).remove(eq("iterable-email")) + verify(mockEditor).remove(eq("iterable-user-id")) + verify(mockEditor).remove(eq("iterable-auth-token")) + verify(mockEditor).apply() + + // Verify failure handler was called + verify(mockDecryptionFailureHandler).onDecryptionFailed(migrationException) + } + + @Test + fun testMigrationOnlyAttemptedOnce() { + // Create mock migrator + val mockMigrator = mock(IterableKeychainEncryptedDataMigrator::class.java) + // First check returns false, subsequent checks return true + `when`(mockMigrator.isMigrationCompleted()) + .thenReturn(false) // first call + + // First initialization + keychain = IterableKeychain( + mockContext, + mockDecryptionFailureHandler, + mockMigrator + ) + + `when`(mockMigrator.isMigrationCompleted()) + .thenReturn(true) // subsequent calls + + // Second initialization + keychain = IterableKeychain( + mockContext, + mockDecryptionFailureHandler, + mockMigrator + ) + + // Verify attemptMigration was called exactly once + verify(mockMigrator, times(1)).attemptMigration() + } +} \ No newline at end of file