From 97b75e19ddc061130127a6fcb761556c9f05584e Mon Sep 17 00:00:00 2001 From: Daewoon Kim Date: Thu, 10 Sep 2026 17:44:48 +0900 Subject: [PATCH 01/18] =?UTF-8?q?docs(scatterlab):=20Android=20prebuilt=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fork의 `ReactAndroid/**` 수정은 소비자에게 도달하지 않는다 — RNGP가 `com.facebook.react:react-android:`을 Maven Central에서 force resolve 하므로(`DependencyUtils.kt:132`), npm tarball의 Kotlin·C++ 소스는 애초에 컴파일되지 않는다. iOS prebuilt와 같은 형태로 패치된 AAR을 실어 보내는 경로를 설계한다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H4MubfsmtFzkS44FBTvSaS --- .github/scatterlab/README.md | 4 + .github/scatterlab/android-prebuilt.md | 138 +++++++++++++++++++++++++ CLAUDE.md | 2 +- 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 .github/scatterlab/android-prebuilt.md diff --git a/.github/scatterlab/README.md b/.github/scatterlab/README.md index df9ebe38c28a..680a1993d509 100644 --- a/.github/scatterlab/README.md +++ b/.github/scatterlab/README.md @@ -256,6 +256,10 @@ controlled는 키스트로크마다 JS를 왕복해 `-_setAttributedString:`을 검증 절차와 회귀 판정 기준은 [ime-qa.md](ime-qa.md). +## Android prebuilt + +Android는 fork의 npm tarball에 든 `ReactAndroid/**` 소스를 아예 컴파일하지 않는다 — RNGP가 `com.facebook.react:react-android:`을 Maven Central에서 force resolve 하기 때문이다. 패치된 AAR을 실어 보내는 경로는 [`android-prebuilt.md`](android-prebuilt.md)에 있다. + ## iOS prebuilt core를 우리가 만든다 0.86에서 iOS prebuilt는 **opt-out 기본값**이고, prebuilt가 켜지면 `podspec_sources`가 헤더만 반환해 **모든 React\* pod의 구현이 `React.xcframework`에서 온다**(81개 podspec 중 69개가 이 스위치를 탄다). 실측: `RCTUITextView` / `RCTUITextField` / `RCTTextInputComponentView` 세 클래스 모두 상류 0.86.2 아티팩트의 Mach-O에 심볼로 존재한다(`nm -gU React`). 즉 **iOS 소스 수정은 prebuilt가 켜진 채로는 조용히 무효**다. diff --git a/.github/scatterlab/android-prebuilt.md b/.github/scatterlab/android-prebuilt.md new file mode 100644 index 000000000000..5007af3634f3 --- /dev/null +++ b/.github/scatterlab/android-prebuilt.md @@ -0,0 +1,138 @@ +# Android prebuilt + +fork가 수정한 **Android** 네이티브 코드를 소비자에게 실어 보내는 경로. iOS prebuilt([README.md](README.md))와 대칭이며, 존재 이유도 같다 — **소스만 고치면 조용히 무효다.** + +## 왜 필요한가 + +iOS는 prebuilt가 켜진 채로 소스를 고치면 무효였다. Android는 그보다 앞선 지점에서 끊긴다: 소비자의 빌드는 fork의 npm tarball에 든 `ReactAndroid/**` 소스를 **아예 컴파일하지 않는다.** + +RNGP가 모든 configuration에 대해 좌표를 force resolve 한다: + +```kotlin +// DependencyUtils.kt:132 +configuration.resolutionStrategy.force( + "${coordinates.reactGroupString}:react-android:${coordinates.versionString}") +``` + +`versionString`은 `ReactAndroid/gradle.properties`의 `VERSION_NAME`이고(`readVersionAndGroupStrings`), 그 값은 업스트림 base로 고정해야 한다(fork 접미사를 붙이면 존재하지 않는 좌표가 된다). 따라서 소비자는 기본적으로 **Maven Central의 업스트림 `com.facebook.react:react-android:` AAR**을 받는다. fork의 Kotlin·C++ 변경은 그 AAR에 없다. + +`node_modules`에 든 소스로 빌드하게 만들려면 `includeBuild`로 ReactAndroid 전체를 소비자 빌드에 끌어와야 하는데, NDK·CMake 컴파일이 소비자 CI마다 반복된다. 그래서 iOS와 같은 형태를 택한다 — **fork가 한 번 빌드해 배포하고, 소비자는 받아 쓴다.** + +## 결정 + +| 항목 | 값 | 이유 | +| --- | --- | --- | +| 산출물 | `/tmp/maven-local` 트리 전체를 tar | `publishAllToMavenTempLocal`의 고정 출력 경로(`ReactAndroid/publish.gradle:16`) | +| 릴리스 태그 | `prebuilt-android-` | iOS의 `prebuilt-ios-`과 대칭. `v`로 시작하지 않아 상류 `publish-npm.yml`의 `v0.*.*` 글롭에 안 걸린다 | +| 에셋 | `react-native-android-maven-.tar.gz` + `.sha256` | | +| 호스팅 | GitHub Release 에셋 | 릴리스 AAR 160MB · 디버그 AAR 268MB. npm tarball 동봉은 tarball-diff 게이트와 install 시간을 둘 다 깨뜨린다 | +| 소비 배선 | fork npm 패키지의 gradle 스크립트 + 소비자 `settings.gradle.kts` 한 줄 | 판정·다운로드·검증·abort를 fork가 소유한다. `@react-native/gradle-plugin`에는 넣을 수 없다 — sibling 7개는 업스트림 정확 버전 고정이 불변식이다 | +| 게이트 | 릴리스가 없으면 **abort** | iOS `FORK_REQUIRES_OWN_PREBUILT`와 동일. 모든 `-scatterlab.N`이 Android 릴리스를 가져야 한다 | +| 캐시 | `~/.gradle/scatterlab-react-native//maven` | 아래 "좌표 충돌" 참조 | + +## 좌표 충돌 — 이 설계의 핵심 함정 + +`VERSION_NAME`이 base로 고정되므로 **모든 fork 버전의 AAR이 같은 좌표를 갖는다**: `com.facebook.react:react-android:0.87.1`. `-scatterlab.2`의 AAR과 `-scatterlab.3`의 AAR은 Gradle이 보기에 구별 불가능한 같은 모듈이다. + +따라서 캐시 디렉터리를 **fork 버전으로 스코프**한다. 버전마다 별개의 maven 저장소 루트를 만들고, 그 경로를 `react.internal.mavenLocalRepo`로 넘긴다. 한 루트를 공유하면 새 버전을 받아도 Gradle 모듈 캐시가 옛 AAR을 계속 쓰고, **에러 없이 옛 네이티브 코드가 출고된다.** + +iOS의 warm `~/Library/Caches/ReactNative` 함정과 같은 모양이지만, 여기서는 좌표까지 같아 더 조용하다. + +## 빌드 — `scatterlab-prebuild-android.yml` + +`workflow_dispatch(version)`. `prepare` → `build` → `verify` 3잡, iOS 워크플로와 같은 골격. + +러너는 `arc-messenger-dev`(Linux). GitHub-hosted 러너는 org IP allow list 밖이라 인증된 `api.github.com` 쓰기가 403이다. + +핵심 스텝: + +```bash +env ORG_GRADLE_PROJECT_react.internal.useHermesStable=true \ + ./gradlew publishAllToMavenTempLocal -PenableWarningsAsErrors=true +``` + +- **상류 `.github/actions/build-android`를 재사용하지 않는다.** 그 액션은 `scripts/releases/set-rn-artifacts-version.js`를 부르는데, 이 스크립트가 `VERSION_NAME`을 재작성해 위의 불변식을 정확히 깨뜨린다(실행 금지 목록에 있다). +- `react.internal.useHermesStable=true`로 Hermes는 빌드하지 않고 Maven Central 안정판을 쓴다. `com.facebook.hermes:hermes-android`는 다른 publishing group이라 아래의 그룹 제외에 걸리지 않는다. +- `isSnapshot`을 켜지 않는다. 켜면 버전에 `-SNAPSHOT`이 붙어 좌표가 어긋난다. +- ABI는 기본값(`armeabi-v7a,arm64-v8a,x86,x86_64`)을 그대로 둔다. 소비자의 `reactNativeArchitectures`와 같아야 한다. +- Debug·Release 두 variant는 `components.default` 멀티 variant 퍼블리시로 한 번에 나온다(`publish.gradle`). +- ccache를 러너에 유지한다. 없으면 매 실행이 전체 C++ 재컴파일이다. +- 빌드 산출물은 수 GB다. `if: always()`로 정리한다. + +에셋 업로드는 **clobber 하지 않는다.** 소비자 캐시가 버전별로 스코프돼 있어 같은 태그의 에셋을 갈아끼우면 이미 받아 둔 개발자·러너는 영구히 옛 것을 쓴다. 새 `-scatterlab.N`을 낸다. + +## 소비자 배선 + +로직은 fork의 npm 패키지에 담는다: `packages/react-native/scripts/android/scatterlab-prebuilt-maven.gradle`. + +동작 순서: + +1. `packages/react-native/package.json`의 `version`을 읽는다. `^\d+\.\d+\.\d+-scatterlab\.\d+$`가 아니면 **no-op** — 업스트림 react-native로도 이 스크립트가 붙은 채 동작해야 한다. +2. `~/.gradle/scatterlab-react-native//maven`이 이미 있으면 그대로 쓴다. +3. 없으면 `https://github.com/scatterlab/react-native/releases/download/prebuilt-android-/react-native-android-maven-.tar.gz`를 받아 `.sha256`으로 검증하고 임시 디렉터리에 푼 뒤 최종 경로로 rename 한다. 부분 추출된 트리가 성공한 캐시로 남으면 안 된다. +4. 릴리스가 없거나 sha가 어긋나면 **빌드를 중단한다.** 메시지는 `rncore.rb:378`과 같은 형태로 — 무엇이 없고 어떤 워크플로를 돌려야 하는지 적는다. +5. `gradle.beforeProject`에서 각 프로젝트에 `react.internal.mavenLocalRepo`를 그 경로로 세팅한다. + +캐시가 이미 있으면 네트워크를 타지 않으므로 `--offline` 빌드도 그대로 된다. 캐시가 없는데 네트워크가 없으면 abort한다 — 조용히 업스트림으로 떨어뜨리지 않는다. + +그러면 RNGP가(`DependencyUtils.kt:56-87`): + +- 그 디렉터리를 maven 저장소로 추가하고, +- **Maven Central에서 `com.facebook.react` 그룹 전체를 제외한다.** + +두 번째가 중요하다 — 우리 저장소가 그 그룹의 아티팩트를 **전부** 들고 있어야 한다. `publishAllToMavenTempLocal`의 산출물이 정확히 그 집합이다. + +소비자(zeta) 쪽 변경은 `packages/app/android/settings.gradle.kts` 한 줄이다: + +```kotlin +apply(from = "../../../node_modules/react-native/scripts/android/scatterlab-prebuilt-maven.gradle") +``` + +`settings.gradle` 평가 시점에 동기적으로 받는다. `pod install`이 xcframework를 받는 자리와 같다. 별도 태스크로 빼고 CI·postinstall이 먼저 부르게 하는 형태는 택하지 않는다 — 그 단계를 빠뜨린 로컬 빌드가 **에러 없이 업스트림 AAR로 돌아간다.** + +## 검증 + +**워크플로 `verify` 잡** — 릴리스가 소비 가능한지 확인한다: + +- 에셋 이름이 정확히 기대한 2개인지 (`gh api ... --jq '.assets[].name'` diff). GitHub은 예상 밖 문자가 든 에셋 이름을 재작성한다. +- tarball을 받아 sha256이 맞는지. +- 트리에 `com/facebook/react/react-android//`의 `react-android--debug.aar`, `-release.aar`, `.module`, `.pom`이 모두 있는지. + +**패치가 실려 있는지 증명하는 게이트** — "내용이 바뀌었는데 식별자가 같다" 계열 사고를 막는 유일한 수단이다. 좌표가 버전 간 동일하므로 여기서는 필수다. + +워크플로에 `verify_symbol` 입력을 둔다. 이 버전이 도입한 식별자(예: 클래스명·메서드명)를 넣으면 `verify` 잡이 릴리스 AAR의 `classes.jar`를 풀어 `javap -p`로 그 심볼을 찾고, 0건이면 실패한다. 빈 값이면 이 검사를 건너뛴다 — base bump처럼 새 심볼이 없는 릴리스가 있다. + +소비자 쪽 확인은 `--info` 실행에서 해석된 AAR 경로가 `~/.gradle/scatterlab-react-native//maven` 아래인지 보는 것으로 족하다. 새 fork 버전으로 처음 빌드할 때 한 번 본다. + +**단위 테스트** — 수정마다 ReactAndroid의 Robolectric 테스트를 붙인다. + +**엔드투엔드** — 소비자에서 release APK를 빌드해 실기기로 증상을 확인한다. 시뮬레이터·에뮬레이터로 대체하지 않는다. + +## 출고 순서 + +iOS와 같은 제약이 하나 더 붙는다. + +``` +prebuilt-ios- 릴리스 +prebuilt-android- 릴리스 ← 추가 +npm publish (scatterlab-publish.yml) +소비자 핀 갱신 +``` + +두 prebuilt 릴리스가 npm보다 **먼저** 있어야 한다. 뒤집으면 소비자의 `pod install`(iOS)과 Gradle configure(Android)가 각각 abort한다. 그게 의도다 — 패치 없는 네이티브를 조용히 출고하는 것보다 낫다. + +이 순서를 `scatterlab-publish.yml`이 검사하지는 **않는다.** iOS와 같은 선택이다 — 강제는 소비자 쪽 abort 한 곳에만 두고, publish는 순서를 모른 채 돈다. publish 후 릴리스를 만들어도 결과는 같고, 그 사이에 설치한 소비자만 abort를 본다. + +한 flavor만 성공한 **부분 릴리스**가 iOS에서 위험했던 것과 같은 이유로, Android도 `verify` 잡이 두 variant를 모두 확인한 뒤에야 릴리스를 소비 가능으로 본다. + +## 절대 하면 안 되는 것 + +[README.md](README.md)의 표에 이어서: + +| 금지 | 이유 | +| --- | --- | +| `scatterlab-prebuild-android.yml`에서 상류 `.github/actions/build-android` 재사용 | 그 액션이 `set-rn-artifacts-version.js`를 불러 `VERSION_NAME`을 재작성한다 | +| 캐시 디렉터리를 fork 버전으로 스코프하지 않기 | 좌표가 버전 간 동일해 옛 AAR이 조용히 재사용된다 | +| 릴리스 에셋 clobber | 이미 받아 둔 소비자가 영구히 옛 AAR을 쓴다 | +| `isSnapshot=true` | 좌표에 `-SNAPSHOT`이 붙어 어긋난다 | +| maven 트리에서 일부 아티팩트만 골라 올리기 | RNGP가 Maven Central에서 `com.facebook.react` 그룹을 통째로 제외하므로, 빠진 아티팩트는 어디서도 못 찾는다 | diff --git a/CLAUDE.md b/CLAUDE.md index e077e05b4f41..843b54ee176c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md -`scatterlab/react-native` — `react/react-native`의 fork. 설계 문서는 [`.github/scatterlab/README.md`](.github/scatterlab/README.md), IME 검증 절차는 [`.github/scatterlab/ime-qa.md`](.github/scatterlab/ime-qa.md). 이 파일은 **여기서 작업할 때의 운용 규칙과 함정**만 담는다. +`scatterlab/react-native` — `react/react-native`의 fork. 설계 문서는 [`.github/scatterlab/README.md`](.github/scatterlab/README.md), Android 아티팩트 배포는 [`.github/scatterlab/android-prebuilt.md`](.github/scatterlab/android-prebuilt.md), IME 검증 절차는 [`.github/scatterlab/ime-qa.md`](.github/scatterlab/ime-qa.md). 이 파일은 **여기서 작업할 때의 운용 규칙과 함정**만 담는다. ## 이 레포의 목적 From 1de4b8d349c364fe325e49ba19d5594f7c14f206 Mon Sep 17 00:00:00 2001 From: Daewoon Kim Date: Thu, 10 Sep 2026 17:54:26 +0900 Subject: [PATCH 02/18] =?UTF-8?q?docs(scatterlab):=20Android=20prebuilt=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 태스크 6개 — #58366 체리픽, 소비자 settings 스크립트, 빌드·릴리스 워크플로, fork PR, zeta 배선, 출고. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H4MubfsmtFzkS44FBTvSaS --- .../plans/2026-09-10-android-prebuilt.md | 1072 +++++++++++++++++ 1 file changed, 1072 insertions(+) create mode 100644 .github/scatterlab/plans/2026-09-10-android-prebuilt.md diff --git a/.github/scatterlab/plans/2026-09-10-android-prebuilt.md b/.github/scatterlab/plans/2026-09-10-android-prebuilt.md new file mode 100644 index 000000000000..442ba7752bf3 --- /dev/null +++ b/.github/scatterlab/plans/2026-09-10-android-prebuilt.md @@ -0,0 +1,1072 @@ +# Android prebuilt 배포 파이프라인 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** fork가 수정한 Android 네이티브 코드가 실제로 소비자 앱에 실리는 경로를 만들고, 첫 화물로 `TextDecorationStyle` IndexOutOfBounds 크래시 수정을 태운다. + +**Architecture:** fork CI가 `publishAllToMavenTempLocal`로 패치된 maven 트리를 만들어 GitHub Release에 올린다. fork npm 패키지에 든 Gradle settings 스크립트가 소비자 빌드의 설정 단계에서 그 tarball을 받아 캐시하고, `react.internal.mavenLocalRepo`를 그 경로로 세팅한다. 그러면 RNGP가 Maven Central에서 `com.facebook.react` 그룹을 제외하고 우리 AAR을 쓴다. + +**Tech Stack:** Kotlin (ReactAndroid), Groovy (Gradle settings 스크립트), GitHub Actions, bash + +**Spec:** [`.github/scatterlab/android-prebuilt.md`](../android-prebuilt.md) + +## Global Constraints + +이 값들을 어기면 조용히 깨진다. 모든 태스크에 암묵적으로 적용된다. + +- 작업 브랜치는 `daewoon/android-prebuilt`, base는 `scatterlab/0.87.1`. `main`에 push 금지. +- `packages/react-native/ReactAndroid/gradle.properties`의 `VERSION_NAME`은 `0.87.1`로 **고정**. 절대 변경하지 않는다. +- `react.internal.publishingGroup`은 `com.facebook.react`로 고정. +- `packages/react-native/package.json`의 `@react-native/*` sibling 7개 핀은 exact `0.87.1`로 고정. +- `scripts/releases/set-version.js`와 `scripts/releases/set-rn-artifacts-version.js`는 **실행 금지**. +- `v`로 시작하는 태그 생성 금지. 릴리스 태그는 `prebuilt-android-`. +- fork 버전 형식은 `0.87.1-scatterlab.N` — 대시 정확히 1개. +- 커밋 메시지 본문은 한국어. 타입 접두어(`feat`/`fix`/`ci`/`docs`/`chore`)와 코드 식별자·파일 경로·명령어는 원문 유지. +- push는 항상 브랜치 명시: `git push origin daewoon/android-prebuilt`. `--tags`/`--follow-tags` 금지 (로컬에 상류 `v0.*` 태그 660여 개). +- `packages/react-native/` 안의 파일을 새로 건드리거나 추가하면 `.github/scatterlab/allowed-tarball-diff.txt`에 그 **tarball 상대 경로**를 추가해야 한다. `packages/react-native/` 접두어는 붙이지 않는다. `ReactAndroid/src/test/**`는 `files` 필드가 제외하므로 tarball에 없다 — 추가하지 않는다. +- Gradle 실행에는 JDK 17 이상이 필요하다. `JAVA_HOME`이 그보다 낮으면 명시한다: + `JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew ...` + +**작업 디렉터리:** `~/GitHub/react-native/.claude/worktrees/daewoon+android-prebuilt` (Task 5만 zeta-frontend). 이 워크트리 절대경로로 파일을 연다 — 메인 체크아웃(`~/GitHub/react-native`)을 편집하면 다른 세션의 작업을 오염시킨다. + +--- + +## File Structure + +| 파일 | 책임 | 태스크 | +| --- | --- | --- | +| `packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt` | 잘린 layout 밖 offset을 clamp | 1 | +| `packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextDecorationStyleTest.kt` | 위 동작의 회귀 테스트 | 1 | +| `packages/react-native/scripts/android/scatterlab-prebuilt-maven.gradle` | 소비자 설정 단계: 버전 판정 · 다운로드 · 검증 · 캐시 · 프로퍼티 주입 · abort | 2 | +| `packages/react-native/package.json` | `files`에 `scripts/android` 추가 | 2 | +| `.github/scatterlab/__tests__/android-prebuilt-consumer-test.sh` | 위 스크립트의 3개 분기 스모크 테스트 | 2 | +| `.github/workflows/scatterlab-prebuild-android.yml` | AAR 빌드 · 릴리스 · 검증 | 3 | +| `.github/scatterlab/allowed-tarball-diff.txt` | 새로 다른 tarball 경로 등록 | 1, 2 | +| `CLAUDE.md` | 배포 절차에 Android 단계 추가 | 3 | +| `packages/app/android/settings.gradle.kts` (zeta-frontend) | 스크립트 apply 한 줄 | 5 | + +--- + +### Task 1: `TextDecorationStyle` clamp 수정 (첫 화물) + +상류 PR [#58366](https://github.com/react/react-native/pull/58366)의 체리픽이다. 이슈 [#58356](https://github.com/react/react-native/issues/58356). `drawSpannedDecoration()`이 잘리지 않은 전체 `Spanned`에서 온 `start`/`end`를 `Layout.getPrimaryHorizontal()`에 그대로 넘겨, `numberOfLines`로 잘린 layout에서 `IndexOutOfBoundsException`이 난다. + +**Files:** +- Modify: `packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt` +- Test: `packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextDecorationStyleTest.kt` +- Modify: `.github/scatterlab/allowed-tarball-diff.txt` + +**Interfaces:** +- Consumes: 없음 (첫 태스크) +- Produces: `private fun visibleTextEnd(layout: Layout): Int` — Task 3의 `verify_symbol` 게이트가 이 이름을 심볼로 찾는다. + +- [ ] **Step 1: 실패하는 테스트를 쓴다** + +`TextDecorationStyleTest.kt`의 import 블록을 아래로 교체한다: + +```kotlin +package com.facebook.react.views.text + +import android.graphics.Canvas +import android.graphics.Color +import android.text.StaticLayout +import android.text.TextPaint +import android.text.TextUtils +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class TextDecorationStyleTest { +``` + +기존 `class TextDecorationStyleTest {` 줄은 위 블록이 대신하므로 지운다. 클래스 닫는 `}` 직전에 아래를 넣는다: + +```kotlin + @Test + fun drawSpannedDecorationClampsSpanPastTailEllipsis() { + val layout = buildTailEllipsizedLayout() + val visibleEnd = layout.getLineStart(0) + layout.getEllipsisStart(0) + val baseline = layout.getLineBaseline(0).toFloat() + val x1 = layout.getPrimaryHorizontal(0) + val x2 = layout.getPrimaryHorizontal(visibleEnd) + val canvas = mock() + + // The span covers the whole string, well past what survived the ellipsis; pre-fix this + // called layout.getPrimaryHorizontal(TAIL_TEXT.length) and crashed with + // IndexOutOfBoundsException, since the ellipsized layout only resolves up to visibleEnd. + drawSpannedDecoration( + 0, + TAIL_TEXT.length, + canvas, + layout, + Color.BLACK, + TextDecorationStyle.SOLID, + ) { _, lineBaseline, thickness -> + lineBaseline + thickness + 1f + } + + verify(canvas).drawLine(eq(x1), eq(baseline + 1f), eq(x2), eq(baseline + 1f), any()) + } + + @Test + fun drawSpannedDecorationSkipsSpanEntirelyPastTailEllipsis() { + val layout = buildTailEllipsizedLayout() + val visibleEnd = layout.getLineStart(0) + layout.getEllipsisStart(0) + val baseline = layout.getLineBaseline(0).toFloat() + val x = layout.getPrimaryHorizontal(visibleEnd) + val canvas = mock() + + // The whole span (e.g. a nested Text) starts after the ellipsis, fully hidden: it must + // collapse to a zero-length line at the visible boundary, not draw anything past it. + drawSpannedDecoration( + visibleEnd, + TAIL_TEXT.length, + canvas, + layout, + Color.BLACK, + TextDecorationStyle.SOLID, + ) { _, lineBaseline, thickness -> + lineBaseline + thickness + 1f + } + + verify(canvas).drawLine(eq(x), eq(baseline + 1f), eq(x), eq(baseline + 1f), any()) + } + + /** + * A single line, tail-ellipsized right after "Hello" because the paragraph break in + * [TAIL_TEXT] hides everything after it once `maxLines` is reached. + */ + private fun buildTailEllipsizedLayout(): StaticLayout { + val paint = TextPaint().apply { textSize = 32f } + val layout = + StaticLayout.Builder.obtain(TAIL_TEXT, 0, TAIL_TEXT.length, paint, 400) + .setMaxLines(1) + .setEllipsize(TextUtils.TruncateAt.END) + .build() + assertThat(layout.lineCount).isEqualTo(1) + assertThat(layout.getEllipsisCount(0)).isGreaterThan(0) + return layout + } + + private companion object { + const val TAIL_TEXT = "Hello\ndecorated world" + } +``` + +- [ ] **Step 2: 테스트가 실패하는 것을 확인한다** + +```bash +cd ~/GitHub/react-native/.claude/worktrees/daewoon+android-prebuilt +./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest \ + --tests "com.facebook.react.views.text.TextDecorationStyleTest" +``` + +기대: 새 테스트 2개가 `IndexOutOfBoundsException: offset(...) should be less than line limit(...)`로 실패. 기존 `fromString*` 테스트 6개는 통과. + +이 에러가 정확히 재현되지 않으면 멈춘다 — 재현 없는 수정은 검증되지 않는다. + +- [ ] **Step 3: 최소 구현을 넣는다** + +`TextDecorationStyle.kt`에서 `drawDecorationLine` 함수가 끝나는 `}` 다음, `drawSpannedDecoration`의 KDoc 앞에 아래를 삽입한다: + +```kotlin +/** + * The last offset [layout] can resolve a horizontal position for. Truncation (via `numberOfLines`) + * can leave the last line shorter than [Layout.getLineEnd] reports, since [layout] keeps the full + * untruncated text and only clips how much of it is laid out. When the last line ends in a tail + * ellipsis, anything from the ellipsis onward is unresolvable too; a leading/middle ellipsis + * doesn't shorten what's resolvable on that line, so it's left to [Layout.getLineEnd]. + */ +private fun visibleTextEnd(layout: Layout): Int { + val lastLine = layout.lineCount - 1 + val lineStart = layout.getLineStart(lastLine) + val lineEnd = layout.getLineEnd(lastLine) + val ellipsisStart = layout.getEllipsisStart(lastLine) + val ellipsisCount = layout.getEllipsisCount(lastLine) + return if (ellipsisCount > 0 && ellipsisStart + ellipsisCount == lineEnd - lineStart) { + lineStart + ellipsisStart + } else { + lineEnd + } +} +``` + +그리고 `drawSpannedDecoration` 안의 아래 블록을 + +```kotlin + val startLine = layout.getLineForOffset(start) + val endLine = layout.getLineForOffset(end) + for (line in startLine..endLine) { + val baseline = layout.getLineBaseline(line).toFloat() + val rawX1 = + if (line == startLine) layout.getPrimaryHorizontal(start) else layout.getLineLeft(line) + val rawX2 = if (line == endLine) layout.getPrimaryHorizontal(end) else layout.getLineRight(line) +``` + +이렇게 바꾼다: + +```kotlin + val visibleEnd = visibleTextEnd(layout) + val clampedStart = min(start, visibleEnd) + val clampedEnd = min(end, visibleEnd) + + val startLine = layout.getLineForOffset(clampedStart) + val endLine = layout.getLineForOffset(clampedEnd) + for (line in startLine..endLine) { + val baseline = layout.getLineBaseline(line).toFloat() + val rawX1 = + if (line == startLine) layout.getPrimaryHorizontal(clampedStart) + else layout.getLineLeft(line) + val rawX2 = + if (line == endLine) layout.getPrimaryHorizontal(clampedEnd) else layout.getLineRight(line) +``` + +`min`은 이미 `kotlin.math.min`으로 import 되어 있다. 새 import 없다. + +- [ ] **Step 4: 테스트가 통과하는 것을 확인한다** + +```bash +./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest \ + --tests "com.facebook.react.views.text.TextDecorationStyleTest" +``` + +기대: 8개 전부 PASS. + +- [ ] **Step 5: tarball diff 게이트에 경로를 등록한다** + +`.github/scatterlab/allowed-tarball-diff.txt` 끝에 추가한다: + +``` +# Android text decoration crash: drawSpannedDecoration() passed offsets from the full +# Spanned into a numberOfLines-truncated Layout, so getPrimaryHorizontal() threw +# IndexOutOfBoundsException while drawing an underline/strikethrough that the truncation +# had hidden. Upstream https://github.com/react/react-native/pull/58366 (open). +# Reaches the app only through this fork's Android prebuilt release - see android-prebuilt.md. +ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt +``` + +테스트 파일은 `package.json`의 `files`가 `!ReactAndroid/src/test`로 제외하므로 tarball에 없다. 추가하지 않는다. + +- [ ] **Step 6: 게이트가 통과하는지 확인한다** + +```bash +cd packages/react-native && npm pack --silent && cd - +.github/scatterlab/verify-tarball.sh packages/react-native/*.tgz +``` + +기대: 통과. `TextDecorationStyle.kt`가 allowlist 밖이라고 실패하면 Step 5의 경로 문자열이 tarball 상대 경로와 다른 것이다 — `tar tzf`로 실제 경로를 확인하고 맞춘다. + +- [ ] **Step 7: 커밋** + +```bash +rm -f packages/react-native/*.tgz +git add packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt \ + packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextDecorationStyleTest.kt \ + .github/scatterlab/allowed-tarball-diff.txt +git commit -m "$(cat <<'MSG' +fix(android): 잘린 layout 밖으로 나간 text decoration offset을 clamp + +`numberOfLines`로 잘린 `Layout`에 전체 `Spanned` 기준 offset을 넘겨 +`Layout.getPrimaryHorizontal()`이 `IndexOutOfBoundsException`을 던졌다. +중첩 `Text`의 `textDecorationLine`이 잘려 안 보이는 위치에 있으면 그리기 +단계에서 앱이 죽는다. + +상류 PR https://github.com/react/react-native/pull/58366 체리픽. +이슈 https://github.com/react/react-native/issues/58356 + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01H4MubfsmtFzkS44FBTvSaS +MSG +)" +``` + +--- + +### Task 2: 소비자 Gradle settings 스크립트 + +**Files:** +- Create: `packages/react-native/scripts/android/scatterlab-prebuilt-maven.gradle` +- Modify: `packages/react-native/package.json` (`files` 배열) +- Modify: `.github/scatterlab/allowed-tarball-diff.txt` +- Test: `.github/scatterlab/__tests__/android-prebuilt-consumer-test.sh` + +**Interfaces:** +- Consumes: 없음 +- Produces: + - 릴리스 에셋 이름 `react-native-android-maven-.tar.gz` 와 `react-native-android-maven-.tar.gz.sha256` — Task 3이 정확히 이 이름으로 올린다. + - 릴리스 태그 `prebuilt-android-` — Task 3이 이 태그를 만든다. + - tar 아카이브의 루트는 maven 저장소 루트 자체다 (`com/facebook/react/...`가 최상위). Task 3이 `tar -C /tmp/maven-local -czf ... .`로 만든다. + - 캐시 경로 `/scatterlab-react-native//maven`. + - 소비자가 apply 할 경로: `/react-native/scripts/android/scatterlab-prebuilt-maven.gradle` — Task 5가 이 경로를 쓴다. + +- [ ] **Step 1: 실패하는 스모크 테스트를 쓴다** + +`.github/scatterlab/__tests__/android-prebuilt-consumer-test.sh` 를 만든다: + +```bash +#!/usr/bin/env bash +# Smoke test for scripts/android/scatterlab-prebuilt-maven.gradle. +# +# The script locates the package version relative to its own file, so each case builds a +# throwaway tree that mimics node_modules/react-native and copies the script into it. +# GRADLE_USER_HOME is sandboxed per case so a warm cache from one case cannot leak into +# another - the whole point of the script is what it does when the cache is cold. +set -euo pipefail + +REPO=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd) +SCRIPT="$REPO/packages/react-native/scripts/android/scatterlab-prebuilt-maven.gradle" +GRADLE="$REPO/gradlew" +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT +failures=0 + +# Lays out /rn/{package.json,scripts/android/