diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a5644595c..a6ce4e1a8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,6 @@ dependencies { implementation(projects.core.domain) implementation(projects.core.model) implementation(projects.core.navigation) - implementation(projects.feature.sample.impl) - implementation(projects.feature.sample.api) implementation(projects.feature.pose.api) implementation(projects.feature.pose.impl) implementation(projects.feature.archive.api) @@ -32,4 +30,4 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.navigation3.ui) -} \ No newline at end of file +} diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 8250c3e29..7a26d84e2 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -9,5 +9,6 @@ android { dependencies { api(libs.timber) + implementation(libs.androidx.security.crypto) } \ No newline at end of file diff --git a/core/common/src/main/java/com/neki/android/core/common/crypto/CryptoManager.kt b/core/common/src/main/java/com/neki/android/core/common/crypto/CryptoManager.kt new file mode 100644 index 000000000..c18f5203f --- /dev/null +++ b/core/common/src/main/java/com/neki/android/core/common/crypto/CryptoManager.kt @@ -0,0 +1,64 @@ +package com.neki.android.core.common.crypto + +import android.annotation.SuppressLint +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.GCMParameterSpec + +object CryptoManager { + private const val ALGORITHM = "AES/GCM/NoPadding" + private const val KEY_ALIAS = "neki_token_encryption_key" + + private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { + load(null) + } + + private fun getSecretKey(): SecretKey { + val existingKey = keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry + return existingKey?.secretKey ?: createKey() + } + + @SuppressLint("NewApi") + private fun createKey(): SecretKey { + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply { + init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build(), + ) + }.generateKey() + } + + @SuppressLint("NewApi") + fun encrypt(text: String): String { + val cipher = Cipher.getInstance(ALGORITHM) + cipher.init(Cipher.ENCRYPT_MODE, getSecretKey()) + + val encryptedBytes = cipher.doFinal(text.toByteArray()) + val combined = cipher.iv + encryptedBytes + + return Base64.encodeToString(combined, Base64.NO_WRAP) + } + + @SuppressLint("NewApi") + fun decrypt(encryptedText: String): String { + val combined = Base64.decode(encryptedText, Base64.NO_WRAP) + val encryptedData = combined.sliceArray(12 until combined.size) + + val cipher = Cipher.getInstance(ALGORITHM) + val iv = combined.sliceArray(0 until 12) + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec) + + return String(cipher.doFinal(encryptedData)) + } +} diff --git a/core/data-api/build.gradle.kts b/core/data-api/build.gradle.kts index ed411b848..8ff0f0e1f 100644 --- a/core/data-api/build.gradle.kts +++ b/core/data-api/build.gradle.kts @@ -4,4 +4,6 @@ plugins { dependencies { implementation(projects.core.model) + implementation(libs.kotlinx.coroutines.core) + } \ No newline at end of file diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/DataStoreRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/DataStoreRepository.kt new file mode 100644 index 000000000..1e50840fb --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/DataStoreRepository.kt @@ -0,0 +1,14 @@ +package com.neki.android.core.dataapi.repository + +import kotlinx.coroutines.flow.Flow + +interface DataStoreRepository { + suspend fun saveTokens( + accessToken: String, + refreshToken: String, + ) + + fun getAccessToken(): Flow + fun getRefreshToken(): Flow + suspend fun clearTokens() +} diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/SampleRespository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/SampleRespository.kt deleted file mode 100644 index 4ce138ea9..000000000 --- a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/SampleRespository.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.neki.android.core.dataapi.repository - -import com.neki.android.core.model.Post - -interface SampleRepository { - suspend fun getPosts(): List - suspend fun getPost( - id: Int, - ): Post -} diff --git a/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreRepository.kt b/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreModule.kt similarity index 100% rename from core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreRepository.kt rename to core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreModule.kt diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt b/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt index 5c3686db4..78ca2cc8c 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt @@ -1,18 +1,20 @@ package com.neki.android.core.data.repository.di -import com.neki.android.core.data.repository.impl.SampleRepositoryImpl -import com.neki.android.core.dataapi.repository.SampleRepository +import com.neki.android.core.data.repository.impl.DataStoreRepositoryImpl +import com.neki.android.core.dataapi.repository.DataStoreRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -internal abstract class RepositoryModule { +internal interface RepositoryModule { @Binds - abstract fun bindSampleRepositoryImpl( - sampleRepositoryImpl: SampleRepositoryImpl, - ): SampleRepository + @Singleton + fun bindDataStoreRepositoryImpl( + dataStoreRepositoryImpl: DataStoreRepositoryImpl, + ): DataStoreRepository } diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/DataStoreRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/DataStoreRepositoryImpl.kt new file mode 100644 index 000000000..49cbced43 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/DataStoreRepositoryImpl.kt @@ -0,0 +1,46 @@ +package com.neki.android.core.data.repository.impl + +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.neki.android.core.common.crypto.CryptoManager +import com.neki.android.core.dataapi.repository.DataStoreRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class DataStoreRepositoryImpl @Inject constructor( + private val dataStore: DataStore, +) : DataStoreRepository { + companion object { + private val ACCESS_TOKEN = stringPreferencesKey("access_token") + private val REFRESH_TOKEN = stringPreferencesKey("refresh_token") + } + + override suspend fun saveTokens( + accessToken: String, + refreshToken: String, + ) { + dataStore.edit { preferences -> + preferences[ACCESS_TOKEN] = CryptoManager.encrypt(accessToken) + preferences[REFRESH_TOKEN] = CryptoManager.encrypt(refreshToken) + } + } + + override fun getAccessToken(): Flow { + return dataStore.data.map { preferences -> + preferences[ACCESS_TOKEN]?.let { CryptoManager.decrypt(it) } + } + } + + override fun getRefreshToken(): Flow { + return dataStore.data.map { preferences -> + preferences[REFRESH_TOKEN]?.let { CryptoManager.decrypt(it) } + } + } + + override suspend fun clearTokens() { + dataStore.edit { it.clear() } + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/SampleRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/SampleRepositoryImpl.kt deleted file mode 100644 index bcafcc53f..000000000 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/SampleRepositoryImpl.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.neki.android.core.data.repository.impl - -import com.neki.android.core.data.remote.api.ApiService -import com.neki.android.core.dataapi.repository.SampleRepository -import com.neki.android.core.model.Post -import javax.inject.Inject - -class SampleRepositoryImpl @Inject constructor( - private val apiService: ApiService, -// private val dataStore: DataStore, -) : SampleRepository { - override suspend fun getPosts(): List { - return apiService.getPosts() - .map { it.toModel() } - } - - override suspend fun getPost( - id: Int, - ): Post { - return apiService.getPost(id = id) - .toModel() - } -} diff --git a/core/data/src/main/java/com/neki/android/core/data/util/RunSuspendCatching.kt b/core/data/src/main/java/com/neki/android/core/data/util/RunSuspendCatching.kt new file mode 100644 index 000000000..9fb0130c8 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/util/RunSuspendCatching.kt @@ -0,0 +1,20 @@ +package com.neki.android.core.data.util + +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract +import kotlin.coroutines.cancellation.CancellationException + +@OptIn(ExperimentalContracts::class) +internal inline fun runSuspendCatching(block: () -> T): Result { + // Kotlin 의 contract(계약) 시스템을 이용해 block 이 정확히 한번만 호출 되어야 함을 나타냄 + contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } + + return runCatching(block).also { result -> + // 만약 람다에서 예외가 발생하면, Result 객체는 실패를 나타내고 해당 예외를 포함, 추가적인 작업을 실행 + val maybeException = result.exceptionOrNull() + // 만약 예외가 CancellationException 이면 예외를 던져 코루틴 계층 구조에 따라 상위 코루틴까지 취소 신호를 전파 + // 이를 통해, 상위 코루틴에서 적절한 예외 처리 루틴을 수행할 수 있음 + if (maybeException is CancellationException) throw maybeException + } +} diff --git a/feature/sample/api/.gitignore b/feature/sample/api/.gitignore deleted file mode 100644 index 42afabfd2..000000000 --- a/feature/sample/api/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/feature/sample/api/build.gradle.kts b/feature/sample/api/build.gradle.kts deleted file mode 100644 index 128f6619a..000000000 --- a/feature/sample/api/build.gradle.kts +++ /dev/null @@ -1,7 +0,0 @@ -plugins { - alias(libs.plugins.neki.android.feature.api) -} - -android { - namespace = "com.neki.android.feature.sample.api" -} \ No newline at end of file diff --git a/feature/sample/api/src/main/java/com/neki/android/feature/sample/api/SampleNavKey.kt b/feature/sample/api/src/main/java/com/neki/android/feature/sample/api/SampleNavKey.kt deleted file mode 100644 index c5a5d3763..000000000 --- a/feature/sample/api/src/main/java/com/neki/android/feature/sample/api/SampleNavKey.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.neki.android.feature.sample.api - -import androidx.navigation3.runtime.NavKey -import com.neki.android.core.navigation.Navigator -import kotlinx.serialization.Serializable - -sealed interface SampleNavKey : NavKey { - - @Serializable - data class Sample(val id: Long) : SampleNavKey -} - -fun Navigator.navigateToSample(id: Long) { - navigate(SampleNavKey.Sample(id)) -} diff --git a/feature/sample/impl/.gitignore b/feature/sample/impl/.gitignore deleted file mode 100644 index 42afabfd2..000000000 --- a/feature/sample/impl/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/feature/sample/impl/build.gradle.kts b/feature/sample/impl/build.gradle.kts deleted file mode 100644 index 17361a44a..000000000 --- a/feature/sample/impl/build.gradle.kts +++ /dev/null @@ -1,14 +0,0 @@ -plugins { - alias(libs.plugins.neki.android.feature.impl) -} - -android { - namespace = "com.neki.android.feature.sample.impl" -} - -dependencies { - implementation(libs.androidx.activity.compose) - implementation(libs.androidx.appcompat) - implementation(libs.androidx.core.ktx) - implementation(projects.feature.sample.api) -} \ No newline at end of file diff --git a/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/SampleEntryProvider.kt b/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/SampleEntryProvider.kt deleted file mode 100644 index 9adf4e256..000000000 --- a/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/SampleEntryProvider.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.neki.android.feature.sample.impl - -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.navigation3.runtime.EntryProviderScope -import androidx.navigation3.runtime.NavKey -import com.neki.android.core.navigation.EntryProviderInstaller -import com.neki.android.core.navigation.Navigator -import com.neki.android.feature.sample.api.SampleNavKey -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityRetainedComponent -import dagger.multibindings.IntoSet - -@Module -@InstallIn(ActivityRetainedComponent::class) -object SampleEntryProviderModule { - - @IntoSet - @Provides - fun provideSampleEntryBuilder(navigator: Navigator): EntryProviderInstaller = { - sampleEntry(navigator) - } -} - -private fun EntryProviderScope.sampleEntry(navigator: Navigator) { - entry { key -> - navigator - val viewModel = hiltViewModel( - creationCallback = { factory -> - factory.create(key) - }, - ) - SampleScreen(viewModel = viewModel) - } -} diff --git a/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/SampleScreen.kt b/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/SampleScreen.kt deleted file mode 100644 index fc24a677b..000000000 --- a/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/SampleScreen.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.neki.android.feature.sample.impl - -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel - -@Composable -fun SampleScreen( - modifier: Modifier = Modifier, - viewModel: SampleViewModel = hiltViewModel(), -) { - viewModel - Text( - modifier = modifier, - text = "Sample", - ) -} diff --git a/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/SampleViewModel.kt b/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/SampleViewModel.kt deleted file mode 100644 index 91021e222..000000000 --- a/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/SampleViewModel.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.neki.android.feature.sample.impl - -import androidx.lifecycle.ViewModel -import com.neki.android.feature.sample.api.SampleNavKey -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.lifecycle.HiltViewModel - -@HiltViewModel(assistedFactory = SampleViewModel.Factory::class) -class SampleViewModel @AssistedInject constructor( - @Assisted val navKey: SampleNavKey.Sample, -) : ViewModel() { - - val id = navKey.id - - @AssistedFactory - interface Factory { - fun create(navKey: SampleNavKey.Sample): SampleViewModel - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ea615c9de..bc5cc4e08 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,8 @@ jetbrainsKotlinJvmVersion = "2.1.0" hilt = "2.54" ktor = "2.3.12" androidxDatastore = "1.1.2" +kotlinxCoroutines = "1.8.1" +securityCrypto = "1.1.0" timber = "5.0.1" androidxNavigation3 = "1.0.0" androidxLifecycleViewModelNavigation3 = "2.10.0" @@ -45,6 +47,7 @@ androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigat androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "androidxNavigation3" } androidx-lifecycle-viewModel-navigation3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "androidxLifecycleViewModelNavigation3" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } @@ -59,6 +62,7 @@ androidx-annotation-experimental = { module = "androidx.annotation:annotation-ex androidx-datastore-core = { group = "androidx.datastore", name = "datastore", version.ref = "androidxDatastore" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDatastore" } +androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 79c5f5252..7efad2502 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,8 +30,6 @@ include(":core:domain") include(":core:data") include(":core:data-api") include(":core:model") -include(":feature:sample:api") -include(":feature:sample:impl") include(":core:navigation") include(":feature:pose:api") include(":feature:pose:impl")