diff --git a/.github/workflows/android_ci.yml b/.github/workflows/android_ci.yml index bb59fb2d..e0e343cd 100644 --- a/.github/workflows/android_ci.yml +++ b/.github/workflows/android_ci.yml @@ -30,6 +30,9 @@ jobs: - name: Set up local.properties run: echo "${{ secrets.LOCAL_PROPERTIES }}" > local.properties + - name: Set up google-services.json + run: echo '${{ secrets.GOOGLE_SERVICES }}' | base64 -d > app/google-services.json + - name: Code style checks run: ./gradlew ktlintCheck detekt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 90ab0433..b9ff797b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,10 +1,16 @@ plugins { id("ndgl.application") + alias(libs.plugins.google.services) + alias(libs.plugins.firebase.crashlytics) } android { namespace = Configuration.APPLICATION_ID + buildFeatures { + buildConfig = true + } + buildTypes { release { isMinifyEnabled = true diff --git a/app/src/main/java/com/yapp/ndgl/NDGLApplication.kt b/app/src/main/java/com/yapp/ndgl/NDGLApplication.kt index 3ab95ace..b3c356d0 100644 --- a/app/src/main/java/com/yapp/ndgl/NDGLApplication.kt +++ b/app/src/main/java/com/yapp/ndgl/NDGLApplication.kt @@ -2,6 +2,15 @@ package com.yapp.ndgl import android.app.Application import dagger.hilt.android.HiltAndroidApp +import timber.log.Timber @HiltAndroidApp -class NDGLApplication : Application() +class NDGLApplication : Application() { + override fun onCreate() { + super.onCreate() + + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } + } +} diff --git a/build-logic/src/main/kotlin/NDGLAndroidLibraryPlugin.kt b/build-logic/src/main/kotlin/NDGLAndroidLibraryPlugin.kt index 6b5d82f5..9e306ad0 100644 --- a/build-logic/src/main/kotlin/NDGLAndroidLibraryPlugin.kt +++ b/build-logic/src/main/kotlin/NDGLAndroidLibraryPlugin.kt @@ -1,6 +1,7 @@ import convention.configureComposeAndroid import convention.configureFirebase import convention.configureKotlinAndroid +import convention.configureTimber import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.dependencies @@ -16,6 +17,7 @@ class NDGLAndroidLibraryPlugin : Plugin { configureKotlinAndroid() configureFirebase() configureComposeAndroid() + configureTimber() dependencies { "implementation"(libs.findLibrary("kotlinx-immutable").get()) diff --git a/build-logic/src/main/kotlin/NDGLDataPlugin.kt b/build-logic/src/main/kotlin/NDGLDataPlugin.kt index 71affa75..456b875f 100644 --- a/build-logic/src/main/kotlin/NDGLDataPlugin.kt +++ b/build-logic/src/main/kotlin/NDGLDataPlugin.kt @@ -1,17 +1,33 @@ import convention.configureCoroutineAndroid import convention.configureHiltAndroid import convention.configureKotlinAndroid +import convention.configureTimber import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies +import util.libs class NDGLDataPlugin : Plugin { override fun apply(target: Project): Unit = with(target) { with(pluginManager) { apply("com.android.library") + apply("org.jetbrains.kotlin.plugin.serialization") } configureKotlinAndroid() configureHiltAndroid() configureCoroutineAndroid() + configureTimber() + + if (path != ":data:core") { + dependencies.add("implementation", project(":data:core")) + } + + dependencies { + "implementation"(libs.findLibrary("retrofit").get()) + "implementation"(libs.findLibrary("retrofit-kotlinx-serialization-json").get()) + "implementation"(libs.findLibrary("kotlinx-serialization-json").get()) + "implementation"(libs.findLibrary("okhttp").get()) + } } } diff --git a/build-logic/src/main/kotlin/NDGLFeaturePlugin.kt b/build-logic/src/main/kotlin/NDGLFeaturePlugin.kt index da84f274..44515d93 100644 --- a/build-logic/src/main/kotlin/NDGLFeaturePlugin.kt +++ b/build-logic/src/main/kotlin/NDGLFeaturePlugin.kt @@ -1,6 +1,4 @@ -import convention.configureComposeAndroid import convention.configureCoroutineAndroid -import convention.configureFirebase import convention.configureHiltAndroid import org.gradle.api.Plugin import org.gradle.api.Project diff --git a/core/util/src/main/java/com/yapp/ndgl/core/util/ResultUtil.kt b/core/util/src/main/java/com/yapp/ndgl/core/util/ResultUtil.kt new file mode 100644 index 00000000..61e77e2a --- /dev/null +++ b/core/util/src/main/java/com/yapp/ndgl/core/util/ResultUtil.kt @@ -0,0 +1,13 @@ +package com.yapp.ndgl.core.util + +import kotlin.coroutines.cancellation.CancellationException + +suspend inline fun T.suspendRunCatching(crossinline block: suspend T.() -> R): Result { + return try { + Result.success(block()) + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + Result.failure(t) + } +} diff --git a/data/auth/build.gradle.kts b/data/auth/build.gradle.kts index fd26b5b8..1465a39c 100644 --- a/data/auth/build.gradle.kts +++ b/data/auth/build.gradle.kts @@ -7,5 +7,5 @@ android { } dependencies { - implementation(project(":data:core")) + implementation(libs.androidx.datastore) } diff --git a/data/auth/src/main/java/com/yapp/ndgl/data/auth/api/AuthApi.kt b/data/auth/src/main/java/com/yapp/ndgl/data/auth/api/AuthApi.kt new file mode 100644 index 00000000..c5746900 --- /dev/null +++ b/data/auth/src/main/java/com/yapp/ndgl/data/auth/api/AuthApi.kt @@ -0,0 +1,20 @@ +package com.yapp.ndgl.data.auth.api + +import com.yapp.ndgl.data.auth.model.AuthResponse +import com.yapp.ndgl.data.auth.model.CreateUserRequest +import com.yapp.ndgl.data.auth.model.LoginRequest +import com.yapp.ndgl.data.core.model.BaseResponse +import retrofit2.http.Body +import retrofit2.http.POST + +interface AuthApi { + @POST("/api/v1/auth/users") + suspend fun createUser( + @Body request: CreateUserRequest, + ): BaseResponse + + @POST("/api/v1/auth/login") + suspend fun login( + @Body request: LoginRequest, + ): BaseResponse +} diff --git a/data/auth/src/main/java/com/yapp/ndgl/data/auth/di/AuthModule.kt b/data/auth/src/main/java/com/yapp/ndgl/data/auth/di/AuthModule.kt new file mode 100644 index 00000000..7d1af3ae --- /dev/null +++ b/data/auth/src/main/java/com/yapp/ndgl/data/auth/di/AuthModule.kt @@ -0,0 +1,19 @@ +package com.yapp.ndgl.data.auth.di + +import com.yapp.ndgl.data.auth.token.TokenManagerImpl +import com.yapp.ndgl.data.core.token.TokenManager +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class AuthModule { + @Binds + @Singleton + abstract fun bindTokenManager( + impl: TokenManagerImpl, + ): TokenManager +} diff --git a/data/auth/src/main/java/com/yapp/ndgl/data/auth/di/AuthNetworkModule.kt b/data/auth/src/main/java/com/yapp/ndgl/data/auth/di/AuthNetworkModule.kt new file mode 100644 index 00000000..d23cf8f2 --- /dev/null +++ b/data/auth/src/main/java/com/yapp/ndgl/data/auth/di/AuthNetworkModule.kt @@ -0,0 +1,34 @@ +package com.yapp.ndgl.data.auth.di + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import com.yapp.ndgl.data.auth.api.AuthApi +import com.yapp.ndgl.data.core.adapter.NDGLCallAdapterFactory +import com.yapp.ndgl.data.core.di.AuthClient +import dagger.Module +import dagger.Provides +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.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AuthNetworkModule { + @Provides + @Singleton + fun provideAuthApi( + json: Json, + baseUrl: String, + @AuthClient okHttpClient: OkHttpClient, + callAdapterFactory: NDGLCallAdapterFactory, + ): AuthApi = Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .addCallAdapterFactory(callAdapterFactory) + .build() + .create(AuthApi::class.java) +} diff --git a/data/auth/src/main/java/com/yapp/ndgl/data/auth/local/LocalAuthDataSource.kt b/data/auth/src/main/java/com/yapp/ndgl/data/auth/local/LocalAuthDataSource.kt new file mode 100644 index 00000000..b57b885c --- /dev/null +++ b/data/auth/src/main/java/com/yapp/ndgl/data/auth/local/LocalAuthDataSource.kt @@ -0,0 +1,57 @@ +package com.yapp.ndgl.data.auth.local + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.yapp.ndgl.data.auth.local.util.handleException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LocalAuthDataSource @Inject constructor( + private val dataStore: DataStore, +) { + private val accessToken: Flow = dataStore.data + .handleException() + .map { preferences -> + preferences[ACCESS_TOKEN_KEY] ?: "" + } + + private val uuid: Flow = dataStore.data + .handleException() + .map { preferences -> + preferences[UUID_KEY] ?: "" + } + + suspend fun getAccessToken(): String = accessToken.first() + + suspend fun getUuid(): String = uuid.first() + + suspend fun setAccessToken(token: String) { + dataStore.edit { preferences -> + preferences[ACCESS_TOKEN_KEY] = token + } + } + + suspend fun setUuid(uuid: String) { + dataStore.edit { preferences -> + preferences[UUID_KEY] = uuid + } + } + + suspend fun clearSession() { + dataStore.edit { preferences -> + preferences.remove(ACCESS_TOKEN_KEY) + preferences.remove(UUID_KEY) + } + } + + private companion object { + private val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token") + private val UUID_KEY = stringPreferencesKey("uuid") + } +} diff --git a/data/auth/src/main/java/com/yapp/ndgl/data/auth/local/di/DataStoreModule.kt b/data/auth/src/main/java/com/yapp/ndgl/data/auth/local/di/DataStoreModule.kt new file mode 100644 index 00000000..e3283ff9 --- /dev/null +++ b/data/auth/src/main/java/com/yapp/ndgl/data/auth/local/di/DataStoreModule.kt @@ -0,0 +1,28 @@ +package com.yapp.ndgl.data.auth.local.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 + +@Module +@InstallIn(SingletonComponent::class) +object DataStoreModule { + + private const val TOKEN_PREFERENCES = "token_preferences" + private val Context.tokenDataStore: DataStore by preferencesDataStore(name = TOKEN_PREFERENCES) + + @Provides + @Singleton + fun provideTokenDataStore( + @ApplicationContext context: Context, + ): DataStore { + return context.tokenDataStore + } +} diff --git a/data/auth/src/main/java/com/yapp/ndgl/data/auth/local/util/DataStoreUtil.kt b/data/auth/src/main/java/com/yapp/ndgl/data/auth/local/util/DataStoreUtil.kt new file mode 100644 index 00000000..da2fce15 --- /dev/null +++ b/data/auth/src/main/java/com/yapp/ndgl/data/auth/local/util/DataStoreUtil.kt @@ -0,0 +1,16 @@ +package com.yapp.ndgl.data.auth.local.util + +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import java.io.IOException + +internal fun Flow.handleException(): Flow = + this.catch { exception -> + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } diff --git a/data/auth/src/main/java/com/yapp/ndgl/data/auth/model/AuthResponse.kt b/data/auth/src/main/java/com/yapp/ndgl/data/auth/model/AuthResponse.kt new file mode 100644 index 00000000..83909386 --- /dev/null +++ b/data/auth/src/main/java/com/yapp/ndgl/data/auth/model/AuthResponse.kt @@ -0,0 +1,10 @@ +package com.yapp.ndgl.data.auth.model + +import kotlinx.serialization.Serializable + +@Serializable +data class AuthResponse( + val uuid: String, + val accessToken: String, + val nickname: String, +) diff --git a/data/auth/src/main/java/com/yapp/ndgl/data/auth/model/CreateUserRequest.kt b/data/auth/src/main/java/com/yapp/ndgl/data/auth/model/CreateUserRequest.kt new file mode 100644 index 00000000..50a8e52a --- /dev/null +++ b/data/auth/src/main/java/com/yapp/ndgl/data/auth/model/CreateUserRequest.kt @@ -0,0 +1,12 @@ +package com.yapp.ndgl.data.auth.model + +import kotlinx.serialization.Serializable + +@Serializable +data class CreateUserRequest( + val fcmToken: String, + val deviceModel: String, + val deviceOs: String, + val deviceOsVersion: String, + val appVersion: String, +) diff --git a/data/auth/src/main/java/com/yapp/ndgl/data/auth/model/LoginRequest.kt b/data/auth/src/main/java/com/yapp/ndgl/data/auth/model/LoginRequest.kt new file mode 100644 index 00000000..86d07ffb --- /dev/null +++ b/data/auth/src/main/java/com/yapp/ndgl/data/auth/model/LoginRequest.kt @@ -0,0 +1,8 @@ +package com.yapp.ndgl.data.auth.model + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginRequest( + val uuid: String, +) diff --git a/data/auth/src/main/java/com/yapp/ndgl/data/auth/token/TokenManagerImpl.kt b/data/auth/src/main/java/com/yapp/ndgl/data/auth/token/TokenManagerImpl.kt new file mode 100644 index 00000000..faf7056e --- /dev/null +++ b/data/auth/src/main/java/com/yapp/ndgl/data/auth/token/TokenManagerImpl.kt @@ -0,0 +1,43 @@ +package com.yapp.ndgl.data.auth.token + +import com.yapp.ndgl.data.auth.api.AuthApi +import com.yapp.ndgl.data.auth.local.LocalAuthDataSource +import com.yapp.ndgl.data.auth.model.LoginRequest +import com.yapp.ndgl.data.core.model.getData +import com.yapp.ndgl.data.core.token.TokenManager +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TokenManagerImpl @Inject constructor( + private val localAuthDataSource: LocalAuthDataSource, + private val authApi: AuthApi, +) : TokenManager { + + override suspend fun getAccessToken(): String { + return localAuthDataSource.getAccessToken() + } + + override suspend fun getUuid(): String { + return localAuthDataSource.getUuid() + } + + override suspend fun setAccessToken(accessToken: String) { + localAuthDataSource.setAccessToken(accessToken) + } + + override suspend fun setUuid(uuid: String) { + localAuthDataSource.setUuid(uuid) + } + + override suspend fun refreshToken(): String { + val uuid = getUuid() + check(uuid.isNotEmpty()) { "UUID is empty" } + + val response = authApi.login(LoginRequest(uuid)).getData() + setAccessToken(response.accessToken) + setUuid(response.uuid) + + return response.accessToken + } +} diff --git a/data/core/build.gradle.kts b/data/core/build.gradle.kts index 1b88e1e2..633d9910 100644 --- a/data/core/build.gradle.kts +++ b/data/core/build.gradle.kts @@ -1,7 +1,26 @@ +import java.util.Properties +import kotlin.apply + plugins { id("ndgl.data") } android { namespace = "com.yapp.ndgl.data.core" + + defaultConfig { + val localProperties = Properties().apply { + load(rootProject.file("local.properties").bufferedReader()) + } + + buildConfigField("String", "NDGL_BASE_URL", "\"${localProperties["NDGL_BASE_URL"]}\"") + } + + buildFeatures { + buildConfig = true + } +} + +dependencies { + implementation(libs.okhttp.logging.interceptor) } diff --git a/data/core/src/main/java/com/yapp/ndgl/data/core/adapter/NDGLCallAdapterFactory.kt b/data/core/src/main/java/com/yapp/ndgl/data/core/adapter/NDGLCallAdapterFactory.kt new file mode 100644 index 00000000..46115098 --- /dev/null +++ b/data/core/src/main/java/com/yapp/ndgl/data/core/adapter/NDGLCallAdapterFactory.kt @@ -0,0 +1,93 @@ +package com.yapp.ndgl.data.core.adapter + +import com.yapp.ndgl.data.core.model.BaseResponse +import com.yapp.ndgl.data.core.model.error.ErrorResponse +import com.yapp.ndgl.data.core.model.error.HttpResponseException +import kotlinx.serialization.json.Json +import okhttp3.Request +import okio.Timeout +import retrofit2.Call +import retrofit2.CallAdapter +import retrofit2.Callback +import retrofit2.Response +import retrofit2.Retrofit +import timber.log.Timber +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NDGLCallAdapterFactory @Inject constructor() : CallAdapter.Factory() { + override fun get( + type: Type, + annotations: Array, + retrofit: Retrofit, + ): CallAdapter<*, *>? { + if (getRawType(type) != Call::class.java) return null + + val wrapperType = getParameterUpperBound(0, type as ParameterizedType) + if (getRawType(wrapperType) != BaseResponse::class.java) return null + + return NDGLCallAdapter(wrapperType) + } +} + +private class NDGLCallAdapter( + private val resultType: Type, +) : CallAdapter> { + override fun responseType(): Type = resultType + override fun adapt(call: Call): Call = NDGLCall(call) +} + +private class NDGLCall( + private val delegate: Call, +) : Call { + private val json = Json { ignoreUnknownKeys = true } + + override fun enqueue(callback: Callback) { + delegate.enqueue( + object : Callback { + override fun onResponse(call: Call, response: Response) { + val body = response.body() + + if (response.isSuccessful && body != null) { + callback.onResponse(this@NDGLCall, response) + } else { + val errorBody = response.errorBody()?.string() ?: "" + + // errorBody를 ErrorResponse로 디코딩 + val errorResponse = try { + json.decodeFromString(errorBody) + } catch (e: Exception) { + Timber.e(e, "Failed to parse error response") + null + } + + val exception = HttpResponseException( + code = errorResponse?.code ?: response.code().toString(), + errorMessage = errorResponse?.message ?: errorBody, + fieldErrors = errorResponse?.errors, + ) + + callback.onFailure(this@NDGLCall, exception) + } + } + + override fun onFailure(call: Call, throwable: Throwable) { + callback.onFailure(this@NDGLCall, throwable) + } + }, + ) + } + + override fun clone(): Call = NDGLCall(delegate.clone()) + override fun execute(): Response = + throw NotImplementedError("NDGLCall doesn't support execute()") + + override fun isExecuted(): Boolean = delegate.isExecuted + override fun cancel() = delegate.cancel() + override fun isCanceled(): Boolean = delegate.isCanceled + override fun request(): Request = delegate.request() + override fun timeout(): Timeout = delegate.timeout() +} diff --git a/data/core/src/main/java/com/yapp/ndgl/data/core/authenticator/NDGLAuthenticator.kt b/data/core/src/main/java/com/yapp/ndgl/data/core/authenticator/NDGLAuthenticator.kt new file mode 100644 index 00000000..e0573624 --- /dev/null +++ b/data/core/src/main/java/com/yapp/ndgl/data/core/authenticator/NDGLAuthenticator.kt @@ -0,0 +1,54 @@ +package com.yapp.ndgl.data.core.authenticator + +import com.yapp.ndgl.data.core.token.TokenManager +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import timber.log.Timber +import javax.inject.Inject + +class NDGLAuthenticator @Inject constructor( + private val tokenManager: TokenManager, +) : Authenticator { + private val mutex = Mutex() + + override fun authenticate(route: Route?, response: Response): Request? { + val originRequest = response.request + + if (originRequest.header("Authorization").isNullOrEmpty()) { + return null + } + + val retryCount = originRequest.header(RETRY_HEADER)?.toIntOrNull() ?: 0 + if (retryCount >= MAX_RETRY_COUNT) { + return null + } + + val newAccessToken = runBlocking { + mutex.withLock { + try { + tokenManager.refreshToken() + } catch (e: Exception) { + Timber.e(e, "Failed to refresh token") + null + } + } + } ?: return null + + val newRequest = originRequest.newBuilder() + .header(RETRY_HEADER, (retryCount + 1).toString()) + .header("Authorization", "Bearer $newAccessToken") + .build() + + return newRequest + } + + companion object { + private const val MAX_RETRY_COUNT = 3 + private const val RETRY_HEADER = "Retry-Count" + } +} diff --git a/data/core/src/main/java/com/yapp/ndgl/data/core/di/NetworkModule.kt b/data/core/src/main/java/com/yapp/ndgl/data/core/di/NetworkModule.kt new file mode 100644 index 00000000..0b9eca12 --- /dev/null +++ b/data/core/src/main/java/com/yapp/ndgl/data/core/di/NetworkModule.kt @@ -0,0 +1,66 @@ +package com.yapp.ndgl.data.core.di + +import com.yapp.ndgl.data.core.BuildConfig +import com.yapp.ndgl.data.core.authenticator.NDGLAuthenticator +import com.yapp.ndgl.data.core.interceptor.NDGLInterceptor +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import javax.inject.Qualifier +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + @Singleton + @Provides + fun provideJson(): Json = Json { + ignoreUnknownKeys = true + } + + @Singleton + @Provides + fun provideBaseUrl(): String = BuildConfig.NDGL_BASE_URL + + @Singleton + @Provides + fun provideDefaultOkHttpClient( + interceptor: NDGLInterceptor, + authenticator: NDGLAuthenticator, + ): OkHttpClient { + val builder = OkHttpClient.Builder() + .addInterceptor(interceptor) + .authenticator(authenticator) + + if (BuildConfig.DEBUG) { + val loggingInterceptor = HttpLoggingInterceptor() + loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY + builder.addInterceptor(loggingInterceptor) + } + + return builder.build() + } + + @AuthClient + @Singleton + @Provides + fun provideAuthOkHttpClient(): OkHttpClient { + val builder = OkHttpClient.Builder() + + if (BuildConfig.DEBUG) { + val loggingInterceptor = HttpLoggingInterceptor() + loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY + builder.addInterceptor(loggingInterceptor) + } + + return builder.build() + } +} + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AuthClient diff --git a/data/core/src/main/java/com/yapp/ndgl/data/core/interceptor/NDGLInterceptor.kt b/data/core/src/main/java/com/yapp/ndgl/data/core/interceptor/NDGLInterceptor.kt new file mode 100644 index 00000000..2b988a4e --- /dev/null +++ b/data/core/src/main/java/com/yapp/ndgl/data/core/interceptor/NDGLInterceptor.kt @@ -0,0 +1,34 @@ +package com.yapp.ndgl.data.core.interceptor + +import com.yapp.ndgl.data.core.token.TokenManager +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import javax.inject.Inject + +class NDGLInterceptor @Inject constructor( + private val tokenManager: TokenManager, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val originRequest = chain.request() + val requestBuilder = originRequest.newBuilder() + + if (isAccessTokenUsed(originRequest)) { + requestBuilder.addHeader( + "Authorization", + "Bearer ${runBlocking { tokenManager.getAccessToken() }}", + ) + } + + return chain.proceed(requestBuilder.build()) + } + + private fun isAccessTokenUsed(request: Request): Boolean { + return when (request.url.encodedPath) { + "/api/v1/auth/users" -> false + "/api/v1/auth/login" -> false + else -> true + } + } +} diff --git a/data/core/src/main/java/com/yapp/ndgl/data/core/model/BaseResponse.kt b/data/core/src/main/java/com/yapp/ndgl/data/core/model/BaseResponse.kt new file mode 100644 index 00000000..6a9c45f2 --- /dev/null +++ b/data/core/src/main/java/com/yapp/ndgl/data/core/model/BaseResponse.kt @@ -0,0 +1,14 @@ +package com.yapp.ndgl.data.core.model + +import kotlinx.serialization.Serializable + +@Serializable +data class BaseResponse( + val code: String, + val message: String, + val data: T?, +) + +fun BaseResponse.getData(): T { + return data ?: error("Response data is null. code=$code, message=$message") +} diff --git a/data/core/src/main/java/com/yapp/ndgl/data/core/model/error/ErrorResponse.kt b/data/core/src/main/java/com/yapp/ndgl/data/core/model/error/ErrorResponse.kt new file mode 100644 index 00000000..79c3c618 --- /dev/null +++ b/data/core/src/main/java/com/yapp/ndgl/data/core/model/error/ErrorResponse.kt @@ -0,0 +1,16 @@ +package com.yapp.ndgl.data.core.model.error + +import kotlinx.serialization.Serializable + +@Serializable +data class ErrorResponse( + val code: String, + val message: String, + val errors: List? = null, +) + +@Serializable +data class FieldError( + val field: String, + val message: String, +) diff --git a/data/core/src/main/java/com/yapp/ndgl/data/core/model/error/HttpResponseException.kt b/data/core/src/main/java/com/yapp/ndgl/data/core/model/error/HttpResponseException.kt new file mode 100644 index 00000000..93ca5b5c --- /dev/null +++ b/data/core/src/main/java/com/yapp/ndgl/data/core/model/error/HttpResponseException.kt @@ -0,0 +1,7 @@ +package com.yapp.ndgl.data.core.model.error + +class HttpResponseException( + val code: String, + val errorMessage: String, + val fieldErrors: List? = null, +) : Exception(errorMessage) diff --git a/data/core/src/main/java/com/yapp/ndgl/data/core/token/TokenManager.kt b/data/core/src/main/java/com/yapp/ndgl/data/core/token/TokenManager.kt new file mode 100644 index 00000000..f25d62cf --- /dev/null +++ b/data/core/src/main/java/com/yapp/ndgl/data/core/token/TokenManager.kt @@ -0,0 +1,9 @@ +package com.yapp.ndgl.data.core.token + +interface TokenManager { + suspend fun getAccessToken(): String + suspend fun getUuid(): String + suspend fun setAccessToken(accessToken: String) + suspend fun setUuid(uuid: String) + suspend fun refreshToken(): String +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 47615e63..3ad8f1c7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ hiltNavigationCompose = "1.3.0" # Retrofit retrofit = "3.0.0" -okhttp = "5.3.2" +okhttp = "4.12.0" # Kotlinx Serialization kotlinxSerializationJson = "1.7.3" @@ -30,12 +30,17 @@ nav3Core = "1.0.0" # Coroutines coroutines = "1.10.2" +# DataStore +datastore = "1.1.1" + # Lifecycle lifecycleViewmodelCompose = "2.10.0" lifecycleViewmodelNav3 = "2.10.0" # Firebase firebaseBom = "34.5.0" +googleServices = "4.4.4" +firebaseCrashlytics = "3.0.6" # Other timber = "5.0.1" @@ -43,7 +48,6 @@ desugarJdkLibs = "2.1.5" appcompat = "1.7.1" material = "1.13.0" - # Static Analysis detekt = "1.23.8" ktlint-gradle = "12.1.1" @@ -88,6 +92,9 @@ androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecy kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +# DataStore +androidx-datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } + # Lifecycle lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleViewmodelCompose" } @@ -102,6 +109,7 @@ kotlinx-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collectio firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } +firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } # Other timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } @@ -116,7 +124,6 @@ ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devto androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } - [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } @@ -128,3 +135,5 @@ ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } +firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlytics" }