Skip to content
Merged
24 changes: 23 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ plugins {
// New
alias(libs.plugins.kotlin.ksp)
alias(libs.plugins.dagger.hilt.android)
alias(libs.plugins.google.protobuf)
}

android {
Expand All @@ -22,7 +23,7 @@ android {
versionCode = 1
versionName = "1.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunner = "com.debatetimer.app.HiltTestRunner"
}

buildTypes {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
20 changes: 20 additions & 0 deletions app/src/androidTest/java/com/debatetimer/app/HiltTestRunner.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
33 changes: 33 additions & 0 deletions app/src/androidTest/java/com/debatetimer/app/di/FakeDataModule.kt
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
Original file line number Diff line number Diff line change
@@ -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<ByteArray, ByteArray> {
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)
}
}
13 changes: 13 additions & 0 deletions app/src/main/java/com/debatetimer/app/data/model/TokenBundle.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.debatetimer.app.data.model

import kotlinx.serialization.Serializable

@Serializable
data class TokenBundle(
val accessToken: String,
val refreshToken: String,
) {
override fun toString(): String {
return "TokenBundle(accessToken='***', refreshToken='***')"
}
}
8 changes: 8 additions & 0 deletions app/src/main/java/com/debatetimer/app/data/repo/TokenRepo.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.debatetimer.app.data.repo

import com.debatetimer.app.data.model.TokenBundle

interface TokenRepo {
suspend fun getTokens(): Result<TokenBundle?>
suspend fun saveTokens(bundle: TokenBundle): Result<Unit>
}
79 changes: 79 additions & 0 deletions app/src/main/java/com/debatetimer/app/data/repo/TokenRepoImpl.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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.gson.JsonSyntaxException
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 {
companion object {
private val gson = Gson().newBuilder()
.disableHtmlEscaping()
.create()
}

private val tokenDataStore: DataStore<EncryptedTokens> = context.tokenDataStore

override suspend fun getTokens(): Result<TokenBundle?> {
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)

val bundle = gson.fromJson(decryptedTokens, TokenBundle::class.java)

return if (bundle != null) {
Result.success(bundle)
} else {
Result.failure(NoSuchElementException())
}
} 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<Unit> {
try {
require(bundle.accessToken.isNotBlank()) { "Access token cannot be blank" }
require(bundle.refreshToken.isNotBlank()) { "Refresh token cannot be blank" }

tokenDataStore.updateData { current ->
val serializedBundle = try {
gson.toJson(bundle)
} catch (e: Exception) {
throw IllegalStateException("Failed to serialize token bundle", e)
}
val encryptionOutput = cryptoManager.encrypt(serializedBundle)

current.toBuilder()
.setEncryptedTokenBundle(encryptionOutput.first.toByteString())
.setInitializationVector(encryptionOutput.second.toByteString())
.build()
}

return Result.success(Unit)
} catch (e: Exception) {
return Result.failure(e)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<EncryptedTokens> {
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<EncryptedTokens> by dataStore(
fileName = FILE_NAME,
serializer = TokenSerializer
)
27 changes: 27 additions & 0 deletions app/src/main/java/com/debatetimer/app/di/DataModule.kt
Original file line number Diff line number Diff line change
@@ -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
}
Loading