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
101 changes: 61 additions & 40 deletions app/src/main/java/com/kuit/afternote/data/remote/AuthInterceptor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ class AuthInterceptor
private const val BASE_URL = "https://afternote.kro.kr/"
private const val REISSUE_ENDPOINT = "auth/reissue"

/**
* 토큰 재발급 동기화 락.
* companion object에 두어 프로세스 전역에서 하나의 refresh만 실행되도록 보장.
*/
private val refreshTokenLock = Any()

/**
* 인증이 필요 없는 경로 목록.
*/
Expand Down Expand Up @@ -107,70 +113,85 @@ class AuthInterceptor

val response = proceedAndLog(chain, authenticatedRequest)

// 401 응답 시 토큰 재발급 시도
// 401 응답 시 토큰 재발급 시도 (synchronized double-check locking)
if (response.code == 401) {
Log.w(TAG, "Auth: 401 Unauthorized - Attempting token refresh")
return handleTokenRefresh(chain, originalRequest, response)
return handleTokenRefresh(chain, originalRequest, response, accessToken)
}

return response
}

/**
* 401 응답 시 토큰 재발급을 시도하고 요청을 재시도합니다.
*
* synchronized + double-check locking으로 동시 401에서 reissue API가
* 한 번만 호출되도록 보장합니다. 나머지 스레드는 lock을 기다린 후
* 이미 갱신된 토큰으로 바로 재시도합니다.
*
* @param failedAccessToken 401을 받은 시점의 accessToken (double-check에 사용)
*/
private fun handleTokenRefresh(
chain: Interceptor.Chain,
originalRequest: Request,
originalResponse: Response
originalResponse: Response,
failedAccessToken: String,
): Response {
val refreshToken = runBlocking { tokenManager.getRefreshToken() }
// refreshTokenLock으로 동기화 — 동시에 하나의 스레드만 refresh 수행
synchronized(refreshTokenLock) {
// Double-check: lock을 기다리는 동안 다른 스레드가 이미 갱신했는지 확인
val currentAccessToken = runBlocking { tokenManager.getAccessToken() }
if (currentAccessToken != failedAccessToken && !currentAccessToken.isNullOrEmpty()) {
// 다른 스레드가 이미 토큰을 갱신함 → refresh 없이 새 토큰으로 재시도
Log.d(TAG, "TokenRefresh: Already refreshed by another thread, retrying")
originalResponse.close()
val newRequest = originalRequest
.newBuilder()
.header(AUTHORIZATION_HEADER, "$BEARER_PREFIX$currentAccessToken")
.build()
return proceedAndLog(chain, newRequest)
}

if (refreshToken.isNullOrEmpty()) {
Log.e(TAG, "TokenRefresh: No refresh token available")
// 토큰이 전혀 없는 상태에서는 추가 조치를 하지 않고 401을 그대로 반환한다.
// (UI 레이어에서 isLoggedInFlow를 보고 로그인 화면으로 유도)
return originalResponse
}
// 이 스레드가 최초 진입 → 실제 refresh 수행
val refreshToken = runBlocking { tokenManager.getRefreshToken() }

if (refreshToken.isNullOrEmpty()) {
Log.e(TAG, "TokenRefresh: No refresh token available")
return originalResponse
}

Log.d(TAG, "TokenRefresh: Attempting with refreshToken=${refreshToken.take(n = 20)}...")
Log.d(TAG, "TokenRefresh: Attempting with refreshToken=${refreshToken.take(n = 20)}...")

return try {
val newTokens = refreshAccessToken(refreshToken)
return try {
val newTokens = refreshAccessToken(refreshToken)

if (newTokens != null) {
Log.d(TAG, "TokenRefresh: SUCCESS - New tokens received")
if (newTokens != null) {
Log.d(TAG, "TokenRefresh: SUCCESS - New tokens received")

// 새 토큰 저장
runBlocking {
tokenManager.updateTokens(
accessToken = newTokens.accessToken ?: "",
refreshToken = newTokens.refreshToken ?: refreshToken
)
}
runBlocking {
tokenManager.updateTokens(
accessToken = newTokens.accessToken ?: "",
refreshToken = newTokens.refreshToken ?: refreshToken
)
}

// 원래 응답 닫기
originalResponse.close()
originalResponse.close()

// 새 토큰으로 원래 요청 재시도
val newRequest = originalRequest
.newBuilder()
.header(AUTHORIZATION_HEADER, "$BEARER_PREFIX${newTokens.accessToken}")
.build()
val newRequest = originalRequest
.newBuilder()
.header(AUTHORIZATION_HEADER, "$BEARER_PREFIX${newTokens.accessToken}")
.build()

Log.d(TAG, "TokenRefresh: Retrying original request with new token")
proceedAndLog(chain, newRequest)
} else {
Log.e(TAG, "TokenRefresh: FAILED - keeping existing tokens")
// 재발급 실패 시 기존 토큰을 삭제하지 않고 401을 그대로 반환한다.
// 이렇게 해야 사용자가 이미 로그인했다고 생각하는 상태에서
// 토큰이 갑자기 null로 사라지는 문제를 방지할 수 있다.
Log.d(TAG, "TokenRefresh: Retrying original request with new token")
proceedAndLog(chain, newRequest)
} else {
Log.e(TAG, "TokenRefresh: FAILED - keeping existing tokens")
originalResponse
}
} catch (e: Exception) {
Log.e(TAG, "TokenRefresh: Exception - ${e.message}", e)
originalResponse
}
} catch (e: Exception) {
Log.e(TAG, "TokenRefresh: Exception - ${e.message}", e)
// 예외 발생 시에도 토큰을 강제로 삭제하지 않고 401 응답을 그대로 반환한다.
originalResponse
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
Expand All @@ -41,6 +42,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import coil3.network.NetworkHeaders
import coil3.network.httpHeaders
import coil3.request.ImageRequest
Expand Down Expand Up @@ -82,6 +84,15 @@ fun FuneralVideoUpload(
val context = LocalContext.current
var thumbnailBitmap by remember(videoUrl) { mutableStateOf<ImageBitmap?>(null) }

LaunchedEffect(videoUrl, thumbnailUrl, hasVideo, thumbnailBitmap) {
Log.d(
TAG,
"FuneralVideoUpload active: videoUrl=${videoUrl?.take(50) ?: "null"}, " +
"thumbnailUrl=${thumbnailUrl?.take(80) ?: "null"}, hasVideo=$hasVideo, " +
"hasBitmap=${thumbnailBitmap != null}"
)
}

LaunchedEffect(videoUrl) {
if (videoUrl.isNullOrBlank()) {
thumbnailBitmap = null
Expand Down Expand Up @@ -175,6 +186,9 @@ fun FuneralVideoUpload(
) {
when {
!thumbnailUrl.isNullOrBlank() -> {
SideEffect {
Log.d(TAG, "Branch=URL, thumbnailUrl=$thumbnailUrl")
}
AsyncImage(
model =
ImageRequest.Builder(context)
Expand All @@ -188,24 +202,42 @@ fun FuneralVideoUpload(
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
error = painterResource(R.drawable.ic_add_circle),
onError = { state ->
error = painterResource(R.drawable.img_placeholder_1),
onLoading = {
Log.d(TAG, "Coil loading started: url=$thumbnailUrl")
},
onSuccess = {
Log.d(TAG, "Coil load success: url=$thumbnailUrl")
},
onError = { state: AsyncImagePainter.State.Error ->
Log.e(
TAG,
"Thumbnail load failed: url=$thumbnailUrl",
"Coil load failed: url=$thumbnailUrl",
state.result.throwable
)
}
)
}
thumbnailBitmap != null -> {
SideEffect {
Log.d(TAG, "Branch=LocalBitmap, videoUrl=$videoUrl")
}
Image(
bitmap = thumbnailBitmap!!,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
else -> {
SideEffect {
Log.w(
TAG,
"Branch=NoThumbnail, videoUrl=$videoUrl, " +
"thumbnailUrl=$thumbnailUrl, thumbnailBitmap=null"
)
}
}
}
Image(
painter = painterResource(R.drawable.ic_playback),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,10 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
Expand Down Expand Up @@ -59,6 +56,9 @@ fun RecordMainScreen(
Scaffold(
modifier = modifier.fillMaxSize(),
containerColor = Gray1,
topBar = {
TopBar(title = "나의 모든 기록")
},
bottomBar = {
BottomNavigationBar(
selectedItem = selectedBottomNavItem,
Expand Down Expand Up @@ -103,12 +103,7 @@ fun RecordMainScreen(
modifier = Modifier
.fillMaxWidth()
.padding(paddingValues)
.windowInsetsPadding(WindowInsets.statusBars) // 상태바 만큼 패딩을 줘서 겹치지 않도록
) {
// 샹단 제목
TopBar(
title = "나의 모든 기록"
)
// 리스트
val allItems = listOf(
"데일리 질문답변" to "매일 다른 질문들에 나를 남겨보세요",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,26 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.kuit.afternote.feature.afternote.domain.model.AfternoteItem
import com.kuit.afternote.feature.afternote.domain.usecase.GetAfternotesUseCase
import com.kuit.afternote.feature.dailyrecord.data.dto.Emotion
import com.kuit.afternote.feature.dailyrecord.data.dto.EmotionResponse
import com.kuit.afternote.feature.dailyrecord.data.dto.PostMindRecordRequest
import com.kuit.afternote.feature.dailyrecord.domain.usecase.*
import com.kuit.afternote.feature.dailyrecord.domain.usecase.CreateMindRecordUseCase
import com.kuit.afternote.feature.dailyrecord.domain.usecase.DeleteMindRecordUseCase
import com.kuit.afternote.feature.dailyrecord.domain.usecase.EditMindRecordUseCase
import com.kuit.afternote.feature.dailyrecord.domain.usecase.GetDailyQuestionUseCase
import com.kuit.afternote.feature.dailyrecord.domain.usecase.GetEmotionsUseCase
import com.kuit.afternote.feature.dailyrecord.domain.usecase.GetMindRecordUseCase
import com.kuit.afternote.feature.dailyrecord.domain.usecase.GetMindRecordsUseCase
import com.kuit.afternote.feature.dailyrecord.presentation.uimodel.MindRecordUiModel
import com.kuit.afternote.feature.dailyrecord.presentation.uimodel.MindRecordUiState
import com.kuit.afternote.feature.home.presentation.component.CalendarDay
import com.kuit.afternote.feature.home.presentation.component.CalendarDayStyle
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.time.DayOfWeek
Expand Down Expand Up @@ -48,6 +58,14 @@ data class EditRecordParams(
val isDraft: Boolean
)

/**
* Contract for HomeScreen so a fake can be used in Previews (no Hilt).
*/
interface MindRecordHomeContract {
val calendarDays: StateFlow<List<CalendarDay>>
fun loadRecordsForDiaryList()
}

@HiltViewModel
class MindRecordViewModel @Inject constructor(
private val getMindRecordsUseCase: GetMindRecordsUseCase,
Expand All @@ -58,7 +76,7 @@ class MindRecordViewModel @Inject constructor(
private val editMindRecordUseCase: EditMindRecordUseCase,
private val getAfternotesUseCase: GetAfternotesUseCase,
private val getEmotionsUseCase: GetEmotionsUseCase
) : ViewModel() {
) : ViewModel(), MindRecordHomeContract {

// --- State 정의 ---
private val _records = MutableStateFlow<List<MindRecordUiModel>>(emptyList())
Expand Down Expand Up @@ -124,7 +142,7 @@ class MindRecordViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WeeklySummaryUiState())

// 기존 캘린더 데이 (메인 화면용 유지)
val calendarDays: StateFlow<List<CalendarDay>> = _records
override val calendarDays: StateFlow<List<CalendarDay>> = _records
.map { recordList ->
val recordedDates = recordList.map { it.originalDate }.toSet()
val today = LocalDate.now()
Expand Down Expand Up @@ -215,7 +233,7 @@ class MindRecordViewModel @Inject constructor(
MindRecordUiModel(
id = summary.recordId,
title = summary.title ?: "",
formattedDate = runCatching { formatDate(summary.date) }.getOrElse { summary.date },
formattedDate = runCatching { formatDate(summary.date) }.getOrNull() ?: summary.date,
draftLabel = if (summary.isDraft) "임시저장" else "완료",
content = summary.content,
originalDate = summary.date,
Expand All @@ -239,7 +257,7 @@ class MindRecordViewModel @Inject constructor(
}
}

fun loadRecordsForDiaryList() {
override fun loadRecordsForDiaryList() {
viewModelScope.launch {
try {
val diaryResult = getMindRecordsUseCase("DIARY", "LIST", null, null)
Expand All @@ -251,7 +269,7 @@ class MindRecordViewModel @Inject constructor(
MindRecordUiModel(
id = summary.recordId,
title = summary.title ?: "",
formattedDate = runCatching { formatDate(summary.date) }.getOrElse { summary.date },
formattedDate = runCatching { formatDate(summary.date) }.getOrNull() ?: summary.date,
draftLabel = if (summary.isDraft) "임시저장" else "완료",
content = summary.content,
originalDate = summary.date,
Expand Down Expand Up @@ -358,7 +376,7 @@ class MindRecordViewModel @Inject constructor(
_selectedRecord.value = MindRecordUiModel(
id = detail.recordId,
title = detail.title,
formattedDate = runCatching { formatDate(detail.date) }.getOrElse { detail.date },
formattedDate = runCatching { formatDate(detail.date) }.getOrNull() ?: detail.date,
draftLabel = if (detail.isDraft) "임시저장" else "완료",
content = detail.content,
type = detail.type,
Expand Down Expand Up @@ -396,9 +414,7 @@ fun AfternoteItem.toMindRecordUiModel(): MindRecordUiModel {

val parsedDate = runCatching {
java.time.LocalDate.parse(this.date, inputFormatter)
}.getOrElse {
java.time.LocalDate.now() // 파싱 실패 시 오늘 날짜로 폴백 (시스템 안정성)
}
}.getOrNull() ?: java.time.LocalDate.now() // 파싱 실패 시 오늘 날짜로 폴백 (시스템 안정성)

val originalDateStr = parsedDate.format(isoFormatter) // "2026-02-06"
val formattedDateStr = "${parsedDate.monthValue}월 ${parsedDate.dayOfMonth}일"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import com.kuit.afternote.ui.theme.Gray9
@Composable
fun HomeHeader(
modifier: Modifier = Modifier,
onProfileClick: () -> Unit = {},
onSettingsClick: () -> Unit = {}
) {
Row(
Expand All @@ -37,7 +36,7 @@ fun HomeHeader(
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(R.drawable.logo_blue),
painter = painterResource(R.drawable.ic_home_topbar_logo),
contentDescription = "AFTERNOTE",
modifier = Modifier.height(16.dp)
)
Expand Down
Loading
Loading