Skip to content

feat(platform): 검증된 App Link 를 목적지로 받아 관문 뒤에 재개한다 (#924) - #2093

Closed
OnlyReadOneBook wants to merge 9 commits into
developfrom
feat/924-app-links-receive
Closed

OnlyReadOneBook wants to merge 9 commits into
developfrom
feat/924-app-links-receive

Conversation

@OnlyReadOneBook

@OnlyReadOneBook OnlyReadOneBook commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

핵심 파일

설계가 드러나는 파일이다. 나머지는 이 파일들을 따라가는 변경이다.

  • 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 로그인
/afternote AfternoteHome 로그인, 지문
/mindrecord/daily-question DailyQuestionCompose 로그인
/settings/notification NotificationSettings 로그인

거절도 값이다. AppLinkResolution.Rejected 가 사유(AppLinkRejectionReason)와 안전한 기본 진입(홈)을 함께 들고 오고, 예외를 던지지 않으며 null 도 없다. 사유를 남기는 이유는 서버가 앱보다 먼저 새 경로를 배포했는지, 남이 도메인을 흉내 냈는지가 집계돼야 갈리기 때문이다. 원본 링크는 리포팅에 싣지 않는다. 거절된 링크는 계약 밖 입력이라 ID 가 실린 경로나 query 가 그대로 들어 있을 수 있다.

애프터노트 상세 /afternote/{id} 는 계약에서 뺐다. 그 화면은 애프터노트 피처의 로컬 Navigation 3 스택 안에 있고 그 스택의 시작점이 지문 관문으로 고정돼 있어, 밖에서 상세를 지목해 들어갈 인자가 없다. 도착할 자리가 없는 경로를 계약에만 실으면 링크가 앱을 열고 홈으로 떨어지는 반쪽 활성화가 되므로, 경로도 intent-filter 도 열지 않고 브라우저에 남긴다. 자리가 열릴 때 목적지와 경로와 배선을 함께 더한다. 그 몫은 #2088 이 갖는다.

타임레터 상세 /timeletter/{id} 도 뺐다. 이쪽은 도착할 자리는 있는데 ID 가 무엇인지 정할 발급처가 없다. 파서는 그 숫자가 양의 정수인지만 볼 수 있고, 어느 번호 공간의 것인지는 링크를 만드는 쪽만 안다. 서버에는 이 링크를 만드는 코드가 없고(Afternote-BE origin/main 7c961ec 기준 /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).
  • cold start 의 최초 intent 와 warm onNewIntent 가 같은 계약(AppLinkIntentContract)을 지난다. 두 경계가 각자 intent 를 읽으면 한쪽만 조건이 늘어 목적지가 갈린다.
  • ACTION_VIEW 가 아니면 링크가 아니다. 런처 진입과 알림 PendingIntent 가 같은 Activity 로 오므로 액션으로 먼저 가른다. 반대로 VIEW 인데 data 가 없으면 무시가 아니라 거절이다. 매니페스트 필터가 data 를 요구하는데 여기까지 왔다는 것은 모르는 경로로 들어왔다는 뜻이다.
  • 재생성으로 같은 initial intent 가 다시 배달되는 경우는 읽지 않는다(savedInstanceState == null 일 때만 읽는다). 그 링크가 어디까지 처리됐는지는 이미 MainViewModel 의 SavedState 에 있다.

3. 관문 뒤 재개 (app)

  • 관문 판정은 하드코딩하지 않고 목적지가 싣고 온 requiredGates 를 읽는다. 로그인은 앱 셸이 지키고 지문은 목적지 그래프 자신이 지키므로, 셸이 보는 것은 AuthGate.LOGIN 하나다.
  • 목적지는 SavedState 에 정규 URL 로 남아 프로세스 재생성을 견딘다. 저장 형식이 정규 URL 이라 되읽기가 곧 계약 왕복이고, 옛 버전이 남긴 계약 밖 값은 해석되지 않아 조용히 버려진다.
  • 폐기 경로는 셋이다. 재개를 마쳤을 때, 로그아웃했을 때, 새 링크가 덮을 때. 로그아웃은 "false 로 시작" 이 아니라 true 에서 false 로 가는 전이만 본다. 앱이 잠겨 있을 때 눌러 둔 링크는 살아야 한다. 반대로 로그아웃 뒤에 남으면 다른 사람이 누른 링크가 다음 계정의 세션에 이어 붙는다. 지금 목적지에는 ID 가 없어 남의 자료가 열리지는 않지만, ID 를 싣는 목적지가 들어오는 순간 그 경로가 된다.
  • 재개 시점은 "로그인됨" 이 아니라 "루트가 온보딩을 떠났음" 이다. 로그인 성공은 세션 저장과 온보딩 스택의 popUpTo(0) { inclusive } 를 연달아 일으키는데, 그 사이에 목적지를 쌓으면 뒤따르는 교체가 방금 쌓은 화면까지 걷어내 사용자는 링크를 눌렀는데 홈에 서 있게 된다.
  • navigation 엔진과의 결합은 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건이다.

파일 건수 고정하는 것
AfternoteAppLinkParserTest 16 계약 표 전 행, 정규화 허용 범위, 거절 8종, 상세·수신자 경로 미개방
NavigationTargetContractTest 7 관문 조합, 정규 경로 유일성, URL 왕복
MainViewModelAppLinkTest 8 관문 대기, 1회 재개, 재생성 복원, 로그아웃 폐기
AppLinkIntentContractTest 4 링크 진입 판별, data 없는 VIEW
AppLinkResumeEffectTest 3 온보딩 대기, 1회 재개
AppLinkTargetBackStackTest 7 목적지별 착지 스택, 애프터노트 복원 금지, 중복 방지, 인자가 다른 같은 화면 위 착지

🧪 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 로 머지돼도 추적을 잃지 않는다.

둘 다 #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 의 로그아웃 폐기 케이스
SavedState 소비 기록 누락 MainViewModelAppLinkTest 의 재생성 후 되살아남 케이스
목적지 매핑을 서로 바꿈 AppLinkTargetBackStackTest 의 해당 목적지 케이스
애프터노트 탭 상태 복원 허용 AppLinkTargetBackStackTest 의 복원 금지 케이스
상세 경로를 계약에 되살림 AfternoteAppLinkParserTest 의 상세 경로 거절 케이스
pushUnlessOnTop 을 launchSingleTop 으로 되돌림 AppLinkTargetBackStackTest 의 답변 수정 중 링크 케이스
ACTION_VIEW 검사 제거 AppLinkIntentContractTest 의 런처·알림 구분 케이스
온보딩 대기 제거 AppLinkResumeEffectTest 의 온보딩 대기 케이스

확인받고 싶은 판단

query 가 하나라도 붙으면 거절한다. 지금은 지원하는 키가 없으니 계약대로지만, 공유 링크에 추적 파라미터를 붙이기로 하면 파서부터 고쳐야 하고 그전까지는 그런 링크가 전부 홈으로 떨어진다. 알려진 키만 무시하고 나머지를 거절하는 쪽이 나은지 봐 주면 좋겠다.

브라우저에서 온 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
@github-actions github-actions Bot added sibling-conflict 열린 형제 PR 과 같은 파일에서 충돌 — 나중에 머지되는 쪽이 base 를 병합해야 한다. 해소되면 자동으로 떨어진다 android-test PR head에서 Android Managed Device 계측 테스트 실행 labels Sep 13, 2026
@github-actions

github-actions Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

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

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

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

…을 벗긴다 (#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 수신부도 이 파서를 부르지 않는다. 링크 계약을 서버와
공유하는 외부 계약이라 부른 문장도 서버 쪽 상대가 없어 사실과 달랐다.
엔진과 수명을 떼는 실제 이유(앱 밖에 공개된 주소는 옛 앱 버전이 남는 동안
바꿀 수 없다)로 바꿨다.
@OnlyReadOneBook

Copy link
Copy Markdown
Collaborator Author

머지하지 않고 닫는다. 브랜치 feat/924-app-links-receive 는 남겨 둔다.

이 PR 의 경로 표는 FE 가 혼자 정한 계약이다. afternote.kro.kr 의 경로는 서버가 서비스하는 주소이고(지금 /, /afternote, /settings/notification 을 열면 서버가 JSON 404 를 돌려준다), 어떤 링크를 누구에게 보낼지는 링크를 만드는 쪽이 정한다. 그런데 서버에는 App Link 발급처가 없고(Afternote-BE origin/main 7c961ec 기준 서버가 만드는 링크는 초대 링크 /invitations/{token} 하나), 푸시가 목적지를 URL 로 실을지 키로 실을지도 Afternote/Afternote-BE#261 에서 정해지지 않았다. #924 의 착수 조건인 발송 경로부터 확정한다도 풀리지 않은 채 이 PR 이 열렸다.

발급처가 정해지면 그 계약으로 남은 범위만 새 이슈로 연다. 그때는 파서를 직접 짜지 않고 루트 엔진의 공식 딥링크 API 로 받는다. 루트가 그대로 Nav2 면 navDeepLink, #1702 로 NavDisplay 가 됐으면 Navigation 3 1.2.0 의 DeepLinkMatcher 다(#1702 코멘트). 로그인 관문 뒤 재개와 launchSingleTop 대신 인자까지 비교하는 쌓기는 이 브랜치에서 가져다 쓸 수 있다.

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 계측 테스트 실행 sibling-conflict 열린 형제 PR 과 같은 파일에서 충돌 — 나중에 머지되는 쪽이 base 를 병합해야 한다. 해소되면 자동으로 떨어진다

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(platform): App Links URI 계약·인텐트 수신·인증 후 목적지 재개

2 participants