Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ android {
applicationId = "com.sampoom.android"
minSdk = 26
targetSdk = 36
versionCode = 4
versionName = "1.0.3"
versionCode = 5
versionName = "1.0.4"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
Expand Down
37 changes: 35 additions & 2 deletions app/src/main/java/com/sampoom/android/app/navigation/AppNavHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,16 @@ fun AppNavHost(
}
}

LaunchedEffect(Unit) {
authViewModel.logoutEvent.collect {
navController.navigate(ROUTE_LOGIN) {
popUpTo(navController.graph.startDestinationId) { inclusive = true }
launchSingleTop = true
}
}
}

// 앱 로그인 도중 로딩 상태 표시로 화면 깜빡임 제거
if (isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
Expand All @@ -128,22 +138,24 @@ fun AppNavHost(

NavHost(
navController = navController,
// startDestination = ROUTE_HOME,
startDestination = if (isLoggedIn) ROUTE_HOME else ROUTE_LOGIN,
modifier = Modifier.background(backgroundColor())
) {
// 로그인
composable(ROUTE_LOGIN) {
LoginScreen(
onSuccess = {
authViewModel.updateLoginState()
navController.navigate(ROUTE_HOME) {
popUpTo(ROUTE_LOGIN) { inclusive = true } // 로그인 화면 스택 제거
popUpTo(ROUTE_LOGIN) { inclusive = true }
}
},
onNavigateSignUp = {
navController.navigate(ROUTE_SIGNUP)
})
}

// 회원가입
composable(ROUTE_SIGNUP) {
SignUpScreen(
onSuccess = {
Expand All @@ -156,7 +168,11 @@ fun AppNavHost(
}
)
}

// 홈
composable(ROUTE_HOME) { MainScreen(navController, user) }

// 부품 조회
composable(ROUTE_PARTS) {
PartScreen(
onNavigateBack = {
Expand All @@ -169,6 +185,8 @@ fun AppNavHost(
}
)
}

// 부품 리스트 조회
composable(
ROUTE_PART_LIST,
arguments = listOf(
Expand All @@ -183,6 +201,8 @@ fun AppNavHost(
navController = navController
)
}

// 주문 상세
composable(
ROUTE_ORDER_DETAIL,
arguments = listOf(
Expand All @@ -196,6 +216,8 @@ fun AppNavHost(
}
)
}

// 설정
composable(
ROUTE_SETTINGS
) {
Expand All @@ -212,6 +234,8 @@ fun AppNavHost(
}
)
}

// 직원 관리
composable(ROUTE_EMPLOYEE) {
EmployeeListScreen(
onNavigateBack = {
Expand All @@ -238,6 +262,7 @@ fun MainScreen(
navController = navController,
startDestination = ROUTE_DASHBOARD
) {
// 대시보드
composable(ROUTE_DASHBOARD) {
DashboardScreen(
paddingValues = innerPadding,
Expand All @@ -260,16 +285,22 @@ fun MainScreen(
}
)
}

// 출고 목록
composable(ROUTE_OUTBOUND) {
OutboundListScreen(
paddingValues = innerPadding
)
}

// 장바구니
composable(ROUTE_CART) {
CartListScreen(
paddingValues = innerPadding
)
}

// 주문 관리
composable(ROUTE_ORDERS) {
OrderListScreen(
paddingValues = innerPadding,
Expand All @@ -283,6 +314,7 @@ fun MainScreen(
}
}

/** Floating Button */
@Composable
fun PartsFab(navController: NavHostController) {
FloatingActionButton(
Expand All @@ -305,6 +337,7 @@ fun PartsFab(navController: NavHostController) {
}
}

/** Navigation Bar */
@Composable
fun BottomNavigationBar(navController: NavHostController) {
val bottomNavItems = listOf(
Expand Down
10 changes: 6 additions & 4 deletions app/src/main/java/com/sampoom/android/core/di/NetworkModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.google.gson.GsonBuilder
import com.sampoom.android.BuildConfig
import com.sampoom.android.core.network.TokenAuthenticator
import com.sampoom.android.core.network.TokenInterceptor
import com.sampoom.android.core.network.TokenLogoutEmitter
import com.sampoom.android.core.network.TokenRefreshService
import com.sampoom.android.core.preferences.AuthPreferences
import dagger.Module
Expand All @@ -24,9 +25,10 @@ object NetworkModule {
@Provides
@Singleton
fun provideTokenInterceptor(
authPreferences: AuthPreferences
authPreferences: AuthPreferences,
tokenLogoutEmitter: TokenLogoutEmitter
): TokenInterceptor {
return TokenInterceptor(authPreferences)
return TokenInterceptor(authPreferences, tokenLogoutEmitter)
}

@Provides
Expand All @@ -41,7 +43,7 @@ object NetworkModule {
@Singleton
fun provideOkHttpClient(
tokenInterceptor: TokenInterceptor,
tokenAuthenticator: TokenAuthenticator
// tokenAuthenticator: TokenAuthenticator
): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
Expand All @@ -58,7 +60,7 @@ object NetworkModule {
}
)
.addInterceptor(tokenInterceptor) // 토큰 자동 삽입
.authenticator(tokenAuthenticator) // 토큰 갱신 (Interceptor 대신)
// .authenticator(tokenAuthenticator) // 토큰 갱신 (Interceptor 대신)
.build()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.google.gson.JsonSyntaxException
import com.sampoom.android.core.model.ApiErrorResponse
import retrofit2.HttpException

/** 서버에서 반환되는 에러 메시지를 추출하는 함수 */
fun Throwable.serverMessageOrNull(): String? {
if (this is HttpException) {
val errorBody = response()?.errorBody()?.string() ?: return null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import okhttp3.Route
import javax.inject.Inject
import javax.inject.Singleton

/** 토큰 Authentication 인증 로직 */
@Singleton
class TokenAuthenticator @Inject constructor(
private val authPreferences: AuthPreferences,
private val tokenRefreshService: TokenRefreshService
private val tokenRefreshService: TokenRefreshService,
private val tokenLogoutEmitter: TokenLogoutEmitter
) : Authenticator {
private val refreshMutex = Mutex()

Expand All @@ -41,7 +43,10 @@ class TokenAuthenticator @Inject constructor(
when (e.code()) {
400, 401 -> {
// 인증 실패: 토큰 삭제
runBlocking { authPreferences.clear() }
runBlocking {
authPreferences.clear()
tokenLogoutEmitter.emit()
}
null
}
403 -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ package com.sampoom.android.core.network
import com.sampoom.android.core.preferences.AuthPreferences
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import javax.inject.Inject

/** 토큰 Interceptor */
class TokenInterceptor @Inject constructor(
private val authPreferences: AuthPreferences
private val authPreferences: AuthPreferences,
private val tokenLogoutEmitter: TokenLogoutEmitter
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
Expand All @@ -17,7 +20,7 @@ class TokenInterceptor @Inject constructor(
val requestWithoutFlag = originalRequest.newBuilder()
.removeHeader("X-No-Auth")
.build()
return chain.proceed(requestWithoutFlag)
return proceedAndLogoutOnForbidden(chain, requestWithoutFlag)
}

val existingAuth = originalRequest.header("Authorization")
Expand All @@ -29,10 +32,21 @@ class TokenInterceptor @Inject constructor(
val newRequest = originalRequest.newBuilder()
.header("Authorization", "Bearer $accessToken")
.build()
return chain.proceed(newRequest)
return proceedAndLogoutOnForbidden(chain, newRequest)
}
}

return chain.proceed(originalRequest)
return proceedAndLogoutOnForbidden(chain, originalRequest)
}

private fun proceedAndLogoutOnForbidden(chain: Interceptor.Chain, request: Request): Response {
val response = chain.proceed(request)
if (response.code == 401) {
runBlocking {
authPreferences.clear()
tokenLogoutEmitter.emit()
}
}
return response
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.sampoom.android.core.network

import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class TokenLogoutEmitter @Inject constructor() {
private val _events = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val events: SharedFlow<Unit> = _events.asSharedFlow()

suspend fun emit() {
_events.emit(Unit)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import retrofit2.converter.gson.GsonConverterFactory
import javax.inject.Inject
import javax.inject.Singleton

/** 토큰 Refresh 로직 */
@Singleton
class TokenRefreshService @Inject constructor(
private val authPreferences: AuthPreferences
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import javax.inject.Singleton
// Per official guidance, DataStore instance should be single and at top-level.
private val Context.authDataStore by preferencesDataStore(name = "auth_prefs")

/** Encrypt Shared Preferences with CryptoManager */
@Singleton
class AuthPreferences @Inject constructor(
@param:ApplicationContext private val context: Context,
Expand All @@ -39,6 +40,7 @@ class AuthPreferences @Inject constructor(
val USER_ENDED_AT: Preferences.Key<String> = stringPreferencesKey("user_ended_at")
}

/** User 모델 저장 */
suspend fun saveUser(user: User) {
val expiresAt = System.currentTimeMillis() + (user.expiresIn * 1000)
dataStore.edit { prefs ->
Expand All @@ -58,6 +60,7 @@ class AuthPreferences @Inject constructor(
}
}

/** User 토큰 저장 */
suspend fun saveToken(accessToken: String, refreshToken: String, expiresIn: Long) {
val expiresAt = System.currentTimeMillis() + (expiresIn * 1000)
dataStore.edit { prefs ->
Expand All @@ -67,6 +70,7 @@ class AuthPreferences @Inject constructor(
}
}

/** User 모델 조회 */
suspend fun getStoredUser(): User? {
val prefs = dataStore.data.first()
val userId = prefs[Keys.USER_ID]
Expand Down Expand Up @@ -114,6 +118,7 @@ class AuthPreferences @Inject constructor(
} else return null
}

/** Access Token 조회 */
suspend fun getAccessToken(): String? {
val encrypted = dataStore.data.first()[Keys.ACCESS_TOKEN] ?: return null
return try {
Expand All @@ -123,6 +128,7 @@ class AuthPreferences @Inject constructor(
}
}

/** RefreshToken 조회 */
suspend fun getRefreshToken(): String? {
val encrypted = dataStore.data.first()[Keys.REFRESH_TOKEN] ?: return null
return try {
Expand All @@ -132,15 +138,18 @@ class AuthPreferences @Inject constructor(
}
}

/** Token 만료 조회 */
suspend fun isTokenExpired(): Boolean {
val expiresAt = dataStore.data.first()[Keys.TOKEN_EXPIRES_AT]
return expiresAt == null || System.currentTimeMillis() > expiresAt
}

/** 저장된 User 모델 삭제 */
suspend fun clear() {
dataStore.edit { it.clear() }
}

/** 토큰 여부 판별 */
suspend fun hasToken(): Boolean {
val accessToken = getAccessToken()
val refreshToken = getRefreshToken()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import com.sampoom.android.core.ui.theme.textSecondaryColor
* onClick = { ... }
* )
*/

@Composable
fun CommonButton(
modifier: Modifier = Modifier,
Expand Down
Loading