From c48380afa7b8e240fc05dfaeb1392251d69eb9f1 Mon Sep 17 00:00:00 2001 From: koojawon Date: Thu, 5 Mar 2026 10:20:48 +0900 Subject: [PATCH 1/4] refactor(security): add ValueEncryption with KeyStore AES-256-GCM KeyStore-backed value encryption for DataStore --- .../android/core/security/ValueEncryption.kt | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 app/src/main/java/com/codexbar/android/core/security/ValueEncryption.kt diff --git a/app/src/main/java/com/codexbar/android/core/security/ValueEncryption.kt b/app/src/main/java/com/codexbar/android/core/security/ValueEncryption.kt new file mode 100644 index 0000000..cd2ab88 --- /dev/null +++ b/app/src/main/java/com/codexbar/android/core/security/ValueEncryption.kt @@ -0,0 +1,72 @@ +package com.codexbar.android.core.security + +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 javax.inject.Inject +import javax.inject.Singleton + +/** + * KeyStore 기반 AES-256-GCM 값 암복호화. + * DataStore에 민감한 값을 저장할 때 사용합니다. + */ +@Singleton +class ValueEncryption @Inject constructor() { + private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + private val keyAlias = "codexbar_datastore_aes_key" + private val gcmTagLength = 128 + private val gcmIvLength = 12 + + private val secretKey: SecretKey by lazy { + if (keyStore.containsAlias(keyAlias)) { + (keyStore.getKey(keyAlias, null) as SecretKey) + } else { + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run { + init( + KeyGenParameterSpec.Builder( + keyAlias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build() + ) + generateKey() + } + } + } + + fun encrypt(plain: String): String { + if (plain.isEmpty()) return "" + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + val iv = cipher.iv + val encrypted = cipher.doFinal(plain.toByteArray(Charsets.UTF_8)) + val combined = ByteArray(iv.size + encrypted.size).apply { + System.arraycopy(iv, 0, this, 0, iv.size) + System.arraycopy(encrypted, 0, this, iv.size, encrypted.size) + } + return Base64.encodeToString(combined, Base64.NO_WRAP) + } + + fun decrypt(encoded: String): String? { + if (encoded.isEmpty()) return "" + return try { + val combined = Base64.decode(encoded, Base64.NO_WRAP) + if (combined.size <= gcmIvLength) return null + val iv = combined.copyOfRange(0, gcmIvLength) + val encrypted = combined.copyOfRange(gcmIvLength, combined.size) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(gcmTagLength, iv)) + String(cipher.doFinal(encrypted), Charsets.UTF_8) + } catch (_: Exception) { + null + } + } +} From c8454d93c3f148bd7495cc6da195ef2bc950a362 Mon Sep 17 00:00:00 2001 From: koojawon Date: Thu, 5 Mar 2026 10:21:07 +0900 Subject: [PATCH 2/4] refactor(security): migrate EncryptedPrefsManager to DataStore Replace EncryptedSharedPreferences with DataStore Preferences. Sensitive values encrypted via ValueEncryption; in-memory cache for hasCredential/getRefreshInterval/isNotificationsEnabled. --- .../core/security/EncryptedPrefsManager.kt | 225 +++++++++++------- .../com/codexbar/android/di/SecurityModule.kt | 6 +- 2 files changed, 146 insertions(+), 85 deletions(-) diff --git a/app/src/main/java/com/codexbar/android/core/security/EncryptedPrefsManager.kt b/app/src/main/java/com/codexbar/android/core/security/EncryptedPrefsManager.kt index a0129c7..586bb2f 100644 --- a/app/src/main/java/com/codexbar/android/core/security/EncryptedPrefsManager.kt +++ b/app/src/main/java/com/codexbar/android/core/security/EncryptedPrefsManager.kt @@ -1,78 +1,119 @@ package com.codexbar.android.core.security import android.content.Context -import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKeys +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore import com.codexbar.android.core.domain.model.AiService import com.codexbar.android.core.domain.model.Credential import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import java.time.Instant +import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import javax.inject.Singleton +private val Context.secureDataStore: DataStore by preferencesDataStore( + name = "codexbar_secure_prefs" +) + @Singleton class EncryptedPrefsManager @Inject constructor( - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, + private val valueEncryption: ValueEncryption ) { - private val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) - - private val prefs: SharedPreferences by lazy { - EncryptedSharedPreferences.create( - "codexbar_secure_prefs", - masterKeyAlias, - context, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) + private val dataStore = context.secureDataStore + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private data class Cache( + val credentialServices: Set = emptySet(), + val refreshIntervalMinutes: Long = 30L, + val notificationsEnabled: Boolean = true + ) + + private val cache = AtomicReference(null) + + private suspend fun ensureCache() { + if (cache.get() != null) return + val prefs = dataStore.data.first() + val services = AiService.entries.filter { service -> + prefs[stringPreferencesKey("${service.name}_access_token")] != null + }.toSet() + val interval = prefs[longPreferencesKey("refresh_interval_minutes")] ?: 30L + val notifications = prefs[booleanPreferencesKey("notifications_enabled")] ?: true + cache.set(Cache(services, interval, notifications)) } - fun saveCredential(service: AiService, credential: Credential) { - val editor = prefs.edit() - val prefix = service.name + private fun getEncrypted(key: String, prefs: Preferences): String? { + val raw = prefs[stringPreferencesKey(key)] ?: return null + return valueEncryption.decrypt(raw) ?: raw + } - editor.putString("${prefix}_access_token", credential.accessToken) - editor.putString("${prefix}_refresh_token", credential.refreshToken) + suspend fun saveCredential(service: AiService, credential: Credential) { + dataStore.edit { prefs -> + val prefix = service.name + prefs[stringPreferencesKey("${prefix}_access_token")] = + valueEncryption.encrypt(credential.accessToken) + credential.refreshToken?.let { + prefs[stringPreferencesKey("${prefix}_refresh_token")] = valueEncryption.encrypt(it) + } - when (credential) { - is Credential.ClaudeCredential -> { - credential.expiresAt?.let { - editor.putLong("${prefix}_expires_at", it.epochSecond) + when (credential) { + is Credential.ClaudeCredential -> { + credential.expiresAt?.let { + prefs[longPreferencesKey("${prefix}_expires_at")] = it.epochSecond + } + credential.scopes?.let { + prefs[stringPreferencesKey("${prefix}_scopes")] = valueEncryption.encrypt(it) + } + credential.rateLimitTier?.let { + prefs[stringPreferencesKey("${prefix}_rate_limit_tier")] = + valueEncryption.encrypt(it) + } } - credential.scopes?.let { - editor.putString("${prefix}_scopes", it) + is Credential.CodexCredential -> { + credential.accountId?.let { + prefs[stringPreferencesKey("${prefix}_account_id")] = + valueEncryption.encrypt(it) + } } - credential.rateLimitTier?.let { - editor.putString("${prefix}_rate_limit_tier", it) + is Credential.GeminiCredential -> { + prefs[longPreferencesKey("${prefix}_expires_at_ms")] = credential.expiresAtMs + prefs[stringPreferencesKey("${prefix}_oauth_client_id")] = + valueEncryption.encrypt(credential.oauthClientId) + prefs[stringPreferencesKey("${prefix}_oauth_client_secret")] = + valueEncryption.encrypt(credential.oauthClientSecret) } } - is Credential.CodexCredential -> { - credential.accountId?.let { - editor.putString("${prefix}_account_id", it) - } - } - is Credential.GeminiCredential -> { - editor.putLong("${prefix}_expires_at_ms", credential.expiresAtMs) - editor.putString("${prefix}_oauth_client_id", credential.oauthClientId) - editor.putString("${prefix}_oauth_client_secret", credential.oauthClientSecret) - } } - - editor.apply() // atomic write via SharedPreferences commit semantics + cache.set( + cache.get()?.copy(credentialServices = cache.get()!!.credentialServices + service) + ?: Cache(credentialServices = setOf(service)) + ) } - fun loadCredential(service: AiService): Credential? { + suspend fun loadCredential(service: AiService): Credential? { + ensureCache() + val prefs = dataStore.data.first() val prefix = service.name - val accessToken = prefs.getString("${prefix}_access_token", null) ?: return null + val accessToken = getEncrypted("${prefix}_access_token", prefs) ?: return null return when (service) { AiService.CLAUDE -> { - val refreshToken = prefs.getString("${prefix}_refresh_token", null) - val expiresAt = prefs.getLong("${prefix}_expires_at", -1L) - .takeIf { it > 0 } + val refreshToken = getEncrypted("${prefix}_refresh_token", prefs) + val expiresAt = prefs[longPreferencesKey("${prefix}_expires_at")] + ?.takeIf { it > 0 } ?.let { Instant.ofEpochSecond(it) } - val scopes = prefs.getString("${prefix}_scopes", null) - val rateLimitTier = prefs.getString("${prefix}_rate_limit_tier", null) + val scopes = getEncrypted("${prefix}_scopes", prefs) + val rateLimitTier = getEncrypted("${prefix}_rate_limit_tier", prefs) Credential.ClaudeCredential( accessToken = accessToken, refreshToken = refreshToken, @@ -82,8 +123,8 @@ class EncryptedPrefsManager @Inject constructor( ) } AiService.CODEX -> { - val refreshToken = prefs.getString("${prefix}_refresh_token", null) ?: return null - val accountId = prefs.getString("${prefix}_account_id", null) + val refreshToken = getEncrypted("${prefix}_refresh_token", prefs) ?: return null + val accountId = getEncrypted("${prefix}_account_id", prefs) Credential.CodexCredential( accessToken = accessToken, refreshToken = refreshToken, @@ -91,11 +132,11 @@ class EncryptedPrefsManager @Inject constructor( ) } AiService.GEMINI -> { - val refreshToken = prefs.getString("${prefix}_refresh_token", null) ?: return null - val expiresAtMs = prefs.getLong("${prefix}_expires_at_ms", -1L) - .takeIf { it > 0 } ?: return null - val clientId = prefs.getString("${prefix}_oauth_client_id", null) ?: return null - val clientSecret = prefs.getString("${prefix}_oauth_client_secret", null) ?: return null + val refreshToken = getEncrypted("${prefix}_refresh_token", prefs) ?: return null + val expiresAtMs = prefs[longPreferencesKey("${prefix}_expires_at_ms")] + ?.takeIf { it > 0 } ?: return null + val clientId = getEncrypted("${prefix}_oauth_client_id", prefs) ?: return null + val clientSecret = getEncrypted("${prefix}_oauth_client_secret", prefs) ?: return null Credential.GeminiCredential( accessToken = accessToken, refreshToken = refreshToken, @@ -107,60 +148,78 @@ class EncryptedPrefsManager @Inject constructor( } } - fun deleteCredential(service: AiService) { - val prefix = service.name - val editor = prefs.edit() - - val keys = prefs.all.keys.filter { it.startsWith(prefix) } - keys.forEach { editor.remove(it) } - - editor.apply() + suspend fun deleteCredential(service: AiService) { + dataStore.edit { prefs -> + val toRemove = prefs.asMap().keys.filter { it.name.startsWith(service.name) } + toRemove.forEach { prefs.remove(it) } + } + cache.set( + cache.get()?.copy(credentialServices = cache.get()!!.credentialServices - service) + ?: Cache() + ) } - fun deleteAllCredentials() { - prefs.edit().clear().apply() + suspend fun deleteAllCredentials() { + dataStore.edit { it.clear() } + cache.set(Cache()) } fun hasCredential(service: AiService): Boolean { - return prefs.getString("${service.name}_access_token", null) != null + val c = cache.get() + if (c != null) return service in c.credentialServices + scope.launch { ensureCache() } + return false } fun getRefreshInterval(): Long { - return prefs.getLong("refresh_interval_minutes", 30L) + val c = cache.get() + if (c != null) return c.refreshIntervalMinutes + scope.launch { ensureCache() } + return 30L } - fun setRefreshInterval(minutes: Long) { - prefs.edit().putLong("refresh_interval_minutes", minutes).apply() + suspend fun setRefreshInterval(minutes: Long) { + dataStore.edit { + it[longPreferencesKey("refresh_interval_minutes")] = minutes + } + cache.set(cache.get()?.copy(refreshIntervalMinutes = minutes) ?: Cache(refreshIntervalMinutes = minutes)) } fun isNotificationsEnabled(): Boolean { - return prefs.getBoolean("notifications_enabled", true) + val c = cache.get() + if (c != null) return c.notificationsEnabled + scope.launch { ensureCache() } + return true } - fun setNotificationsEnabled(enabled: Boolean) { - prefs.edit().putBoolean("notifications_enabled", enabled).apply() + suspend fun setNotificationsEnabled(enabled: Boolean) { + dataStore.edit { + it[booleanPreferencesKey("notifications_enabled")] = enabled + } + cache.set(cache.get()?.copy(notificationsEnabled = enabled) ?: Cache(notificationsEnabled = enabled)) } - fun saveResetTimes(service: AiService, windows: List>) { - val editor = prefs.edit() - windows.forEach { (label, resetsAt) -> - val key = "${service.name}_${label}_resets_at" - if (resetsAt != null) { - editor.putLong(key, resetsAt.epochSecond) - } else { - editor.remove(key) + suspend fun saveResetTimes(service: AiService, windows: List>) { + dataStore.edit { prefs -> + windows.forEach { (label, resetsAt) -> + val key = longPreferencesKey("${service.name}_${label}_resets_at") + if (resetsAt != null) { + prefs[key] = resetsAt.epochSecond + } else { + prefs.remove(key) + } } } - editor.apply() } - fun loadResetTimes(service: AiService): Map { + suspend fun loadResetTimes(service: AiService): Map { + val prefs = dataStore.data.first() val prefix = "${service.name}_" val suffix = "_resets_at" - return prefs.all - .filter { it.key.startsWith(prefix) && it.key.endsWith(suffix) } + return prefs.asMap() + .filter { (key, _) -> key.name.startsWith(prefix) && key.name.endsWith(suffix) } .mapNotNull { (key, value) -> - val label = key.removePrefix(prefix).removeSuffix(suffix) + val label = key.name.removePrefix(prefix).removeSuffix(suffix) val epochSecond = (value as? Long)?.takeIf { it > 0 } ?: return@mapNotNull null label to Instant.ofEpochSecond(epochSecond) } diff --git a/app/src/main/java/com/codexbar/android/di/SecurityModule.kt b/app/src/main/java/com/codexbar/android/di/SecurityModule.kt index 1afc050..61a029f 100644 --- a/app/src/main/java/com/codexbar/android/di/SecurityModule.kt +++ b/app/src/main/java/com/codexbar/android/di/SecurityModule.kt @@ -2,6 +2,7 @@ package com.codexbar.android.di import android.content.Context import com.codexbar.android.core.security.EncryptedPrefsManager +import com.codexbar.android.core.security.ValueEncryption import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -16,6 +17,7 @@ object SecurityModule { @Provides @Singleton fun provideEncryptedPrefsManager( - @ApplicationContext context: Context - ): EncryptedPrefsManager = EncryptedPrefsManager(context) + @ApplicationContext context: Context, + valueEncryption: ValueEncryption + ): EncryptedPrefsManager = EncryptedPrefsManager(context, valueEncryption) } From 6f39abb7d6a7992c1f2431ef7a384648d37d0c8d Mon Sep 17 00:00:00 2001 From: koojawon Date: Thu, 5 Mar 2026 10:21:22 +0900 Subject: [PATCH 3/4] refactor: use suspend prefs API in ViewModel and Worker SettingsViewModel: launch for load/save/settings; QuotaRefreshWorker: make checkForResets suspend for loadResetTimes/saveResetTimes. --- .../core/workmanager/QuotaRefreshWorker.kt | 2 +- .../feature/settings/SettingsViewModel.kt | 26 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/codexbar/android/core/workmanager/QuotaRefreshWorker.kt b/app/src/main/java/com/codexbar/android/core/workmanager/QuotaRefreshWorker.kt index ad24411..d1dda18 100644 --- a/app/src/main/java/com/codexbar/android/core/workmanager/QuotaRefreshWorker.kt +++ b/app/src/main/java/com/codexbar/android/core/workmanager/QuotaRefreshWorker.kt @@ -79,7 +79,7 @@ class QuotaRefreshWorker @AssistedInject constructor( } } - private fun checkForResets(quotas: List) { + private suspend fun checkForResets(quotas: List) { val now = Instant.now() for (quota in quotas) { val previousResetTimes = prefsManager.loadResetTimes(quota.service) diff --git a/app/src/main/java/com/codexbar/android/feature/settings/SettingsViewModel.kt b/app/src/main/java/com/codexbar/android/feature/settings/SettingsViewModel.kt index d6773f5..6565c04 100644 --- a/app/src/main/java/com/codexbar/android/feature/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/codexbar/android/feature/settings/SettingsViewModel.kt @@ -40,7 +40,7 @@ class SettingsViewModel @Inject constructor( private val pendingChanges = MutableStateFlow?>(null) init { - loadSavedCredentials() + viewModelScope.launch { loadSavedCredentials() } _uiState.update { it.copy( refreshIntervalMinutes = prefsManager.getRefreshInterval(), @@ -50,7 +50,7 @@ class SettingsViewModel @Inject constructor( observePendingChanges() } - private fun loadSavedCredentials() { + private suspend fun loadSavedCredentials() { for (service in AiService.entries) { val credential = prefsManager.loadCredential(service) ?: continue val state = when (credential) { @@ -110,7 +110,7 @@ class SettingsViewModel @Inject constructor( pendingChanges.value = service to System.currentTimeMillis().toString() } - private fun saveCredential(service: AiService) { + private suspend fun saveCredential(service: AiService) { val state = _uiState.value.serviceStates[service] ?: return if (state.accessToken.isBlank()) return @@ -149,9 +149,6 @@ class SettingsViewModel @Inject constructor( AiService.GEMINI -> geminiRepository } - // Ensure saved before validation - saveCredential(service) - _uiState.update { state -> val current = state.serviceStates[service] ?: ServiceCredentialState() state.copy( @@ -160,6 +157,7 @@ class SettingsViewModel @Inject constructor( } viewModelScope.launch { + saveCredential(service) val result = repo.validateCredential() val validationResult = when (result) { is Result.Success -> ValidationResult.Success @@ -179,12 +177,12 @@ class SettingsViewModel @Inject constructor( } fun setRefreshInterval(minutes: Long) { - prefsManager.setRefreshInterval(minutes) + viewModelScope.launch { prefsManager.setRefreshInterval(minutes) } _uiState.update { it.copy(refreshIntervalMinutes = minutes) } } fun setNotificationsEnabled(enabled: Boolean) { - prefsManager.setNotificationsEnabled(enabled) + viewModelScope.launch { prefsManager.setNotificationsEnabled(enabled) } _uiState.update { it.copy(notificationsEnabled = enabled) } } @@ -197,11 +195,13 @@ class SettingsViewModel @Inject constructor( } fun deleteAllCredentials() { - prefsManager.deleteAllCredentials() - _uiState.update { - SettingsUiState( - refreshIntervalMinutes = it.refreshIntervalMinutes - ) + viewModelScope.launch { + prefsManager.deleteAllCredentials() + _uiState.update { + SettingsUiState( + refreshIntervalMinutes = it.refreshIntervalMinutes + ) + } } } From 266cdfb7880581edaf501207911efcf24810bb8d Mon Sep 17 00:00:00 2001 From: koojawon Date: Thu, 5 Mar 2026 10:21:52 +0900 Subject: [PATCH 4/4] chore: remove security-crypto, fix DataStore tests and README Drop EncryptedSharedPreferences dependency. Stub suspend loadCredential in tests with runBlocking; update README security line. --- README.md | 2 +- app/build.gradle.kts | 3 --- .../com/codexbar/android/core/data/ClaudeRepositoryImplTest.kt | 3 ++- .../com/codexbar/android/core/data/CodexRepositoryImplTest.kt | 3 ++- .../com/codexbar/android/core/data/GeminiRepositoryImplTest.kt | 3 ++- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1927118..dde8ee6 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ APK output: `app/build/outputs/apk/debug/app-debug.apk` - Kotlin 2.1.0, Jetpack Compose, Material 3 - Hilt (DI), Retrofit2 + OkHttp (networking) -- WorkManager (background sync), EncryptedSharedPreferences (security) +- WorkManager (background sync), DataStore + KeyStore value encryption (security) - KSP, kotlinx.serialization ## Acknowledgments diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9527a4b..dfeb726 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -107,9 +107,6 @@ dependencies { // WorkManager implementation(libs.work.runtime.ktx) - // Security - implementation(libs.security.crypto) - // Accompanist implementation(libs.accompanist.permissions) diff --git a/app/src/test/java/com/codexbar/android/core/data/ClaudeRepositoryImplTest.kt b/app/src/test/java/com/codexbar/android/core/data/ClaudeRepositoryImplTest.kt index 06770ef..f23bd8b 100644 --- a/app/src/test/java/com/codexbar/android/core/data/ClaudeRepositoryImplTest.kt +++ b/app/src/test/java/com/codexbar/android/core/data/ClaudeRepositoryImplTest.kt @@ -18,6 +18,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import okhttp3.MediaType.Companion.toMediaType import org.mockito.Mockito.mock @@ -61,7 +62,7 @@ class ClaudeRepositoryImplTest { .create(ClaudeTokenRefreshService::class.java) prefsManager = mock(EncryptedPrefsManager::class.java) - `when`(prefsManager.loadCredential(AiService.CLAUDE)).thenReturn(testCredential) + runBlocking { `when`(prefsManager.loadCredential(AiService.CLAUDE)).thenReturn(testCredential) } repository = ClaudeRepositoryImpl(apiService, tokenRefreshService, prefsManager) } diff --git a/app/src/test/java/com/codexbar/android/core/data/CodexRepositoryImplTest.kt b/app/src/test/java/com/codexbar/android/core/data/CodexRepositoryImplTest.kt index 5e6203e..29ef5be 100644 --- a/app/src/test/java/com/codexbar/android/core/data/CodexRepositoryImplTest.kt +++ b/app/src/test/java/com/codexbar/android/core/data/CodexRepositoryImplTest.kt @@ -18,6 +18,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.mockito.Mockito.mock import org.mockito.Mockito.`when` @@ -61,7 +62,7 @@ class CodexRepositoryImplTest { .create(CodexTokenRefreshService::class.java) prefsManager = mock(EncryptedPrefsManager::class.java) - `when`(prefsManager.loadCredential(AiService.CODEX)).thenReturn(testCredential) + runBlocking { `when`(prefsManager.loadCredential(AiService.CODEX)).thenReturn(testCredential) } repository = CodexRepositoryImpl(apiService, tokenRefreshService, prefsManager) } diff --git a/app/src/test/java/com/codexbar/android/core/data/GeminiRepositoryImplTest.kt b/app/src/test/java/com/codexbar/android/core/data/GeminiRepositoryImplTest.kt index 32ff7ed..6ee657d 100644 --- a/app/src/test/java/com/codexbar/android/core/data/GeminiRepositoryImplTest.kt +++ b/app/src/test/java/com/codexbar/android/core/data/GeminiRepositoryImplTest.kt @@ -18,6 +18,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.mockito.Mockito.mock import org.mockito.Mockito.`when` @@ -63,7 +64,7 @@ class GeminiRepositoryImplTest { .create(GeminiTokenRefreshService::class.java) prefsManager = mock(EncryptedPrefsManager::class.java) - `when`(prefsManager.loadCredential(AiService.GEMINI)).thenReturn(testCredential) + runBlocking { `when`(prefsManager.loadCredential(AiService.GEMINI)).thenReturn(testCredential) } repository = GeminiRepositoryImpl(apiService, tokenRefreshService, prefsManager) }