From d0a484f7ddd378eaf5fc497272a017fb3f90b534 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Sun, 3 Aug 2025 02:35:52 +0900 Subject: [PATCH 01/11] chore: add project structure tree --- .../java/com/debatetimer/app/structure.txt | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 app/src/main/java/com/debatetimer/app/structure.txt diff --git a/app/src/main/java/com/debatetimer/app/structure.txt b/app/src/main/java/com/debatetimer/app/structure.txt new file mode 100644 index 0000000..6f3648c --- /dev/null +++ b/app/src/main/java/com/debatetimer/app/structure.txt @@ -0,0 +1,40 @@ +└── app/ + └── src/ + └── main/ + └── java/ + └── com/yourcompany/yourapp/ + ├── data/ + │ ├── remote/ + │ │ ├── api/ + │ │ │ └── YourApiService.kt // Retrofit interface for API endpoints + │ │ ├── client/ + │ │ │ └── YourApiClient.kt // Custom Retrofit client and setup + │ │ └── model/ + │ │ │ └── LoginResponse.kt // API-specific data classes (e.g., JSON response) + │ └── repository/ + │ │ └── YourRepository.kt // Abstracts data sources (API, local cache) + │ │ └── YourRepositoryImpl.kt // Implementation of the repository + │ └── model/ + │ │ └── User.kt // Domain-specific data classes for the app + │ + ├── domain/ + │ └── usecase/ + │ └── LoginUseCase.kt // Optional: for complex business logic + │ + ├── common/ + │ └── UiState.kt // The sealed class you provided + │ + ├── ui/ + │ ├── login/ + │ │ ├── LoginScreen.kt // The Composable for the login page + │ │ ├── LoginViewModel.kt // ViewModel for the login page + │ │ └── component/ + │ │ │ └── GoogleLoginButton.kt // UI components specific to the login page + │ └── home/ + │ ├── HomeScreen.kt + │ └── HomeViewModel.kt + │ + └── di/ + ├── AppModule.kt // Hilt module for app-level dependencies + ├── DataModule.kt // Hilt module for data layer dependencies + └── ViewModelModule.kt // Hilt module for ViewModel dependencies \ No newline at end of file From 2e3efb021f04b8fea5687609288c2ff367491e5b Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Sun, 3 Aug 2025 02:36:09 +0900 Subject: [PATCH 02/11] feat: add TokenBundle class --- .../java/com/debatetimer/app/data/model/TokenBundle.kt | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 app/src/main/java/com/debatetimer/app/data/model/TokenBundle.kt diff --git a/app/src/main/java/com/debatetimer/app/data/model/TokenBundle.kt b/app/src/main/java/com/debatetimer/app/data/model/TokenBundle.kt new file mode 100644 index 0000000..6e90fba --- /dev/null +++ b/app/src/main/java/com/debatetimer/app/data/model/TokenBundle.kt @@ -0,0 +1,9 @@ +package com.debatetimer.app.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class TokenBundle( + val accessToken: String, + val refreshToken: String, +) \ No newline at end of file From 01d293df9015a298792a388ac6e94e3e19760bc1 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Sun, 3 Aug 2025 02:38:44 +0900 Subject: [PATCH 03/11] chore: add Protobuf to dependencies --- app/build.gradle.kts | 22 ++++++++++++++++++++++ build.gradle.kts | 1 + gradle/libs.versions.toml | 8 ++++++++ 3 files changed, 31 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cc8948c..fca81d8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,6 +9,7 @@ plugins { // New alias(libs.plugins.kotlin.ksp) alias(libs.plugins.dagger.hilt.android) + alias(libs.plugins.google.protobuf) } android { @@ -56,6 +57,23 @@ kotlin { } } + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:4.31.1" + } + + generateProtoTasks { + all().configureEach { + builtins { + maybeCreate("java").apply { + option("lite") // Use lite runtime for Android + } + } + } + } +} + dependencies { // Default implementation(libs.androidx.core.ktx) @@ -83,5 +101,9 @@ dependencies { implementation(libs.squareup.retrofit2) implementation(libs.squareup.retrofit2.converter.gson) implementation(libs.google.gson) + implementation(libs.protobuf.kotlin.lite) + implementation(libs.datastore.core) + implementation(libs.datastore.preferences) + androidTestImplementation(libs.dagger.hilt.android.testing) lintChecks(libs.slack.lint) } \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 7177efd..b217889 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,4 +8,5 @@ plugins { // New alias(libs.plugins.kotlin.ksp) apply false alias(libs.plugins.dagger.hilt.android) apply false + alias(libs.plugins.google.protobuf) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1f60695..48c3f74 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,9 @@ androidxHilt = "1.2.0" retrofit2 = "3.0.0" gson = "2.13.1" lint = "1.4.2" +protobufKotlin = "4.31.1" +protobuf = "0.9.5" +datastore = "1.1.7" [libraries] # Default @@ -42,10 +45,14 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHilt" } dagger-hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } dagger-hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +dagger-hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } squareup-retrofit2 = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit2" } squareup-retrofit2-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit2" } google-gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } slack-lint = { group = "com.slack.lint.compose", name = "compose-lint-checks", version.ref = "lint" } +protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobufKotlin" } +datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastore" } +datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } [plugins] # Default @@ -55,4 +62,5 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko # New kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } dagger-hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +google-protobuf = { id = "com.google.protobuf", version.ref = "protobuf" } From b3a790228c206f942c92f30211dc674ea70d6f41 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Sun, 3 Aug 2025 02:39:06 +0900 Subject: [PATCH 04/11] feat: define proto structure for token bundle --- app/src/main/proto/encrypted_tokens.proto | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 app/src/main/proto/encrypted_tokens.proto diff --git a/app/src/main/proto/encrypted_tokens.proto b/app/src/main/proto/encrypted_tokens.proto new file mode 100644 index 0000000..7187a16 --- /dev/null +++ b/app/src/main/proto/encrypted_tokens.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +option java_package = "com.debatetimer.app"; +option java_multiple_files = true; + +message EncryptedTokens { + bytes encrypted_token_bundle = 1; + bytes initialization_vector = 2; +} \ No newline at end of file From 0d63b14b986f65c82b88bf8a16c1f1f377f1fad8 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Sun, 3 Aug 2025 02:40:44 +0900 Subject: [PATCH 05/11] feat: add serializer for EncryptedTokens proto instance --- .../app/data/serializer/TokenSerializer.kt | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 app/src/main/java/com/debatetimer/app/data/serializer/TokenSerializer.kt diff --git a/app/src/main/java/com/debatetimer/app/data/serializer/TokenSerializer.kt b/app/src/main/java/com/debatetimer/app/data/serializer/TokenSerializer.kt new file mode 100644 index 0000000..403a3c8 --- /dev/null +++ b/app/src/main/java/com/debatetimer/app/data/serializer/TokenSerializer.kt @@ -0,0 +1,34 @@ +package com.debatetimer.app.data.serializer + +import android.content.Context +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.DataStore +import androidx.datastore.core.Serializer +import androidx.datastore.dataStore +import com.debatetimer.app.EncryptedTokens +import com.google.protobuf.InvalidProtocolBufferException +import java.io.InputStream +import java.io.OutputStream + +const val FILE_NAME = "encrypted_tokens.pb" + +object TokenSerializer : Serializer { + override val defaultValue: EncryptedTokens = EncryptedTokens.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): EncryptedTokens { + try { + return EncryptedTokens.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto.", exception) + } + } + + override suspend fun writeTo(t: EncryptedTokens, output: OutputStream) { + t.writeTo(output) + } +} + +val Context.tokenDataStore: DataStore by dataStore( + fileName = FILE_NAME, + serializer = TokenSerializer +) \ No newline at end of file From f7c3b5d6547b4fd11f076d45f6fe4bcdc8aa1018 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Sun, 3 Aug 2025 02:41:34 +0900 Subject: [PATCH 06/11] feat: add CryptoManager that encrypts/decrypts tokens --- .../app/util/crypto/CryptoManager.kt | 6 ++ .../app/util/crypto/CryptoManagerImpl.kt | 80 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 app/src/main/java/com/debatetimer/app/util/crypto/CryptoManager.kt create mode 100644 app/src/main/java/com/debatetimer/app/util/crypto/CryptoManagerImpl.kt diff --git a/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManager.kt b/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManager.kt new file mode 100644 index 0000000..c80d682 --- /dev/null +++ b/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManager.kt @@ -0,0 +1,6 @@ +package com.debatetimer.app.util.crypto + +interface CryptoManager { + fun encrypt(plainText: String): Pair + fun decrypt(encryptedText: ByteArray, iv: ByteArray): String +} \ No newline at end of file diff --git a/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManagerImpl.kt b/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManagerImpl.kt new file mode 100644 index 0000000..7e8d596 --- /dev/null +++ b/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManagerImpl.kt @@ -0,0 +1,80 @@ +package com.debatetimer.app.util.crypto + +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 + +class CryptoManagerImpl @Inject constructor() : CryptoManager { + companion object { + // Define the constants for the encryption algorithm and key alias + private const val KEY_ALIAS = "debate_timer_oauth_key" + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES + private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM + private const val PADDING = KeyProperties.ENCRYPTION_PADDING_NONE + private const val KEY_SIZE = 256 + private const val TRANSFORMATION = "AES/GCM/NoPadding" + } + + // Keystore instance + private val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { + load(null) + } + + /** + * Android Keystore에 저장된 키를 가져오거나, 없다면 새로 생성합니다. + */ + private fun getOrCreateKey(): SecretKey { + // 1. Try to get the key + val existingKey = keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry + + // 2. If key exists, return it + if (existingKey != null) { + return existingKey.secretKey + } + + // 3. Else, prepare key generation spec + val paramsBuilder = KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + paramsBuilder.apply { + setBlockModes(BLOCK_MODE) + setEncryptionPaddings(PADDING) + setKeySize(KEY_SIZE) + } + + // 4. Generate key with the spec + val keyGenerator = KeyGenerator.getInstance(ALGORITHM, ANDROID_KEYSTORE) + keyGenerator.init(paramsBuilder.build()) + return keyGenerator.generateKey() + } + + override fun encrypt(plainText: String): Pair { + // 1. Load cipher instance + val cipher = Cipher.getInstance(TRANSFORMATION) + + // 2. Init cipher with encryption mode + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()) + + // 3. Return encrypted text and IV + return Pair(cipher.doFinal(plainText.toByteArray()), cipher.iv) + } + + override fun decrypt(encryptedText: ByteArray, iv: ByteArray): String { + // 1. Load cipher instance and prepare variables + val cipher = Cipher.getInstance(TRANSFORMATION) + val spec = GCMParameterSpec(128, iv) + + // 2. Init cipher with decryption mode + cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec) + + // 3. Return decrypted text + return String(cipher.doFinal(encryptedText), Charsets.UTF_8) + } +} \ No newline at end of file From 78cc0a6bc02ef27d3a6120113a6ab5e60ad1232b Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Sun, 3 Aug 2025 02:42:07 +0900 Subject: [PATCH 07/11] feat: add TokenRepo that manages token with Protobuf --- .../debatetimer/app/data/repo/TokenRepo.kt | 8 +++ .../app/data/repo/TokenRepoImpl.kt | 68 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 app/src/main/java/com/debatetimer/app/data/repo/TokenRepo.kt create mode 100644 app/src/main/java/com/debatetimer/app/data/repo/TokenRepoImpl.kt diff --git a/app/src/main/java/com/debatetimer/app/data/repo/TokenRepo.kt b/app/src/main/java/com/debatetimer/app/data/repo/TokenRepo.kt new file mode 100644 index 0000000..c00b579 --- /dev/null +++ b/app/src/main/java/com/debatetimer/app/data/repo/TokenRepo.kt @@ -0,0 +1,8 @@ +package com.debatetimer.app.data.repo + +import com.debatetimer.app.data.model.TokenBundle + +interface TokenRepo { + suspend fun getTokens(): Result + suspend fun saveTokens(bundle: TokenBundle): Result +} \ No newline at end of file diff --git a/app/src/main/java/com/debatetimer/app/data/repo/TokenRepoImpl.kt b/app/src/main/java/com/debatetimer/app/data/repo/TokenRepoImpl.kt new file mode 100644 index 0000000..36ce3ca --- /dev/null +++ b/app/src/main/java/com/debatetimer/app/data/repo/TokenRepoImpl.kt @@ -0,0 +1,68 @@ +package com.debatetimer.app.data.repo + +import android.content.Context +import androidx.datastore.core.DataStore +import com.debatetimer.app.EncryptedTokens +import com.debatetimer.app.data.model.TokenBundle +import com.debatetimer.app.data.serializer.tokenDataStore +import com.debatetimer.app.util.crypto.CryptoManager +import com.google.gson.Gson +import com.google.protobuf.kotlin.toByteString +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +class TokenRepoImpl @Inject constructor( + @ApplicationContext context: Context, + private val cryptoManager: CryptoManager +) : + TokenRepo { + private val tokenDataStore: DataStore = context.tokenDataStore + + private suspend fun getBundle(): TokenBundle? { + try { + val encryptedTokens = tokenDataStore.data.first() + val ciphertext = encryptedTokens.encryptedTokenBundle.toByteArray() + val iv = encryptedTokens.initializationVector.toByteArray() + val decryptedTokens = cryptoManager.decrypt(ciphertext, iv) + + return Gson().fromJson(decryptedTokens, TokenBundle::class.java) + } catch (_: NoSuchElementException) { + return null + } catch (e: Exception) { + throw e + } + } + + override suspend fun getTokens(): Result { + try { + val bundle = getBundle() + + return if (bundle != null) { + Result.success(bundle) + } else { + Result.failure(NoSuchElementException()) + } + } catch (e: Exception) { + return Result.failure(e) + } + } + + override suspend fun saveTokens(bundle: TokenBundle): Result { + try { + tokenDataStore.updateData { current -> + val serializedBundle = Gson().toJson(bundle) + val encryptionOutput = cryptoManager.encrypt(serializedBundle) + + current.toBuilder() + .setEncryptedTokenBundle(encryptionOutput.first.toByteString()) + .setInitializationVector(encryptionOutput.second.toByteString()) + .build() + } + + return Result.success(true) + } catch (e: Exception) { + return Result.failure(e) + } + } +} \ No newline at end of file From e4446f51be0fafa40314ab285f0eab84510f3710 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Sun, 3 Aug 2025 02:42:28 +0900 Subject: [PATCH 08/11] feat: add Hilt module to use DI --- .../java/com/debatetimer/app/di/DataModule.kt | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 app/src/main/java/com/debatetimer/app/di/DataModule.kt diff --git a/app/src/main/java/com/debatetimer/app/di/DataModule.kt b/app/src/main/java/com/debatetimer/app/di/DataModule.kt new file mode 100644 index 0000000..fc01e97 --- /dev/null +++ b/app/src/main/java/com/debatetimer/app/di/DataModule.kt @@ -0,0 +1,27 @@ +package com.debatetimer.app.di + +import com.debatetimer.app.data.repo.TokenRepo +import com.debatetimer.app.data.repo.TokenRepoImpl +import com.debatetimer.app.util.crypto.CryptoManager +import com.debatetimer.app.util.crypto.CryptoManagerImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class DataModule { + @Binds + @Singleton + abstract fun bindCryptoManager( + cryptoManagerImpl: CryptoManagerImpl + ): CryptoManager + + @Binds + @Singleton + abstract fun bindTokenRepo( + tokenRepoImpl: TokenRepoImpl + ): TokenRepo +} \ No newline at end of file From 11e2636c67f806a04ec526b0433f4edceb7d89b2 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Sun, 3 Aug 2025 02:42:56 +0900 Subject: [PATCH 09/11] test: add test codes --- app/build.gradle.kts | 2 +- .../com/debatetimer/app/HiltTestRunner.kt | 20 ++++++++ .../app/data/repo/TokenRepoImplHiltTest.kt | 47 +++++++++++++++++++ .../com/debatetimer/app/di/FakeDataModule.kt | 33 +++++++++++++ .../app/util/crypto/CryptoManagerImplTest.kt | 35 ++++++++++++++ .../app/util/crypto/FakeCryptoManagerImpl.kt | 18 +++++++ 6 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 app/src/androidTest/java/com/debatetimer/app/HiltTestRunner.kt create mode 100644 app/src/androidTest/java/com/debatetimer/app/data/repo/TokenRepoImplHiltTest.kt create mode 100644 app/src/androidTest/java/com/debatetimer/app/di/FakeDataModule.kt create mode 100644 app/src/androidTest/java/com/debatetimer/app/util/crypto/CryptoManagerImplTest.kt create mode 100644 app/src/androidTest/java/com/debatetimer/app/util/crypto/FakeCryptoManagerImpl.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fca81d8..949fdbf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,7 +23,7 @@ android { versionCode = 1 versionName = "1.0" - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "com.debatetimer.app.HiltTestRunner" } buildTypes { diff --git a/app/src/androidTest/java/com/debatetimer/app/HiltTestRunner.kt b/app/src/androidTest/java/com/debatetimer/app/HiltTestRunner.kt new file mode 100644 index 0000000..bd16331 --- /dev/null +++ b/app/src/androidTest/java/com/debatetimer/app/HiltTestRunner.kt @@ -0,0 +1,20 @@ +package com.debatetimer.app + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +// 안드로이드의 기본 테스트 러너를 상속받는 커스텀 러너 +class HiltTestRunner : AndroidJUnitRunner() { + + // 테스트 프로세스를 위한 새로운 Application 객체를 만드는 함수를 오버라이드 + override fun newApplication( + cl: ClassLoader?, + className: String?, + context: Context? + ): Application { + // 기본 Application 클래스 대신, Hilt 테스트 전용 Application 클래스를 사용하도록 강제 + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/debatetimer/app/data/repo/TokenRepoImplHiltTest.kt b/app/src/androidTest/java/com/debatetimer/app/data/repo/TokenRepoImplHiltTest.kt new file mode 100644 index 0000000..8dcc7b5 --- /dev/null +++ b/app/src/androidTest/java/com/debatetimer/app/data/repo/TokenRepoImplHiltTest.kt @@ -0,0 +1,47 @@ +package com.debatetimer.app.data.repo + +import com.debatetimer.app.data.model.TokenBundle +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.runBlocking +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class TokenRepoImplHiltTest { + + @get:Rule + var hiltRule = HiltAndroidRule(this) + + // Hilt가 FakeDataModule을 기반으로 의존성을 주입해줍니다. + // 이 tokenRepo는 내부에 FakeCryptoManagerImpl을 가지고 있습니다. + @Inject + lateinit var tokenRepo: TokenRepo + + @Before + fun setUp() { + hiltRule.inject() + } + + @Test + fun saveAndGetTokens_withFakeCryptoManager_returnsCorrectBundle() = runBlocking { + // Arrange (준비) + val originalBundle = TokenBundle( + accessToken = "access_token_hilt_test", + refreshToken = "refresh_token_hilt_test" + ) + + // Act (실행) + tokenRepo.saveTokens(originalBundle) + val result = tokenRepo.getTokens() + + // Assert (검증) + assertThat(result.isSuccess, equalTo(true)) + val retrievedBundle = result.getOrNull() + assertThat(retrievedBundle, equalTo(originalBundle)) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/debatetimer/app/di/FakeDataModule.kt b/app/src/androidTest/java/com/debatetimer/app/di/FakeDataModule.kt new file mode 100644 index 0000000..9c574a1 --- /dev/null +++ b/app/src/androidTest/java/com/debatetimer/app/di/FakeDataModule.kt @@ -0,0 +1,33 @@ +package com.debatetimer.app.di + +import com.debatetimer.app.data.repo.TokenRepo +import com.debatetimer.app.data.repo.TokenRepoImpl +import com.debatetimer.app.util.crypto.CryptoManager +import com.debatetimer.app.util.crypto.FakeCryptoManagerImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DataModule::class] +) +abstract class FakeDataModule { + // 2. CryptoManager 요청 시 FakeCryptoManagerImpl을 주입하도록 바인딩합니다. + @Binds + @Singleton + abstract fun bindCryptoManager( + fakeCryptoManagerImpl: FakeCryptoManagerImpl + ): CryptoManager + + // 3. TokenRepo는 기존과 동일하게 TokenRepoImpl을 주입하도록 바인딩합니다. + // (DataModule을 통째로 비활성화했으므로, 필요한 다른 바인딩도 여기에 다시 선언해줘야 합니다.) + @Binds + @Singleton + abstract fun bindTokenRepo( + tokenRepoImpl: TokenRepoImpl + ): TokenRepo +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/debatetimer/app/util/crypto/CryptoManagerImplTest.kt b/app/src/androidTest/java/com/debatetimer/app/util/crypto/CryptoManagerImplTest.kt new file mode 100644 index 0000000..0b9e1dc --- /dev/null +++ b/app/src/androidTest/java/com/debatetimer/app/util/crypto/CryptoManagerImplTest.kt @@ -0,0 +1,35 @@ +package com.debatetimer.app.util.crypto + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +// 안드로이드 환경에서 JUnit4 테스트를 실행하기 위한 Runner +@RunWith(AndroidJUnit4::class) +class CryptoManagerImplTest { + private lateinit var cryptoManager: CryptoManagerImpl + + // @Before: 각 테스트(@Test)가 실행되기 직전에 항상 먼저 실행되는 함수 + @Before + fun setUp() { + // Hilt를 사용하지 않고, 테스트 대상 클래스를 직접 생성합니다. + cryptoManager = CryptoManagerImpl() + } + + // @Test: 이 함수가 테스트 케이스임을 나타냅니다. + @Test + fun encryptionAndDecryption_roundTrip_returnsOriginalText() { + // Arrange (준비): 테스트에 필요한 변수와 상태를 설정합니다. + val originalText = "This is a secret message for a round-trip test." + + // Act (실행): 테스트하려는 실제 동작을 수행합니다. + val (ciphertext, iv) = cryptoManager.encrypt(originalText) + val decryptedText = cryptoManager.decrypt(ciphertext, iv) + + // Assert (검증): 실행 결과가 예상과 일치하는지 확인합니다. + assertThat(decryptedText, equalTo(originalText)) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/debatetimer/app/util/crypto/FakeCryptoManagerImpl.kt b/app/src/androidTest/java/com/debatetimer/app/util/crypto/FakeCryptoManagerImpl.kt new file mode 100644 index 0000000..c1da671 --- /dev/null +++ b/app/src/androidTest/java/com/debatetimer/app/util/crypto/FakeCryptoManagerImpl.kt @@ -0,0 +1,18 @@ +package com.debatetimer.app.util.crypto + +import javax.inject.Inject + +class FakeCryptoManagerImpl @Inject constructor() : CryptoManager { + private val dummyIv = "dummy_iv".toByteArray() + private val suffix = "_encrypted" + + override fun encrypt(plainText: String): Pair { + val fakeCiphertext = (plainText + suffix).toByteArray(Charsets.UTF_8) + return Pair(fakeCiphertext, dummyIv) + } + + override fun decrypt(encryptedText: ByteArray, iv: ByteArray): String { + val fakePlaintext = String(encryptedText, Charsets.UTF_8) + return fakePlaintext.removeSuffix(suffix) + } +} \ No newline at end of file From 0c8e5c74dd5c7cef7836d2b1b5b642992092b95f Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Mon, 4 Aug 2025 15:28:14 +0900 Subject: [PATCH 10/11] feat: apply CodeRabbit's suggestions --- .../debatetimer/app/data/model/TokenBundle.kt | 6 ++- .../debatetimer/app/data/repo/TokenRepo.kt | 4 +- .../app/data/repo/TokenRepoImpl.kt | 43 ++++++++++++------- .../app/util/crypto/CryptoManager.kt | 15 +++++++ .../app/util/crypto/CryptoManagerImpl.kt | 37 +++++++++++----- app/src/main/proto/encrypted_tokens.proto | 2 + 6 files changed, 79 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/debatetimer/app/data/model/TokenBundle.kt b/app/src/main/java/com/debatetimer/app/data/model/TokenBundle.kt index 6e90fba..997e80e 100644 --- a/app/src/main/java/com/debatetimer/app/data/model/TokenBundle.kt +++ b/app/src/main/java/com/debatetimer/app/data/model/TokenBundle.kt @@ -6,4 +6,8 @@ import kotlinx.serialization.Serializable data class TokenBundle( val accessToken: String, val refreshToken: String, -) \ No newline at end of file +) { + override fun toString(): String { + return "TokenBundle(accessToken='***', refreshToken='***')" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/debatetimer/app/data/repo/TokenRepo.kt b/app/src/main/java/com/debatetimer/app/data/repo/TokenRepo.kt index c00b579..3cc2879 100644 --- a/app/src/main/java/com/debatetimer/app/data/repo/TokenRepo.kt +++ b/app/src/main/java/com/debatetimer/app/data/repo/TokenRepo.kt @@ -3,6 +3,6 @@ package com.debatetimer.app.data.repo import com.debatetimer.app.data.model.TokenBundle interface TokenRepo { - suspend fun getTokens(): Result - suspend fun saveTokens(bundle: TokenBundle): Result + suspend fun getTokens(): Result + suspend fun saveTokens(bundle: TokenBundle): Result } \ No newline at end of file diff --git a/app/src/main/java/com/debatetimer/app/data/repo/TokenRepoImpl.kt b/app/src/main/java/com/debatetimer/app/data/repo/TokenRepoImpl.kt index 36ce3ca..11705a9 100644 --- a/app/src/main/java/com/debatetimer/app/data/repo/TokenRepoImpl.kt +++ b/app/src/main/java/com/debatetimer/app/data/repo/TokenRepoImpl.kt @@ -7,6 +7,7 @@ import com.debatetimer.app.data.model.TokenBundle import com.debatetimer.app.data.serializer.tokenDataStore import com.debatetimer.app.util.crypto.CryptoManager import com.google.gson.Gson +import com.google.gson.JsonSyntaxException import com.google.protobuf.kotlin.toByteString import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.first @@ -17,41 +18,53 @@ class TokenRepoImpl @Inject constructor( private val cryptoManager: CryptoManager ) : TokenRepo { + companion object { + private val gson = Gson().newBuilder() + .disableHtmlEscaping() + .create() + } + private val tokenDataStore: DataStore = context.tokenDataStore - private suspend fun getBundle(): TokenBundle? { + override suspend fun getTokens(): Result { try { val encryptedTokens = tokenDataStore.data.first() + + if (encryptedTokens.encryptedTokenBundle.isEmpty || encryptedTokens.initializationVector.isEmpty) { + return Result.success(null) + } + val ciphertext = encryptedTokens.encryptedTokenBundle.toByteArray() val iv = encryptedTokens.initializationVector.toByteArray() val decryptedTokens = cryptoManager.decrypt(ciphertext, iv) - return Gson().fromJson(decryptedTokens, TokenBundle::class.java) - } catch (_: NoSuchElementException) { - return null - } catch (e: Exception) { - throw e - } - } - - override suspend fun getTokens(): Result { - try { - val bundle = getBundle() + val bundle = gson.fromJson(decryptedTokens, TokenBundle::class.java) return if (bundle != null) { Result.success(bundle) } else { Result.failure(NoSuchElementException()) } + } catch (_: NoSuchElementException) { + return Result.success(null) + } catch (e: JsonSyntaxException) { + return Result.failure(IllegalStateException("Corrupted token data", e)) } catch (e: Exception) { return Result.failure(e) } } - override suspend fun saveTokens(bundle: TokenBundle): Result { + override suspend fun saveTokens(bundle: TokenBundle): Result { try { + require(bundle.accessToken.isNotBlank()) + require(bundle.refreshToken.isNotBlank()) + tokenDataStore.updateData { current -> - val serializedBundle = Gson().toJson(bundle) + val serializedBundle = try { + gson.toJson(bundle) + } catch (e: Exception) { + throw IllegalStateException("Failed to serialize token bundle", e) + } val encryptionOutput = cryptoManager.encrypt(serializedBundle) current.toBuilder() @@ -60,7 +73,7 @@ class TokenRepoImpl @Inject constructor( .build() } - return Result.success(true) + return Result.success(Unit) } catch (e: Exception) { return Result.failure(e) } diff --git a/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManager.kt b/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManager.kt index c80d682..041b556 100644 --- a/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManager.kt +++ b/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManager.kt @@ -1,6 +1,21 @@ package com.debatetimer.app.util.crypto +/** + * 토큰의 암호화 및 복호화를 담당하는 인터페이스 + */ interface CryptoManager { + /** + * 평문을 암호화합니다. + * @param plainText 암호화 할 평문 + * @return 암호화된 암호문와 IV의 Pair + */ fun encrypt(plainText: String): Pair + + /** + * 암호문을 평문으로 복호화합니다. + * @param encryptedText 복호화 할 암호문 + * @param iv IV + * @return 복호화된 평문 + */ fun decrypt(encryptedText: ByteArray, iv: ByteArray): String } \ No newline at end of file diff --git a/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManagerImpl.kt b/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManagerImpl.kt index 7e8d596..6a2fa68 100644 --- a/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManagerImpl.kt +++ b/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManagerImpl.kt @@ -22,8 +22,14 @@ class CryptoManagerImpl @Inject constructor() : CryptoManager { } // Keystore instance - private val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { - load(null) + private val keyStore by lazy { + try { + KeyStore.getInstance(ANDROID_KEYSTORE).apply { + load(null) + } + } catch (e: Exception) { + throw RuntimeException("Failed to load Android KeyStore", e) + } } /** @@ -56,6 +62,9 @@ class CryptoManagerImpl @Inject constructor() : CryptoManager { } override fun encrypt(plainText: String): Pair { + // 0. Check whether plainText is not empty + require(plainText.isNotEmpty()) { "Plain text cannot be empty" } + // 1. Load cipher instance val cipher = Cipher.getInstance(TRANSFORMATION) @@ -63,18 +72,26 @@ class CryptoManagerImpl @Inject constructor() : CryptoManager { cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()) // 3. Return encrypted text and IV - return Pair(cipher.doFinal(plainText.toByteArray()), cipher.iv) + return Pair(cipher.doFinal(plainText.toByteArray(charset = Charsets.UTF_8)), cipher.iv) } override fun decrypt(encryptedText: ByteArray, iv: ByteArray): String { - // 1. Load cipher instance and prepare variables - val cipher = Cipher.getInstance(TRANSFORMATION) - val spec = GCMParameterSpec(128, iv) + require(encryptedText.isNotEmpty()) { "Encrypted text cannot be empty" } + require(iv.isNotEmpty()) { "IV cannot be empty" } + require(iv.size == 12) { "IV must be 12 bytes" } - // 2. Init cipher with decryption mode - cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec) + try { + // 1. Load cipher instance and prepare variables + val cipher = Cipher.getInstance(TRANSFORMATION) + val spec = GCMParameterSpec(128, iv) - // 3. Return decrypted text - return String(cipher.doFinal(encryptedText), Charsets.UTF_8) + // 2. Init cipher with decryption mode + cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec) + + // 3. Return decrypted text + return String(cipher.doFinal(encryptedText), Charsets.UTF_8) + } catch (e: Exception) { + throw RuntimeException("Failed to decrypt data", e) + } } } \ No newline at end of file diff --git a/app/src/main/proto/encrypted_tokens.proto b/app/src/main/proto/encrypted_tokens.proto index 7187a16..22ea148 100644 --- a/app/src/main/proto/encrypted_tokens.proto +++ b/app/src/main/proto/encrypted_tokens.proto @@ -1,5 +1,7 @@ syntax = "proto3"; +package com.debatetimer.app.data; + option java_package = "com.debatetimer.app"; option java_multiple_files = true; From 3c1076864f95c1f4f6e656122bcb6215c449bcf6 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Mon, 4 Aug 2025 15:32:43 +0900 Subject: [PATCH 11/11] refactor: clarify error message and exception type --- .../java/com/debatetimer/app/data/repo/TokenRepoImpl.kt | 6 ++---- .../com/debatetimer/app/util/crypto/CryptoManagerImpl.kt | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/debatetimer/app/data/repo/TokenRepoImpl.kt b/app/src/main/java/com/debatetimer/app/data/repo/TokenRepoImpl.kt index 11705a9..e7679ae 100644 --- a/app/src/main/java/com/debatetimer/app/data/repo/TokenRepoImpl.kt +++ b/app/src/main/java/com/debatetimer/app/data/repo/TokenRepoImpl.kt @@ -45,8 +45,6 @@ class TokenRepoImpl @Inject constructor( } else { Result.failure(NoSuchElementException()) } - } catch (_: NoSuchElementException) { - return Result.success(null) } catch (e: JsonSyntaxException) { return Result.failure(IllegalStateException("Corrupted token data", e)) } catch (e: Exception) { @@ -56,8 +54,8 @@ class TokenRepoImpl @Inject constructor( override suspend fun saveTokens(bundle: TokenBundle): Result { try { - require(bundle.accessToken.isNotBlank()) - require(bundle.refreshToken.isNotBlank()) + require(bundle.accessToken.isNotBlank()) { "Access token cannot be blank" } + require(bundle.refreshToken.isNotBlank()) { "Refresh token cannot be blank" } tokenDataStore.updateData { current -> val serializedBundle = try { diff --git a/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManagerImpl.kt b/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManagerImpl.kt index 6a2fa68..4e6271f 100644 --- a/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManagerImpl.kt +++ b/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManagerImpl.kt @@ -28,7 +28,7 @@ class CryptoManagerImpl @Inject constructor() : CryptoManager { load(null) } } catch (e: Exception) { - throw RuntimeException("Failed to load Android KeyStore", e) + throw IllegalStateException("Failed to load Android KeyStore", e) } } @@ -91,7 +91,7 @@ class CryptoManagerImpl @Inject constructor() : CryptoManager { // 3. Return decrypted text return String(cipher.doFinal(encryptedText), Charsets.UTF_8) } catch (e: Exception) { - throw RuntimeException("Failed to decrypt data", e) + throw IllegalStateException("Failed to decrypt data", e) } } } \ No newline at end of file