Skip to content

feat(receiver,setting): 수신자 등록에 카카오톡 초대 발급·수락 흐름을 잇는다 (#944) - #2109

Closed
OnlyReadOneBook wants to merge 10 commits into
developfrom
feat/944-kakao-receiver-invite
Closed

OnlyReadOneBook wants to merge 10 commits into
developfrom
feat/944-kakao-receiver-invite

Conversation

@OnlyReadOneBook

@OnlyReadOneBook OnlyReadOneBook commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

현재 이 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 은 그 계약이 확정한 층만 넣는다.

  • core: POST/GET/POST accept receiver-invitations 세 엔드포인트, 도메인 계약 ReceiverInvitationRepository, 실패 코드 1000·1905·1906·1907·1908·1909 를 ReceiverInvitationFailure 로 옮기는 번역, 대기 토큰 저장소 PendingReceiverInvitationStore(core:datastore, 기기 스코프, 로그아웃에 지우지 않는다).
  • app: 매니페스트에 kakao{앱키}://kakaolink intent-filter. MainActivity 가 onCreate·onNewIntent 의 intent.data 에서 inviteToken 을 뽑아 저장소에 넣고, MainViewModel 이 저장 토큰과 로그인 상태를 합쳐 랜딩을 띄울지 정한다. launchMode 는 알림 탭이 목적지로 갈 수 없는 구조 — MainActivity onNewIntent 부재, PendingIntent 빌더 2벌 중복 #1111 의 판단대로 두었다.
  • receiver: 초대 랜딩(4996:40023)과 수락 완료(4996:39921) 화면을 Nav3 로컬 스택으로. 비로그인이면 토큰을 남긴 채 온보딩으로 보내고 로그인이 확정되면 다시 띄운다. 수락 성공·없음·만료·타인 수락·본인 초대는 토큰을 지우고, 이미 등록됨은 지운 뒤 받은 기록함으로, 네트워크·5xx 는 남기고 재시도 팝업이다.
  • setting: 수신자 등록 화면에 "카카오톡으로 초대 보내기". 바텀시트(4996:39786) → 초대 발급 → Kakao SDK v2-share 로 템플릿 136940 공유 → "초대를 보냈어요"(4996:39909). 설정 모듈에 새 네비게이션 표면은 넣지 않았다(보냈어요 화면은 기존 등록 화면 안의 phase 로 그린다). 템플릿 인자는 inviteToken 과 senderName 둘이고 내 이름을 못 읽으면 공유를 열지 않는다. 토큰은 로그·리포팅에 남기지 않는다.

기존 이메일 등록 흐름은 그대로 둔다. 서버가 POST users/receivers 를 지웠으므로 그 버튼은 실패하지만, 폼 값의 행방과 기존 이메일 수신자 이행이 PM 결정(#1948)이라 이 PR 에서 지우지 않는다.

시안과 다른 곳

  • 랜딩 카드는 "나를 등록한 분" 한 줄만 그린다. 시안의 "관계"·"지금 볼 수 있는 기록" 은 비로그인 조회 응답에 없다.
  • 진행 불가 안내 문구 5종(없음·만료·타인 수락·본인 초대·미등재 오류)은 시안이 없어 잠정안이다.
  • 시안 확인 요청(류하늬 → 전건호, 2026-09-17)에 아직 답이 없다. PM 결정이 필요한 넷은 피그마 시안 댓글로 옮겼고 enhancement(receiver): 카카오 초대 생성·수락·계정 연결 계약을 확정하고 검증한다 #1948 은 닫았다.

검증

  • 최신 develop (f69332d99)를 반영해 초대 파일과 골든을 receiver/ 패키지로 옮기고 설정 문자열 접두사를 맞췄다. 누락된 rootNavigationCallbacks 호출부를 복구했다. 설정 내부에서만 소비하는 초대 계약·ViewModel·등록 화면과 앱 모듈 안에서만 호출하는 AppNavigation은 internal, 공유 템플릿 상수는 private로 제한했다.
  • 현재 HEAD 04fda27b67cc5c8252d536c53b950c763244b032에서 설정 76개, receiver 62개, 앱 69개, Konsist 54개 테스트 통과. 앱 main·unit·androidTest 컴파일과 관련 ktlint 통과. 스크린샷 비교는 현재 HEAD의 CI 표준 환경에서 확인한다.
  • 이전 기기 검증을 수행한 2026-09-18 당시 :app:compileDebugUnitTestKotlin · :app:compileDebugAndroidTestKotlin · :app:assembleDebugAndroidTest · :konsist:test --rerun-tasks · 앱과 core:data·core:datastore·core:ui·receiver·setting 단위 테스트 · ktlintCheck 통과.
  • 계약은 curl 로 실측했고, 앱 왕복은 2026-09-18 갤럭시 S25(Android 16) debug 빌드로 실측했다. 카카오 API 가 네이티브 앱 키 요청의 Android 키 해시를 검사하므로 debug 빌드도 팀 공유 debug keystore(maintenance(platform): debug 빌드에서 카카오톡 앱-투-앱 로그인이 조용히 웹으로 폴백한다 — 공유 debug keystore 미배포 (#533 후속) #1871)로 서명해야 카카오톡 공유가 열린다. 기본 debug 키로는 공유가 실패한다.
단계 결과
발신자: 수신자 등록 → 바텀시트 → 초대 발급 → 카카오톡 앱 공유 → "초대를 보냈어요" 통과. 카카오톡에 템플릿 136940 메시지가 도착했다
수신자: 스킴 진입 → 랜딩 → 수락 → 완료 화면 통과. 서버 수신자 목록에 receiverId 21 로 붙었다
1907 타인 수락 · 1908 본인 초대 · 1909 이미 등록 각각 안내 팝업 · 안내 팝업 · 받은 기록함 이동으로 갈린다

카카오톡 메시지의 "초대 수락하기" 버튼은 콘솔 설정이 받는 이에게만(receiver) 노출이라 "나와의 채팅" 으로는 안 보인다. 그래서 수신 쪽은 버튼이 여는 것과 같은 kakao{앱키}://kakaolink?inviteToken=… 을 am start 로 던져 실측했다. 카카오 콘솔에 Android 스토어 링크가 미등록이라 앱 미설치 폴백은 지금 동작하지 않는다. 수락 뒤 받은 기록함이 비어 보이는 것은 #607 의 기존 상태다.

실기기 캡처 (S25)

발신자: 바텀시트 → 초대를 보냈어요 → 카카오톡 도착 메시지

초대 바텀시트 초대를 보냈어요

카카오톡 도착 메시지

수신자: 랜딩 → 수락 완료

초대 랜딩 수락 완료

실패 경로: 1908 본인 초대 → 1909 이미 등록(받은 기록함) → 1907 타인 수락

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": []
  }
}

- 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 를 더해 카카오 노랑 위 검정 라벨을 그린다
@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

열린 PR 중 이 PR 과 같은 파일을 서로 다르게 고친 것이 있다. 어느 쪽이 먼저 머지되든 나중 쪽은 base 를 병합해야 통과한다 (#1750).

머지 자체는 GitHub 이 막는다 — 한쪽이 들어가면 다른 쪽은 CONFLICTING 이 되어 merge queue 에 못 들어간다.
이 라벨은 그 전에 순서를 정하라는 신호다. 먼저 갈 쪽을 정하고, 나중 쪽은 그 머지 뒤에 base 를 병합한다.
상대 PR 에도 같은 안내가 달린다.

이 코멘트는 리컨사일러가 매 실행 현재 상태로 고쳐 쓴다. 해소되면 sibling-conflict 라벨은 자동으로 떨어진다.

@github-actions

Copy link
Copy Markdown
Contributor

📸 CI 표준 환경에서 screenshot baseline 10개를 생성·검증해 61da6b9 로 커밋했습니다.
실행 로그·생성 artifact: https://github.com/Afternote/Afternote-FE/actions/runs/35348506339
이미지 내용은 이 PR의 PNG diff에서 눈으로 최종 확인해 주세요. 필수 검사도 새 SHA에 다시 요청했습니다.

@github-actions github-actions Bot removed the screenshot-baseline CI 표준 환경에서 PR screenshot baseline 생성·적용 요청 label Sep 18, 2026
@github-actions github-actions Bot added the conflict base 와 충돌 — CI 가 트리거되지 않는다. base 를 병합하면 자동으로 떨어진다 label Sep 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

이 PR 은 develop 와 충돌 상태이고, 그래서 CI 가 돌지 않는다.

pull_request 워크플로는 base 와 head 를 합친 가상 머지 커밋(refs/pull/<N>/merge)에서
실행되는데, 충돌이면 GitHub 이 그 ref 를 만들지 못해 워크플로가 트리거조차 되지 않는다.
체크가 실패하는 것이 아니라 아예 생성되지 않으므로, 새 커밋을 올려도 no checks reported
인 채 최신 커밋이 한 번도 검증되지 않은 상태로 남는다 (#1028).

develop 를 병합해 충돌을 풀면 검증이 다시 돈다. 해소되면 이 라벨은 자동으로 떨어진다.

…eiver-invite

# Conflicts:
#	feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverRegisterScreen.kt
@github-actions github-actions Bot removed the conflict base 와 충돌 — CI 가 트리거되지 않는다. base 를 병합하면 자동으로 떨어진다 label Sep 19, 2026
@github-actions github-actions Bot added the conflict base 와 충돌 — CI 가 트리거되지 않는다. base 를 병합하면 자동으로 떨어진다 label Sep 19, 2026
@github-actions github-actions Bot removed the conflict base 와 충돌 — CI 가 트리거되지 않는다. base 를 병합하면 자동으로 떨어진다 label Sep 20, 2026

@Sadturtleman Sadturtleman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

두 가지 동작 문제가 있어 리뷰 남깁니다. 나머지는 확인했고 문제 없어 보입니다.

val backStack = rememberNavBackStack(ReceiverInvitationRoute.LandingRoute)
val actions = remember(backStack) { ReceiverInvitationLocalNavActions(backStack) }

FeatureNavDisplay(

@Sadturtleman Sadturtleman Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

랜딩 로컬 스택은 크기가 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))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sendInvite() 는 시작 시에만 sheetReceiverName 을 확인하고, create() 성공 후에는 시트가 아직 열려 있는지 보지 않고 Created 를 dispatch 합니다. 발급 중에도 «나중에 보내기»로 시트를 닫을 수 있어서, 보내기 → 곧바로 «나중에 보내기» 를 누르면 시트는 닫혔는데 카카오톡 공유가 뜨고(서버 초대도 발급됨) 이후 «초대를 보냈어요» 상태로 넘어갑니다. SheetClosed 이후 도착한 결과는 버리거나(예: 진행 중 Job 취소), 발급 중에는 닫기를 막아 주세요.

val onDeferredState by rememberUpdatedState(onDeferredUntilLogin)
return remember(appState) {
object : ReceiverInvitationExternalActions {
override fun requestLogin() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

«수신자 목록으로 이동하기» 버튼이 onBackClick 에 연결돼 있어서, 수신자 목록에서 등록으로 들어온 경우에만 라벨대로 동작합니다. 설정 홈의 «수신자 등록» 으로 들어오면 설정 홈으로, 홈의 «수신인 지정 미완료» 칩으로 들어오면 홈으로 돌아갑니다. #2173 이 머지되면 칩 진입은 등록 화면이 스택 바닥이라 설정 host 를 아예 빠져나갑니다.

라벨과 실제 목적지가 어긋나니 둘 중 하나로 맞추면 좋겠습니다.

@@ -0,0 +1,89 @@
package com.afternote.feature.receiver.presentation.invitation

import androidx.compose.foundation.background

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

본문을 ReceiverInvitationLandingContent 로 나눌 때 import 가 그대로 남은 것 같습니다. 이 파일에서 실제로 쓰는 import 는 Composable, getValue, Modifier, collectAsStateWithLifecycle, ObserveSignal 뿐이고, 나머지 20여 개(background, Arrangement, Box … ReceiverErrorPopupHost)는 쓰이지 않습니다.

같은 패턴이 몇 군데 더 있습니다.

  • ReceiverInvitationLandingContent.kt: collectAsStateWithLifecycle, ObserveSignal, getValue
  • setting/.../component/ReceiverInviteSheet.kt: Column, Spacer, fillMaxWidth, height, padding, Text, Color, stringResource, dp, asString, AfternoteButton, AfternoteButtonType
  • setting/.../component/ReceiverInviteSheetContent.kt: ExperimentalMaterial3Api, ModalBottomSheet
  • setting/.../receiver/ReceiverInviteSentContent.kt: remember, rememberCoroutineScope, LocalContext, shareReceiverInvitationViaKakao, launch

ktlint 가 이번 CI 에서는 잡지 않았지만 정리해 주시면 좋겠습니다.

@github-actions github-actions Bot added the conflict base 와 충돌 — CI 가 트리거되지 않는다. base 를 병합하면 자동으로 떨어진다 label Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

android-test PR head에서 Android Managed Device 계측 테스트 실행 conflict base 와 충돌 — CI 가 트리거되지 않는다. base 를 병합하면 자동으로 떨어진다 sibling-conflict 열린 형제 PR 과 같은 파일에서 충돌 — 나중에 머지되는 쪽이 base 를 병합해야 한다. 해소되면 자동으로 떨어진다

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants