diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c42ee85..46b4a35 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,7 +27,24 @@ android { versionName = libs.versions.versionName.get() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // Server URL to BuildConfig buildConfigField("String", "BASE_URL", properties["base.url"].toString()) + + // KAKAO_NATIVE_APP_KEY + buildConfigField( + "String", + "KAKAO_NATIVE_APP_KEY", + "\"${properties["kakao.native.app.key"]}\"" // 명시적으로 따옴표 추가 + ) + + // manifestPlaceholders for AndroidManifest + manifestPlaceholders["NATIVE_APP_KEY"] = properties["kakao.native.app.key"].toString() + + // Todo : (Issue) LocalProperties의 "" 유무 및 일관성 + //buildConfigField("String", "KAKAO_NATIVE_KEY", properties["kakao.native.app.key"].toString()) + // manifestPlaceholders["NATIVE_APP_KEY"] = properties["kakao.native.app.key"].toString().replace("\"", "") + } buildTypes { @@ -78,4 +95,9 @@ dependencies { implementation(libs.timber) + implementation(libs.kakao.user) + + + implementation(libs.androidx.datastore.preferences) + implementation(libs.tink.android) } \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb43..a9e8ae7 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,4 +18,27 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +# 카카오 SDK의 모델 클래스는 JSON 변환에 사용되므로 난독화하지 않음 +-keep class com.kakao.sdk.**.model.* { ; } + +# OkHttp 관련 선택적 보안 라이브러리 경고 무시 +-dontwarn org.bouncycastle.jsse.** +-dontwarn org.conscrypt.* +-dontwarn org.openjsse.** + +# Retrofit2 (with r8 full mode) +# Retrofit API 인터페이스 보존 (어노테이션 기반이므로 필수) +-if interface * { @retrofit2.http.* ; } +-keep,allowobfuscation interface <1> + +# 코루틴 Continuation 클래스 보존 (suspend 함수 지원용) +-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation + +# Retrofit 메서드의 반환 타입 보존 +-if interface * { @retrofit2.http.* public *** *(...); } +-keep,allowoptimization,allowshrinking,allowobfuscation class <3> + +# Retrofit Response 클래스 보존 +-keep,allowobfuscation,allowshrinking class retrofit2.Response \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 811472a..2d4e24e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + + + - + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/kiero/KieroApplication.kt b/app/src/main/java/com/kiero/KieroApplication.kt index b084382..55a19ec 100644 --- a/app/src/main/java/com/kiero/KieroApplication.kt +++ b/app/src/main/java/com/kiero/KieroApplication.kt @@ -2,6 +2,7 @@ package com.kiero import android.app.Application import androidx.appcompat.app.AppCompatDelegate +import com.kakao.sdk.common.KakaoSdk import dagger.hilt.android.HiltAndroidApp import timber.log.Timber @@ -11,6 +12,7 @@ class KieroApplication : Application() { super.onCreate() setTimber() setDayMode() + initKakaoSdk() } private fun setTimber() { @@ -22,5 +24,13 @@ class KieroApplication : Application() { private fun setDayMode() { AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) } - + private fun initKakaoSdk() { + try { + KakaoSdk.init(this, BuildConfig.KAKAO_NATIVE_APP_KEY) + // 태그를 명시적으로 지정 + Timber.tag("KAKAO_INIT").d("✅ 카카오 SDK 초기화 성공") + } catch (e: Exception) { + Timber.tag("KAKAO_INIT").e(e, "❌ 카카오 SDK 초기화 실패") + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/kiero/core/network/di/NetworkModule.kt b/app/src/main/java/com/kiero/core/network/di/NetworkModule.kt index b3735af..e221666 100644 --- a/app/src/main/java/com/kiero/core/network/di/NetworkModule.kt +++ b/app/src/main/java/com/kiero/core/network/di/NetworkModule.kt @@ -67,4 +67,4 @@ object NetworkModule { .addConverterFactory(converterFactory) .client(client) .build() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/core/network/model/BaseResponse.kt b/app/src/main/java/com/kiero/core/network/model/BaseResponse.kt index 3b013fd..5e44b05 100644 --- a/app/src/main/java/com/kiero/core/network/model/BaseResponse.kt +++ b/app/src/main/java/com/kiero/core/network/model/BaseResponse.kt @@ -5,11 +5,11 @@ import kotlinx.serialization.Serializable @Serializable data class BaseResponse( - @SerialName("code") - val code: String, + @SerialName("status") + val status: Int, @SerialName("message") val message: String, @SerialName("data") - val data: T, + val data: T? = null, ) -// TODO : 명세서 확인 후 수정 예정 + diff --git a/app/src/main/java/com/kiero/core/security/CryptoManager.kt b/app/src/main/java/com/kiero/core/security/CryptoManager.kt new file mode 100644 index 0000000..b6613e0 --- /dev/null +++ b/app/src/main/java/com/kiero/core/security/CryptoManager.kt @@ -0,0 +1,6 @@ +package com.kiero.core.security + +interface CryptoManager { + fun encrypt(plaintext: String): String + fun decrypt(ciphertext: String): String +} \ No newline at end of file diff --git a/app/src/main/java/com/kiero/core/security/EncryptionException.kt b/app/src/main/java/com/kiero/core/security/EncryptionException.kt new file mode 100644 index 0000000..72d2b83 --- /dev/null +++ b/app/src/main/java/com/kiero/core/security/EncryptionException.kt @@ -0,0 +1,4 @@ +package com.kiero.core.security + +class EncryptionException(message: String, cause: Throwable? = null) : + Exception(message, cause) \ No newline at end of file diff --git a/app/src/main/java/com/kiero/core/security/TinkCryptoManager.kt b/app/src/main/java/com/kiero/core/security/TinkCryptoManager.kt new file mode 100644 index 0000000..184705e --- /dev/null +++ b/app/src/main/java/com/kiero/core/security/TinkCryptoManager.kt @@ -0,0 +1,92 @@ +package com.kiero.core.security + +import android.content.Context +import android.util.Base64 +import com.google.crypto.tink.Aead +import com.google.crypto.tink.InsecureSecretKeyAccess +import com.google.crypto.tink.KeysetHandle +import com.google.crypto.tink.TinkJsonProtoKeysetFormat +import com.google.crypto.tink.aead.AeadConfig +import com.google.crypto.tink.aead.PredefinedAeadParameters +import dagger.hilt.android.qualifiers.ApplicationContext +import jakarta.inject.Inject +import java.io.File +import java.nio.charset.StandardCharsets + + +class TinkCryptoManager @Inject constructor( + @param:ApplicationContext private val context: Context +) : CryptoManager { + + private val aead: Aead + + init { + try { + AeadConfig.register() + + val keysetFile = File(context.filesDir, KEYSET_FILENAME) + val keysetHandle = if (keysetFile.exists()) { + loadKeysetHandle(keysetFile) + } else { + generateAndSaveKeysetHandle(keysetFile) + } + + aead = keysetHandle.getPrimitive(Aead::class.java) + } catch (e: Exception) { + throw IllegalStateException("암호화 초기화 실패", e) + } + } + + private fun generateAndSaveKeysetHandle(keysetFile: File): KeysetHandle { + return try { + val keysetHandle = KeysetHandle.generateNew(PredefinedAeadParameters.AES256_GCM) + val keysetJson = TinkJsonProtoKeysetFormat.serializeKeyset( + keysetHandle, + InsecureSecretKeyAccess.get() + ) + keysetFile.writeText(keysetJson) + keysetHandle + } catch (e: Exception) { + throw EncryptionException("키셋 생성 및 저장 실패", e) + } + } + + private fun loadKeysetHandle(keysetFile: File): KeysetHandle { + return try { + val keysetJson = keysetFile.readText() + TinkJsonProtoKeysetFormat.parseKeyset( + keysetJson, + InsecureSecretKeyAccess.get() + ) + } catch (e: Exception) { + throw EncryptionException("키셋 로드 실패", e) + } + } + + override fun encrypt(plaintext: String): String { + return try { + val plaintextBytes = plaintext.toByteArray(CHARSET) + val encryptedBytes = aead.encrypt(plaintextBytes, ASSOCIATED_DATA) + Base64.encodeToString(encryptedBytes, BASE64_FLAGS) + } catch (e: Exception) { + throw EncryptionException("암호화 실패", e) + } + } + + override fun decrypt(ciphertext: String): String { + return try { + val encryptedBytes = Base64.decode(ciphertext, BASE64_FLAGS) + val decryptedBytes = aead.decrypt(encryptedBytes, ASSOCIATED_DATA) + String(decryptedBytes, CHARSET) + } catch (e: Exception) { + throw EncryptionException("복호화 실패", e) + } + } + + companion object { + private const val KEYSET_FILENAME = "kiero_tink_keyset.json" + private val CHARSET = StandardCharsets.UTF_8 + private const val BASE64_FLAGS = Base64.NO_WRAP + private val ASSOCIATED_DATA = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kiero/core/security/di/SecurityModule.kt b/app/src/main/java/com/kiero/core/security/di/SecurityModule.kt new file mode 100644 index 0000000..0c73a4e --- /dev/null +++ b/app/src/main/java/com/kiero/core/security/di/SecurityModule.kt @@ -0,0 +1,24 @@ +package com.kiero.core.security.di + +import android.content.Context +import com.kiero.core.security.CryptoManager +import com.kiero.core.security.TinkCryptoManager +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 SecurityModule { + + @Provides + @Singleton + fun providesCryptoManager( + @ApplicationContext context: Context + ): CryptoManager { + return TinkCryptoManager(context) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kiero/data/auth/local/datasource/AuthLocalDataSource.kt b/app/src/main/java/com/kiero/data/auth/local/datasource/AuthLocalDataSource.kt new file mode 100644 index 0000000..d39c6de --- /dev/null +++ b/app/src/main/java/com/kiero/data/auth/local/datasource/AuthLocalDataSource.kt @@ -0,0 +1,16 @@ +package com.kiero.data.auth.local.datasource + + +interface AuthLocalDataSource { + + // @param token JWT 액세스 토큰 + suspend fun saveAccessToken(token: String) + // @param token JWT 리프레시 토큰 + suspend fun saveRefreshToken(token: String) + // @return 저장된 액세스 토큰, 없으면 null + suspend fun getAccessToken(): String? + // @return 저장된 리프레시 토큰, 없으면 null + suspend fun getRefreshToken(): String? + // 토큰 삭제. + suspend fun clearTokens() +} \ No newline at end of file diff --git a/app/src/main/java/com/kiero/data/auth/local/datasourceimpl/AuthLocalDataSourceImpl.kt b/app/src/main/java/com/kiero/data/auth/local/datasourceimpl/AuthLocalDataSourceImpl.kt new file mode 100644 index 0000000..694dcfa --- /dev/null +++ b/app/src/main/java/com/kiero/data/auth/local/datasourceimpl/AuthLocalDataSourceImpl.kt @@ -0,0 +1,78 @@ +package com.kiero.data.auth.local.datasourceimpl + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.kiero.core.common.util.suspendRunCatching +import com.kiero.core.security.CryptoManager +import com.kiero.data.auth.local.datasource.AuthLocalDataSource +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import timber.log.Timber +import javax.inject.Inject + +private const val DATASTORE_NAME = "kiero_auth_datastore" +private val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token") +private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token") + +private val Context.authDataStore: DataStore by preferencesDataStore( + name = DATASTORE_NAME +) + +class AuthLocalDataSourceImpl @Inject constructor( + @param:ApplicationContext private val context: Context, + private val cryptoManager: CryptoManager, +) : AuthLocalDataSource { + + override suspend fun saveAccessToken(token: String) { + suspendRunCatching { + val encryptedToken = cryptoManager.encrypt(token) + context.authDataStore.edit { it[KEY_ACCESS_TOKEN] = encryptedToken } + }.onFailure { throwable -> + Timber.e(throwable, "AccessToken 저장 실패") + } + } + + override suspend fun saveRefreshToken(token: String) { + suspendRunCatching { + val encryptedToken = cryptoManager.encrypt(token) + context.authDataStore.edit { it[KEY_REFRESH_TOKEN] = encryptedToken } + }.onFailure { throwable -> + Timber.e(throwable, "RefreshToken 저장 실패") + } + } + + override suspend fun getAccessToken(): String? { + return suspendRunCatching { + val encryptedToken = context.authDataStore.data + .map { it[KEY_ACCESS_TOKEN] } + .first() + encryptedToken?.let { cryptoManager.decrypt(it) } + }.onFailure { throwable -> + Timber.e(throwable, "AccessToken 로드 실패") + }.getOrNull() + } + + override suspend fun getRefreshToken(): String? { + return suspendRunCatching { + val encryptedToken = context.authDataStore.data + .map { it[KEY_REFRESH_TOKEN] } + .first() + encryptedToken?.let { cryptoManager.decrypt(it) } + }.onFailure { throwable -> + Timber.e(throwable, "RefreshToken 로드 실패") + }.getOrNull() + } + + override suspend fun clearTokens() { + suspendRunCatching { + context.authDataStore.edit { it.clear() } + }.onFailure { throwable -> + Timber.e(throwable, "토큰 삭제 실패") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kiero/data/auth/remote/api/AuthService.kt b/app/src/main/java/com/kiero/data/auth/remote/api/AuthService.kt new file mode 100644 index 0000000..169e680 --- /dev/null +++ b/app/src/main/java/com/kiero/data/auth/remote/api/AuthService.kt @@ -0,0 +1,13 @@ +package com.kiero.data.auth.remote.api + +import com.kiero.core.network.model.BaseResponse +import com.kiero.data.auth.remote.dto.response.AuthLoginResponseDto +import retrofit2.http.POST +import retrofit2.http.Query + +interface AuthService { + @POST("api/v1/parents/login/access-token") + suspend fun postAuthLogin( + @Query("accessToken") accessToken: String + ): BaseResponse +} \ No newline at end of file diff --git a/app/src/main/java/com/kiero/data/auth/remote/datasource/AuthDataSource.kt b/app/src/main/java/com/kiero/data/auth/remote/datasource/AuthDataSource.kt new file mode 100644 index 0000000..f6e3e45 --- /dev/null +++ b/app/src/main/java/com/kiero/data/auth/remote/datasource/AuthDataSource.kt @@ -0,0 +1,11 @@ +package com.kiero.data.auth.remote.datasource + +import android.content.Context +import com.kakao.sdk.auth.model.OAuthToken +import com.kiero.data.auth.remote.dto.response.AuthLoginResponseDto + + +interface AuthDataSource { + suspend fun getKakaoToken(context: Context): Result + suspend fun postAuthLogin(accessToken: String): Result +} \ No newline at end of file diff --git a/app/src/main/java/com/kiero/data/auth/remote/datasourceimpl/AuthDataSourceImpl.kt b/app/src/main/java/com/kiero/data/auth/remote/datasourceimpl/AuthDataSourceImpl.kt new file mode 100644 index 0000000..37ddb14 --- /dev/null +++ b/app/src/main/java/com/kiero/data/auth/remote/datasourceimpl/AuthDataSourceImpl.kt @@ -0,0 +1,81 @@ +package com.kiero.data.auth.remote.datasourceimpl + +import android.content.Context +import com.kakao.sdk.auth.model.OAuthToken +import com.kakao.sdk.common.model.ClientError +import com.kakao.sdk.common.model.ClientErrorCause +import com.kakao.sdk.user.UserApiClient +import com.kiero.core.common.util.suspendRunCatching +import com.kiero.data.auth.remote.api.AuthService +import com.kiero.data.auth.remote.datasource.AuthDataSource +import com.kiero.data.auth.remote.dto.response.AuthLoginResponseDto +import kotlinx.coroutines.suspendCancellableCoroutine +import timber.log.Timber +import javax.inject.Inject +import kotlin.coroutines.resume + +/** + * AuthDataSource 구현체 + */ +class AuthDataSourceImpl @Inject constructor( + private val authService: AuthService, +) : AuthDataSource { + + override suspend fun getKakaoToken(context: Context): Result = + suspendCancellableCoroutine { continuation -> + Timber.d("🚀 카카오 토큰 요청 시작") + + // 카카오계정으로 로그인하는 공통 콜백 + val accountCallback: (OAuthToken?, Throwable?) -> Unit = { token, error -> + when { + error != null -> { + Timber.e(error, "❌ 카카오 계정 로그인 실패") + continuation.resume(Result.failure(error)) + } + token != null -> { + Timber.i("✅ 카카오 계정 로그인 성공") + continuation.resume(Result.success(token)) + } + } + } + + // 카카오톡 설치 여부에 따라 분기 + if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { + Timber.d("📱 카카오톡 앱 로그인 시도") + UserApiClient.instance.loginWithKakaoTalk(context) { token, error -> + when { + error != null -> { + if (error is ClientError && error.reason == ClientErrorCause.Cancelled) { + Timber.w("⚠️ 사용자가 로그인을 취소함") + continuation.resume(Result.failure(error)) + } else { + Timber.e(error, "⚠️ 앱 로그인 실패 -> 계정 로그인으로 전환") + UserApiClient.instance.loginWithKakaoAccount(context, callback = accountCallback) + } + } + token != null -> { + Timber.i("✅ 카카오톡 앱 로그인 성공") + continuation.resume(Result.success(token)) + } + } + } + } else { + Timber.d("🌐 카카오톡 미설치: 계정 로그인 시도") + UserApiClient.instance.loginWithKakaoAccount(context, callback = accountCallback) + } + } + + /** + * 카카오 토큰으로 우리 서버에 로그인 + */ + override suspend fun postAuthLogin(accessToken: String): Result = + suspendRunCatching { + Timber.d("📡 서버 로그인 API 호출") + val response = authService.postAuthLogin(accessToken) + response.data ?: throw Exception("응답 데이터가 없습니다: ${response.message}") + }.onSuccess { + Timber.i("✅ 서버 로그인 API 응답 성공") + }.onFailure { + Timber.e(it, "❌ 서버 로그인 API 호출 실패") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kiero/data/auth/remote/dto/response/AuthLoginResponseDto.kt b/app/src/main/java/com/kiero/data/auth/remote/dto/response/AuthLoginResponseDto.kt new file mode 100644 index 0000000..cdba6ee --- /dev/null +++ b/app/src/main/java/com/kiero/data/auth/remote/dto/response/AuthLoginResponseDto.kt @@ -0,0 +1,21 @@ +package com.kiero.data.auth.remote.dto.response + + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AuthLoginResponseDto( + @SerialName("name") + val name: String, + @SerialName("email") + val email: String, + @SerialName("image") + val image: String, + @SerialName("role") + val role: String, + @SerialName("accessToken") + val accessToken: String, + @SerialName("refreshToken") + val refreshToken: String +) \ No newline at end of file diff --git a/app/src/main/java/com/kiero/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/kiero/data/auth/repository/AuthRepository.kt new file mode 100644 index 0000000..73e6a67 --- /dev/null +++ b/app/src/main/java/com/kiero/data/auth/repository/AuthRepository.kt @@ -0,0 +1,9 @@ +package com.kiero.data.auth.repository + +import android.content.Context +import com.kiero.data.auth.remote.dto.response.AuthLoginResponseDto + +interface AuthRepository { + suspend fun loginWithKakao(context: Context): Result + suspend fun saveAuthTokens(accessToken: String, refreshToken: String): Result +} \ No newline at end of file diff --git a/app/src/main/java/com/kiero/data/auth/repositoryimpl/AuthRepositoryImpl.kt b/app/src/main/java/com/kiero/data/auth/repositoryimpl/AuthRepositoryImpl.kt new file mode 100644 index 0000000..f959a7a --- /dev/null +++ b/app/src/main/java/com/kiero/data/auth/repositoryimpl/AuthRepositoryImpl.kt @@ -0,0 +1,50 @@ +package com.kiero.data.auth.repositoryimpl + +import android.content.Context +import com.kiero.core.common.util.handleError +import com.kiero.core.common.util.suspendRunCatching +import com.kiero.data.auth.local.datasource.AuthLocalDataSource +import com.kiero.data.auth.remote.datasource.AuthDataSource +import com.kiero.data.auth.remote.dto.response.AuthLoginResponseDto +import com.kiero.data.auth.repository.AuthRepository +import timber.log.Timber +import javax.inject.Inject + +class AuthRepositoryImpl @Inject constructor( + private val authRemoteDataSource: AuthDataSource, + private val authLocalDataSource: AuthLocalDataSource, +) : AuthRepository { + + override suspend fun loginWithKakao(context: Context): Result = suspendRunCatching { + Timber.d("🚀 카카오 로그인 프로세스 시작") + + // 1. 카카오 토큰 가져오기 (실패 시 여기서 바로 catch 블록으로 이동) + val kakaoToken = authRemoteDataSource.getKakaoToken(context).getOrThrow() + Timber.d("✅ 카카오 토큰 획득 성공") + + // 2. 서버 로그인 요청 (실패 시 여기서 바로 catch 블록으로 이동) + val loginResponse = authRemoteDataSource.postAuthLogin(kakaoToken.accessToken).getOrThrow() + Timber.i("🎉 서버 로그인 최종 성공") + + loginResponse + }.onFailure { throwable -> + // 공통 에러 로그 기록 (Throwable을 넘겨서 상세 정보 출력) + Timber.e(throwable, "❌ 로그인 과정 중 에러 발생") + }.mapCatching { response -> + // 성공 시 데이터 반환 (필요 시 여기서 데이터 가공 가능) + response + }.recoverCatching { throwable -> + // 에러 발생 시 사용자가 정의한 handleError 메시지로 변환하여 새로운 Failure 반환 + throw Exception(handleError(throwable)) + } + + override suspend fun saveAuthTokens(accessToken: String, refreshToken: String) = suspendRunCatching { + Timber.d("💾 토큰 저장 시작") + authLocalDataSource.saveAccessToken(accessToken) + authLocalDataSource.saveRefreshToken(refreshToken) + }.onSuccess { + Timber.i("✅ 토큰 저장 완료") + }.onFailure { + Timber.e(it, "❌ 토큰 저장 실패") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kiero/data/di/DataSourceModule.kt b/app/src/main/java/com/kiero/data/di/DataSourceModule.kt index 77a2f80..b3c506d 100644 --- a/app/src/main/java/com/kiero/data/di/DataSourceModule.kt +++ b/app/src/main/java/com/kiero/data/di/DataSourceModule.kt @@ -1,6 +1,10 @@ package com.kiero.data.di +import com.kiero.data.auth.local.datasource.AuthLocalDataSource +import com.kiero.data.auth.local.datasourceimpl.AuthLocalDataSourceImpl +import com.kiero.data.auth.remote.datasource.AuthDataSource import com.kiero.data.auth.remote.datasource.DummyDataSource +import com.kiero.data.auth.remote.datasourceimpl.AuthDataSourceImpl import com.kiero.data.auth.remote.datasourceimpl.DummyDataSourceImpl import dagger.Binds import dagger.Module @@ -17,4 +21,17 @@ abstract class DummyDataSourceModule { abstract fun bindDummyDataSource( dummyDataSourceImpl: DummyDataSourceImpl, ): DummyDataSource + + + @Binds + @Singleton + abstract fun bindAuthDataSource( + authDataSourceImpl: AuthDataSourceImpl, + ): AuthDataSource + + @Binds + @Singleton + abstract fun bindAuthLocalDataSource( + authLocalDataSourceImpl: AuthLocalDataSourceImpl, + ): AuthLocalDataSource } \ No newline at end of file diff --git a/app/src/main/java/com/kiero/data/di/RepositoryModule.kt b/app/src/main/java/com/kiero/data/di/RepositoryModule.kt index 0311472..026da9c 100644 --- a/app/src/main/java/com/kiero/data/di/RepositoryModule.kt +++ b/app/src/main/java/com/kiero/data/di/RepositoryModule.kt @@ -1,7 +1,9 @@ package com.kiero.data.di +import com.kiero.data.auth.repository.AuthRepository import com.kiero.data.auth.repositoryimpl.DummyRepositoryImpl import com.kiero.data.auth.repository.DummyRepository +import com.kiero.data.auth.repositoryimpl.AuthRepositoryImpl import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -18,4 +20,10 @@ interface RepositoryModule { dummyRepositoryImpl: DummyRepositoryImpl ): DummyRepository + @Binds + @Singleton + fun bindsAuthRepository( + authRepositoryImpl: AuthRepositoryImpl + ): AuthRepository + } \ No newline at end of file diff --git a/app/src/main/java/com/kiero/data/di/ServiceModule.kt b/app/src/main/java/com/kiero/data/di/ServiceModule.kt index 3f8b3d3..410c9cf 100644 --- a/app/src/main/java/com/kiero/data/di/ServiceModule.kt +++ b/app/src/main/java/com/kiero/data/di/ServiceModule.kt @@ -1,5 +1,6 @@ package com.kiero.data.di +import com.kiero.data.auth.remote.api.AuthService import com.kiero.data.auth.remote.api.DummyService import dagger.Module import dagger.Provides @@ -17,4 +18,10 @@ object ServiceModule { fun providesDummyService(retrofit: Retrofit): DummyService = retrofit.create(DummyService::class.java) + // 카카오 로그인 + @Provides + @Singleton + fun providesAuthService(retrofit: Retrofit): AuthService { + return retrofit.create(AuthService::class.java) + } } \ No newline at end of file diff --git a/app/src/main/java/com/kiero/presentation/auth/AuthScreen.kt b/app/src/main/java/com/kiero/presentation/auth/AuthScreen.kt index caec38b..339d3d8 100644 --- a/app/src/main/java/com/kiero/presentation/auth/AuthScreen.kt +++ b/app/src/main/java/com/kiero/presentation/auth/AuthScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Button -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -20,7 +19,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.kiero.core.designsystem.theme.KieroTheme -import com.kiero.core.model.UiState @Composable fun AuthRoute( diff --git a/app/src/main/java/com/kiero/presentation/auth/model/AuthContract.kt b/app/src/main/java/com/kiero/presentation/auth/model/AuthContract.kt index 19646bc..a079acc 100644 --- a/app/src/main/java/com/kiero/presentation/auth/model/AuthContract.kt +++ b/app/src/main/java/com/kiero/presentation/auth/model/AuthContract.kt @@ -1,18 +1,14 @@ package com.kiero.presentation.auth.model import androidx.compose.runtime.Immutable -import com.kiero.core.model.UiState -import com.kiero.data.auth.model.DummyEntity -import kotlinx.collections.immutable.PersistentList @Immutable -data class DummyState( - val uiState: UiState> = UiState.Loading, +data class AuthState( + val isLoading: Boolean = false, ) -sealed class DummySideEffect { - data class ShowSnackBar(val message: String) : DummySideEffect() - data object NavigateUp : DummySideEffect() - data object NavigateNext : DummySideEffect() +sealed interface AuthSideEffect { + data class ShowSnackBar(val message: String) : AuthSideEffect + data object NavigateUp : AuthSideEffect } diff --git a/app/src/main/java/com/kiero/presentation/auth/navigation/AuthNavigation.kt b/app/src/main/java/com/kiero/presentation/auth/navigation/AuthNavigation.kt index f978ba7..ff3460f 100644 --- a/app/src/main/java/com/kiero/presentation/auth/navigation/AuthNavigation.kt +++ b/app/src/main/java/com/kiero/presentation/auth/navigation/AuthNavigation.kt @@ -9,21 +9,24 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.navigation import com.kiero.core.navigation.Route import com.kiero.presentation.auth.AuthRoute +import com.kiero.presentation.auth.parent.AuthParentRoute +import com.kiero.presentation.auth.parent.navigation.ParentLogin +import com.kiero.presentation.auth.parent.navigation.navigateToAuthParent import kotlinx.serialization.Serializable -@Serializable -sealed interface Auth : Route + +interface Auth : Route @Serializable data object AuthGraph : Route @Serializable -data object Login : Auth +data object Selection : Auth fun NavController.navigateToAuth( navOptions: NavOptions? = null, ) { - navigate(Login, navOptions) + navigate(Selection, navOptions) } fun NavGraphBuilder.authNavGraph( @@ -33,16 +36,22 @@ fun NavGraphBuilder.authNavGraph( navigateToParent: () -> Unit, navigateToKid: () -> Unit, ) { - navigation( - startDestination = Login - ) { - composable { + navigation(startDestination = Selection) { + + composable { AuthRoute( paddingValues = paddingValues, navigateUp = navigateUp, - navigateToParent = navigateToParent, + navigateToParent = navController::navigateToAuthParent, navigateToKid = navigateToKid, ) } + + composable { + AuthParentRoute( + paddingValues = paddingValues, + navigateUp = navigateUp, + ) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/kiero/presentation/auth/parent/AuthParentScreen.kt b/app/src/main/java/com/kiero/presentation/auth/parent/AuthParentScreen.kt new file mode 100644 index 0000000..c9cc025 --- /dev/null +++ b/app/src/main/java/com/kiero/presentation/auth/parent/AuthParentScreen.kt @@ -0,0 +1,88 @@ +package com.kiero.presentation.auth.parent + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.kiero.core.designsystem.theme.KieroTheme +import com.kiero.presentation.auth.model.AuthSideEffect +import com.kiero.presentation.auth.parent.component.KakaoLoginButton +import com.kiero.presentation.auth.parent.viewmodel.ParentLoginViewModel + +@Composable +fun AuthParentRoute( + paddingValues: PaddingValues, + navigateUp: () -> Unit, + viewModel: ParentLoginViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(viewModel.sideEffect) { + viewModel.sideEffect.collect { effect -> + when (effect) { + is AuthSideEffect.ShowSnackBar -> { /* 스낵바 로직 */ } + is AuthSideEffect.NavigateUp -> navigateUp() + } + } + } + + AuthParentScreen( + paddingValues = paddingValues, + isLoading = state.isLoading, + onLoginClick = { viewModel.loginWithKakao(context) } + ) +} + +@Composable +fun AuthParentScreen( + paddingValues: PaddingValues, + isLoading: Boolean, + onLoginClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(KieroTheme.colors.black) + .padding(paddingValues) + .padding(horizontal = 15.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 상단에 빈 공간을 채워 버튼을 아래로 밀어내기 + Spacer(modifier = Modifier.weight(1f)) + + KakaoLoginButton( + onClick = onLoginClick, + isLoading = isLoading, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(65.dp)) + } +} + +@Preview(showBackground = true, name = "기본 화면") +@Composable +private fun LoginScreenPreview() { + KieroTheme { + AuthParentScreen( + paddingValues = PaddingValues(), + isLoading = false, + onLoginClick = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kiero/presentation/auth/parent/component/KakaoLoginButton.kt b/app/src/main/java/com/kiero/presentation/auth/parent/component/KakaoLoginButton.kt new file mode 100644 index 0000000..578476e --- /dev/null +++ b/app/src/main/java/com/kiero/presentation/auth/parent/component/KakaoLoginButton.kt @@ -0,0 +1,98 @@ +package com.kiero.presentation.auth.parent.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.kiero.R +import com.kiero.core.designsystem.theme.KieroTheme + + +@Composable +fun KakaoLoginButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + isLoading: Boolean = false +) { + Button( + onClick = onClick, + enabled = !isLoading, + modifier = modifier + .width(328.dp) + .height(45.dp), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFFAE100), + contentColor = KieroTheme.colors.black + ), + contentPadding = PaddingValues(top = 5.dp, end = 21.dp, bottom = 5.dp), + elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_kakao_login), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = KieroTheme.colors.black + ) + + Spacer(modifier = Modifier.width(41.dp)) + + Text( + text = if (isLoading) "로그인 중..." else "카카오톡 로그인", + style = KieroTheme.typography.semiBold.title3, + color = Color.Black + ) + } +} + +@Preview(showBackground = true, name = "카카오 로그인 - 기본") +@Composable +private fun KakaoLoginButtonPreview() { + KieroTheme { + Box( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + KakaoLoginButton( + onClick = {}, + isLoading = false + ) + } + } +} + +@Preview(showBackground = true, name = "카카오 로그인 - 로딩 중") +@Composable +private fun KakaoLoginButtonLoadingPreview() { + KieroTheme { + Box( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + KakaoLoginButton( + onClick = {}, + isLoading = true + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kiero/presentation/auth/parent/navigation/AuthParentNavigation.kt b/app/src/main/java/com/kiero/presentation/auth/parent/navigation/AuthParentNavigation.kt new file mode 100644 index 0000000..e4860ec --- /dev/null +++ b/app/src/main/java/com/kiero/presentation/auth/parent/navigation/AuthParentNavigation.kt @@ -0,0 +1,15 @@ +package com.kiero.presentation.auth.parent.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavOptions +import com.kiero.presentation.auth.navigation.Auth +import kotlinx.serialization.Serializable + +@Serializable +data object ParentLogin : Auth + +fun NavController.navigateToAuthParent( + navOptions: NavOptions? = null +) { + navigate(ParentLogin, navOptions) +} \ No newline at end of file diff --git a/app/src/main/java/com/kiero/presentation/auth/parent/viewmodel/ParentLoginViewModel.kt b/app/src/main/java/com/kiero/presentation/auth/parent/viewmodel/ParentLoginViewModel.kt new file mode 100644 index 0000000..9db6e16 --- /dev/null +++ b/app/src/main/java/com/kiero/presentation/auth/parent/viewmodel/ParentLoginViewModel.kt @@ -0,0 +1,45 @@ +package com.kiero.presentation.auth.parent.viewmodel + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.kiero.data.auth.repository.AuthRepository +import com.kiero.presentation.auth.model.AuthSideEffect +import com.kiero.presentation.auth.model.AuthState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class ParentLoginViewModel @Inject constructor( + private val authRepository: AuthRepository, +) : ViewModel() { + + private val _state = MutableStateFlow(AuthState()) + val state: StateFlow = _state.asStateFlow() + + private val _sideEffect = MutableSharedFlow() + val sideEffect = _sideEffect.asSharedFlow() // 외부 노출용은 읽기 전용으로 + fun loginWithKakao(context: Context) = viewModelScope.launch { + + Timber.Forest.d("로그인 프로세스 시작") + // 1. 로딩 시작 + _state.update { it.copy(isLoading = true) } + + authRepository.loginWithKakao(context) + .onSuccess { + Timber.Forest.i("로그인 최종 성공") + _state.update { it.copy(isLoading = false) } + }.onFailure { throwable -> + Timber.Forest.e(throwable, "로그인 실패 에러 발생") + _state.update { it.copy(isLoading = false) } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kiero/presentation/auth/viewmodel/AuthViewModel.kt b/app/src/main/java/com/kiero/presentation/auth/viewmodel/AuthViewModel.kt index f5ff987..64f0302 100644 --- a/app/src/main/java/com/kiero/presentation/auth/viewmodel/AuthViewModel.kt +++ b/app/src/main/java/com/kiero/presentation/auth/viewmodel/AuthViewModel.kt @@ -3,54 +3,35 @@ package com.kiero.presentation.auth.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.kiero.core.common.util.handleError -import com.kiero.core.model.UiState -import com.kiero.data.auth.repository.DummyRepository -import com.kiero.presentation.auth.model.DummySideEffect -import com.kiero.presentation.auth.model.DummyState - +import com.kiero.data.auth.repository.AuthRepository +import com.kiero.presentation.auth.model.AuthSideEffect +import com.kiero.presentation.auth.model.AuthState import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @HiltViewModel class AuthViewModel @Inject constructor( - private val dummyRepository: DummyRepository, + private val authRepository: AuthRepository, ) : ViewModel() { - private val _state = MutableStateFlow(DummyState()) - val state: StateFlow = _state.asStateFlow() - - private val _sideEffect = MutableSharedFlow() - val sideEffect: MutableSharedFlow = _sideEffect + private val _state = MutableStateFlow(AuthState()) + val state: StateFlow = _state.asStateFlow() - fun getDummyList() = viewModelScope.launch { - dummyRepository.getDummyList() - .onSuccess { - _state.value = _state.value.copy( - uiState = UiState.Success(it.toPersistentList()) - ) - }.onFailure { throwable -> - val errorMessage = handleError(throwable) - _state.value = _state.value.copy( - uiState = UiState.Failure(errorMessage) - ) - showSnackBar(errorMessage) - } - } + private val _sideEffect = MutableSharedFlow() + val sideEffect = _sideEffect.asSharedFlow() // 외부 노출용은 읽기 전용으로 private fun showSnackBar(message: String) = viewModelScope.launch { - _sideEffect.emit(DummySideEffect.ShowSnackBar(message)) + _sideEffect.emit(AuthSideEffect.ShowSnackBar(message)) } fun navigateUp() = viewModelScope.launch { - _sideEffect.emit(DummySideEffect.NavigateUp) - } - - fun navigateNext() = viewModelScope.launch { - _sideEffect.emit(DummySideEffect.NavigateNext) + _sideEffect.emit(AuthSideEffect.NavigateUp) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 305df95..834a696 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,6 +48,12 @@ material = "1.13.0" #Logging timber = "5.0.1" +#Kakao SDK +kakao = "2.20.6" + +# DataStore & Tink (여기 추가!) +datastore = "1.0.0" +tink = "1.15.0" [libraries] # Test @@ -100,6 +106,13 @@ material = { group = "com.google.android.material", name = "material", version.r # Timber timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } +# Kakao SDK +kakao-user = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao" } + +# DataStore & Tink +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +tink-android = { group = "com.google.crypto.tink", name = "tink-android", version.ref = "tink" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index d77624b..4b0d903 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://devrepo.kakao.com/nexus/content/groups/public/") } } }