Repository navigation
feat(receiver,setting): 수신자 등록에 카카오톡 초대 발급·수락 흐름을 잇는다 (#944) - #2109
OnlyReadOneBook wants to merge 10 commits into
Conversation
- core:network ReceiverInvitationApiService — 발급·조회·수락 3 엔드포인트와 응답 DTO - core:domain ReceiverInvitationRepository / PendingReceiverInvitationStore 계약, ReceiverInvitationFailure(NotFound·Expired·AcceptedByOther·SelfAccept·AlreadyRegistered· Unauthenticated·Other(isRetryable)) 로 BE code 1000·1905~1909 를 번역 - core:datastore ReceiverInvitationDataSource — DEVICE 스코프, 로그아웃에 지우지 않는다 - core:data 구현·Hilt 바인딩, 번역·보관소 단위 테스트, testFixtures fake 2종 발신자(설정)와 수신자(랜딩)가 같은 초대를 다루므로 feature 한쪽이 아니라 core 에 둔다.
- 매니페스트 MainActivity 에 kakao{앱키}://kakaolink intent-filter. launchMode 는 #1111 의
판단대로 그대로 둔다
- ReceiverInvitationIntentContract — inviteToken 을 base64url 형식으로만 받는 순수 파서
- MainViewModel — 보관 토큰·인증 상태·SavedState 처분 기록을 합쳐 pendingInvitationToken 을 낸다
- AppNavigation — Route.ReceiverInvitation 을 현재 화면 위에 올리고, 로그인 요청·닫기·받은
기록함 이동을 셸 콜백으로 받는다
- ReceiverInvitationNavHost — 랜딩(4996:40023) → 완료(4996:39921), 완료는 랜딩을 대체 - ReceiverInvitationViewModel(MVI) — 토큰은 보관소에서 읽고, 실패 유형별로 토큰 처분과 안내(팝업·안내·로그인·받은 기록함)를 가른다 - 단위 테스트·스크린샷 테스트, konsist intentional 목록에서 MviViewModel·ObserveSignal 해소
- Kakao SDK v2-share 추가. 템플릿 136940 에 inviteToken·senderName 을 실어 shareCustom, 카카오톡이 없으면 웹 공유 URL 을 CustomTab 으로 연다 - ReceiverInviteViewModel(MVI) — 내 이름(캐시→서버 프로필)을 확보한 뒤 초대를 발급. 이름을 못 읽으면 공유를 열지 않는다. 공유가 뜬 초대는 SavedStateHandle 에 두어 회전에 살아남는다 - 등록 폼 아래 CTA → 시트(4996:39786) → 공유 → «초대를 보냈어요»(4996:39909). 보냈어요는 별도 destination 이 아니라 ReceiverRegisterScreen 의 phase 다 — 설정 네비게이션 축은 #1695 소유라 Nav2 표면을 새로 만들지 않는다. «다시 보내기» 는 같은 토큰으로 공유만 다시 연다 - AfternoteButton 에 contentColor 를 더해 카카오 노랑 위 검정 라벨을 그린다
|
열린 PR 중 이 PR 과 같은 파일을 서로 다르게 고친 것이 있다. 어느 쪽이 먼저 머지되든 나중 쪽은 base 를 병합해야 통과한다 (#1750).
머지 자체는 GitHub 이 막는다 — 한쪽이 들어가면 다른 쪽은 CONFLICTING 이 되어 merge queue 에 못 들어간다. 이 코멘트는 리컨사일러가 매 실행 현재 상태로 고쳐 쓴다. 해소되면 |
|
📸 CI 표준 환경에서 screenshot baseline 10개를 생성·검증해 |
|
이 PR 은
|
…eiver-invite # Conflicts: # feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverRegisterScreen.kt
Sadturtleman
left a comment
There was a problem hiding this comment.
두 가지 동작 문제가 있어 리뷰 남깁니다. 나머지는 확인했고 문제 없어 보입니다.
| val backStack = rememberNavBackStack(ReceiverInvitationRoute.LandingRoute) | ||
| val actions = remember(backStack) { ReceiverInvitationLocalNavActions(backStack) } | ||
|
|
||
| FeatureNavDisplay( |
There was a problem hiding this comment.
랜딩 로컬 스택은 크기가 1이라 FeatureNavDisplay 의 back 핸들러가 꺼지고(previousEntries 가 비어 있음), 시스템 back/제스처는 Nav2 가 Route.ReceiverInvitation 을 그냥 pop 합니다. 이 경로에서는 settleInvitation/deferInvitationUntilLogin 이 불리지 않아 토큰이 pending 으로 남고, AppNavigation 의 LaunchedEffect(pendingInvitationToken, currentDestination?.id) 가 destination id 변경을 보고 랜딩을 즉시 다시 navigate 합니다. 결과적으로 시스템 back 으로는 랜딩을 닫을 수 없습니다. 랜딩 entry 에 BackHandler 로 «나중에 결정하기»와 같은 처분(Defer)을 태우거나 pop 경로에서 settle 해 주세요.
같은 이유로 CompleteRoute 도 KDoc 은 back 시 받은 기록함으로 간다고 하지만 실제로는 시스템 back 이 아래 화면(홈)으로 pop 됩니다(토큰은 이미 정리돼 루프는 없음).
| receiverName = receiverName, | ||
| ) | ||
| lastCreated = request | ||
| dispatch(ReceiverInviteReducerEvent.Created(request)) |
There was a problem hiding this comment.
sendInvite() 는 시작 시에만 sheetReceiverName 을 확인하고, create() 성공 후에는 시트가 아직 열려 있는지 보지 않고 Created 를 dispatch 합니다. 발급 중에도 «나중에 보내기»로 시트를 닫을 수 있어서, 보내기 → 곧바로 «나중에 보내기» 를 누르면 시트는 닫혔는데 카카오톡 공유가 뜨고(서버 초대도 발급됨) 이후 «초대를 보냈어요» 상태로 넘어갑니다. SheetClosed 이후 도착한 결과는 버리거나(예: 진행 중 Job 취소), 발급 중에는 닫기를 막아 주세요.
| val onDeferredState by rememberUpdatedState(onDeferredUntilLogin) | ||
| return remember(appState) { | ||
| object : ReceiverInvitationExternalActions { | ||
| override fun requestLogin() { |
There was a problem hiding this comment.
requestLogin() 은 두 경로에서 불립니다. 하나는 랜딩이 !isLoggedIn 일 때 직접 부르는 경로이고, 다른 하나는 ReceiverInvitationViewModel 이 Unauthenticated(401·1000)를 받아 LoginRequired 를 내는 경로입니다. 두 번째는 셸이 로그인 상태라고 본 뒤에만 탈 수 있습니다(비로그인이면 Accept 가 VM 까지 가지 않음). 이때는 랜딩 아래가 온보딩이 아니라 Home 이라, 이 주석의 전제가 맞지 않습니다.
그 경우 pop 하는 순간에 isLoggedIn 이 아직 true 면 MainViewModel.pendingInvitationToken 이 그대로 토큰을 냅니다. token == awaitingLogin && !loggedIn 조건이 로그인 상태에서는 걸리지 않기 때문입니다. 그러면 currentDestination?.id 키가 바뀐 LaunchedEffect 가 랜딩을 곧바로 다시 push 할 수 있습니다. TokenAuthenticator 가 refresh 실패 때 clearSession() 을 하긴 하지만, DataStore 방출과 이 effect 중 어느 쪽이 먼저 오는지는 보장되지 않습니다. refresh 가 네트워크 오류로 끝나 세션이 남는 경우에는 수락을 누를 때마다 같은 일이 되풀이됩니다.
로그인 상태에서 받은 401 은 requestLogin 과 갈라서 처리하면 좋겠습니다. 예를 들어 오류 팝업을 띄우거나, 셸 쪽 defer 판정이 로그인 여부를 보지 않게 하는 방법이 있습니다. 이 분기를 덮는 테스트(로그인 상태 + accept 가 1000 을 받는 경우)도 하나 있으면 좋겠습니다.
| receiverName = sentInvitation.receiverName, | ||
| snackbarHostState = snackbarHostState, | ||
| onBackClick = onBackClick, | ||
| onOpenReceiverList = onBackClick, |
There was a problem hiding this comment.
«수신자 목록으로 이동하기» 버튼이 onBackClick 에 연결돼 있어서, 수신자 목록에서 등록으로 들어온 경우에만 라벨대로 동작합니다. 설정 홈의 «수신자 등록» 으로 들어오면 설정 홈으로, 홈의 «수신인 지정 미완료» 칩으로 들어오면 홈으로 돌아갑니다. #2173 이 머지되면 칩 진입은 등록 화면이 스택 바닥이라 설정 host 를 아예 빠져나갑니다.
라벨과 실제 목적지가 어긋나니 둘 중 하나로 맞추면 좋겠습니다.
- 목록으로 보내는 콜백을 따로 두기(refactor(setting): Nav3와 수신자 목록 MVI 진입을 선행으로 정리한다 (#1695) #2173 이후라면
SettingLocalNavActions에서 등록 화면을RecipientListRoute()로 교체) - 목적지가 확정될 때까지 라벨을 «돌아가기» 류로 바꾸기
| @@ -0,0 +1,89 @@ | |||
| package com.afternote.feature.receiver.presentation.invitation | |||
|
|
|||
| import androidx.compose.foundation.background | |||
There was a problem hiding this comment.
본문을 ReceiverInvitationLandingContent 로 나눌 때 import 가 그대로 남은 것 같습니다. 이 파일에서 실제로 쓰는 import 는 Composable, getValue, Modifier, collectAsStateWithLifecycle, ObserveSignal 뿐이고, 나머지 20여 개(background, Arrangement, Box … ReceiverErrorPopupHost)는 쓰이지 않습니다.
같은 패턴이 몇 군데 더 있습니다.
ReceiverInvitationLandingContent.kt:collectAsStateWithLifecycle,ObserveSignal,getValuesetting/.../component/ReceiverInviteSheet.kt:Column,Spacer,fillMaxWidth,height,padding,Text,Color,stringResource,dp,asString,AfternoteButton,AfternoteButtonTypesetting/.../component/ReceiverInviteSheetContent.kt:ExperimentalMaterial3Api,ModalBottomSheetsetting/.../receiver/ReceiverInviteSentContent.kt:remember,rememberCoroutineScope,LocalContext,shareReceiverInvitationViaKakao,launch
ktlint 가 이번 CI 에서는 잡지 않았지만 정리해 주시면 좋겠습니다.
현재 이 PR은 루트 Navigation 3 전환(#1702) 전에 추가한 Nav2 진입·복귀 배선이 다시 작성되는 문제로 철회합니다. 기능 구현 완료를 뜻하지 않습니다. 기존 구현과 검증 자료는 아래 및 원래 브랜치에 보존합니다. #1702가 준비된 뒤 최신 서버·제품 계약에 맞는 범위만 최종 루트에 연결합니다.
핵심 파일
설계가 드러나는 파일이다. 나머지는 이 파일들을 따라가는 변경이다.
core/data/src/main/java/com/afternote/core/data/repoimpl/invitation/ReceiverInvitationFailureTranslation.kt(서버 실패 코드 → 도메인 실패 번역)app/src/main/java/com/afternote/afternote_fe/MainViewModel.kt(저장 토큰 + 로그인 상태로 랜딩을 띄우는 결정)app/src/main/java/com/afternote/afternote_fe/invitation/ReceiverInvitationIntentContract.kt(스킴 검증)feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/invitation/ReceiverInvitationViewModel.kt(수락 결과별 분기)변경
수신자 등록을 이메일 인증에서 카카오톡 초대로 옮기는 첫 배선이다. 서버 계약(Afternote/Afternote-BE#227, 2026-09-08 배포)은 2026-09-18 에 테스트 계정 둘로 실측했고 결과는 #1948 코멘트에 있다. 이 PR 은 그 계약이 확정한 층만 넣는다.
POST/GET/POST accept receiver-invitations세 엔드포인트, 도메인 계약ReceiverInvitationRepository, 실패 코드 1000·1905·1906·1907·1908·1909 를ReceiverInvitationFailure로 옮기는 번역, 대기 토큰 저장소PendingReceiverInvitationStore(core:datastore, 기기 스코프, 로그아웃에 지우지 않는다).kakao{앱키}://kakaolinkintent-filter.MainActivity가onCreate·onNewIntent의intent.data에서inviteToken을 뽑아 저장소에 넣고,MainViewModel이 저장 토큰과 로그인 상태를 합쳐 랜딩을 띄울지 정한다.launchMode는 알림 탭이 목적지로 갈 수 없는 구조 — MainActivity onNewIntent 부재, PendingIntent 빌더 2벌 중복 #1111 의 판단대로 두었다.v2-share로 템플릿 136940 공유 → "초대를 보냈어요"(4996:39909). 설정 모듈에 새 네비게이션 표면은 넣지 않았다(보냈어요 화면은 기존 등록 화면 안의 phase 로 그린다). 템플릿 인자는inviteToken과senderName둘이고 내 이름을 못 읽으면 공유를 열지 않는다. 토큰은 로그·리포팅에 남기지 않는다.기존 이메일 등록 흐름은 그대로 둔다. 서버가
POST users/receivers를 지웠으므로 그 버튼은 실패하지만, 폼 값의 행방과 기존 이메일 수신자 이행이 PM 결정(#1948)이라 이 PR 에서 지우지 않는다.시안과 다른 곳
검증
develop(f69332d99)를 반영해 초대 파일과 골든을receiver/패키지로 옮기고 설정 문자열 접두사를 맞췄다. 누락된rootNavigationCallbacks호출부를 복구했다. 설정 내부에서만 소비하는 초대 계약·ViewModel·등록 화면과 앱 모듈 안에서만 호출하는AppNavigation은internal, 공유 템플릿 상수는private로 제한했다.04fda27b67cc5c8252d536c53b950c763244b032에서 설정 76개, receiver 62개, 앱 69개, Konsist 54개 테스트 통과. 앱 main·unit·androidTest 컴파일과 관련 ktlint 통과. 스크린샷 비교는 현재 HEAD의 CI 표준 환경에서 확인한다.:app:compileDebugUnitTestKotlin·:app:compileDebugAndroidTestKotlin·:app:assembleDebugAndroidTest·:konsist:test --rerun-tasks· 앱과 core:data·core:datastore·core:ui·receiver·setting 단위 테스트 ·ktlintCheck통과.카카오톡 메시지의 "초대 수락하기" 버튼은 콘솔 설정이 받는 이에게만(receiver) 노출이라 "나와의 채팅" 으로는 안 보인다. 그래서 수신 쪽은 버튼이 여는 것과 같은
kakao{앱키}://kakaolink?inviteToken=…을am start로 던져 실측했다. 카카오 콘솔에 Android 스토어 링크가 미등록이라 앱 미설치 폴백은 지금 동작하지 않는다. 수락 뒤 받은 기록함이 비어 보이는 것은 #607 의 기존 상태다.실기기 캡처 (S25)
발신자: 바텀시트 → 초대를 보냈어요 → 카카오톡 도착 메시지
수신자: 랜딩 → 수락 완료
실패 경로: 1908 본인 초대 → 1909 이미 등록(받은 기록함) → 1907 타인 수락
연결 이슈
Refs #944
Refs #1948
CI Test Plan
{ "androidTest": { "mode": "full", "reason": "gradle/libs.versions.toml 에 kakao v2-share 를 추가하고 AndroidManifest·MainActivity·receiver navigation 을 바꿨다. 경계 경로가 여럿이라 선택 실행 대신 전체를 돌린다.", "tests": [] } }