From 0f676ebb6fde8c26744f95f5a90c76dcb2463de1 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:43:46 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AA=20Add=20testing=20for=20theme=20Ap?= =?UTF-8?q?pearancePreferences?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: SayanthRock <202829406+SayanthRock@users.noreply.github.com> --- .jules/testing.md | 3 + .../theme/AppearancePreferences.kt | 26 ++-- .../theme/AppearancePreferencesTest.kt | 130 ++++++++++++++++++ pr_description.md | 3 - 4 files changed, 148 insertions(+), 14 deletions(-) create mode 100644 .jules/testing.md create mode 100644 app/src/test/java/com/rockmusic/app/presentation/theme/AppearancePreferencesTest.kt delete mode 100644 pr_description.md diff --git a/.jules/testing.md b/.jules/testing.md new file mode 100644 index 0000000..db05229 --- /dev/null +++ b/.jules/testing.md @@ -0,0 +1,3 @@ +## 2023-11-20 - [Testing preferences that use EncryptedSharedPreferences] +**Learning:** `EncryptedSharedPreferences` requires an Android Keystore which doesn't work correctly in basic Robolectric tests due to missing cryptographic algorithm implementations (java.security.KeyStoreException). +**Action:** When testing classes like `AppearancePreferences` that rely on `EncryptedSharedPreferences`, create a `Fake` wrapper class that overrides the internal `SharedPreferences` object with a basic, in-memory implementation for testing, rather than trying to configure Robolectric to support the Android KeyStore correctly. diff --git a/app/src/main/java/com/rockmusic/app/presentation/theme/AppearancePreferences.kt b/app/src/main/java/com/rockmusic/app/presentation/theme/AppearancePreferences.kt index 633048d..25a2939 100644 --- a/app/src/main/java/com/rockmusic/app/presentation/theme/AppearancePreferences.kt +++ b/app/src/main/java/com/rockmusic/app/presentation/theme/AppearancePreferences.kt @@ -36,18 +36,22 @@ val AppearanceSettingsSaver: Saver = listSaver( }, ) -class AppearancePreferences(context: Context) { - private val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() +open class AppearancePreferences(context: Context) { + private val masterKey by lazy { + MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + } - private val preferences = EncryptedSharedPreferences.create( - context, - PREFERENCES_NAME, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) + public open val preferences: android.content.SharedPreferences by lazy { + EncryptedSharedPreferences.create( + context, + PREFERENCES_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } fun load(): AppearanceSettings = AppearanceSettings( themeMode = runCatching { diff --git a/app/src/test/java/com/rockmusic/app/presentation/theme/AppearancePreferencesTest.kt b/app/src/test/java/com/rockmusic/app/presentation/theme/AppearancePreferencesTest.kt new file mode 100644 index 0000000..4c9fd27 --- /dev/null +++ b/app/src/test/java/com/rockmusic/app/presentation/theme/AppearancePreferencesTest.kt @@ -0,0 +1,130 @@ +package com.rockmusic.app.presentation.theme + +import android.content.Context +import android.content.SharedPreferences +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import androidx.compose.runtime.saveable.SaverScope + +class FakeAppearancePreferences(context: Context) : AppearancePreferences(context) { + private val store = mutableMapOf() + + override val preferences: SharedPreferences = object : SharedPreferences { + override fun getAll(): MutableMap = store.toMutableMap() + override fun getString(key: String, defValue: String?): String? = store[key] as? String ?: defValue + override fun getStringSet(key: String, defValues: MutableSet?): MutableSet? { @Suppress("UNCHECKED_CAST") return store[key] as? MutableSet ?: defValues } + override fun getInt(key: String, defValue: Int): Int = store[key] as? Int ?: defValue + override fun getLong(key: String, defValue: Long): Long = store[key] as? Long ?: defValue + override fun getFloat(key: String, defValue: Float): Float = store[key] as? Float ?: defValue + override fun getBoolean(key: String, defValue: Boolean): Boolean = store[key] as? Boolean ?: defValue + override fun contains(key: String): Boolean = store.containsKey(key) + + override fun edit(): SharedPreferences.Editor = object : SharedPreferences.Editor { + override fun putString(key: String, value: String?): SharedPreferences.Editor { + if (value == null) store.remove(key) else store[key] = value + return this + } + override fun putStringSet(key: String, values: MutableSet?): SharedPreferences.Editor { + if (values == null) store.remove(key) else store[key] = values + return this + } + override fun putInt(key: String, value: Int): SharedPreferences.Editor { store[key] = value; return this } + override fun putLong(key: String, value: Long): SharedPreferences.Editor { store[key] = value; return this } + override fun putFloat(key: String, value: Float): SharedPreferences.Editor { store[key] = value; return this } + override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor { store[key] = value; return this } + override fun remove(key: String): SharedPreferences.Editor { store.remove(key); return this } + override fun clear(): SharedPreferences.Editor { store.clear(); return this } + override fun commit(): Boolean = true + override fun apply() {} + } + + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {} + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {} + } +} + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class AppearancePreferencesTest { + + private lateinit var preferences: FakeAppearancePreferences + + @Before + fun setup() { + preferences = FakeAppearancePreferences(ApplicationProvider.getApplicationContext()) + } + + @Test + fun load_returnsDefaultSettings_whenNoPreferencesSaved() { + val settings = preferences.load() + + assertEquals(RockThemeMode.SYSTEM, settings.themeMode) + assertTrue(settings.useSystemColor) + assertTrue(settings.useBlurFrames) + } + + @Test + fun save_and_load_restoresSavedSettings() { + val customSettings = AppearanceSettings( + themeMode = RockThemeMode.DARK, + useSystemColor = false, + useBlurFrames = false, + ) + + preferences.save(customSettings) + + val loadedSettings = preferences.load() + + assertEquals(RockThemeMode.DARK, loadedSettings.themeMode) + assertFalse(loadedSettings.useSystemColor) + assertFalse(loadedSettings.useBlurFrames) + } + + @Test + fun load_handlesInvalidEnumSafely() { + preferences.preferences.edit().putString("theme_mode", "INVALID_ENUM_VALUE").commit() + val loadedSettings = preferences.load() + assertEquals(RockThemeMode.SYSTEM, loadedSettings.themeMode) + } + + @Test + fun saver_savesAndRestoresCorrectly() { + val customSettings = AppearanceSettings( + themeMode = RockThemeMode.LIGHT, + useSystemColor = false, + useBlurFrames = false, + ) + + val savedList = with(AppearanceSettingsSaver) { + SaverScope { true }.save(customSettings) + } as List<*> + + assertEquals(RockThemeMode.LIGHT.name, savedList[0]) + assertEquals(false, savedList[1]) + assertEquals(false, savedList[2]) + + val restoredSettings = AppearanceSettingsSaver.restore(savedList as Any) + + assertEquals(customSettings, restoredSettings) + } + + @Test + fun saver_handlesInvalidEnumSafely() { + val invalidList = listOf( + "INVALID_ENUM_VALUE", + true, + true, + ) + + val restoredSettings = AppearanceSettingsSaver.restore(invalidList) + + assertEquals(RockThemeMode.SYSTEM, restoredSettings?.themeMode) + } +} diff --git a/pr_description.md b/pr_description.md deleted file mode 100644 index 11bc1b5..0000000 --- a/pr_description.md +++ /dev/null @@ -1,3 +0,0 @@ -🎯 What: Added tests for `EncryptedSpotifyPkceRequestStore` to address the untested timing edge case in `consume` and ensure proper behavior when the request is expired, valid, or has a mismatched state. -📊 Coverage: Added coverage for `consume` (valid request, expired request, incorrect state) and `clearExpired` (valid request, expired request). -✨ Result: Improved reliability of the Spotify PKCE request handling by explicitly validating the `System.currentTimeMillis()` based expiration logic.