Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7f065a8
mod/#61 로그인 및 회원가입 로직 수정
sonms Jan 21, 2026
294a373
mod/#61 qa 2차 반영 및 자녀 회원가입 로직 수정
sonms Jan 21, 2026
af816e0
feat/#61 자녀 소원 화면 로직 및 UI 수정
sonms Jan 21, 2026
988eba0
feat/#61 회원가입 QA 및 버그 수정
sonms Jan 21, 2026
e887d8c
fix/#61 SSE 연결 및 모델 수정
sonms Jan 21, 2026
45b554a
feat: 자녀 화면 SSE 이벤트 구독 및 실시간 데이터 갱신 기능 추가
sonms Jan 21, 2026
1614210
mod/#61 SseManager 구독 로직 리팩토링 및 안정성 강화
Jan 22, 2026
456161f
feat/#61 SSE 실시간 데이터 반영
Jan 22, 2026
9c46662
mod/#61 QA 반영 - 키보드 외 클릭 시 키보드 내리기
Jan 22, 2026
059dc22
mod/#61 QA 반영 - 텍스트 가운데 정렬
Jan 22, 2026
de8c5d5
feat/#61 탭 클릭 시 새로고침 기능 추가
Jan 22, 2026
3ca5eea
mod/#61 버그 수정 및 QA 2차 반영
sonms Jan 22, 2026
903bce2
Merge branch 'develop' of https://github.com/Team-Kiero/Kiero-Android…
sonms Jan 22, 2026
2c2e85d
Merge branch 'develop' of https://github.com/Team-Kiero/Kiero-Android…
sonms Jan 22, 2026
a74e8cc
Merge branch 'develop' of https://github.com/Team-Kiero/Kiero-Android…
sonms Jan 22, 2026
60e0902
Merge branch 'develop' of https://github.com/Team-Kiero/Kiero-Android…
sonms Jan 22, 2026
b8a6314
mod/#61: KieroLoadingIndicator 내 로딩 애니메이션 반복 횟수 및 완료 콜백(onSuccess) 추가…
seungjae708 Jan 22, 2026
d06eefb
Merge branch 'mod/#61-qa-reflect-four' of https://github.com/Team-Kie…
sonms Jan 22, 2026
36710b0
feat/#61: Presigned URL을 이용한 S3 이미지 업로드 기능 추가
sonms Jan 22, 2026
451696c
refactor/#61: S3 서비스 추가 및 관련 코드 수정
sonms Jan 22, 2026
6b2f7fe
feat/#61: QA 반영 및 로그아웃 로직 수정
sonms Jan 22, 2026
e829633
feat/#61: 부모 알림 화면 `Pull to Refresh` 기능 추가 및 UI/로직 개선
sonms Jan 22, 2026
ce94965
feat/#61: 자녀 회원가입 시 데모 데이터 생성 및 미션/소원 화면 개선
sonms Jan 22, 2026
731daf6
fix: `BaseResponse`를 `Response`로 변경
sonms Jan 22, 2026
a40b2e3
refactor/#61: 카메라 기능 및 전반적인 UI/로직 수정
sonms Jan 22, 2026
5abad0d
refactor/#61: 미션 이름 수정 시 커서 위치 유지 및 코드 개선
sonms Jan 23, 2026
65ecbb5
feat/#61: 스플래시 화면 로직 수정 및 UI 개선
sonms Jan 23, 2026
3104008
refactor/#61: Coil 이미지 로더 설정 최적화 및 탐색 로직 개선
sonms Jan 23, 2026
f83ff33
refactor/#61: 알림장 분석 실패 시 예외 처리 개선
sonms Jan 23, 2026
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
2 changes: 1 addition & 1 deletion app/src/main/java/com/kiero/KieroApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,4 @@ class KieroApplication : Application(), ImageLoaderFactory {
.bitmapConfig(Bitmap.Config.HARDWARE)
.build()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ fun KieroButtonMedium(
enabled = isEnabled,
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = if (isEnabled) containerColor else KieroTheme.colors.gray300,
contentColor = if (isEnabled) contentColor else KieroTheme.colors.gray600
color = if (isEnabled) containerColor else KieroTheme.colors.gray900,
contentColor = if (isEnabled) contentColor else KieroTheme.colors.white
) {
Row(
modifier = Modifier.padding(vertical = 13.dp, horizontal = 16.dp),
Expand Down Expand Up @@ -76,7 +76,7 @@ private fun KieroButtonMediumPreview() {
horizontalAlignment = Alignment.CenterHorizontally
) {
KieroButtonMedium(
text = "시작하기", onClick = { })
text = "시작하기", onClick = { }, isEnabled = false)
KieroButtonMedium(
text = "일정 추가하기",
leadingIcon = ImageVector.vectorResource(id = com.kiero.R.drawable.ic_kid_camera),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,13 @@ class AuthDataSourceImpl @Inject constructor(
val accountCallback: (OAuthToken?, Throwable?) -> Unit = { token, error ->
when {
error != null -> {
Timber.e(error, "❌ 카카오 계정 로그인 실패")
continuation.resume(Result.failure(error))
if (error is ClientError && error.reason == ClientErrorCause.Cancelled) {
Timber.d("⚠️ 사용자 웹 로그인 취소")
continuation.resume(Result.failure(error))
} else {
Timber.e(error, "❌ 카카오 계정 로그인 실패")
continuation.resume(Result.failure(error))
}
}
token != null -> {
Timber.i("✅ 카카오 계정 로그인 성공")
Expand Down Expand Up @@ -89,4 +94,4 @@ class AuthDataSourceImpl @Inject constructor(
override suspend fun postAuthKidLogin(authKidRequestDto: AuthKidRequestDto): BaseResponse<AuthKidResponseDto> =
authService.postAuthKidLogin(body = authKidRequestDto)

}
}
205 changes: 101 additions & 104 deletions app/src/main/java/com/kiero/data/sse/manager/SseManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,21 @@ import com.kiero.data.sse.repository.SseRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.cancellation.CancellationException

@Singleton
class SseManager @Inject constructor(
Expand All @@ -25,7 +29,7 @@ class SseManager @Inject constructor(

private var sseJob: Job? = null
private var tokenRefreshJob: Job? = null

private var cachedAccessToken: String? = null
private val mutex = Mutex()

// 부모 이벤트
Expand All @@ -50,148 +54,141 @@ class SseManager @Inject constructor(
private var isParentMode = true

fun startParentSubscription() {
initSubscription(isParent = true)
}

fun startChildSubscription() {
initSubscription(isParent = false)
}

private fun initSubscription(isParent: Boolean) {
scope.launch {
mutex.withLock {
if (isSubscribed) {
Timber.d("SSE 이미 구독 중 - 중복 시작 방지")
return@launch
}
if (isSubscribed && isParentMode == isParent) return@launch

stopSubscriptionInternal()

isSubscribed = true
isParentMode = true
stopSubscription()
isParentMode = isParent
cachedAccessToken = null

sseJob = launch {
sseRepository.issueSubscribeToken()
.onSuccess { accessToken ->
_connectionState.emit(true)
startTokenRefreshTimer()

sseRepository.subscribeEvents(accessToken)
.collect { event ->
handleParentEvent(event)
}
}
.onFailure { e ->
Timber.e(e, "SSE 구독 실패")
isSubscribed = false
_connectionState.emit(false)
}
subscriptionLoop(isParent)
}

startTokenRefreshTimer()
}
}
}
private suspend fun subscriptionLoop(isParent: Boolean) {
while (currentCoroutineContext().isActive && isSubscribed) {
try {
// 실패 시 재발급으로 continue
val token = getValidToken() ?: continue

fun startChildSubscription() {
scope.launch {
mutex.withLock {
if (isSubscribed) {
Timber.d("SSE 이미 구독 중 - 중복 시작 방지")
return@launch
}
Timber.d("🔄 SSE 연결 시도 (Token: ${token.take(10)}...)")

isSubscribed = true
isParentMode = false
stopSubscription()
sseRepository.subscribeEvents(token)
.collect { event ->
_connectionState.emit(true)
if (isParent) handleParentEvent(event)
else handleChildEvent(event)
}

sseJob = launch {
sseRepository.issueSubscribeToken()
.onSuccess { accessToken ->
_connectionState.emit(true)
startTokenRefreshTimer()

sseRepository.subscribeEvents(accessToken)
.collect { event ->
handleChildEvent(event)
}
}
.onFailure { e ->
Timber.e(e, "SSE 구독 실패")
isSubscribed = false
_connectionState.emit(false)
}
Timber.w("SSE 스트림 종료됨. 즉시 재연결 시도.")

} catch (e: Exception) {
// 상위에 에러 던지기
if (e is CancellationException) throw e

Timber.e(e, "SSE 연결 중 에러 발생")
_connectionState.emit(false)

if (isTokenExpiredError(e)) {
Timber.w("토큰 만료 감지 -> 캐시 삭제 후 재발급 예정")
cachedAccessToken = null
}

delay(3000L)
}
}
}

private fun startTokenRefreshTimer() {
tokenRefreshJob?.cancel()

tokenRefreshJob = scope.launch {
while (isActive) {
delay(180_000L)
Timber.d("🔄 SSE 토큰 자동 갱신 (3분 주기)")
restartSubscription()
}
}
}
Timber.d("⏰ SSE 토큰 갱신 주기 도래 (3분)")

private fun restartSubscription() {
Timber.d("🔄 SSE 재연결 시작")
sseJob?.cancel()
mutex.withLock {
if (isSubscribed) {
cachedAccessToken = null

isSubscribed = false
// 현재 job 취소
sseJob?.cancel()

if (isParentMode) {
startParentSubscription()
} else {
startChildSubscription()
sseJob = launch {
subscriptionLoop(isParentMode)
}
}
}
}
}
}

private suspend fun handleParentEvent(event: SseEvent) {
when (event) {
is SseEvent.Connected -> {
Timber.d("SSE 연결 완료")
}

is SseEvent.Invite -> {
Timber.d("📨 초대 이벤트: childId=${event.data.childId}")
_parentInviteEvents.emit(event)
}

is SseEvent.Feed -> {
Timber.d("📢 피드 이벤트: ${event.data.eventType}")
_parentFeedEvents.emit(event)
}
private suspend fun getValidToken(): String? {
cachedAccessToken?.let { return it }

is SseEvent.Mission,
is SseEvent.Schedule -> {
Timber.w("부모 SSE에서 자녀 이벤트 수신: $event")
return try {
val result = sseRepository.issueSubscribeToken()
result.getOrNull()?.also { newToken ->
cachedAccessToken = newToken
Timber.d("새 SSE 토큰 발급 완료")
}
} catch (e: Exception) {
Timber.e(e, "토큰 발급 실패")
delay(5000L)
null
}
}

private suspend fun handleChildEvent(event: SseEvent) {
when (event) {
is SseEvent.Connected -> {
Timber.d("SSE 연결 완료")
}

is SseEvent.Mission -> {
Timber.d("📋 미션 이벤트: ${event.data.missionName}, 보상: ${event.data.reward}금화")
_childMissionEvents.emit(event)
}

is SseEvent.Schedule -> {
Timber.d("📅 일정 이벤트: ${event.data.scheduleName}")
_childScheduleEvents.emit(event)
}
private fun isTokenExpiredError(t: Throwable): Boolean {
return t.message?.contains("403") == true || t.message?.contains("Unauthorized") == true
}

is SseEvent.Invite,
is SseEvent.Feed -> {
Timber.w("자녀 SSE에서 부모 이벤트 수신: $event")
fun stopSubscription() {
scope.launch {
mutex.withLock {
stopSubscriptionInternal()
}
}
}

fun stopSubscription() {
private suspend fun stopSubscriptionInternal() {
isSubscribed = false
sseJob?.cancel()
cachedAccessToken = null
sseJob?.cancelAndJoin()
tokenRefreshJob?.cancel()
scope.launch {
_connectionState.emit(false)
_connectionState.emit(false)
Timber.d("⛔ SSE 구독 완전 중지")
}

private suspend fun handleParentEvent(event: SseEvent) {
when (event) {
is SseEvent.Connected -> Timber.d("부모 SSE Connected")
is SseEvent.Invite -> _parentInviteEvents.emit(event)
is SseEvent.Feed -> _parentFeedEvents.emit(event)
else -> Timber.w("부모 모드에서 알 수 없는 이벤트: $event")
}
}

private suspend fun handleChildEvent(event: SseEvent) {
when (event) {
is SseEvent.Connected -> Timber.d("아이 SSE Connected")
is SseEvent.Mission -> _childMissionEvents.emit(event)
is SseEvent.Schedule -> _childScheduleEvents.emit(event)
else -> Timber.w("자녀 모드에서 알 수 없는 이벤트: $event")
}
Timber.d("SSE 구독 중지")
}
}
}
8 changes: 4 additions & 4 deletions app/src/main/java/com/kiero/data/sse/model/SseEvent.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package com.kiero.data.sse.model

import com.kiero.data.sse.remote.dto.event.FeedDataDto
import com.kiero.data.sse.remote.dto.event.InviteDataDto
import com.kiero.data.sse.remote.dto.event.MissionDataDto
import com.kiero.data.sse.remote.dto.event.ScheduleDataDto
import com.kiero.data.sse.remote.dto.response.FeedDataDto
import com.kiero.data.sse.remote.dto.response.InviteDataDto
import com.kiero.data.sse.remote.dto.response.MissionDataDto
import com.kiero.data.sse.remote.dto.response.ScheduleDataDto

sealed interface SseEvent {
// 공통
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,16 @@ class SseDataSourceImpl @Inject constructor(
accessToken: String
): Flow<RawSseEvent> = callbackFlow {
val request = Request.Builder()
.url("${BuildConfig.BASE_URL}/api/v1/subscribe")
.url("${BuildConfig.BASE_URL}api/v1/subscribe")
.header("Accept", "text/event-stream")
.header("Authorization", "Bearer $accessToken")
.build()

val eventSource = EventSources.createFactory(okHttpClient)
val sseClient = okHttpClient.newBuilder()
.readTimeout(0, java.util.concurrent.TimeUnit.MILLISECONDS)
.build()

val eventSource = EventSources.createFactory(sseClient)
.newEventSource(request, object : EventSourceListener() {

override fun onOpen(eventSource: EventSource, response: Response) {
Expand Down Expand Up @@ -74,4 +78,4 @@ class SseDataSourceImpl @Inject constructor(
eventSource.cancel()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.kiero.data.sse.remote.dto.event
package com.kiero.data.sse.remote.dto.response

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.kiero.data.sse.remote.dto.event
package com.kiero.data.sse.remote.dto.response

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
package com.kiero.data.sse.repositoryimpl

import com.kiero.core.common.util.suspendRunCatching
import com.kiero.data.sse.remote.datasource.SseDataSource
import com.kiero.data.sse.model.RawSseEvent
import com.kiero.data.sse.model.SseEvent
import com.kiero.data.sse.model.SseEventType
import com.kiero.data.sse.remote.dto.event.FeedDataDto
import com.kiero.data.sse.remote.dto.event.InviteDataDto
import com.kiero.data.sse.remote.dto.event.MissionDataDto
import com.kiero.data.sse.remote.dto.event.ScheduleDataDto
import com.kiero.data.sse.remote.dto.response.FeedDataDto
import com.kiero.data.sse.remote.dto.response.InviteDataDto
import com.kiero.data.sse.remote.dto.response.MissionDataDto
import com.kiero.data.sse.remote.dto.response.ScheduleDataDto
import com.kiero.data.sse.repository.SseRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.mapNotNull
Expand Down
Loading
Loading