Repository navigation
feat(platform): 검증된 App Link 를 목적지로 받아 관문 뒤에 재개한다 (#924) - #2093
OnlyReadOneBook wants to merge 9 commits into
Conversation
브라우저에서 온 https 링크를 엔진 중립 목적지로 해석하고, 로그인 관문을 통과한 뒤 그 목적지를 한 번만 연다. 파서는 #1857 에서 만든 것을 그대로 쓰되 도착할 자리가 있는 목적지만 남겼다. 계약(core:common deeplink) - 정규 경로 다섯 개만 받는다. scheme·host·query·fragment·ID 형식을 벗어나면 사유를 담은 거절이고, 거절도 안전한 기본 진입(홈)을 값으로 들고 온다. - 애프터노트 상세 /afternote/{id} 는 뺐다. 그 화면은 애프터노트 피처의 로컬 Navigation 3 스택 안에 있고 스택 시작점이 지문 관문으로 고정돼 있어 밖에서 지목해 들어갈 인자가 없다. 계약에만 실으면 링크가 앱을 열고 홈으로 떨어지는 반쪽 활성화가 되므로 intent-filter 에서도 그 경로를 잡지 않고 브라우저에 남긴다. 수신(app) - MainActivity 의 VIEW/BROWSABLE/https intent-filter 에 autoVerify 를 켜고, 파서가 해석하는 경로만 열거했다. 도메인 전체를 잡지 않는다. - cold start 최초 intent 와 warm onNewIntent 가 같은 계약(AppLinkIntentContract) 을 지난다. 재생성으로 같은 intent 가 다시 배달되는 경우는 읽지 않는다. 그 진행 상태는 SavedState 에 있다. 재개(app) - 관문 판정은 목적지가 싣고 온 requiredGates 를 읽는다. 로그인은 앱 셸이 지키고, 지문은 목적지 그래프 자신이 지킨다. - 목적지는 SavedState 에 정규 URL 로 남아 프로세스 재생성을 견디고, 소비 로그아웃 잘못된 링크에서 폐기된다. - 로그인 직후 온보딩 스택이 루트를 비우기 전에 쌓으면 그 교체에 함께 지워지므로, 루트가 온보딩을 떠난 뒤에 재개한다. 검증 - :core:common:testDebugUnitTest, :app:testDebugUnitTest, :konsist:test, :app:compileDebugUnitTestKotlin, :app:ktlintCheck, :core:common:ktlintCheck, :app:lintDebug 통과. - 새 가드 여덟 곳에 변이를 넣어 실제로 실패하는지 확인했다(관문 무시, 로그아웃 폐기 제거, SavedState 소비 기록 누락, 목적지 오배선, 애프터노트 탭 상태 복원 허용, 상세 경로 부활, VIEW 액션 검사 제거, 온보딩 대기 제거). 실기기 adb https 진입과 Digital Asset Links 검증(완료 조건 5)은 남아 있다. Refs #924
|
열린 PR 중 이 PR 과 같은 파일을 서로 다르게 고친 것이 있다. 어느 쪽이 먼저 머지되든 나중 쪽은 base 를 병합해야 통과한다 (#1750).
머지 자체는 GitHub 이 막는다 — 한쪽이 들어가면 다른 쪽은 CONFLICTING 이 되어 merge queue 에 못 들어간다. 이 코멘트는 리컨사일러가 매 실행 현재 상태로 고쳐 쓴다. 해소되면 |
…을 벗긴다 (#924) Intent.action 과 Intent.dataString 은 필드 읽기라 예외 경로가 없다. 알림 계약의 getStringExtra 는 Bundle 지연 언파슬 때문에 감쌀 근거가 있지만 여기는 모양만 따라온 것이고, 만에 하나 던져도 두 번째 줄은 원인을 MALFORMED_URI 로 잘못 이름 붙인다. 없는 예외를 막는 코드는 읽는 사람에게 「여기서 뭔가 던지나」 라는 틀린 질문을 남기므로 그대로 읽는다.
https://evil@afternote.kro.kr/ 의 host 는 실제로 우리 도메인이고, 위장이 되는 반대 모양은 host 검사에서 이미 떨어진다. 거절은 유지하고 근거만 고친다. 반대 모양 입력을 host 거절 테스트에 더한다.
userinfo 와 포트는 host 가 우리 도메인일 때 걸리는 정규형 위반이라, UNSUPPORTED_HOST 에 섞이면 남의 도메인 집계가 흐려진다. query·fragment 처럼 성분마다 사유를 둔다(UNSUPPORTED_USERINFO, UNSUPPORTED_PORT).
0 으로 시작하는 입력을 전부 거절하면 0 자체도 함께 떨어져, 뒤의 id > 0 검사는 도달할 수 없다. Long 범위를 넘는 19자리 입력을 거절 테스트에 더한다.
/timeletter/{id} 의 ID 가 무엇인지 정할 쪽이 없다. 서버에는 이 링크를
만드는 코드가 없고(Afternote-BE origin/main 7c961ec), FCM 링크 계약
Afternote/Afternote-BE#261 도 결정 전이다. 상세 화면이 부르는
GET time-letters/{id} 는 작성자 본인 편지만 돌려주는데, 이 경로를 설계할 때
붙인 알림 후보 타임레터 도착은 수신자가 받는다. 수신 레코드 ID 는 작성자
타임레터 ID 와 번호 공간이 따로라 형식 검사로는 가를 수 없고, 겹치면
엉뚱한 편지가 오류 없이 열린다.
ID 를 싣는 경로가 남지 않아 ID 형식 검사와 MALFORMED_ID 도 함께 뺐다.
비-ASCII 세그먼트 거절은 경로 거절 목록에서 계속 잠근다.
launchSingleTop 은 목적지 종류만 비교한다. 같은 종류가 맨 위에 있으면 인자만 바꾼 entry 로 갈아 끼우고 ViewModel 은 처음 인자를 쥔 채 남긴다. 그래서 답변 수정(DailyQuestionWriteRoute(answerId = …)) 중에 데일리질문 링크가 오면 새 작성 화면 대신 수정 화면이 남았다. 맨 위가 인자까지 같을 때만 쌓지 않도록 바꿨다.
파서 KDoc 은 FCM data.link 도 같은 함수를 지난다고 적었지만, FCM 이 목적지를 URL 로 실을지 키로 실을지는 Afternote/Afternote-BE#261 이 아직 정하지 않았고 FCM 수신부도 이 파서를 부르지 않는다. 링크 계약을 서버와 공유하는 외부 계약이라 부른 문장도 서버 쪽 상대가 없어 사실과 달랐다. 엔진과 수명을 떼는 실제 이유(앱 밖에 공개된 주소는 옛 앱 버전이 남는 동안 바꿀 수 없다)로 바꿨다.
|
머지하지 않고 닫는다. 브랜치 이 PR 의 경로 표는 FE 가 혼자 정한 계약이다. afternote.kro.kr 의 경로는 서버가 서비스하는 주소이고(지금 발급처가 정해지면 그 계약으로 남은 범위만 새 이슈로 연다. 그때는 파서를 직접 짜지 않고 루트 엔진의 공식 딥링크 API 로 받는다. 루트가 그대로 Nav2 면 |
핵심 파일
설계가 드러나는 파일이다. 나머지는 이 파일들을 따라가는 변경이다.
core/common/src/main/kotlin/com/afternote/core/common/deeplink/AfternoteAppLinkParser.kt(App Link URI 계약)core/common/src/main/kotlin/com/afternote/core/common/deeplink/NavigationTarget.kt(목적지 타입)app/src/main/java/com/afternote/afternote_fe/MainViewModel.kt(관문 뒤 재개 결정)app/src/main/java/com/afternote/afternote_fe/navigation/AppLinkResumeEffect.kt(재개 시점)📌𝘐𝘴𝘴𝘶𝘦𝘴
📎𝘞𝘰𝘳𝘬 𝘋𝘦𝘴𝘤𝘳𝘪𝘱𝘵𝘪𝘰𝘯
브라우저에서 온 https 링크를 엔진 중립 목적지로 해석하고, 로그인 관문을 통과한 뒤 그 목적지를 한 번만 연다. 계약은
core:common에 두고 수신과 재개는 앱 셸에 둔다.1. 링크 계약 (core:common deeplink)
AfternoteAppLinkParser가 링크 문자열 하나를NavigationTarget으로 옮기는 단일 관문이다. 입력을String으로 받아 이 모듈이 Android 플랫폼 타입에 묶이지 않고, 파서 테스트가 Robolectric 없이 도는 순수 JVM 테스트가 된다. 지금 이 함수를 부르는 곳은 브라우저 intent 하나다. FCM 이 목적지를 URL 로 실을지 키로 실을지는 Afternote/Afternote-BE#261 이 정하고, URL 로 정해질 때만 같은 함수를 지난다.경로 표는 앱 화면 주소라 FE 가 정했다. 서버가 링크를 만들기 시작하면 이 표를 따르면 되고, 서버가 따로 정해야 할 몫(ID 가 무엇인지)이 있는 경로는 아래처럼 표에 넣지 않았다. Navigation 3 가 1.2.0(2026-09-23 정식판)에서 공식 딥링크 매처를 냈지만, 루트가 아직 Nav2 라 매처가 만드는 백스택을 받을 자리가 없다. 옮길지는 루트를 바꾸는 #1702 에서 판정한다.
받는 것은 정규형뿐이다. scheme 은 https, host 는
afternote.kro.kr(대소문자 무시), 포트는 생략 또는 443 이고, userinfo·query·fragment 는 붙는 순간 거절이다. 거절 사유는 URI 성분마다 따로 둔다. userinfo 와 포트는 host 가 우리 도메인일 때 걸리는 정규형 위반이라UNSUPPORTED_HOST에 섞으면 남의 도메인 집계가 흐려진다. 끝 슬래시, 대문자, 퍼센트 인코딩 변형도 전부 거절이다. 링크는 앱 밖에서 오는 입력이라 관대하게 받은 만큼이 공격면이 된다. 커스텀 scheme 은 Digital Asset Links 검증을 못 받아 아무 앱이나 선점할 수 있다. userinfo 거절은 위장 방어가 아니라 정규형 때문이다.https://evil@afternote.kro.kr/의 host 는 실제로 우리 도메인이고, 위장이 되는 반대 모양https://afternote.kro.kr@evil.com/은 host 검사에서 이미 떨어진다. userinfo 는 계약에서 뜻이 없는 칸이라 받으면 목적지 하나를 가리키는 URL 이 끝없이 생긴다./Home/afternoteAfternoteHome/mindrecord/daily-questionDailyQuestionCompose/settings/notificationNotificationSettings거절도 값이다.
AppLinkResolution.Rejected가 사유(AppLinkRejectionReason)와 안전한 기본 진입(홈)을 함께 들고 오고, 예외를 던지지 않으며 null 도 없다. 사유를 남기는 이유는 서버가 앱보다 먼저 새 경로를 배포했는지, 남이 도메인을 흉내 냈는지가 집계돼야 갈리기 때문이다. 원본 링크는 리포팅에 싣지 않는다. 거절된 링크는 계약 밖 입력이라 ID 가 실린 경로나 query 가 그대로 들어 있을 수 있다.애프터노트 상세
/afternote/{id}는 계약에서 뺐다. 그 화면은 애프터노트 피처의 로컬 Navigation 3 스택 안에 있고 그 스택의 시작점이 지문 관문으로 고정돼 있어, 밖에서 상세를 지목해 들어갈 인자가 없다. 도착할 자리가 없는 경로를 계약에만 실으면 링크가 앱을 열고 홈으로 떨어지는 반쪽 활성화가 되므로, 경로도 intent-filter 도 열지 않고 브라우저에 남긴다. 자리가 열릴 때 목적지와 경로와 배선을 함께 더한다. 그 몫은 #2088 이 갖는다.타임레터 상세
/timeletter/{id}도 뺐다. 이쪽은 도착할 자리는 있는데 ID 가 무엇인지 정할 발급처가 없다. 파서는 그 숫자가 양의 정수인지만 볼 수 있고, 어느 번호 공간의 것인지는 링크를 만드는 쪽만 안다. 서버에는 이 링크를 만드는 코드가 없고(Afternote-BEorigin/main7c961ec 기준/timeletter/0건, FCM 실호출은 관리자 테스트 푸시 하나), 링크를 싣는 방식을 정할 Afternote/Afternote-BE#261 도 결정 전이다.그 상태에서 FE 가 뜻을 정해 두면 틀린 쪽으로 굳는다. 상세 화면이 부르는
GET time-letters/{id}는findByIdAndUserId로 작성자 본인 편지만 돌려준다. 그런데 이 경로를 처음 설계할 때(#924 코멘트) 붙인 알림 후보는 타임레터 도착 알림이고, 그 알림을 받는 사람은 수신자다. 서버가 자기 초대 수락을 막으므로 작성자는 자기 편지의 수신자가 될 수 없다. 수신자가 가진 ID 는time_letter_receiver.id이고, 수신자 편지를 여는 API 는receiverId와 이 ID 두 개를 받는다. 이 번호 공간은time_letters.id와 따로 돌아 형식으로는 둘을 가를 수 없다. 수신자용 링크가 이 경로로 발급되면 대부분 404 오류 화면이 뜨고, 번호가 우연히 겹치면 여는 사람의 엉뚱한 편지가 오류 없이 열린다.남의 편지가 새는 경로는 없다. 소유 검사가 서버에 있고 남의 ID 와 없는 ID 가 같은 404/1300 으로 나가기 때문이다. 경로를 뺀 이유는 보안이 아니라 계약의 뜻이 비어 있다는 데 있다. 발급처가 ID 공간을 정할 때 목적지와 경로와 배선을 함께 더한다. 착지 화면이 어떤 모양이 될지는 그때 정해지므로 지금 따로 이슈를 열지 않는다.
2. intent 수신 (app)
MainActivity의 VIEW/BROWSABLE/https intent-filter 에android:autoVerify를 켜고, 파서가 해석하는 경로만 열거했다. 도메인 전체(pathPrefix="/")를 잡으면 앱에 자리가 없는 주소까지 브라우저에서 가로채 홈으로 떨어뜨리게 된다. 한 filter 안의<data>속성은 요소별이 아니라 통째로 조합되므로 속성마다 한 줄씩 적었다(IntentFilterUniqueDataAttributes).onNewIntent가 같은 계약(AppLinkIntentContract)을 지난다. 두 경계가 각자 intent 를 읽으면 한쪽만 조건이 늘어 목적지가 갈린다.ACTION_VIEW가 아니면 링크가 아니다. 런처 진입과 알림 PendingIntent 가 같은 Activity 로 오므로 액션으로 먼저 가른다. 반대로 VIEW 인데 data 가 없으면 무시가 아니라 거절이다. 매니페스트 필터가 data 를 요구하는데 여기까지 왔다는 것은 모르는 경로로 들어왔다는 뜻이다.savedInstanceState == null일 때만 읽는다). 그 링크가 어디까지 처리됐는지는 이미MainViewModel의 SavedState 에 있다.3. 관문 뒤 재개 (app)
requiredGates를 읽는다. 로그인은 앱 셸이 지키고 지문은 목적지 그래프 자신이 지키므로, 셸이 보는 것은AuthGate.LOGIN하나다.popUpTo(0) { inclusive }를 연달아 일으키는데, 그 사이에 목적지를 쌓으면 뒤따르는 교체가 방금 쌓은 화면까지 걷어내 사용자는 링크를 눌렀는데 홈에 서 있게 된다.AppState.navigateToAppLinkTarget한 곳에 모았다. 루트가NavDisplay로 바뀌면(refactor(platform): 앱 루트를 Navigation 3 NavDisplay로 전환 #1702) 계약은 그대로 두고 이 함수만 갈린다. 탭 목적지 둘은 기존navigateToBottomBarRoute를 그대로 타는데, 애프터노트 홈은 그 함수가restoreState를 끄는 유일한 라우트라 링크 진입에서도 저장된 스택으로 복원되어 지문 관문을 건너뛰는 일이 없다. 나머지 둘은 보고 있던 화면 위에 쌓되, 맨 위가 인자까지 같은 화면이면 쌓지 않는다(pushUnlessOnTop).launchSingleTop을 쓰지 않은 이유가 있다. 그 옵션은 목적지 종류만 비교해서, 같은 종류가 맨 위에 있으면 인자만 바꾼 entry 로 갈아 끼우고 ViewModel 은 처음 인자를 쥔 채 남긴다. 그래서 답변 수정 화면(DailyQuestionWriteRoute(answerId = …))에 있을 때 데일리질문 링크가 오면 새 작성 화면 대신 수정 화면이 그대로 남았다.4. 검증
로컬에서
:core:common:testDebugUnitTest,:app:testDebugUnitTest,:konsist:test,:app:compileDebugUnitTestKotlin,:app:ktlintCheck,:core:common:ktlintCheck,:app:lintDebug가 통과했다.새 단위 테스트 45건이다.
AfternoteAppLinkParserTestNavigationTargetContractTestMainViewModelAppLinkTestAppLinkIntentContractTestAppLinkResumeEffectTestAppLinkTargetBackStackTest🧪 CI Test Plan
{ "androidTest": { "mode": "selected", "reason": "AndroidManifest.xml, MainActivity.kt, app/src/main/.../navigation/ 두 파일이 경계 경로라 none 은 반려된다. 반대로 full 강제 경로(app/build.gradle.kts, 루트 빌드 파일, build-logic/, gradle/, 계측 하네스 워크플로)는 한 줄도 건드리지 않아 full 은 과하다. 고른 lane 은 이 변경이 실제로 지나는 진입 경로다. onCreate 의 최초 intent 처리에 enqueue 한 줄이 늘어 NotificationColdStart 가 같은 자리를 지나고, onNewIntent 에도 같은 처리가 붙어 NotificationNavigation 의 warm 재진입 3종이 그 경계를 밟는다. 재개가 온보딩 스택 교체 뒤를 기다리므로 로그인 성공 직후 루트가 교체되는 경로(AppOnboardingCanary 3종)를 함께 돌린다. 계측 테스트 파일은 한 줄도 바꾸지 않아 전량 선언 규칙에 걸리는 파일이 없다. device 는 전부 api30 으로 둔다. api34 lane 은 접근성 스모크 전용이고 이 변경에는 UI 가 없다.", "tests": [ { "path": "app/src/androidTest/java/com/afternote/afternote_fe/NotificationColdStartAndroidTest.kt", "selector": "com.afternote.afternote_fe.NotificationColdStartAndroidTest#coldPendingIntentLaunch_enqueuesInitialNotificationEntry", "device": "api30" }, { "path": "app/src/androidTest/java/com/afternote/afternote_fe/NotificationNavigationAndroidTest.kt", "selector": "com.afternote.afternote_fe.NotificationNavigationAndroidTest#warmNotificationWhileLoggedOut_isReceivedWithoutBypassingOnboarding", "device": "api30" }, { "path": "app/src/androidTest/java/com/afternote/afternote_fe/NotificationNavigationAndroidTest.kt", "selector": "com.afternote.afternote_fe.NotificationNavigationAndroidTest#warmNotificationWhileLoggedIn_isReceivedWithoutRecreatingActivity", "device": "api30" }, { "path": "app/src/androidTest/java/com/afternote/afternote_fe/NotificationNavigationAndroidTest.kt", "selector": "com.afternote.afternote_fe.NotificationNavigationAndroidTest#backgroundNotification_resumesSameActivityAndKeepsCurrentScreen", "device": "api30" }, { "path": "app/src/androidTest/java/com/afternote/afternote_fe/AppOnboardingCanaryTest.kt", "selector": "com.afternote.afternote_fe.AppOnboardingCanaryTest#coldStartWithoutSession_opensLoginFromWelcome", "device": "api30" }, { "path": "app/src/androidTest/java/com/afternote/afternote_fe/AppOnboardingCanaryTest.kt", "selector": "com.afternote.afternote_fe.AppOnboardingCanaryTest#emailLogin_networkFailureThenRetry_entersHomeOnce", "device": "api30" }, { "path": "app/src/androidTest/java/com/afternote/afternote_fe/AppOnboardingCanaryTest.kt", "selector": "com.afternote.afternote_fe.AppOnboardingCanaryTest#loginInput_survivesActivityRecreation", "device": "api30" } ] } }📷𝘚𝘤𝘳𝘦𝘦𝘯𝘴𝘩𝘰𝘵
새 화면도 새 컴포넌트도 없다. 링크가 여는 네 화면은 전부 이미 있는 화면이고 이 PR 은 그리로 가는 길만 놓는다.
💬𝘛𝘰 𝘙𝘦𝘷𝘪𝘦𝘸𝘦𝘳𝘴
이슈를 닫는가
완료 조건 1, 2, 3, 4, 6 은 코드와 위 테스트로 닫혔고 7 은 이 PR 의 CI 가 닫는다. 완료 조건 1 의 ID validation 은 ID 를 싣는 경로가 계약에 남지 않아 대상이 없다. 그 이유는 위 1절에 있다. 코드로 닫히지 않는 두 몫은 후속 이슈로 뗐다.
Closes #924로 머지돼도 추적을 잃지 않는다./afternote/{id}를 목적지로 여는 몫을 갖는다. 이 PR 이 계약에서도 파서에서도 intent-filter 에서도 뺀 그 경로다.둘 다 #924 를
blocked_by로 걸어 뒀다. 이 PR 이 머지돼 #924 가 닫히면 그때 풀린다.나눈 기준은 판정 수단과 선행 조건이다. #2087 은 코드가 아니라 기기에서 판정되고, 막히면 서버가 올린
assetlinks.json의 서명 지문을 고쳐야 한다. #2088 은 코드 범위이고AfternoteNavHost를 물고 있는 열린 PR 세 건(#2021, #2001, #1962)이 선행이다. 걸리는 지점도 푸는 사람도 달라서 한 이슈에 담으면 둘 중 하나가 다른 하나를 기다리게 된다.지금까지 확인한 것은
assetlinks.json이 HTTP 200 이고 패키지명과 relation 둘 다 맞다는 데까지다(2026-09-13 22:36 KST 조회). 다만 게시된 서명 지문 두 개가 Afternote/Afternote-BE#228 이 고치기 전 값 그대로다. 그 이슈가 지문 재확인을 FE 몫으로 남긴 채 닫혔으므로, 검증이 실패하면 첫 용의자는 지문이다. 근거는 #2087 본문에 있다.본문이 #924 를 닫는 참조로 걸려 있는 것은 대표 이슈를 그 형태로만 걸 수 있기 때문이다(#1748). 남는 몫을 산문에 적어 두는 대신 이슈로 뗀 것이 그 규칙이 요구하는 처리다.
시안 대조
이 PR 은 화면을 만들지 않아 대조할 시안이 없다. 다만 이 세션은 Figma 에 접근하지 못했으므로(플러그인 인증이 되지 않은 상태), 링크가 여는 네 화면의 현재 시안을 열어 본 것은 아니다. 그 화면들의 모양은 이 PR 의 범위 밖이다.
스택이 아니다
base 는 develop 이고, 앞에 기다릴 PR 이 없어 머지 순서 제약도 없다. 앞선 PR #1859 는 머지되지 않고 닫혔고 그 이슈 #1857 도 not planned 로 닫혔으므로, 파서를 develop 에 처음 들이는 것은 이 PR 이다. 그 닫힌 브랜치와 비교해 계약에서 달라진 점은 셋이다.
AfternoteDetail과TimeLetterDetail목적지를 뺐고, userinfo 와 포트 거절 사유를UNSUPPORTED_HOST에서 떼어 냈다.변이 검증
새 가드 아홉 곳에 변이를 넣어 실제로 실패하는지 확인했다. 초록인 채로 아무것도 안 잡는 테스트가 아니다.
requiredGates를 읽지 않고 항상 재개MainViewModelAppLinkTest의 관문 미통과 케이스MainViewModelAppLinkTest의 로그아웃 폐기 케이스MainViewModelAppLinkTest의 재생성 후 되살아남 케이스AppLinkTargetBackStackTest의 해당 목적지 케이스AppLinkTargetBackStackTest의 복원 금지 케이스AfternoteAppLinkParserTest의 상세 경로 거절 케이스pushUnlessOnTop을launchSingleTop으로 되돌림AppLinkTargetBackStackTest의 답변 수정 중 링크 케이스ACTION_VIEW검사 제거AppLinkIntentContractTest의 런처·알림 구분 케이스AppLinkResumeEffectTest의 온보딩 대기 케이스확인받고 싶은 판단
query 가 하나라도 붙으면 거절한다. 지금은 지원하는 키가 없으니 계약대로지만, 공유 링크에 추적 파라미터를 붙이기로 하면 파서부터 고쳐야 하고 그전까지는 그런 링크가 전부 홈으로 떨어진다. 알려진 키만 무시하고 나머지를 거절하는 쪽이 나은지 봐 주면 좋겠다.