diff --git a/android/festago/common/build.gradle.kts b/android/festago/common/build.gradle.kts index 796d1ff0d..9fd20d703 100644 --- a/android/festago/common/build.gradle.kts +++ b/android/festago/common/build.gradle.kts @@ -38,10 +38,15 @@ android { kotlin.jvmToolchain(17) dependencies { + // common + implementation(project(":domain")) // hilt implementation("com.google.dagger:hilt-android:2.44") kapt("com.google.dagger:hilt-android-compiler:2.44") + // kakao login + implementation("com.kakao.sdk:v2-user:2.12.0") + // firebase implementation(platform("com.google.firebase:firebase-bom:32.2.0")) implementation("com.google.firebase:firebase-analytics-ktx") diff --git a/android/festago/common/src/main/java/com/festago/festago/common/kakao/KakaoAuthorization.kt b/android/festago/common/src/main/java/com/festago/festago/common/kakao/KakaoAuthorization.kt new file mode 100644 index 000000000..d9a97983f --- /dev/null +++ b/android/festago/common/src/main/java/com/festago/festago/common/kakao/KakaoAuthorization.kt @@ -0,0 +1,86 @@ +package com.festago.festago.common.kakao + +import android.content.Context +import com.festago.festago.domain.model.nonce.NonceGenerator +import com.kakao.sdk.auth.TokenManagerProvider +import com.kakao.sdk.common.model.ClientError +import com.kakao.sdk.common.model.ClientErrorCause +import com.kakao.sdk.user.UserApiClient +import javax.inject.Inject +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +class KakaoAuthorization @Inject constructor() { + + suspend fun getIdToken(context: Context): Result { + return runCatching { + val nonce = NonceGenerator().generate() + if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { + try { + loginWithKakaoTalk(context, nonce) + } catch (error: Throwable) { + if (error is ClientError && error.reason == ClientErrorCause.Cancelled) throw error + loginWithKakaoAccount(context, nonce) + } + } else { + loginWithKakaoAccount(context, nonce) + } + } + } + + private suspend fun loginWithKakaoTalk(context: Context, nonce: String?): String { + return suspendCoroutine { continuation -> + UserApiClient.instance.loginWithKakaoTalk(context, nonce = nonce) { token, error -> + if (error != null) { + continuation.resumeWithException(error) + } else if (token != null) { + if (token.idToken != null) { + continuation.resume(token.idToken!!) + } else { + continuation.resumeWithException(RuntimeException("Failure get kakao id token")) + } + } else { + continuation.resumeWithException(RuntimeException("Failure get kakao access token")) + } + } + } + } + + private suspend fun loginWithKakaoAccount(context: Context, nonce: String?): String { + return suspendCoroutine { continuation -> + UserApiClient.instance.loginWithKakaoAccount(context, nonce = nonce) { token, error -> + if (error != null) { + continuation.resumeWithException(error) + } else if (token != null) { + if (token.idToken != null) { + continuation.resume(token.idToken!!) + } else { + continuation.resumeWithException(RuntimeException("Failure get kakao id token")) + } + } else { + continuation.resumeWithException(RuntimeException("Failure get kakao access token")) + } + } + } + } + + suspend fun signOut(): Result { + UserApiClient.instance.logout {} + return Result.success(Unit) + } + + suspend fun deleteAccount(): Result { + return suspendCoroutine> { continuation -> + TokenManagerProvider.instance.manager.getToken()?.let { + UserApiClient.instance.unlink { error -> + if (error == null) { + continuation.resume(Result.success(Unit)) + } else { + continuation.resumeWithException(error) + } + } + } ?: continuation.resume(Result.success(Unit)) + } + } +} diff --git a/android/festago/data/build.gradle.kts b/android/festago/data/build.gradle.kts index 6abd2785e..065a0b329 100644 --- a/android/festago/data/build.gradle.kts +++ b/android/festago/data/build.gradle.kts @@ -49,6 +49,7 @@ kotlin.jvmToolchain(17) dependencies { implementation(project(":domain")) + implementation(project(":common")) // hilt implementation("com.google.dagger:hilt-android:2.50") @@ -68,9 +69,6 @@ dependencies { // Encrypted SharedPreference implementation("androidx.security:security-crypto-ktx:1.1.0-alpha06") - // kakao login - implementation("com.kakao.sdk:v2-user:2.12.0") - // junit4 testImplementation("junit:junit:4.13.2") testImplementation("androidx.test.ext:junit:1.1.5") @@ -93,6 +91,9 @@ dependencies { // logging httpLoggingInterceptor implementation("com.squareup.okhttp3:logging-interceptor:4.11.0") + + // gson + implementation("com.google.code.gson:gson:2.9.0") } fun getSecretKey(propertyKey: String): String { diff --git a/android/festago/data/src/main/java/com/festago/festago/data/datasource/token/TokenDataSource.kt b/android/festago/data/src/main/java/com/festago/festago/data/datasource/token/TokenDataSource.kt new file mode 100644 index 000000000..d61066b05 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/datasource/token/TokenDataSource.kt @@ -0,0 +1,8 @@ +package com.festago.festago.data.datasource.token + +import com.festago.festago.data.model.TokenEntity + +interface TokenDataSource { + var accessToken: TokenEntity? + var refreshToken: TokenEntity? +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/datasource/token/TokenLocalDataSource.kt b/android/festago/data/src/main/java/com/festago/festago/data/datasource/token/TokenLocalDataSource.kt new file mode 100644 index 000000000..27bffe848 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/datasource/token/TokenLocalDataSource.kt @@ -0,0 +1,43 @@ +package com.festago.festago.data.datasource.token + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.festago.festago.data.model.TokenEntity +import com.festago.festago.data.util.getObject +import com.festago.festago.data.util.putObject +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class TokenLocalDataSource @Inject constructor( + @ApplicationContext context: Context, +) : TokenDataSource { + + private val sharedPreference: SharedPreferences by lazy { + val masterKeyAlias = MasterKey + .Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + EncryptedSharedPreferences(context, ENCRYPTED_PREF_FILE, masterKeyAlias) + } + + override var accessToken: TokenEntity? + get() = sharedPreference.getObject(ACCESS_TOKEN_KEY, null) + set(value) { + sharedPreference.putObject(ACCESS_TOKEN_KEY, value) + } + + override var refreshToken: TokenEntity? + get() = sharedPreference.getObject(REFRESH_TOKEN_KEY, null) + set(value) { + sharedPreference.putObject(REFRESH_TOKEN_KEY, value) + } + + companion object { + private const val ENCRYPTED_PREF_FILE = "encrypted_pref_file" + private const val ACCESS_TOKEN_KEY = "access_token_key" + private const val REFRESH_TOKEN_KEY = "refresh_token_key" + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/datasource/userinfo/UserInfoDataSource.kt b/android/festago/data/src/main/java/com/festago/festago/data/datasource/userinfo/UserInfoDataSource.kt new file mode 100644 index 000000000..dc2f0dc2e --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/datasource/userinfo/UserInfoDataSource.kt @@ -0,0 +1,7 @@ +package com.festago.festago.data.datasource.userinfo + +import com.festago.festago.data.model.UserInfoEntity + +interface UserInfoDataSource { + var userInfo: UserInfoEntity? +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/datasource/userinfo/UserInfoLocalDataSource.kt b/android/festago/data/src/main/java/com/festago/festago/data/datasource/userinfo/UserInfoLocalDataSource.kt new file mode 100644 index 000000000..2a9b3d9bb --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/datasource/userinfo/UserInfoLocalDataSource.kt @@ -0,0 +1,29 @@ +package com.festago.festago.data.datasource.userinfo + +import android.content.Context +import android.content.SharedPreferences +import com.festago.festago.data.model.UserInfoEntity +import com.festago.festago.data.util.getObject +import com.festago.festago.data.util.putObject +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class UserInfoLocalDataSource @Inject constructor( + @ApplicationContext context: Context, +) : UserInfoDataSource { + + private val sharedPreference: SharedPreferences by lazy { + context.getSharedPreferences(USER_INFO_PREF, Context.MODE_PRIVATE) + } + + override var userInfo: UserInfoEntity? + get() = sharedPreference.getObject(USER_ID_KEY, null) + set(value) { + sharedPreference.putObject(USER_ID_KEY, value) + } + + companion object { + private const val USER_INFO_PREF = "user_info_pref" + private const val USER_ID_KEY = "user_info_key" + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/ApiModule.kt b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/ApiModule.kt index dce0fa52c..ffe5fcb73 100644 --- a/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/ApiModule.kt +++ b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/ApiModule.kt @@ -1,6 +1,8 @@ package com.festago.festago.data.di.singletonscope import com.festago.festago.data.BuildConfig +import com.festago.festago.data.retrofit.AuthInterceptor +import com.festago.festago.domain.repository.UserRepository import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import dagger.Module import dagger.Provides @@ -8,6 +10,7 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient import retrofit2.Retrofit import javax.inject.Qualifier import javax.inject.Singleton @@ -20,6 +23,14 @@ annotation class NormalRetrofitQualifier @Retention(AnnotationRetention.BINARY) annotation class BaseUrlQualifier +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AuthRetrofitQualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AuthOkHttpClientQualifier + @InstallIn(SingletonComponent::class) @Module object ApiModule { @@ -32,6 +43,14 @@ object ApiModule { return json.asConverterFactory("application/json".toMediaType()) } + @Provides + @Singleton + @AuthOkHttpClientQualifier + fun provideOkHttpClient(userRepository: UserRepository): OkHttpClient = OkHttpClient + .Builder() + .addInterceptor(AuthInterceptor(userRepository)) + .build() + @Provides @Singleton @NormalRetrofitQualifier @@ -43,6 +62,19 @@ object ApiModule { .addConverterFactory(converterFactory) .build() + @Provides + @Singleton + @AuthRetrofitQualifier + fun providesAuthRetrofit( + @BaseUrlQualifier baseUrl: String, + @AuthOkHttpClientQualifier okHttpClient: OkHttpClient, + converterFactory: retrofit2.Converter.Factory, + ): Retrofit = Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(converterFactory) + .build() + @Provides @Singleton @BaseUrlQualifier diff --git a/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/DataSourceModule.kt b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/DataSourceModule.kt new file mode 100644 index 000000000..b0acbb14c --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/DataSourceModule.kt @@ -0,0 +1,21 @@ +package com.festago.festago.data.di.singletonscope + +import com.festago.festago.data.datasource.token.TokenDataSource +import com.festago.festago.data.datasource.token.TokenLocalDataSource +import com.festago.festago.data.datasource.userinfo.UserInfoDataSource +import com.festago.festago.data.datasource.userinfo.UserInfoLocalDataSource +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface DataSourceModule { + + @Binds + fun bindsTokenDataSource(tokenDataSource: TokenLocalDataSource): TokenDataSource + + @Binds + fun bindsUserInfoDataSource(userInfoDataSource: UserInfoLocalDataSource): UserInfoDataSource +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt index de6387478..8b30e0ec9 100644 --- a/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt +++ b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt @@ -5,6 +5,7 @@ import com.festago.festago.data.repository.DefaultFestivalRepository import com.festago.festago.data.repository.DefaultRecentSearchRepository import com.festago.festago.data.repository.DefaultSchoolRepository import com.festago.festago.data.repository.DefaultSearchRepository +import com.festago.festago.data.repository.DefaultUserRepository import com.festago.festago.data.repository.FakeBookmarkRepository import com.festago.festago.domain.repository.ArtistRepository import com.festago.festago.domain.repository.BookmarkRepository @@ -12,6 +13,7 @@ import com.festago.festago.domain.repository.FestivalRepository import com.festago.festago.domain.repository.RecentSearchRepository import com.festago.festago.domain.repository.SchoolRepository import com.festago.festago.domain.repository.SearchRepository +import com.festago.festago.domain.repository.UserRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -44,4 +46,8 @@ interface RepositoryModule { @Binds @Singleton fun bindsBookmarkRepository(bookmarkRepository: FakeBookmarkRepository): BookmarkRepository + + @Binds + @Singleton + fun bindsUserRepository(userRepository: DefaultUserRepository): UserRepository } diff --git a/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/ServiceModule.kt b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/ServiceModule.kt index 482ab4122..9ea7f788d 100644 --- a/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/ServiceModule.kt +++ b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/ServiceModule.kt @@ -1,6 +1,7 @@ package com.festago.festago.data.di.singletonscope import com.festago.festago.data.service.ArtistRetrofitService +import com.festago.festago.data.service.AuthRetrofitService import com.festago.festago.data.service.BookmarkRetrofitService import com.festago.festago.data.service.FestivalRetrofitService import com.festago.festago.data.service.SchoolRetrofitService @@ -54,4 +55,12 @@ object ServiceModule { ): BookmarkRetrofitService { return retrofit.create(BookmarkRetrofitService::class.java) } + + @Provides + @Singleton + fun providesAuthRetrofitService( + @NormalRetrofitQualifier retrofit: Retrofit, + ): AuthRetrofitService { + return retrofit.create(AuthRetrofitService::class.java) + } } diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/user/RefreshRequest.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/RefreshRequest.kt new file mode 100644 index 000000000..7987ec816 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/RefreshRequest.kt @@ -0,0 +1,8 @@ +package com.festago.festago.data.dto.user + +import kotlinx.serialization.Serializable + +@Serializable +class RefreshRequest( + val refreshToken: String, +) diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/user/RefreshResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/RefreshResponse.kt new file mode 100644 index 000000000..99415e01c --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/RefreshResponse.kt @@ -0,0 +1,9 @@ +package com.festago.festago.data.dto.user + +import kotlinx.serialization.Serializable + +@Serializable +data class RefreshResponse( + val accessToken: TokenResponse, + val refreshToken: TokenResponse, +) diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/user/SignInRequest.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/SignInRequest.kt new file mode 100644 index 000000000..3bfa725a5 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/SignInRequest.kt @@ -0,0 +1,9 @@ +package com.festago.festago.data.dto.user + +import kotlinx.serialization.Serializable + +@Serializable +class SignInRequest( + val socialType: String, + val idToken: String, +) diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/user/SignInResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/SignInResponse.kt new file mode 100644 index 000000000..dfdf7b582 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/SignInResponse.kt @@ -0,0 +1,11 @@ +package com.festago.festago.data.dto.user + +import kotlinx.serialization.Serializable + +@Serializable +data class SignInResponse( + val nickname: String, + val profileImageUrl: String, + val accessToken: TokenResponse, + val refreshToken: TokenResponse, +) diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/user/TokenResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/TokenResponse.kt new file mode 100644 index 000000000..b02033d36 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/TokenResponse.kt @@ -0,0 +1,22 @@ +package com.festago.festago.data.dto.user + +import com.festago.festago.data.model.TokenEntity +import com.festago.festago.domain.model.user.Token +import kotlinx.serialization.Serializable +import java.time.LocalDateTime + +@Serializable +data class TokenResponse( + val token: String, + val expiredAt: String, +) { + fun toDomain() = Token( + token = token, + expiredAt = LocalDateTime.parse(expiredAt), + ) + + fun toEntity() = TokenEntity( + token = token, + expiredAt = expiredAt, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/model/TokenEntity.kt b/android/festago/data/src/main/java/com/festago/festago/data/model/TokenEntity.kt new file mode 100644 index 000000000..598f2e426 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/model/TokenEntity.kt @@ -0,0 +1,14 @@ +package com.festago.festago.data.model + +import com.festago.festago.domain.model.user.Token +import java.time.LocalDateTime + +data class TokenEntity( + val token: String, + val expiredAt: String, +) { + fun toDomain() = Token( + token = token, + expiredAt = LocalDateTime.parse(expiredAt), + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/model/UserInfoEntity.kt b/android/festago/data/src/main/java/com/festago/festago/data/model/UserInfoEntity.kt new file mode 100644 index 000000000..f1cb00503 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/model/UserInfoEntity.kt @@ -0,0 +1,13 @@ +package com.festago.festago.data.model + +import com.festago.festago.domain.model.user.UserInfo + +data class UserInfoEntity( + val nickname: String, + val profileImageUrl: String, +) { + fun toDomain() = UserInfo( + nickname = nickname, + profileImageUrl = profileImageUrl, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultUserRepository.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultUserRepository.kt new file mode 100644 index 000000000..6eee73cc8 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultUserRepository.kt @@ -0,0 +1,123 @@ +package com.festago.festago.data.repository + +import android.content.Context +import android.content.SharedPreferences +import com.festago.festago.common.kakao.KakaoAuthorization +import com.festago.festago.data.datasource.token.TokenDataSource +import com.festago.festago.data.datasource.userinfo.UserInfoDataSource +import com.festago.festago.data.dto.user.RefreshRequest +import com.festago.festago.data.dto.user.SignInRequest +import com.festago.festago.data.model.UserInfoEntity +import com.festago.festago.data.service.AuthRetrofitService +import com.festago.festago.data.util.onSuccessOrCatch +import com.festago.festago.data.util.runCatchingResponse +import com.festago.festago.domain.model.user.Token +import com.festago.festago.domain.model.user.UserInfo +import com.festago.festago.domain.repository.UserRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class DefaultUserRepository @Inject constructor( + private val authRetrofitService: AuthRetrofitService, + private val tokenDataSource: TokenDataSource, + private val kakaoAuthorization: KakaoAuthorization, + private val userInfoDataSource: UserInfoDataSource, + @ApplicationContext context: Context, +) : UserRepository { + + private val authPref: SharedPreferences by lazy { + context.getSharedPreferences(AUTH_PREF, Context.MODE_PRIVATE) + } + + override suspend fun isSigned() = getRefreshToken().isSuccess + + override suspend fun isSignRejected() = authPref.getBoolean(IS_SIGN_REJECTED, false) + + override suspend fun getAccessToken(): Result { + val token = tokenDataSource.accessToken?.toDomain() + ?: return Result.failure(NullPointerException("Access token is null")) + + if (!token.isExpired()) { + return Result.success(token) + } + + return try { + val refreshToken = getRefreshToken().getOrThrow() + refresh(refreshToken).getOrThrow() + Result.success(tokenDataSource.accessToken?.toDomain()!!) + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun getRefreshToken(): Result { + val refreshToken = tokenDataSource.refreshToken?.toDomain() + ?: return Result.failure(NullPointerException("Refresh token is null")) + + if (refreshToken.isExpired()) { + return Result.failure(Exception("Refresh token is expired")) + } + + return Result.success(refreshToken) + } + + override suspend fun signIn(idToken: String): Result { + return runCatchingResponse { + authRetrofitService.signIn(SignInRequest(SOCIAL_TYPE, idToken)) + }.onSuccessOrCatch { signInResponse -> + userInfoDataSource.userInfo = + UserInfoEntity(signInResponse.nickname, signInResponse.profileImageUrl) + tokenDataSource.accessToken = signInResponse.accessToken.toEntity() + tokenDataSource.refreshToken = signInResponse.refreshToken.toEntity() + } + } + + override suspend fun rejectSignIn() { + if (isSigned() || isSignRejected()) return + authPref.edit().putBoolean(IS_SIGN_REJECTED, true).apply() + } + + override suspend fun signOut(): Result { + return runCatchingResponse { + authRetrofitService.signOut( + AUTHORIZATION_TOKEN_FORMAT.format(getAccessToken().getOrThrow()), + ) + }.onSuccessOrCatch { + kakaoAuthorization.signOut() + tokenDataSource.accessToken = null + tokenDataSource.refreshToken = null + } + } + + override suspend fun deleteAccount(): Result { + return runCatchingResponse { + authRetrofitService.deleteAccount( + AUTHORIZATION_TOKEN_FORMAT.format(getAccessToken().getOrThrow()), + ) + }.onSuccessOrCatch { + kakaoAuthorization.deleteAccount() + tokenDataSource.accessToken = null + tokenDataSource.refreshToken = null + } + } + + private suspend fun refresh(refreshToken: Token): Result { + return runCatchingResponse { + authRetrofitService.refresh(RefreshRequest(refreshToken.token)) + }.onSuccessOrCatch { refreshTokenResponse -> + tokenDataSource.accessToken = refreshTokenResponse.accessToken.toEntity() + } + } + + override suspend fun getUserInfo(): Result { + return userInfoDataSource.userInfo?.toDomain()?.let { Result.success(it) } + ?: Result.failure(NullPointerException("User info is null")) + } + + companion object { + private const val SOCIAL_TYPE = "KAKAO" + private const val AUTH_PREF = "auth_pref" + private const val IS_SIGN_REJECTED = "is_sign_rejected" + private const val AUTHORIZATION_TOKEN_FORMAT = "Bearer %s" + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeUserRepository.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeUserRepository.kt new file mode 100644 index 000000000..b3cb0c585 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeUserRepository.kt @@ -0,0 +1,46 @@ +package com.festago.festago.data.repository + +import com.festago.festago.domain.model.user.Token +import com.festago.festago.domain.model.user.UserInfo +import com.festago.festago.domain.repository.UserRepository +import java.time.LocalDateTime +import javax.inject.Inject + +class FakeUserRepository @Inject constructor() : UserRepository { + + override suspend fun isSignRejected(): Boolean { + return false + } + + override suspend fun isSigned(): Boolean { + return true + } + + override suspend fun getRefreshToken(): Result { + return Result.success(Token("", LocalDateTime.now())) + } + + override suspend fun getAccessToken(): Result { + return Result.success(Token("", LocalDateTime.now())) + } + + override suspend fun signIn(idToken: String): Result { + return Result.success(Unit) + } + + override suspend fun signOut(): Result { + return Result.success(Unit) + } + + override suspend fun rejectSignIn() { + // handle reject sign in + } + + override suspend fun deleteAccount(): Result { + return Result.success(Unit) + } + + override suspend fun getUserInfo(): Result { + return Result.success(UserInfo("", "")) + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/retrofit/AuthInterceptor.kt b/android/festago/data/src/main/java/com/festago/festago/data/retrofit/AuthInterceptor.kt new file mode 100644 index 000000000..a9727cbeb --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/retrofit/AuthInterceptor.kt @@ -0,0 +1,32 @@ +package com.festago.festago.data.retrofit + +import com.festago.festago.domain.repository.UserRepository +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response + +class AuthInterceptor(private val userRepository: UserRepository) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + return runBlocking { + chain.proceed(request = getNewRequest(chain)) + } + } + + private suspend fun getNewRequest(chain: Interceptor.Chain): Request = + chain.request() + .newBuilder() + .addHeader( + HEADER_AUTHORIZATION, + AUTHORIZATION_TOKEN_FORMAT.format( + userRepository.getAccessToken().getOrNull()?.token ?: "TokenIsNull", + ), + ) + .build() + + companion object { + private const val HEADER_AUTHORIZATION = "Authorization" + private const val AUTHORIZATION_TOKEN_FORMAT = "Bearer %s" + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/service/AuthRetrofitService.kt b/android/festago/data/src/main/java/com/festago/festago/data/service/AuthRetrofitService.kt new file mode 100644 index 000000000..ccaa38fe8 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/service/AuthRetrofitService.kt @@ -0,0 +1,33 @@ +package com.festago.festago.data.service + +import com.festago.festago.data.dto.user.RefreshRequest +import com.festago.festago.data.dto.user.RefreshResponse +import com.festago.festago.data.dto.user.SignInRequest +import com.festago.festago.data.dto.user.SignInResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.Header +import retrofit2.http.POST + +interface AuthRetrofitService { + @POST("api/v1/auth/login/open-id") + suspend fun signIn( + @Body signInRequest: SignInRequest, + ): Response + + @POST("api/v1/auth/refresh") + suspend fun refresh( + @Body refreshRequest: RefreshRequest, + ): Response + + @POST("api/v1/auth/logout") + suspend fun signOut( + @Header("Authorization") token: String, + ): Response + + @DELETE("api/v1/auth") + suspend fun deleteAccount( + @Header("Authorization") token: String, + ): Response +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/util/ResponseExt.kt b/android/festago/data/src/main/java/com/festago/festago/data/util/ResponseExt.kt index 8203cc483..bb0466454 100644 --- a/android/festago/data/src/main/java/com/festago/festago/data/util/ResponseExt.kt +++ b/android/festago/data/src/main/java/com/festago/festago/data/util/ResponseExt.kt @@ -1,5 +1,6 @@ package com.festago.festago.data.util +import com.festago.festago.domain.exception.UnauthorizedException import retrofit2.Response suspend fun runCatchingResponse( @@ -11,6 +12,10 @@ suspend fun runCatchingResponse( return Result.success(response.body()!!) } + if (response.code() == 401) { + throw UnauthorizedException() + } + return Result.failure( Throwable( "{" + diff --git a/android/festago/data/src/main/java/com/festago/festago/data/util/SharedPrefExt.kt b/android/festago/data/src/main/java/com/festago/festago/data/util/SharedPrefExt.kt new file mode 100644 index 000000000..4bf934298 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/util/SharedPrefExt.kt @@ -0,0 +1,14 @@ +package com.festago.festago.data.util + +import android.content.SharedPreferences +import com.google.gson.GsonBuilder + +inline fun SharedPreferences.putObject(key: String, value: T?) { + val jsonString = GsonBuilder().create().toJson(value) + edit().putString(key, jsonString).apply() +} + +inline fun SharedPreferences.getObject(key: String, default: T?): T? { + val value = getString(key, null) ?: return default + return GsonBuilder().create().fromJson(value, T::class.java) +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/exception/UnauthorizedException.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/exception/UnauthorizedException.kt new file mode 100644 index 000000000..62a7e4335 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/exception/UnauthorizedException.kt @@ -0,0 +1,6 @@ +package com.festago.festago.domain.exception + +/* 에러 코드가 401 일때 예외 */ +class UnauthorizedException : Exception() + +fun Throwable.isUnauthorized() = this is UnauthorizedException diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/nonce/NonceGenerator.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/nonce/NonceGenerator.kt new file mode 100644 index 000000000..62276febc --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/nonce/NonceGenerator.kt @@ -0,0 +1,13 @@ +package com.festago.festago.domain.model.nonce + +class NonceGenerator { + fun generate() = + List((MIN_LENGTH..MAX_LENGTH).random()) { (MIN_CHAR..MAX_CHAR).random() }.joinToString("") + + companion object { + private const val MIN_LENGTH = 3 + private const val MAX_LENGTH = 6 + private const val MIN_CHAR = 'a' + private const val MAX_CHAR = 'z' + } +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/user/Token.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/user/Token.kt new file mode 100644 index 000000000..58869af3d --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/user/Token.kt @@ -0,0 +1,10 @@ +package com.festago.festago.domain.model.user + +import java.time.LocalDateTime + +data class Token( + val token: String, + val expiredAt: LocalDateTime, +) { + fun isExpired() = LocalDateTime.now().isAfter(expiredAt) +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/user/UserInfo.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/user/UserInfo.kt new file mode 100644 index 000000000..43b7227ac --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/user/UserInfo.kt @@ -0,0 +1,6 @@ +package com.festago.festago.domain.model.user + +data class UserInfo( + val nickname: String, + val profileImageUrl: String, +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/repository/UserRepository.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/UserRepository.kt new file mode 100644 index 000000000..f2eef3405 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/UserRepository.kt @@ -0,0 +1,16 @@ +package com.festago.festago.domain.repository + +import com.festago.festago.domain.model.user.Token +import com.festago.festago.domain.model.user.UserInfo + +interface UserRepository { + suspend fun isSignRejected(): Boolean + suspend fun isSigned(): Boolean + suspend fun getAccessToken(): Result + suspend fun getRefreshToken(): Result + suspend fun signIn(idToken: String): Result + suspend fun signOut(): Result + suspend fun deleteAccount(): Result + suspend fun rejectSignIn() + suspend fun getUserInfo(): Result +} diff --git a/android/festago/presentation/src/main/AndroidManifest.xml b/android/festago/presentation/src/main/AndroidManifest.xml index fb67ef37a..5ab54f712 100644 --- a/android/festago/presentation/src/main/AndroidManifest.xml +++ b/android/festago/presentation/src/main/AndroidManifest.xml @@ -2,6 +2,25 @@ + + + + + + + + + + + navigateToHome() + SignInEvent.SignInFailure -> handleSignInFailure() + } + } + } + } + + private fun initWindowInsets() { + WindowCompat.setDecorFitsSystemWindows(window, false) + binding.root.setOnApplyWindowInsetsCompatListener { view, windowInsets -> + val navigationInsets = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()) + view.setPadding(0, 0, 0, navigationInsets.bottom) + window.navigationBarColor = Color.TRANSPARENT + window.statusBarColor = Color.TRANSPARENT + setStatusBarMode(isLight = false, backgroundColor = Color.TRANSPARENT) + windowInsets + } + } + + private fun initKakaoLogin() { + binding.btnKakaoLogin.setOnClickListener { + lifecycleScope.launch { + kakaoAuthorization.getIdToken(this@SignInActivity) + .onSuccess { idToken -> + vm.signIn(idToken) + }.onFailure { error -> + handleSignInFailure() + } + } + } + } + + private fun initWithoutLogin() { + binding.tvWithoutLogin.setOnClickListener { + vm.rejectSignIn() + } + } + + private fun navigateToHome() { + startActivity(HomeActivity.getIntent(this)) + finish() + } + + private fun handleSignInFailure() { + Toast.makeText( + this, + getString(R.string.sign_in_default_error_message), + Toast.LENGTH_SHORT, + ).show() + } + + companion object { + + fun getIntent(context: Context): Intent { + return Intent(context, SignInActivity::class.java) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/signin/SignInEvent.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/signin/SignInEvent.kt new file mode 100644 index 000000000..fb11829e1 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/signin/SignInEvent.kt @@ -0,0 +1,6 @@ +package com.festago.festago.presentation.ui.signin + +sealed interface SignInEvent { + object ShowHome : SignInEvent + object SignInFailure : SignInEvent +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/signin/SignInViewModel.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/signin/SignInViewModel.kt new file mode 100644 index 000000000..98889d9b8 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/signin/SignInViewModel.kt @@ -0,0 +1,38 @@ +package com.festago.festago.presentation.ui.signin + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.festago.festago.domain.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SignInViewModel @Inject constructor( + private val userRepository: UserRepository, +) : ViewModel() { + + private val _event: MutableSharedFlow = MutableSharedFlow() + val event: SharedFlow = _event.asSharedFlow() + + fun signIn(idToken: String) { + viewModelScope.launch { + userRepository.signIn(idToken) + .onSuccess { + _event.emit(SignInEvent.ShowHome) + }.onFailure { + _event.emit(SignInEvent.SignInFailure) + } + } + } + + fun rejectSignIn() { + viewModelScope.launch { + userRepository.rejectSignIn() + _event.emit(SignInEvent.ShowHome) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/splash/SplashActivity.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/splash/SplashActivity.kt index 5bde693d3..94b968615 100644 --- a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/splash/SplashActivity.kt +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/splash/SplashActivity.kt @@ -8,12 +8,15 @@ import android.net.Uri import android.os.Bundle import android.widget.Toast import androidx.activity.ComponentActivity +import androidx.activity.viewModels import androidx.core.splashscreen.SplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import com.festago.festago.presentation.BuildConfig import com.festago.festago.presentation.R import com.festago.festago.presentation.databinding.ActivitySplashBinding import com.festago.festago.presentation.ui.home.HomeActivity +import com.festago.festago.presentation.ui.signin.SignInActivity +import com.festago.festago.presentation.util.repeatOnStarted import com.google.firebase.remoteconfig.FirebaseRemoteConfig import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings import com.kakao.sdk.common.KakaoSdk @@ -31,6 +34,7 @@ class SplashActivity : ComponentActivity() { .setMinimumFetchIntervalInSeconds(if (BuildConfig.DEBUG) DEBUG_REMOTE_CONFIG_FETCH_INTERVAL else RELEASE_REMOTE_CONFIG_FETCH_INTERVAL) .build() } + private val vm: SplashViewModel by viewModels() init { firebaseRemoteConfig.setConfigSettingsAsync(configSettings) @@ -38,9 +42,9 @@ class SplashActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - splashScreen = installSplashScreen() - splashScreen.setKeepOnScreenCondition { true } + initSplashScreen() initKakaoSdk() + initObserve() setContentView(binding.root) } @@ -49,10 +53,26 @@ class SplashActivity : ComponentActivity() { checkAppUpdate() } + private fun initSplashScreen() { + splashScreen = installSplashScreen() + splashScreen.setKeepOnScreenCondition { true } + } + private fun initKakaoSdk() { KakaoSdk.init(this.applicationContext, BuildConfig.KAKAO_NATIVE_APP_KEY) } + private fun initObserve() { + repeatOnStarted(this) { + vm.event.collect { + when (it) { + is SplashEvent.ShowHome -> navigateToHome() + is SplashEvent.ShowSignIn -> navigateToSignIn() + } + } + } + } + private fun checkAppUpdate() { firebaseRemoteConfig.fetchAndActivate().addOnCompleteListener(this) { if (it.isSuccessful) { @@ -65,11 +85,13 @@ class SplashActivity : ComponentActivity() { } val latestVersion = firebaseRemoteConfig.getLong(KEY_LATEST_VERSION) if (latestVersion == currentVersion) { - navigateToHome() + vm.checkSignIn() return@addOnCompleteListener } splashScreen.setKeepOnScreenCondition { false } requestUpdate(latestVersion) + } else { + handleError() } } } @@ -93,7 +115,7 @@ class SplashActivity : ComponentActivity() { val sharedPref = getPreferences(MODE_PRIVATE) ?: return val storedLatestVersion = sharedPref.getLong(KEY_STORED_LATEST_VERSION, 0L) if (latestVersion == storedLatestVersion) { - navigateToHome() + vm.checkSignIn() return } alertUpdate(message = message, update = ::handleUpdate) { @@ -103,7 +125,7 @@ class SplashActivity : ComponentActivity() { private fun handleOptionalUpdateCancel(sharedPref: SharedPreferences, latestVersion: Long) { updateStoredLatestVersion(sharedPref, latestVersion) - navigateToHome() + vm.checkSignIn() } private fun updateStoredLatestVersion(sharedPref: SharedPreferences, latestVersion: Long) { @@ -123,6 +145,11 @@ class SplashActivity : ComponentActivity() { finish() } + private fun navigateToSignIn() { + startActivity(SignInActivity.getIntent(this)) + finish() + } + private fun navigateToAppStore() { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName"))) finish() diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/splash/SplashEvent.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/splash/SplashEvent.kt new file mode 100644 index 000000000..718ecf3bf --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/splash/SplashEvent.kt @@ -0,0 +1,6 @@ +package com.festago.festago.presentation.ui.splash + +sealed interface SplashEvent { + object ShowSignIn : SplashEvent + object ShowHome : SplashEvent +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/splash/SplashViewModel.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/splash/SplashViewModel.kt new file mode 100644 index 000000000..f0775d768 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/splash/SplashViewModel.kt @@ -0,0 +1,30 @@ +package com.festago.festago.presentation.ui.splash + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.festago.festago.domain.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SplashViewModel @Inject constructor( + private val userRepository: UserRepository, +) : ViewModel() { + + private val _event: MutableSharedFlow = MutableSharedFlow() + val event: SharedFlow = _event.asSharedFlow() + + fun checkSignIn() { + viewModelScope.launch { + when { + userRepository.isSigned() -> _event.emit(SplashEvent.ShowHome) + userRepository.isSignRejected() -> _event.emit(SplashEvent.ShowHome) + else -> _event.emit(SplashEvent.ShowSignIn) + } + } + } +} diff --git a/android/festago/presentation/src/main/res/drawable/bg_kakao_sign_in.xml b/android/festago/presentation/src/main/res/drawable/bg_kakao_sign_in.xml new file mode 100644 index 000000000..b4e9fe9bc --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/bg_kakao_sign_in.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/bg_sign_in.png b/android/festago/presentation/src/main/res/drawable/bg_sign_in.png new file mode 100644 index 000000000..921fdc3ba Binary files /dev/null and b/android/festago/presentation/src/main/res/drawable/bg_sign_in.png differ diff --git a/android/festago/presentation/src/main/res/drawable/ic_kakao_logo.xml b/android/festago/presentation/src/main/res/drawable/ic_kakao_logo.xml new file mode 100644 index 000000000..7d61060d2 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_kakao_logo.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/festago/presentation/src/main/res/layout/activity_sign_in.xml b/android/festago/presentation/src/main/res/layout/activity_sign_in.xml new file mode 100644 index 000000000..a94efe74c --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/activity_sign_in.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/values/strings.xml b/android/festago/presentation/src/main/res/values/strings.xml index 8ebd557c9..533b1bdf2 100644 --- a/android/festago/presentation/src/main/res/values/strings.xml +++ b/android/festago/presentation/src/main/res/values/strings.xml @@ -91,4 +91,10 @@ 관심있는 축제를 북마크 할 수 있어요 관심있는 학교를 북마크 할 수 있어요 + + 페스타고,\n대학축제를\n더욱 즐겁게! + 카카오로 로그인하기 + 로그인하지 않고 둘러보기 + 로그인 할 수 없습니다. 다시 로그인 하거나 앱을 재실행 해주세요. +