diff --git a/.github/workflows/android-pull-request-ci.yml b/.github/workflows/android-pull-request-ci.yml index 5bb75fef..8854d504 100644 --- a/.github/workflows/android-pull-request-ci.yml +++ b/.github/workflows/android-pull-request-ci.yml @@ -43,6 +43,12 @@ jobs: run: | echo "TEMP_TOKEN=\"TEMP_TOKEN\"" >> local.properties + - name: Access Kakao KAKAO_NATIVE_APP_KEY + env: + KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }} + run: | + echo "KAKAO_NATIVE_APP_KEY=\"$KAKAO_NATIVE_APP_KEY\"" >> local.properties + - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/Near/app/build.gradle.kts b/Near/app/build.gradle.kts index bd42f1a5..6ab76de2 100644 --- a/Near/app/build.gradle.kts +++ b/Near/app/build.gradle.kts @@ -32,10 +32,14 @@ android { ) buildConfigField("String", "NEAR_URL", getProperty("NEAR_PROD_URL")) buildConfigField("String", "TEMP_TOKEN", getProperty("TEMP_TOKEN")) // TODO 추후 삭제 필요 + buildConfigField("String", "KAKAO_NATIVE_APP_KEY", getProperty("KAKAO_NATIVE_APP_KEY")) + manifestPlaceholders["kakaoAppKey"] = getProperty("KAKAO_NATIVE_APP_KEY").replace("\"", "") } debug { buildConfigField("String", "NEAR_URL", getProperty("NEAR_DEV_URL")) buildConfigField("String", "TEMP_TOKEN", getProperty("TEMP_TOKEN")) // TODO 추후 삭제 필요 + buildConfigField("String", "KAKAO_NATIVE_APP_KEY", getProperty("KAKAO_NATIVE_APP_KEY")) + manifestPlaceholders["kakaoAppKey"] = getProperty("KAKAO_NATIVE_APP_KEY").replace("\"", "") } } compileOptions { @@ -88,6 +92,12 @@ dependencies { implementation(libs.navigation.compose) // Serialization implementation(libs.kotlin.serialization.json) + // DataStore + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.datastore.core) + + // Kakao Module + implementation(libs.v2.all) } fun getProperty(propertyKey: String): String = gradleLocalProperties(rootDir, providers).getProperty(propertyKey) diff --git a/Near/app/src/main/AndroidManifest.xml b/Near/app/src/main/AndroidManifest.xml index 08b99e2f..b0ae0a68 100644 --- a/Near/app/src/main/AndroidManifest.xml +++ b/Near/app/src/main/AndroidManifest.xml @@ -15,6 +15,25 @@ android:supportsRtl="true" android:theme="@style/Theme.Near" tools:targetApi="31"> + + + + + + + + + + + + + + = + try { + val token = + if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { + loginWithKakaoTalk() + } else { + loginWithKakaoAccount() + } + + if (token.isNotEmpty()) { + Result.success(token) + } else { + Result.failure(Exception("사용자가 로그인을 취소했습니다")) + } + } catch (exception: Exception) { + Result.failure(exception) + } + + private suspend fun loginWithKakaoTalk(): String = + suspendCancellableCoroutine { continuation -> + UserApiClient.instance.loginWithKakaoTalk(context) { token, error -> + when { + error != null -> { + if (error is ClientError && error.reason == ClientErrorCause.Cancelled) { + continuation.resume("") + } else { + UserApiClient.instance.loginWithKakaoAccount(context) { retryToken, retryError -> + handleLoginResult(retryToken, retryError, continuation) + } + } + } + token != null -> continuation.resume(token.accessToken) + else -> continuation.resume("") + } + } + } + + private suspend fun loginWithKakaoAccount(): String = + suspendCancellableCoroutine { continuation -> + UserApiClient.instance.loginWithKakaoAccount(context) { token, error -> + handleLoginResult(token, error, continuation) + } + } + + private fun handleLoginResult( + token: OAuthToken?, + error: Throwable?, + continuation: CancellableContinuation, + ) { + when { + error != null -> { + if (error is ClientError && error.reason == ClientErrorCause.Cancelled) { + continuation.resume("") + } else { + continuation.resumeWith(Result.failure(error)) + } + } + token != null -> continuation.resume(token.accessToken) + else -> continuation.resume("") + } + } + } diff --git a/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginDataSource.kt b/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginDataSource.kt new file mode 100644 index 00000000..9d2bb534 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginDataSource.kt @@ -0,0 +1,20 @@ +package com.alarmy.near.data.datasource + +import com.alarmy.near.model.ProviderType + +/** + * 소셜 로그인 데이터 소스 인터페이스 + * Strategy 패턴으로 각 소셜 플랫폼별로 구현 + */ +interface SocialLoginDataSource { + /** + * 지원하는 소셜 로그인 타입 + */ + val supportedType: ProviderType + + /** + * 소셜 로그인 수행 + * Context는 생성자에서 주입받아 사용 + */ + suspend fun login(): Result +} diff --git a/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginProcessor.kt b/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginProcessor.kt new file mode 100644 index 00000000..1944d12a --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginProcessor.kt @@ -0,0 +1,32 @@ +package com.alarmy.near.data.datasource + + +import com.alarmy.near.model.ProviderType +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 소셜 로그인 프로세서 + * Strategy 패턴으로 동적으로 로그인 방식 선택 + */ +@Singleton +class SocialLoginProcessor + @Inject + constructor( + private val socialLoginDataSources: Set<@JvmSuppressWildcards SocialLoginDataSource>, + ) { + /** + * 소셜 로그인 처리 + * @param providerType 로그인 제공자 타입 + * @return 로그인 결과 + */ + suspend fun processLogin( + providerType: ProviderType, + ): Result { + val dataSource = + socialLoginDataSources.find { it.supportedType == providerType } + ?: return Result.failure(Exception("지원하지 않는 로그인 타입입니다: ${providerType.name}")) + + return dataSource.login() + } + } diff --git a/Near/app/src/main/java/com/alarmy/near/data/di/DataSourceModule.kt b/Near/app/src/main/java/com/alarmy/near/data/di/DataSourceModule.kt new file mode 100644 index 00000000..4a6fb2b8 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/di/DataSourceModule.kt @@ -0,0 +1,17 @@ +package com.alarmy.near.data.di + +import com.alarmy.near.data.datasource.KakaoDataSource +import com.alarmy.near.data.datasource.SocialLoginDataSource +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(SingletonComponent::class) +interface DataSourceModule { + @Binds + @IntoSet + abstract fun bindKakaoDataSource(kakaoDataSource: KakaoDataSource): SocialLoginDataSource +} diff --git a/Near/app/src/main/java/com/alarmy/near/data/di/DataStoreModule.kt b/Near/app/src/main/java/com/alarmy/near/data/di/DataStoreModule.kt new file mode 100644 index 00000000..5f73bfb6 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/di/DataStoreModule.kt @@ -0,0 +1,25 @@ +package com.alarmy.near.data.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +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 + +// DataStore 확장 프로퍼티 +private val Context.dataStore: DataStore by preferencesDataStore(name = "auth_preferences") + +@Module +@InstallIn(SingletonComponent::class) +object DataStoreModule { + @Provides + @Singleton + fun provideDataStore( + @ApplicationContext context: Context, + ): DataStore = context.dataStore +} diff --git a/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt b/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt index 3dff6205..5d73c37f 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt @@ -1,5 +1,7 @@ package com.alarmy.near.data.di +import com.alarmy.near.data.repository.AuthRepository +import com.alarmy.near.data.repository.AuthRepositoryImpl import com.alarmy.near.data.repository.DefaultFriendRepository import com.alarmy.near.data.repository.ExampleRepository import com.alarmy.near.data.repository.ExampleRepositoryImpl @@ -20,4 +22,8 @@ interface RepositoryModule { @Binds @Singleton abstract fun bindFriendRepository(friendRepository: DefaultFriendRepository): FriendRepository + + @Binds + @Singleton + abstract fun bindAuthRepository(authRepositoryImpl: AuthRepositoryImpl): AuthRepository } diff --git a/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt b/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt new file mode 100644 index 00000000..13f5eaa0 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt @@ -0,0 +1,110 @@ +package com.alarmy.near.data.local.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 토큰 저장소 DataStore 관리 클래스 + * 액세스 토큰과 리프레시 토큰의 저장, 조회, 삭제를 담당 + */ +@Singleton +class TokenPreferences + @Inject + constructor( + private val dataStore: DataStore, + ) { + private val accessTokenKey = stringPreferencesKey("access_token") + private val refreshTokenKey = stringPreferencesKey("refresh_token") + private val expiresAtKey = longPreferencesKey("expires_at") + + /** + * 두 토큰 동시 저장 + */ + suspend fun saveTokens( + accessToken: String, + refreshToken: String?, + expiresIn: Long? = null, + ) { + dataStore.edit { preferences -> + preferences[accessTokenKey] = accessToken + refreshToken?.let { + preferences[refreshTokenKey] = it + } + expiresIn?.let { + val expiresAt = System.currentTimeMillis() + (it * 1000) + preferences[expiresAtKey] = expiresAt + } + } + } + + /** + * 액세스 토큰 조회 + */ + suspend fun getAccessToken(): String? = dataStore.data.first()[accessTokenKey] + + /** + * 리프레시 토큰 조회 + */ + suspend fun getRefreshToken(): String? = dataStore.data.first()[refreshTokenKey] + + /** + * 토큰 존재 여부 확인 + */ + suspend fun hasValidTokens(): Boolean { + val prefs = dataStore.data.first() + val accessToken = prefs[accessTokenKey] + if (accessToken.isNullOrBlank()) return false + + val expiresAt = prefs[expiresAtKey] + val isExpired = expiresAt == null || System.currentTimeMillis() >= expiresAt + return !isExpired + } + + /** + * 토큰 만료 여부 확인 + */ + suspend fun isTokenExpired(): Boolean { + val expiresAt = dataStore.data.first()[expiresAtKey] ?: return true + return System.currentTimeMillis() >= expiresAt + } + + /** + * 토큰 만료 시간 조회 + */ + suspend fun getTokenExpiresAt(): Long? = dataStore.data.first()[expiresAtKey] + + /** + * 모든 토큰 삭제 + */ + suspend fun clearAllTokens() { + dataStore.edit { preferences -> + preferences.remove(accessTokenKey) + preferences.remove(refreshTokenKey) + preferences.remove(expiresAtKey) + } + } + + /** + * 액세스 토큰 관찰 + */ + fun observeAccessToken() = + dataStore.data.map { preferences -> + preferences[accessTokenKey] + } + + /** + * 로그인 상태 관찰 + */ + fun observeLoginStatus() = + dataStore.data.map { preferences -> + !preferences[accessTokenKey].isNullOrBlank() + } + } diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt new file mode 100644 index 00000000..ded14dc6 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt @@ -0,0 +1,32 @@ +package com.alarmy.near.data.repository + +import com.alarmy.near.model.ProviderType +import kotlinx.coroutines.flow.Flow + +interface AuthRepository { + // 소셜 로그인 수행 + suspend fun performSocialLogin(providerType: ProviderType): Result + + /** + * 소셜 로그인 수행 (토큰 직접 전달) + */ + suspend fun socialLogin( + accessToken: String, + providerType: ProviderType, + ): Result + + // 로그아웃 수행 + suspend fun logout() + + // 로그인 상태 확인 + suspend fun isLoggedIn(): Boolean + + // 현재 사용자 토큰 가져오기 + suspend fun getCurrentUserToken(): String? + + // 로그인 상태 확인 + fun observeLoginStatus(): Flow + + // 토큰 갱신 + suspend fun refreshToken(): Boolean +} diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 00000000..43f51f80 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt @@ -0,0 +1,115 @@ +package com.alarmy.near.data.repository + +import com.alarmy.near.data.datasource.SocialLoginProcessor +import com.alarmy.near.model.ProviderType +import com.alarmy.near.network.auth.TokenManager +import com.alarmy.near.network.request.SocialLoginRequest +import com.alarmy.near.network.service.AuthService +import kotlinx.coroutines.flow.Flow +import retrofit2.HttpException +import javax.inject.Inject + +class AuthRepositoryImpl + @Inject + constructor( + private val authService: AuthService, + private val socialLoginProcessor: SocialLoginProcessor, + private val tokenManager: TokenManager, + ) : AuthRepository { + override suspend fun performSocialLogin(providerType: ProviderType): Result = + try { + val result = socialLoginProcessor.processLogin(providerType) + + if (result.isSuccess) { + val accessToken = result.getOrThrow() + socialLogin(accessToken, providerType) + } else { + Result.failure( + createLoginException( + providerType = providerType, + errorMessage = result.exceptionOrNull()?.message, + defaultMessage = "로그인에 실패했습니다", + ), + ) + } + } catch (exception: Exception) { + Result.failure( + createLoginException( + providerType = providerType, + errorMessage = exception.message, + defaultMessage = "로그인 중 오류가 발생했습니다", + ), + ) + } + + override suspend fun socialLogin( + accessToken: String, + providerType: ProviderType, + ): Result = + try { + val request = + SocialLoginRequest( + accessToken = accessToken, + providerType = providerType.name, + ) + + val response = authService.socialLogin(request) + + // 토큰 저장 + val expiresIn = tokenManager.calculateExpiresIn(response.refreshTokenInfo?.expiresAt) + tokenManager.saveTokens( + accessToken = response.accessToken, + refreshToken = response.refreshTokenInfo?.token, + expiresIn = expiresIn, + ) + + Result.success(Unit) + } catch (exception: HttpException) { + val errorMessage = getHttpErrorMessage(exception.code()) + Result.failure(Exception(errorMessage)) + } catch (exception: Exception) { + val errorMessage = exception.message ?: "알 수 없는 오류가 발생했습니다" + Result.failure(Exception(errorMessage)) + } + + override suspend fun logout() { + tokenManager.clearAllTokens() + } + + override suspend fun isLoggedIn(): Boolean = + try { + tokenManager.hasValidToken() + } catch (exception: Exception) { + false + } + + override suspend fun getCurrentUserToken(): String? = + try { + tokenManager.getAccessToken() + } catch (exception: Exception) { + null + } + + override fun observeLoginStatus(): Flow = tokenManager.observeLoginStatus() + + override suspend fun refreshToken(): Boolean = tokenManager.refreshToken() + + private fun createLoginException( + providerType: ProviderType, + errorMessage: String?, + defaultMessage: String, + ): Exception { + val finalMessage = errorMessage ?: "$providerType $defaultMessage" + return Exception(finalMessage) + } + + // TODO 추후 에러 메시지 변경 + private fun getHttpErrorMessage(httpCode: Int): String = + when (httpCode) { + 400 -> "잘못된 요청입니다" + 401 -> "소셜 로그인에 실패했습니다. 다시 시도해주세요." + 403 -> "접근이 거부되었습니다" + 500 -> "서버에 문제가 발생했습니다" + else -> "로그인 중 오류가 발생했습니다" + } + } diff --git a/Near/app/src/main/java/com/alarmy/near/model/LoginResult.kt b/Near/app/src/main/java/com/alarmy/near/model/LoginResult.kt new file mode 100644 index 00000000..e38df6cd --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/model/LoginResult.kt @@ -0,0 +1,8 @@ +package com.alarmy.near.model + +data class LoginResult( + val isSuccess: Boolean, + val accessToken: String? = null, + val refreshToken: String? = null, + val errorMessage: String? = null, +) diff --git a/Near/app/src/main/java/com/alarmy/near/model/ProviderType.kt b/Near/app/src/main/java/com/alarmy/near/model/ProviderType.kt new file mode 100644 index 00000000..9d4f0fbe --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/model/ProviderType.kt @@ -0,0 +1,6 @@ +package com.alarmy.near.model + +enum class ProviderType { + KAKAO, + ETC, +} diff --git a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenAuthenticator.kt b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenAuthenticator.kt new file mode 100644 index 00000000..fb6d01ee --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenAuthenticator.kt @@ -0,0 +1,55 @@ +package com.alarmy.near.network.auth + +import kotlinx.coroutines.runBlocking +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 토큰 인증자 + * 401 에러 발생 시 토큰 갱신을 시도하고 원래 요청을 재시도 + */ +@Singleton +class TokenAuthenticator +@Inject +constructor( + private val tokenManager: TokenManager, +) : Authenticator { + + override fun authenticate(route: Route?, response: Response): Request? { + // 401 에러가 아니면 null 반환 (인증 시도하지 않음) + if (response.code != 401) { + return null + } + + // runBlocking을 사용하여 suspend 함수 호출 + return runBlocking { + try { + // 토큰 갱신 시도 + val refreshSuccess = tokenManager.refreshToken() + + // 토큰 갱신 실패 시 null 반환 (재로그인 필요) + if (!refreshSuccess) { + return@runBlocking null + } + + // 새로운 토큰으로 원래 요청 재시도 + val newToken = tokenManager.getAccessToken() + + if (newToken != null) { + response.request + .newBuilder() + .header("Authorization", "Bearer $newToken") + .build() + } else { + null + } + } catch (e: Exception) { + null + } + } + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt index e2e99566..225e547e 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt @@ -1,21 +1,51 @@ package com.alarmy.near.network.auth -import com.alarmy.near.BuildConfig +import com.alarmy.near.network.model.AuthEndpoint +import kotlinx.coroutines.runBlocking import okhttp3.Interceptor +import okhttp3.Request import okhttp3.Response import javax.inject.Inject +import javax.inject.Singleton +/** + * 토큰 인터셉터 + * Authorization 헤더를 자동으로 추가하고 인증이 필요없는 요청은 제외 + */ +@Singleton class TokenInterceptor - @Inject - constructor() : Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { - val request = chain.request() - return chain.proceed( - request = - request - .newBuilder() - .addHeader("Authorization", "Bearer ${BuildConfig.TEMP_TOKEN}") - .build(), - ) +@Inject +constructor( + private val tokenManager: TokenManager, +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + if (isAuthExcludedRequest(originalRequest)) { + return chain.proceed(originalRequest) + } + + // 현재 토큰을 헤더에 추가 + val token = runBlocking { tokenManager.getAccessToken() } + val requestWithAuth = if (token != null) { + originalRequest + .newBuilder() + .header("Authorization", "Bearer $token") + .build() + } else { + originalRequest } + + // 요청 실행 (401 에러는 Authenticator에서 처리) + return chain.proceed(requestWithAuth) + } + + /** + * 인증이 필요없는 요청인지 확인 + */ + private fun isAuthExcludedRequest(request: Request): Boolean { + val url = request.url.toString() + return AuthEndpoint.excludedPaths.any { url.contains(it) } } +} diff --git a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenManager.kt b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenManager.kt new file mode 100644 index 00000000..29411a4e --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenManager.kt @@ -0,0 +1,125 @@ +package com.alarmy.near.network.auth + +import com.alarmy.near.data.local.datastore.TokenPreferences +import com.alarmy.near.network.request.TokenRefreshRequest +import com.alarmy.near.network.service.AuthService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +/** + * 토큰 관리자 + * 토큰 저장, 조회, 갱신을 담당하는 중앙 관리 클래스 + * Provider 패턴으로 순환 참조 해결 + */ +@Singleton +class TokenManager +@Inject +constructor( + private val tokenPreferences: TokenPreferences, + private val authServiceProvider: Provider, // Provider로 지연 주입 +) { + + private val refreshMutex = Mutex() + + /** + * 현재 액세스 토큰 가져오기 + */ + suspend fun getAccessToken(): String? { + return tokenPreferences.getAccessToken() + } + + /** + * 토큰 갱신 시도 + * @return 갱신 성공 여부 + */ + suspend fun refreshToken(): Boolean { + return refreshMutex.withLock { + try { + val refreshToken = tokenPreferences.getRefreshToken() ?: return false + + // Provider를 통해 AuthService 가져오기 (지연 주입) + val authService = authServiceProvider.get() + + // 토큰 갱신 API 호출 + val request = TokenRefreshRequest(refreshToken = refreshToken) + val response = authService.renewToken(request) + + // 새로운 토큰 저장 + val expiresIn = calculateExpiresIn(response.refreshTokenInfo?.expiresAt) + + tokenPreferences.saveTokens( + accessToken = response.accessToken, + refreshToken = response.refreshTokenInfo?.token, + expiresIn = expiresIn, + ) + + true + } catch (e: Exception) { + // 갱신 실패 시 토큰 삭제 + tokenPreferences.clearAllTokens() + false + } + } + } + + /** + * 토큰이 유효한지 확인 + */ + suspend fun hasValidToken(): Boolean { + return tokenPreferences.hasValidTokens() + } + + /** + * 모든 토큰 삭제 + */ + suspend fun clearAllTokens() { + tokenPreferences.clearAllTokens() + } + + /** + * 로그인 상태 관찰 + */ + fun observeLoginStatus(): Flow { + return tokenPreferences.observeLoginStatus() + } + + /** + * 토큰 저장 + */ + suspend fun saveTokens( + accessToken: String, + refreshToken: String?, + expiresIn: Long?, + ) { + tokenPreferences.saveTokens( + accessToken = accessToken, + refreshToken = refreshToken, + expiresIn = expiresIn, + ) + } + + /** + * 만료 시간 계산 (초 단위) + * java.time 패키지 사용으로 스레드 안전성 보장 + */ + fun calculateExpiresIn(expiresAtString: String?): Long? { + return expiresAtString?.let { expiresAt -> + try { + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + val expiresAtTime = LocalDateTime.parse(expiresAt, formatter) + val expiresAtMillis = expiresAtTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + (expiresAtMillis - System.currentTimeMillis()) / 1000 // 초 단위로 변환 + } catch (e: DateTimeParseException) { + null + } + } + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt b/Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt index de4e1e8d..e6663ac9 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt @@ -1,6 +1,7 @@ package com.alarmy.near.network.di import com.alarmy.near.BuildConfig +import com.alarmy.near.network.auth.TokenAuthenticator import com.alarmy.near.network.auth.TokenInterceptor import dagger.Module import dagger.Provides @@ -32,11 +33,13 @@ object NetworkModule { fun provideOkHttpClient( loggingInterceptor: HttpLoggingInterceptor, tokenInterceptor: TokenInterceptor, + tokenAuthenticator: TokenAuthenticator, ): OkHttpClient = OkHttpClient .Builder() .addInterceptor(loggingInterceptor) .addInterceptor(tokenInterceptor) + .authenticator(tokenAuthenticator) .build() @Provides diff --git a/Near/app/src/main/java/com/alarmy/near/network/di/ServiceModule.kt b/Near/app/src/main/java/com/alarmy/near/network/di/ServiceModule.kt index a6ef4802..6324fd18 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/di/ServiceModule.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/di/ServiceModule.kt @@ -1,5 +1,6 @@ package com.alarmy.near.network.di +import com.alarmy.near.network.service.AuthService import com.alarmy.near.network.service.FriendService import dagger.Module import dagger.Provides @@ -11,6 +12,10 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object ServiceModule { + @Provides + @Singleton + fun provideAuthService(retrofit: Retrofit): AuthService = retrofit.create(AuthService::class.java) + @Provides @Singleton fun provideFriendService(retrofit: Retrofit): FriendService = retrofit.create(FriendService::class.java) diff --git a/Near/app/src/main/java/com/alarmy/near/network/model/AuthEndpoint.kt b/Near/app/src/main/java/com/alarmy/near/network/model/AuthEndpoint.kt new file mode 100644 index 00000000..a19479ed --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/model/AuthEndpoint.kt @@ -0,0 +1,21 @@ +package com.alarmy.near.network.model + +/** + * 인증 관련 API 엔드포인트 + * + * 인증이 필요없는 경로들을 관리하여 TokenInterceptor에서 + * Authorization 헤더 추가를 제외할 수 있습니다. + */ +enum class AuthEndpoint(val path: String) { + SOCIAL_LOGIN("/auth/social"), + TOKEN_RENEW("/auth/renew"), + ; + + companion object { + /** + * 인증이 필요없는 모든 경로 목록 + * TokenInterceptor에서 이 경로들은 Authorization 헤더를 추가하지 않습니다 + */ + val excludedPaths: List = entries.map { it.path } + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/network/request/SocialLoginRequest.kt b/Near/app/src/main/java/com/alarmy/near/network/request/SocialLoginRequest.kt new file mode 100644 index 00000000..56a78fee --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/request/SocialLoginRequest.kt @@ -0,0 +1,18 @@ +package com.alarmy.near.network.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * 소셜 로그인 요청 데이터 클래스 + * + * @param accessToken 소셜 로그인 AccessToken + * @param providerType 소셜 로그인 Provider 타입 (KAKAO, APPLE 등) + */ +@Serializable +data class SocialLoginRequest( + @SerialName("accessToken") + val accessToken: String, + @SerialName("providerType") + val providerType: String, +) diff --git a/Near/app/src/main/java/com/alarmy/near/network/request/TokenRefreshRequest.kt b/Near/app/src/main/java/com/alarmy/near/network/request/TokenRefreshRequest.kt new file mode 100644 index 00000000..2edc96ef --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/request/TokenRefreshRequest.kt @@ -0,0 +1,11 @@ +package com.alarmy.near.network.request + +import kotlinx.serialization.Serializable + +/** + * 토큰 갱신 요청 모델 + */ +@Serializable +data class TokenRefreshRequest( + val refreshToken: String +) diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/LoginResponse.kt b/Near/app/src/main/java/com/alarmy/near/network/response/LoginResponse.kt new file mode 100644 index 00000000..c8ff5fd4 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/response/LoginResponse.kt @@ -0,0 +1,21 @@ +package com.alarmy.near.network.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// 소셜 로그인 응답 데이터 클래스 +@Serializable +data class LoginResponse( + @SerialName("accessToken") + val accessToken: String, + @SerialName("refreshTokenInfo") + val refreshTokenInfo: RefreshTokenInfo? = null, +) + +@Serializable +data class RefreshTokenInfo( + @SerialName("token") + val token: String, + @SerialName("expiresAt") + val expiresAt: String, // "2025-08-20 03:02:07" 형식 +) diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/TokenRefreshResponse.kt b/Near/app/src/main/java/com/alarmy/near/network/response/TokenRefreshResponse.kt new file mode 100644 index 00000000..b4b0dc73 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/response/TokenRefreshResponse.kt @@ -0,0 +1,15 @@ +package com.alarmy.near.network.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * 토큰 갱신 응답 모델 + */ +@Serializable +data class TokenRefreshResponse( + @SerialName("accessToken") + val accessToken: String, + @SerialName("refreshTokenInfo") + val refreshTokenInfo: RefreshTokenInfo? = null, +) diff --git a/Near/app/src/main/java/com/alarmy/near/network/service/AuthService.kt b/Near/app/src/main/java/com/alarmy/near/network/service/AuthService.kt new file mode 100644 index 00000000..587008ea --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/service/AuthService.kt @@ -0,0 +1,25 @@ +package com.alarmy.near.network.service + +import com.alarmy.near.network.request.SocialLoginRequest +import com.alarmy.near.network.request.TokenRefreshRequest +import com.alarmy.near.network.response.LoginResponse +import com.alarmy.near.network.response.TokenRefreshResponse +import retrofit2.http.Body +import retrofit2.http.POST + +/** + * 인증 관련 API 서비스 + */ +interface AuthService { + // 소셜 로그인 API 호출 + @POST("/auth/social") + suspend fun socialLogin( + @Body request: SocialLoginRequest, + ): LoginResponse + + // 토큰 갱신 API 호출 + @POST("/auth/renew") + suspend fun renewToken( + @Body request: TokenRefreshRequest, + ): TokenRefreshResponse +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt new file mode 100644 index 00000000..10990fba --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt @@ -0,0 +1,164 @@ +package com.alarmy.near.presentation.feature.login + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.alarmy.near.R +import com.alarmy.near.model.ProviderType +import com.alarmy.near.presentation.ui.theme.NearTheme + +@Composable +internal fun LoginRoute( + onNavigateToHome: () -> Unit, + viewModel: LoginViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.loginSuccessEvent.collect { + onNavigateToHome() + } + } + + LoginScreen( + uiState = uiState, + onLoginClick = { providerType -> + viewModel.performLogin(providerType) + }, + ) +} + +@Composable +fun LoginScreen( + uiState: LoginUiState, + onLoginClick: (ProviderType) -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(NearTheme.colors.WHITE_FFFFFF) + .systemBarsPadding(), + ) { + LoginIntroductionSection(modifier = Modifier.fillMaxWidth()) + + Spacer(modifier = Modifier.weight(1f)) + + // 소셜 로그인 버튼 + SocialLoginButtons( + isLoading = uiState.isLoading, + onLoginClick = onLoginClick, + ) + } +} + +@Composable +private fun LoginIntroductionSection(modifier: Modifier = Modifier) { + Spacer(modifier = Modifier.height(LoginScreenConstants.TOP_SPACING.dp)) + + Image( + modifier = modifier.size(LoginScreenConstants.LOGO_SIZE.dp), + alignment = Alignment.Center, + painter = painterResource(R.drawable.img_40_character), + contentDescription = stringResource(R.string.near_logo), + ) + + Image( + modifier = modifier.wrapContentSize(Alignment.Center), + alignment = Alignment.Center, + painter = painterResource(R.drawable.ic_near_logo_title), + contentDescription = stringResource(R.string.near_logo_title), + ) + + Spacer(modifier = Modifier.size(LoginScreenConstants.DESCRIPTION_SPACING.dp)) + + Text( + modifier = modifier.wrapContentSize(Alignment.Center), + text = stringResource(R.string.login_near_description), + style = NearTheme.typography.B1_16_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) +} + +@Composable +private fun ColumnScope.SocialLoginButtons( + isLoading: Boolean, + onLoginClick: (ProviderType) -> Unit, +) { + // 카카오 로그인 버튼 + SocialLoginButton( + isEnabled = !isLoading, + providerType = ProviderType.KAKAO, + buttonResource = R.drawable.btn_kakao_login, + contentDescription = stringResource(R.string.login_kakao_login_button_text), + onLoginClick = onLoginClick, + ) + + Spacer(modifier = Modifier.size(LoginScreenConstants.BOTTOM_SPACING.dp)) +} + +@Composable +private fun ColumnScope.SocialLoginButton( + isEnabled: Boolean, + providerType: ProviderType, + buttonResource: Int, + contentDescription: String, + onLoginClick: (ProviderType) -> Unit, +) { + Image( + modifier = + Modifier + .wrapContentSize() + .align(Alignment.CenterHorizontally) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + enabled = isEnabled, + ) { + onLoginClick(providerType) + }, + painter = painterResource(buttonResource), + contentDescription = contentDescription, + ) +} + +private object LoginScreenConstants { + const val TOP_SPACING = 170 + const val LOGO_SIZE = 160 + const val DESCRIPTION_SPACING = 12 + const val BOTTOM_SPACING = 96 +} + +@Preview(showBackground = true) +@Composable +fun LoginScreenPreview() { + NearTheme { + LoginScreen( + uiState = LoginUiState(), + onLoginClick = { }, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt new file mode 100644 index 00000000..5f9a9c68 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt @@ -0,0 +1,67 @@ +package com.alarmy.near.presentation.feature.login + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.alarmy.near.data.repository.AuthRepository +import com.alarmy.near.model.ProviderType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class LoginViewModel + @Inject + constructor( + private val authRepository: AuthRepository, + ) : ViewModel() { + // UI 상태 관리 + private val _uiState = MutableStateFlow(LoginUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + // 에러 이벤트 관리 + private val _errorEvent = Channel() + val errorEvent = _errorEvent.receiveAsFlow() + + // 로그인 성공 이벤트 관리 + private val _loginSuccessEvent = Channel() + val loginSuccessEvent = _loginSuccessEvent.receiveAsFlow() + + /** + * 소셜 로그인 수행 + */ + fun performLogin(providerType: ProviderType) { + viewModelScope.launch { + updateLoadingState(isLoading = true) + + authRepository.performSocialLogin(providerType) + .onSuccess { + updateLoadingState(isLoading = false) + _loginSuccessEvent.send(Unit) + } + .onFailure { exception -> + updateLoadingState(isLoading = false) + _errorEvent.send(exception) + } + } + } + + /** + * 로딩 상태 업데이트 + */ + private fun updateLoadingState(isLoading: Boolean) { + _uiState.value = _uiState.value.copy(isLoading = isLoading) + } + } + +/** + * 로그인 화면 UI 상태 + */ +data class LoginUiState( + val isLoading: Boolean = false, + val hasError: Boolean = false, +) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/navigation/LoginNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/navigation/LoginNavigation.kt new file mode 100644 index 00000000..f2d70ce7 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/navigation/LoginNavigation.kt @@ -0,0 +1,28 @@ +package com.alarmy.near.presentation.feature.login.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.alarmy.near.presentation.feature.login.LoginRoute +import kotlinx.serialization.Serializable + +@Serializable +object RouteLogin + +// 로그인 화면으로 이동하는 확장 함수 +fun NavController.navigateToLogin(navOptions: NavOptions? = null) { + navigate(RouteLogin, navOptions) +} + +// 로그인 화면 NavGraph 정의 +fun NavGraphBuilder.loginNavGraph( + onNavigateToHome: () -> Unit, + onShowErrorSnackBar: (throwable: Throwable?) -> Unit, +) { + composable { + LoginRoute( + onNavigateToHome = onNavigateToHome, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt index 94f9e995..3b7ad4b9 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt @@ -9,6 +9,9 @@ import com.alarmy.near.presentation.feature.friendprofile.navigation.navigateToF import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.friendProfileEditorNavGraph import com.alarmy.near.presentation.feature.home.navigation.RouteHome import com.alarmy.near.presentation.feature.home.navigation.homeNavGraph +import com.alarmy.near.presentation.feature.home.navigation.navigateToHome +import com.alarmy.near.presentation.feature.login.navigation.RouteLogin +import com.alarmy.near.presentation.feature.login.navigation.loginNavGraph @Composable internal fun NearNavHost( @@ -22,14 +25,21 @@ internal fun NearNavHost( NavHost( modifier = modifier, navController = navController, - startDestination = RouteHome, + startDestination = RouteLogin, ) { - friendProfileNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = { - navController.popBackStack() - }) - friendProfileEditorNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = { - navController.popBackStack() - }) + // 로그인 화면 NavGraph + loginNavGraph( + onShowErrorSnackBar = onShowSnackbar, + onNavigateToHome = { + navController.navigateToHome( + navOptions = androidx.navigation.navOptions { + popUpTo(RouteLogin) { inclusive = true } + } + ) + } + ) + + // 홈 화면 NavGraph homeNavGraph( onShowErrorSnackBar = onShowSnackbar, onContactClick = { contactId -> @@ -39,5 +49,21 @@ internal fun NearNavHost( onAlarmClick = {}, onAddContactClick = {}, ) + + // 친구 프로필 화면 NavGraph + friendProfileNavGraph( + onShowErrorSnackBar = onShowSnackbar, + onClickBackButton = { + navController.popBackStack() + } + ) + + // 친구 프로필 편집 화면 NavGraph + friendProfileEditorNavGraph( + onShowErrorSnackBar = onShowSnackbar, + onClickBackButton = { + navController.popBackStack() + } + ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Theme.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Theme.kt index b6167e82..5737bacc 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Theme.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Theme.kt @@ -1,9 +1,13 @@ package com.alarmy.near.presentation.ui.theme +import android.app.Activity import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat val LocalCustomColors = staticCompositionLocalOf { @@ -30,6 +34,20 @@ fun NearTheme( LocalCustomTypography provides Typography, content = content, ) + +/* 스크린에서 상태바 아이콘 색상 +* */ + val view = LocalView.current +// val isDarkTheme = isSystemInDarkTheme() 시스템 다크 모드 여부를 Boolean으로 반환 + val isDarkTheme = false + + SideEffect { + if (!view.isInEditMode) { + val window = (view.context as Activity).window + val insetsController = WindowCompat.getInsetsController(window, view) + insetsController.isAppearanceLightStatusBars = !isDarkTheme + } + } } object NearTheme { diff --git a/Near/app/src/main/res/drawable/btn_kakao_login.xml b/Near/app/src/main/res/drawable/btn_kakao_login.xml new file mode 100644 index 00000000..c477cf1f --- /dev/null +++ b/Near/app/src/main/res/drawable/btn_kakao_login.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/Near/app/src/main/res/drawable/ic_near_logo_title.xml b/Near/app/src/main/res/drawable/ic_near_logo_title.xml new file mode 100644 index 00000000..4a457eac --- /dev/null +++ b/Near/app/src/main/res/drawable/ic_near_logo_title.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml index 3606877d..f6daf58a 100644 --- a/Near/app/src/main/res/values/strings.xml +++ b/Near/app/src/main/res/values/strings.xml @@ -7,6 +7,12 @@ 뒤로 가기 메뉴 + + Near 로고 + Near 타이틀 + 소중한 사람들과 더 가까워지는 시간 + 카카오 로그인 버튼 + MY 이번달 챙길 사람 diff --git a/Near/gradle/libs.versions.toml b/Near/gradle/libs.versions.toml index 9c13f8fc..21b73514 100644 --- a/Near/gradle/libs.versions.toml +++ b/Near/gradle/libs.versions.toml @@ -25,9 +25,15 @@ navigationVersion = "2.9.2" kotlinSerializationVersion = "1.9.0" # OkHttp okHttp = "5.1.0" +# DataStore +datastorePreferences = "1.1.7" +datastoreCore = "1.1.7" +#Kakao +v2All = "2.21.7" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -55,6 +61,8 @@ navigation-compose = { group = "androidx.navigation", name = "navigation-compose kotlin-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinSerializationVersion" } logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okHttp" } retrofit-kotlin-serialization-converter = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofitVersion" } +androidx-datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastoreCore" } +v2-all = { module = "com.kakao.sdk:v2-all", version.ref = "v2All" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/Near/settings.gradle.kts b/Near/settings.gradle.kts index 2d06f9e7..de36a1fc 100644 --- a/Near/settings.gradle.kts +++ b/Near/settings.gradle.kts @@ -16,6 +16,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = java.net.URI("https://devrepo.kakao.com/nexus/content/groups/public/") } } }