Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/datastore/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
28 changes: 28 additions & 0 deletions core/datastore/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
@file:Suppress("INLINE_FROM_HIGHER_PLATFORM")

plugins {
alias(libs.plugins.booket.android.library)
alias(libs.plugins.booket.android.hilt)
}

android {
namespace = "com.ninecraft.booket.core.datastore"

defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
}

dependencies {
implementations(
libs.androidx.datastore.preferences,

libs.logger,
)

androidTestImplementations(
libs.androidx.test.ext.junit,
libs.androidx.test.runner,
libs.kotlinx.coroutines.test,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.ninecraft.booket.core.datastore

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ninecraft.booket.core.datastore.datasource.DefaultTokenPreferencesDataSource
import com.ninecraft.booket.core.datastore.security.CryptoManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import kotlin.io.path.createTempDirectory

@RunWith(AndroidJUnit4::class)
class TokenPreferenceDataSourceTest {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 👍 👍

private lateinit var dataStore: DataStore<Preferences>
private lateinit var dataSource: DefaultTokenPreferencesDataSource
private lateinit var cryptoManager: CryptoManager
private lateinit var tempFile: File

@Before
fun setup() {
val tempFolder = createTempDirectory().toFile()
tempFile = File(tempFolder, "token_prefs.preferences_pb")

dataStore = PreferenceDataStoreFactory.create(
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
produceFile = { tempFile }
)

cryptoManager = CryptoManager()
dataSource = DefaultTokenPreferencesDataSource(dataStore, cryptoManager)
}

@After
fun tearDown() {
tempFile.delete()
}

@Test
fun tokenIsEncryptedWhenStored() = runTest {
// Given
val plainToken = "plain_access_token"
dataSource.setAccessToken(plainToken)

// When
val storedToken = dataStore.data.first()[stringPreferencesKey("ACCESS_TOKEN")]

// Then
assertNotNull(storedToken)
assertNotEquals(plainToken, storedToken)
assertTrue(storedToken!!.isNotEmpty())
}


@Test
fun storedTokenIsDecryptedWhenRetrieved() = runTest {
// Given
val plainToken = "plain_access_token"
dataSource.setAccessToken(plainToken)

// When
val restoredToken = dataSource.accessToken.first()

// Then
assertEquals(plainToken, restoredToken)
}
}
4 changes: 4 additions & 0 deletions core/datastore/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.ninecraft.booket.core.datastore.datasource

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.ninecraft.booket.core.datastore.security.CryptoManager
import com.ninecraft.booket.core.datastore.util.handleIOException
import com.orhanobut.logger.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.security.GeneralSecurityException
import javax.inject.Inject

class DefaultTokenPreferencesDataSource @Inject constructor(
private val dataStore: DataStore<Preferences>,
private val cryptoManager: CryptoManager,
) : TokenPreferencesDataSource {
override val accessToken: Flow<String> = decryptStringFlow(ACCESS_TOKEN)
override val refreshToken: Flow<String> = decryptStringFlow(REFRESH_TOKEN)

override suspend fun setAccessToken(accessToken: String) {
dataStore.edit { prefs ->
prefs[ACCESS_TOKEN] = cryptoManager.encrypt(accessToken)
}
}

override suspend fun setRefreshToken(refreshToken: String) {
dataStore.edit { prefs ->
prefs[REFRESH_TOKEN] = cryptoManager.encrypt(refreshToken)
}
}

override suspend fun clearTokens() {
dataStore.edit { prefs ->
prefs.remove(ACCESS_TOKEN)
prefs.remove(REFRESH_TOKEN)
}
}

private fun decryptStringFlow(
key: Preferences.Key<String>,
): Flow<String> = dataStore.data
.handleIOException()
.map { prefs ->
prefs[key]?.let {
try {
cryptoManager.decrypt(it)
} catch (e: GeneralSecurityException) {
Logger.e(e, "Failed to decrypt token")
""
}
}.orEmpty()
}

companion object {
private val ACCESS_TOKEN = stringPreferencesKey("ACCESS_TOKEN")
private val REFRESH_TOKEN = stringPreferencesKey("REFRESH_TOKEN")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.ninecraft.booket.core.datastore.datasource

import kotlinx.coroutines.flow.Flow

interface TokenPreferencesDataSource {
val accessToken: Flow<String>
val refreshToken: Flow<String>
suspend fun setAccessToken(accessToken: String)
suspend fun setRefreshToken(refreshToken: String)
suspend fun clearTokens()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.ninecraft.booket.core.datastore.di

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import com.ninecraft.booket.core.datastore.datasource.DefaultTokenPreferencesDataSource
import com.ninecraft.booket.core.datastore.datasource.TokenPreferencesDataSource
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {
private const val TOKEN_DATASTORE_NAME = "TOKENS_PREFERENCES"
private val Context.dataStore by preferencesDataStore(name = TOKEN_DATASTORE_NAME)

@Provides
@Singleton
fun provideTokenDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> = context.dataStore
}

@Module
@InstallIn(SingletonComponent::class)
abstract class DataStoreBindModule {

@Binds
@Singleton
abstract fun bindTokenPreferencesDataSource(
tokenPreferencesDataSourceImpl: DefaultTokenPreferencesDataSource,
): TokenPreferencesDataSource
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.ninecraft.booket.core.datastore.security

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class CryptoManager @Inject constructor() {
private val cipher = Cipher.getInstance(TRANSFORMATION)
private val keyStore = KeyStore
.getInstance("AndroidKeyStore")
.apply {
load(null)
}

private fun getKey(): SecretKey {
val existingKey = keyStore
.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry
return existingKey?.secretKey ?: createKey()
}

private fun createKey(): SecretKey {
return KeyGenerator
.getInstance(ALGORITHM)
.apply {
init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or
KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(BLOCK_MODE)
.setEncryptionPaddings(PADDING)
.setRandomizedEncryptionRequired(true)
.setUserAuthenticationRequired(false)
.build(),
)
}
.generateKey()
}

fun encrypt(plainText: String): String {
cipher.init(Cipher.ENCRYPT_MODE, getKey())
val iv = cipher.iv
val encryptedBytes = cipher.doFinal(plainText.toByteArray())
val combined = iv + encryptedBytes
return Base64.encodeToString(combined, Base64.NO_WRAP)
}

fun decrypt(encodedText: String): String {
val combined = Base64.decode(encodedText, Base64.NO_WRAP)
val iv = combined.copyOfRange(0, IV_SIZE)
val encrypted = combined.copyOfRange(IV_SIZE, combined.size)
cipher.init(Cipher.DECRYPT_MODE, getKey(), IvParameterSpec(iv))
val decryptedString = String(cipher.doFinal(encrypted))
return decryptedString
}

companion object {
private const val KEY_ALIAS = "secret"
private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_CBC
private const val PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7
private const val TRANSFORMATION = "$ALGORITHM/$BLOCK_MODE/$PADDING"
private const val IV_SIZE = 16 // AES IV는 항상 16바이트
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.ninecraft.booket.core.datastore.util

import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import java.io.IOException

fun Flow<Preferences>.handleIOException(): Flow<Preferences> =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 👍 👍
이 부분 항상 중복된 코드였는데 확장함수화 해주니까 깔끔하네

catch { exception ->
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}
14 changes: 9 additions & 5 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,14 @@ kotlin-ktlint-source = "0.50.0"

## Test
junit = "4.13.2"
junit-version = "1.2.1"
androidx-test-ext-junit = "1.2.1"
Comment thread
easyhooon marked this conversation as resolved.
androidx-test-runner = "1.6.2"
espresso-core = "3.6.1"
material = "1.12.0"

[libraries]
android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "android-gradle-plugin" }
kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name="kotlin-gradle-plugin", version.ref = "kotlin" }
kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
compose-compiler-gradle-plugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" }

androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" }
Expand All @@ -72,7 +73,7 @@ androidx-activity-compose = { group = "androidx.activity", name = "activity-comp
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-splash = { group = "androidx.core", name = "core-splashscreen", version.ref = "androidx-splash" }
androidx-startup = { group = "androidx.startup", name = "startup-runtime", version.ref = "androidx-startup" }
androidx-datastore-core = { group = "androidx.datastore", name = "datastore", version.ref = "androidx-datastore" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidx-datastore" }

androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidx-compose-bom" }
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
Expand All @@ -94,6 +95,7 @@ retrofit-kotlinx-serialization-converter = { module = "com.squareup.retrofit2:co
okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }

kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinx-datetime" }
kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinx-collections-immutable" }
Expand All @@ -111,7 +113,9 @@ detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-form
kakao-auth = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao-core" }

junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit-version" }

androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test-runner" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }

Expand All @@ -138,7 +142,7 @@ booket-android-application = { id = "booket.android.application", version = "uns
booket-android-application-compose = { id = "booket.android.application.compose", version = "unspecified" }
booket-android-library = { id = "booket.android.library", version = "unspecified" }
booket-android-library-compose = { id = "booket.android.library.compose", version = "unspecified" }
booket-android-retrofit = { id = "booket.android.retrofit", version = "unspecified"}
booket-android-retrofit = { id = "booket.android.retrofit", version = "unspecified" }
booket-android-feature = { id = "booket.android.feature", version = "unspecified" }
booket-android-hilt = { id = "booket.android.hilt", version = "unspecified" }
booket-jvm-library = { id = "booket.jvm.library", version = "unspecified" }
Expand Down
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ include(

":core:designsystem",
":core:network",
":core:datastore",
":core:ui",

":feature:home",
Expand Down
Loading