본문으로 건너뛰기
5분 AI.
TRENDSHIFT #24 딥다이브 · 2026-10-25 분석

cleanyourtv-app 딥다이브
— 컴퓨터 없이 폰이 TV에 ADB를 걸어 정리하는 Kotlin 앱

앱

빠른 판단

무엇
폰이 TV에 직접 ADB로 접속해 광고·부가 앱을 끄고 새 홈 화면을 설치하는 Kotlin 앱
이럴 때
컴퓨터 없이 폰만으로 내 Android TV의 광고·추적 앱을 끄고 복원 지점으로 되돌릴 수 있게 정리할 때
입력
같은 Wi-Fi의 Android TV(네트워크 ADB 5555 켜짐)와 Android 9 이상 폰
결과
끈 앱 목록, 새 홈 화면, 폰 안의 복원 지점과 restore.sh/restore.ps1
도입 전 확인
기기를 바꾸는 도구라 일부 TV(MOKA 계열 등)에서는 홈 화면 교체를 생략함. 기준 0.3.5, 서브모듈 업스트림 런처는 미확인
cleanyourtv-app은 Android TV·Google TV 안의 광고·추적·제조사 부가 앱을 “삭제하지 않고 꺼 주는” 폰 앱입니다. 같은 이름의 데스크톱 도구(Gathin23/cleanyourtv)와 달리 컴퓨터와 adb 설치 없이 폰이 TV의 네트워크 ADB(5555 포트)에 직접 접속합니다. 이 글은 커밋 8681e7f(앱 버전 0.3.5)의 소스를 읽기만 하고 쓴 분석이며, 빌드·테스트·앱 실행은 하지 않았습니다.
목차
  1. 프로젝트 한줄 요약
  2. 왜 주목받는가
  3. 기술 스택 전체 지도
  4. 아키텍처 심화 분석
  5. 디렉토리 구조 해부
  6. 학습 포인트 (기술별)
  7. 하드웨어 / 시스템 요구사항
  8. 직접 해볼 수 있는 실습 과제
  9. 관련 기술 심화 학습 로드맵
  10. 핵심 키워드 사전
  11. 참고 링크

1프로젝트 한줄 요약

TV 정리 도구를 “개발자용 스크립트”에서 “폰 앱 + 안전장치”로 다시 만든 프로젝트

cleanyourtv-app은 폰이 직접 ADB 클라이언트가 되어 TV를 읽고(분석), 계획을 보여 주고(확인), 앱을 끄고 새 홈 화면을 설치하는(정리) Android 앱입니다. 앱은 pm disable-user --user 0 <패키지> 같은 명령을 TV 쪽 셸에 보내며, 끈 앱은 pm enable로 되살릴 수 있습니다. 코드는 Kotlin 약 55,000줄(.kt 380개) 규모입니다.

핵심 비유

이사 전에 짐 목록을 뽑고, 사진을 찍어 두고, 타이머를 맞추고 시작하는 정리 업체

업체는 먼저 집을 둘러보고(Analyse) 무엇을 치울지 목록으로 보여 줍니다. 승인하면 원래 상태를 기록한 뒤 치우고(Clean), 새 가구(새 홈 화면)가 마음에 안 들면 알아서 되돌리는 타이머를 걸어 둡니다.

이 앱에서 목록은 Plan, 사진은 복원 지점(restore point), 타이머는 TV 안에서 도는 워치독입니다. 사용자가 보는 화면은 세 단계뿐이지만 코드의 대부분은 “실패했을 때 어떻게 되돌리는가”에 쓰입니다.

1-1. 같은 이름의 기존 카드(Gathin23/cleanyourtv)와의 관계

카탈로그에는 이미 cleanyourtv-deep-dive.html(Gathin23/cleanyourtv, 컴퓨터용 스크립트·런처)가 있습니다. 두 레포는 다른 저장소이고, 소스에서 확인되는 관계는 아래와 같습니다.

확인 항목근거 파일내용
코드베이스settings.gradle.ktsGradle 모듈 28개로 구성된 독립 Kotlin 프로젝트(rootProject.name = "cleanyourtv-app")
의존 방식.gitmodulesthird_party/cleanyourtv 서브모듈이 Gathin23/cleanyourtv를 가리킴. 고정 커밋 9071ec8
가져다 쓰는 것core/trust/.../Pins.kt업스트림 v2.5.0 런처(패키지 com.gathin.tvhome)를 빌드해 APK로 번들. 빌드가 해시를 대조(verifyLauncherPin)
TV 목록profile-meta.jsonupstreamCommit이 같은 9071ec8, 프로필 131개. 업스트림 examples/*/debloat.txt 형식을 DebloatTxt.kt가 읽음
라이선스·고지README.md, LICENSEMIT. 이름과 패키지 ID(tv.cleanyour)는 업스트림 저자의 허락으로 쓴다고 README가 밝힘(허락 자체는 소스로 검증할 수 없음)

즉 포크가 아니라 “업스트림의 런처와 TV별 목록을 입력으로 쓰는 별도 앱”입니다. 다만 이 체크아웃에는 서브모듈 내용이 비어 있어(gitlink만 존재) 업스트림 런처의 소스는 읽지 못했습니다. 포크 여부는 GitHub 메타데이터의 영역이라 여기서는 판단하지 않습니다.

용어 먼저
ADB (Android Debug Bridge)
Android 기기를 네트워크나 USB로 원격 조작하는 프로토콜입니다. TV에서 “네트워크 디버깅”을 켜면 TV 쪽 adbd가 5555 포트에서 접속을 기다리고, 접속한 쪽은 셸 명령을 실행할 수 있습니다. 이 앱은 adb 실행 파일을 쓰지 않고 이 프로토콜을 Kotlin으로 직접 구현했습니다.
쓰는 사람
내 TV를 직접 정리하고 싶은 일반 사용자, 그리고 “위험한 원격 명령을 어떻게 안전하게 감쌀까”를 배우려는 개발자

앱을 쓰는 입장에서는 폰과 TV가 같은 Wi-Fi에 있고 TV 리모컨으로 디버깅을 켤 수 있으면 됩니다. 코드를 읽는 입장에서는 ADB 프로토콜, 명령 렌더러의 입력 검증, 되돌림 설계를 한 레포에서 볼 수 있습니다.

2왜 주목받는가

“컴퓨터 없이”라는 진입 장벽 제거와, 이상할 만큼 빠른 개발 속도

TrendShift 목록에서 24위로 큐에 올라온 레포입니다. 순위 말고 소스에서 확인되는 매력은 세 가지입니다.

2-1. 필요한 준비물이 폰 한 대로 줄었다

업스트림 도구는 adb가 설치된 컴퓨터나 코딩 에이전트가 필요합니다. 이 앱은 README 기준으로 Android 9 이상 폰, 같은 Wi-Fi, TV의 네트워크 디버깅만 요구합니다. 앱의 AndroidManifest.xml에서 선언한 권한은 인터넷·네트워크 상태·Wi-Fi 멀티캐스트(TV 찾기)·포그라운드 서비스·알림·생체 인증(USE_BIOMETRIC)뿐이고, 위치·저장소·카메라·연락처는 없습니다.

2-2. 사고가 나도 돌아갈 길을 코드로 강제한다

TV는 잘못 건드리면 리모컨조차 안 먹는 기기입니다. 그래서 이 레포의 설계 문서와 코드는 삭제 대신 비활성화, 변경 전 상태 기록, TV 내부 워치독, 보호 패키지 목록(Denylist.kt), 위험한 TV 계열에서 홈 화면 교체 자동 생략(HomeSwitchRisk.kt)을 갖춥니다. 이는 4장에서 코드로 확인합니다.

2-3. 열흘도 안 되는 사이에 0.1.0에서 0.3.5까지

CHANGELOG.md에는 0.1.0(2026-10-20)부터 0.3.5(2026-10-24)까지 8개 버전이 있고, git 로그의 첫 커밋은 2026-10-19, 총 419 커밋입니다. README는 “AI 코딩 에이전트와 사람의 검토로 개발했다”고 밝힙니다. 짧은 기간에 이만한 규모가 나온 것은 테스트(*Test.kt 121개)와 모듈 규칙 검사가 에이전트 작업의 안전망 역할을 했기 때문으로 읽히지만, 그 테스트들이 통과하는지는 이번 분석에서 실행해 확인하지 않았습니다.

주의
기기를 바꾸는 도구이고, 나이가 며칠짜리 프로젝트입니다

CHANGELOG에는 특정 TV(ACER R4G 등)에서 홈 화면 교체가 부팅 문제를 일으킬 수 있어 생략 규칙을 넣었다는 기록이 있습니다. 호환 목록에 없는 TV나 처음 쓰는 버전이라면 복원 방법(복원 스크립트, TV 공장 초기화 전 안내)을 먼저 확인하고 시작하세요.

3기술 스택 전체 지도

서버는 없고, 폰 앱과 TV 쪽 보조 앱, 그리고 빌드 파이프라인이 전부

이 프로젝트에는 백엔드 서버가 없습니다. “백엔드”에 해당하는 것은 폰 앱 안의 순수 Kotlin 모듈(core/*, adb/*)이고, 인프라에 해당하는 것은 GitHub Actions와 재현 가능 빌드용 Docker 이미지입니다.

3-1. 폰 앱 (프론트엔드 + 로직)

영역기술근거
언어·빌드Kotlin 2.4.21, Android Gradle Plugin 9.4.1, Gradle 컨벤션 플러그인(build-logic/)gradle/libs.versions.toml
UIJetpack Compose(BOM 2026.09.00), Navigation3 1.2.0, Material3, 자체 디자인 시스템 ui/design(Outfit 폰트)libs.versions.toml, ui/design/API.md
비동기kotlinx.coroutines 1.11.0, StateFlow 기반 상태 머신core/flows/*Machine.kt
직렬화kotlinx.serialization-json 1.11.0 (스냅샷·저널·인덱스)JournalJson.kt, IndexJson.kt
저수준 I/Ookio 3.18.2 (ADB 프레임 읽기·쓰기)ModuleRules.kt 허용 목록
암호Android Keystore RSA-2048(ADB 인증), BouncyCastle 1.86(Ed25519 minisign 검증), 폰 내부 SecretBoxKeystoreAdbSigner.kt, core/trust

3-2. TV 쪽에 설치되는 것

TV에는 두 개의 작은 APK가 번들됩니다. 앱 자산 android/platform/src/main/assets/bundled/에 cleanyourtv-launcher.apk(약 421KB, 업스트림 홈 화면)와 cleanyourtv-curtain.apk(약 66KB, 정리 중 TV 화면에 진행 상황을 보여 주는 “커튼”)가 들어 있습니다. 커튼은 의존성이 없는 별도 TV 앱(:tv:curtain, 허용 외부 의존 0개)입니다.

3-3. 테스트·품질·배포

구분기술
테스트JUnit4, Robolectric 4.17, kotest 6.2.6, turbine, Roborazzi 1.76(스크린샷 골든 — 저장소의 PNG 약 1,100개 대부분이 이 쪽), 가짜 adbd 서버(testing/fake-adbd), 에뮬레이터 E2E(e2e/ S00~S09 시나리오)
CIpr.yml(테스트·린트·골든·R8 릴리스 빌드·APK 8MB 크기 게이트), release.yml(두 번 빌드해 바이트 비교), fdroid.yml, nightly.yml, pages.yml
배포GitHub 릴리스 APK(full 플레이버), F-Droid 레시피(docs/fdroid/), Google Play용 AAB(play 플레이버)
재현 빌드docker/repro/Dockerfile, scripts/verify-release.sh(서명된 APK = 공개 소스 빌드 + 서명인지 apksigcopier로 대조)

같은 코드에서 두 가지 스토어 빌드가 나옵니다. full은 GitHub에서 업데이트를 확인하고 추가 모듈을 내려받지만, play는 업데이트 확인·외부 다운로드가 없고 네트워크 보안 설정에 GitHub 호스트가 없습니다(app/src/full, app/src/play의 Store.kt와 network_security_config.xml).

4아키텍처 심화 분석

계층 규칙으로 묶은 모듈 그래프, 그리고 “명령”을 타입으로 만든 설계

4-1. 모듈 계층 (ModuleRules.kt)

모듈은 계층 번호를 갖고, 자기보다 낮은 번호의 모듈에만 의존할 수 있습니다. 규칙은 데이터로 코드에 적혀 있고, checkModuleGraph Gradle 작업이 실제 빌드 그래프를 검사합니다. 외부 라이브러리도 모듈별 허용 목록이 있습니다.

layer 9 :app (Store 플레이버, AppGraph — 구현체 연결) layer 8 :feature:{connect,setup,manage,launcher,modules,backup} (화면) layer 7 :ui:design layer 6 :android:platform :android:work(OperationService) (Keystore, mDNS, 파일) layer 5 :core:flows (화면 상태 머신: Setup / Manage / Report ...) layer 4 :core:engine (Plan 실행, 저널, 가드 구간, 롤백) layer 3 :core:planner :core:registry :core:backup layer 2 :adb:session :core:probe :core:trust layer 1 :core:spec :core:ops :adb:wire :adb:pairing layer 0 :core:model (Op, Plan용 타입, 정규식, 오류) 규칙: feature는 :core:flows, :core:model, :ui:design만 볼 수 있다 테스트 전용 모듈(:testing:*, :e2e)은 test 구성에서만 도달 가능 :tv:curtain은 Gradle 의존이 아니라 서명된 자산으로만 번들

4-2. 한 번의 정리가 흐르는 길

phone UI (Compose) | SetupMachine (core/flows) v Prober --- 읽기 전용 셸 스크립트 ---> TV (getprop, pm list packages, dumpsys ...) | Snapshot (TV 상태 해시) v ProfileSource.resolve(facts) --> Exact(131개 프로필) | BrandSureSet(브랜드 공통분모) | None | v Planner.plan(request, snapshot, ...) --> Plan(steps, sections, summary, hash) | - 보호 패키지는 계획에서 제외 | 사용자가 요약/Customise에서 확인 (UI가 plan.hash를 승인) v Engine.start(plan) | Journal.append(Begin) -> OpRenderer.render(op) -> TvSession.exec -> verify read | 구간(GuardedSection): 워치독 무장 -> 홈 교체 -> 폰/TV에서 확인 | 확인됨(CONFIRMED) / 거부·시간초과(ROLLED_BACK, 원래 홈 복구) v RestorePointWriter --> 폰에 복원 지점 + 컴퓨터용 restore.sh / restore.ps1

4-3. 핵심 설계 패턴 1 — 명령을 문자열이 아닌 타입으로

core/model/.../Ops.kt의 sealed interface Op는 TV에 할 수 있는 일의 어휘 전부입니다. Disable, Enable, DefaultState, Install, SettingsPut, SetHome, ArmHomeWatchdog, PushAsset, Reboot, DebugOff, CurtainCue 등 20여 개가 있고, 임의의 셸 문자열을 담는 항목은 없습니다. ShellRenderer가 이 Op를 “상수 단어 + 검증된 토큰”으로만 조립해 정확히 한 줄로 만들고, 쓰기 Op마다 확인용 읽기(verify)를 붙입니다.

// core/ops/ShellRenderer.kt (발췌)
is Op.Disable -> Rendered(op, lines(line(PM, "disable-user", USER, "0", writePkg(op.pkg))), null,
    Privilege.PROFILE_STEP, Op.Read(ProbeKind.DISABLED), T_PM, CAP_DEFAULT)
is Op.Enable  -> Rendered(op, lines(line(PM, "enable", USER, "0", pkg(op.pkg))), null,
    Privilege.PROFILE_STEP, Op.Read(ProbeKind.DISABLED), T_PM, CAP_DEFAULT)

패키지 이름·클래스·실행 토큰은 정규식(PACKAGE_RE, CLASS_RE, RUN_TOKEN_RE)으로 만든 값 클래스이고, 렌더러는 한 번 더 검증합니다. 따라서 악의적이거나 깨진 패키지 이름이 셸 메타문자를 끌고 들어오기 어렵게 되어 있습니다.

4-4. 핵심 설계 패턴 2 — 이중 방어(렌더러 + 세션 가드)

adb/session/.../Support.kt의 PmGuard는 렌더러와 별개로, 세션이 TV에 보내기 직전에 줄을 다시 검사합니다. pm 쓰기 명령이 있는 줄에 ; | & 같은 연결 문자가 섞여 있으면 거부하고, 예외는 워치독 템플릿과 정규식 WATCHDOG_RE가 줄 전체와 일치할 때뿐입니다. 한 군데 버그가 있어도 다른 층이 막도록 한 구조입니다.

4-5. 핵심 설계 패턴 3 — TV 안에서 도는 데드맨 타이머

홈 화면을 바꾸는 순간 폰과의 연결이 끊기거나 새 홈이 죽으면 TV를 쓸 수 없습니다. Watchdog.kt는 TV 셸에서 다음 본문을 백그라운드로 실행합니다.

sleep SECONDS & wait; [ -f /data/local/tmp/cleanyourtv/home-ok-TOKEN ] && exit 0;
pm enable --user 0 STOCKPKG; cmd package set-home-activity --user 0 STOCKCOMP;
am start -W -a android.intent.action.MAIN -c android.intent.category.HOME

폰이 새 홈 화면을 확인하면 home-ok-TOKEN 파일을 만들어 타이머를 무력화하고, 확인이 없으면 TV가 스스로 기본 런처를 되살립니다. 기본 대기는 DefaultPlanner.WATCHDOG_SECONDS = 150초이고(허용 범위 30~300초), 폰 쪽 확인 창은 confirmWindowMs = 90_000입니다. 소스 주석에는 sleep & wait로 쓰는 이유(TV의 sh가 포그라운드 sleep 중에는 SIGTERM을 미뤄 워치독이 안 죽던 Mi TV 실기기 문제)가 적혀 있습니다.

4-6. 핵심 설계 패턴 4 — 저널과 가드 구간

Engine.kt의 JournalRecord(PlanStart, Begin, Done, Failed, Section, Rollback, Paused, End)는 CRC32와 함께 파일에 먼저 기록되고 force된 뒤 다음 단계로 갑니다. 구간 상태는 ARMED → CONFIRMED | ROLLED_BACK이고, 연결이 끊겨 InDoubt가 되거나 구간이 열린 채 남으면, DefaultEngine의 복구 코드가 저널에서 가장 최근 ARMED 기록을 찾아 구간을 이어 가거나 롤백합니다(이 복구 경로를 실제로 돌려 보지는 않았습니다). 상태는 Idle, Running, AwaitingConfirm, Paused, RollingBack, InDoubt, Finished 일곱 가지입니다.

4-7. 핵심 설계 패턴 5 — 위험한 TV에서는 홈 교체를 하지 않는다

HomeSwitchRisk.kt는 “정리만 하고 홈 화면은 건드리지 않는” 조건을 데이터로 가집니다.

위험 분류의미(소스 주석 요지)
WIZARD_WIFI / WIZARD_WIFI_UNKNOWN초기 설정 마법사가 저장한 Wi-Fi를 지우게 되거나 누가 저장했는지 모를 때. 마법사가 켜져 있으면 홈 교체 시 시스템이 크래시 루프에 빠질 수 있음
WIZARD_STAYS_ON어떤 프로필도 모르는 TV. 마법사는 켜진 채라 같은 위험
CASTINGGoogle 자체 기기(sabrina, boreal, kirkwood). 홈 교체에 마법사를 꺼야 하는데 그러면 캐스트가 재시작마다 끊김
BOOT_LOOPMOKA 계열(R3_GTV, R10G, R4_GTV 등). 재시작마다 마법사가 다시 켜지고, 기본 홈이 꺼져 있으면 부팅 실패 사례

나머지 안전 구조는 Denylist.kt입니다. 프레임워크(android), 시스템 UI, 설정, 패키지 설치 관리자, 권한 컨트롤러, Play 서비스·Play 스토어, 블루투스(리모컨 페어링), 네트워크 스택·Wi-Fi 계열 접두사가 코드 상수로 박혀 있어 어떤 프로필도 이를 덮어쓸 수 없습니다. 상시 실행(persistent) 패키지, TV 입력(HDMI·튜너) 제공 패키지, 현재 키보드·화면보호기도 실시간 사실로 보호합니다.

4-8. 신뢰 체인 — 앱이 내려받는 것을 어떻게 믿는가

선택 모듈(예: 번들 인덱스의 “Retro” NES 게임 앱)은 서명된 목록으로만 제공됩니다. MinisignIndexVerifier의 검증 순서는 고정입니다.

  1. keys.json이 앱에 컴파일된 루트 공개키(TrustRoots.INDEX_ROOT_PUBLIC_KEY)의 minisign 서명을 갖는지, 번들된 것보다 오래된 파일이 아닌지 확인
  2. index.json 서명 키가 폐기(revoked)·미등록·서명 불일치가 아닌지 확인
  3. JSON 파싱 한도 확인, 시퀀스 번호가 마지막으로 받은 것보다 작으면 ROLLBACK 거부, 만료일이 지났으면 EXPIRED 거부

네트워크 접속 허용 호스트는 DownloadHosts.kt에 github.com, api.github.com, objects.githubusercontent.com, release-assets.githubusercontent.com 네 개뿐이고, https·기본 포트·사용자 정보 없음·정확한 호스트 일치를 리다이렉트 매 단계마다 검사합니다.

5디렉토리 구조 해부

추적 파일 1,926개. 코드는 모듈별 폴더, 나머지는 증거·골든·문서
cleanyourtv-app/ ├── settings.gradle.kts 모듈 28개 선언 + "JDK 자동 다운로드 없음" 주석 ├── build-logic/convention/ ModuleRules.kt (계층 규칙), 컨벤션 플러그인, ApkSizeCheckTask ├── adb/ │ ├── wire/ ADB 프로토콜 (dadb 2.0.0 파생, Apache-2.0, NOTICE 동봉) │ ├── session/ AdbTvSession, PmGuard, 세션 매니저 │ └── pairing/ build.gradle.kts만 있음 (이 SHA에서는 소스 없음) ├── core/ │ ├── model/ Op, Prior, 정규식, 오류 타입 (최하위 계층) │ ├── ops/ ShellRenderer, Watchdog, Denylist, UndoOrder │ ├── probe/ 읽기 전용 셸 스크립트와 파서 (AdbProber) │ ├── spec/ 프로필·debloat.txt 파서·facts.tsv(패키지 957줄)·런처 설정 │ ├── planner/ DefaultPlanner, HomeSwitchRisk, SwitchOffRule │ ├── engine/ DefaultEngine, FileJournal, 가드 구간 │ ├── backup/ 복원 지점, restore.sh/restore.ps1 생성 │ ├── trust/ minisign, 인덱스 검증, 런처·커튼 핀, 허용 호스트 │ ├── registry/ 서명된 모듈 인덱스, 이어받기 다운로드 │ └── flows/ Setup/Manage/Report 상태 머신 ├── android/platform/ Keystore 서명기, mDNS 탐색, 파일 저장, 번들 APK 자산 ├── android/work/ OperationService (포그라운드 서비스) ├── ui/design/ + feature/*/ Compose 디자인 시스템과 화면 6종 ├── app/ MainActivity, AppGraph(의존 연결), full/play 플레이버 ├── tv/curtain/ TV에 띄우는 진행 화면 앱 ├── testing/fake-adbd/ 가짜 TV (Persona, 셸 파서) ├── e2e/ 에뮬레이터 시나리오 S00~S09 ├── fixtures/ 실제 TV 캡처(mitv, emulator), 모듈 카드 샘플 ├── scripts/ 릴리스·재현 빌드·검증·리댁션 검사 (Python/Shell) ├── docs/ TRIAGE, fdroid, history(설계 결정·리뷰 기록) └── third_party/cleanyourtv 업스트림 서브모듈 (gitlink만 존재)

5-1. 읽는 순서 추천

순서파일이유
1README.md, CHANGELOG.md사용자가 보는 약속과 최근 변경(어떤 TV에서 무엇이 문제였나)
2core/model/.../Ops.kt할 수 있는 일의 전체 어휘
3core/ops/.../ShellRenderer.kt, Watchdog.kt, Denylist.ktOp가 어떤 한 줄이 되는지, 무엇이 금지인지
4core/planner/.../DefaultPlanner.kt, HomeSwitchRisk.kt계획을 만드는 규칙과 홈 교체 생략 조건
5core/engine/.../DefaultEngine.kt저널, 가드 구간, 롤백(약 1,000줄)
6adb/wire/.../Protocol.kt, Connection.ktADB 프레임·인증 핸드셰이크

코드 주석에는 “DECISIONS-W5.md, K2”처럼 docs/history/의 문서 이름이 자주 나옵니다. README에 따르면 PRODUCT-SPEC·ARCHITECTURE·SECURITY 같은 원 설계 문서는 코드보다 먼저 쓰였고 이 저장소에는 없으니, 의도를 확인하려면 docs/history/DECISIONS-W*.md와 reviews/를 보는 것이 가장 빠릅니다.

6학습 포인트 (기술별)

“위험한 일을 시키는 앱”을 만들 때 배울 수 있는 것

6-1. ADB 프로토콜을 직접 구현하기

adb/wire/Protocol.kt는 CNXN, AUTH, OPEN, WRTE, OKAY, CLSE, STLS 명령 상수를 정의합니다. dadb에서 가져온 코드지만 파일 머리말에 변경점이 적혀 있습니다: 명령마다 읽을 길이의 상한을 두고(제어 메시지 256KiB, 데이터 프레임 1MiB), 체크섬과 매직 워드를 검증하고, 알 수 없는 명령은 거부합니다. HostileFrameFuzzTest는 임의로 깨뜨린 프레임을 읽는 쪽이 견디는지 검사합니다.

배울 것: 길이 필드를 믿지 않는 파서, 바이너리 프로토콜의 퍼징 테스트, 라이선스 파생 고지(Apache-2.0 NOTICE).

6-2. 키를 폰 밖으로 내보내지 않는 ADB 인증

KeystoreAdbSigner는 RSA-2048 키를 StrongBox, 안 되면 TEE, 그것도 안 되면 소프트웨어 키(SecretBox로 봉인) 순서로 만들고, 각 경로는 자체 점검을 통과한 뒤에만 사용합니다. 서명은 20바이트 토큰에 대한 DigestInfo(SHA-1) PKCS#1 v1.5 한 가지로 제한해 “아무 데이터나 서명해 주는 오라클”이 되지 않게 했습니다.

6-3. 입력 검증 3중 구조

값 클래스(정규식) → 렌더러 재검증 → 세션 PmGuard. 같은 규칙을 서로 다른 층에서 독립적으로 확인하는 방식은 셸을 다루는 어떤 프로젝트에도 옮겨 쓸 수 있습니다.

6-4. 골든 테스트로 “보내는 명령”을 고정하기

core/ops/src/test/resources/golden/에는 Op별 기대 출력이 01-disable.txt부터 59-cue-finale.txt까지 59개와 planhash.txt로 저장되어 있습니다. 렌더러가 바뀌면 TV로 가는 한 줄이 바뀌므로 테스트가 바로 깨집니다.

6-5. 테스트 대역을 “페르소나”로 만든 가짜 TV

testing/fake-adbd는 실제 ADB 프로토콜로 응답하는 가짜 TV 서버이고 Persona로 기기별 프로퍼티·패키지 목록을 바꿉니다. fixtures/conformance/mitv-axso0-2026-10-18 같은 실제 TV 캡처가 가짜 TV의 기준이 됩니다. 캡처에서 시리얼 등을 지우는지는 scripts/check-redaction.py가 CI에서 검사합니다.

6-6. 릴리스 투명성: 재현 빌드와 서명 검증

CI는 서명 키를 갖지 않고, 컨테이너에서 같은 커밋을 두 번 빌드해 바이트 단위로 비교한 서명 전 APK를 올립니다. 관리자가 로컬에서 서명한 APK는 verify-release.sh가 “서명 전 빌드 + 서명뿐”인지 확인합니다. README는 릴리스 인증서 SHA-256(eaf7af38…cd65)을 공개합니다.

이 레포의 가장 값진 학습 포인트는 기능이 아니라, 모든 위험한 동작에 “입력 제한, 사전 기록, 확인 읽기, 복구 경로”를 한 세트로 붙이는 습관입니다.

7하드웨어 / 시스템 요구사항

사용자 쪽(폰·TV)과 개발자 쪽(빌드)
대상요구근거
폰Android 9 이상(MIN_SDK = 28), 화면 잠금 설정, TV와 같은 Wi-FiREADME, Support.kt
TV네트워크 ADB(5555) 켜기. 리모컨으로 켜는 방법을 앱이 안내README
페어링 코드만 있는 TV컴퓨터에서 한 번 페어링해야 함(앱이 명령 3개를 보여 줌)README “Network debugging”
정리만 하고 홈 교체는 안 하는 TVFire TV, TCL 중국 빌드, Android 7, 알 수 없는 벤더 홈, Chromecast with Google TV·Google TV Streamer 등README “Supported TVs”, HomeSwitchRisk.kt
빌드JDK 21, Android SDK(compile 37, build tools 36), Linux 또는 macOS, 서브모듈 초기화CONTRIBUTING.md
APK 크기릴리스 APK/AAB는 8MB 크기 게이트CONTRIBUTING.md, ApkSizeCheckTask.kt
GPU·서버·API 키필요 없음. 계정·분석·광고·크래시 리포트 서비스도 없음(README 개인정보 절)README, PRIVACY.md

지원 범위는 README가 “131개 모델, 46개 브랜드”로 밝히고, 소스에서는 profile-meta.json의 프로필 수 131이 확인됩니다. 모델이 목록에 없어도 브랜드 공통분모(BrandSureSet)만 끄는 보수적 정리를 합니다.

8직접 해볼 수 있는 실습 과제

진짜 TV를 건드리지 않고 시작하는 순서로 정렬했습니다
과제 1 · 입문 읽기만

워치독 한 줄을 손으로 따라가 보기

Watchdog.kt의 BODY에서 SECONDS, STOCKPKG, STOCKCOMP, TOKEN 네 자리표시자를 직접 채워 보고, core/ops/src/test/resources/golden/16-arm-home-watchdog-a.txt와 같은지 비교하세요. 이어서 PmGuard.WATCHDOG_RE가 이 줄만 통과시키는 이유를 정규식 조각별로 설명해 봅니다.

과제 2 · 초급 단위 테스트

순수 JVM 모듈의 테스트만 돌려 보기

JDK 21과 Android SDK를 준비한 뒤(local.properties에 sdk.dir) 서브모듈과 함께 클론합니다. 저는 이 명령들을 실행하지 않았으므로, 첫 시도는 가장 작은 모듈부터 하는 것이 안전합니다.

git clone --recurse-submodules https://github.com/udhaykumarbala/cleanyourtv-app.git
cd cleanyourtv-app
echo "sdk.dir=$ANDROID_HOME" > local.properties
./gradlew :core:ops:test --console=plain

통과하면 GoldenTest의 기대 파일 하나를 일부러 고쳐 어떤 실패 메시지가 나오는지 확인한 뒤 되돌려 보세요.

과제 3 · 중급 규칙 추가

Denylist에 테스트가 먼저 오게 새 보호 패키지 추가해 보기

가상의 시스템 패키지 하나를 정해 WatchdogAndProtectedTest.kt 스타일로 “보호 목록에 있으면 Op.Disable 렌더링이 거부된다”는 테스트를 먼저 쓰고, Denylist.exact에 한 줄을 넣어 통과시킵니다. 실제 PR로 보낼 목적이 아니라면 로컬 브랜치에서만 하세요. 보호 목록은 안전장치라 CONTRIBUTING.md의 검토 규칙을 먼저 읽어야 합니다.

과제 4 · 중급 프로필 읽기

debloat.txt 후보 파일을 프로필로 파싱해 보기

core/spec/candidates/sony-bravia-vh1/debloat.txt는 “DRAFT, NOT TESTED” 머리말과 “tracking” 같은 그룹 주석, 패키지 줄, # 메모로 이루어져 있습니다. DebloatTxt.kt의 정규식(PACKAGE_LINE)과 그룹 규칙을 읽고, 직접 만든 3줄짜리 파일로 파서 테스트를 써 보세요. 후보 목록은 릴리스 빌드에 포함되지 않고 테스트 빌드의 특정 옵션으로만 제공된다고 CHANGELOG 0.3.5가 밝힙니다.

과제 5 · 심화 에뮬레이터

E2E 시나리오 S03(비활성화와 되돌리기)을 안드로이드 TV 에뮬레이터로 따라가기

e2e/src/emulatorTest/.../S03DebloatAndUndo.kt와 docker/emulator/Dockerfile을 읽고, Android TV 에뮬레이터를 하나 띄워 앱의 Setup 흐름을 처음부터 끝까지 본 뒤 Manage 화면에서 “하나만 되돌리기”와 “전부 되돌리기”를 비교합니다. 실제 TV는 이 과제의 대상이 아닙니다.

9관련 기술 심화 학습 로드맵

6주 계획 (주당 5~8시간 기준)
주차주제할 일
1주ADB와 Android 패키지 관리자adb shell pm list packages -d 같은 읽기 명령, pm disable-user와 uninstall의 차이를 공식 문서로 정리. 에뮬레이터에서만 실습
2주ADB 프로토콜Protocol.kt, Connection.kt, AdbAuth.kt를 읽고 CNXN→AUTH→OPEN 흐름을 그림으로 그리기
3주Kotlin 코루틴·상태 머신core/flows의 SetupMachine을 읽고 StateFlow로 화면 상태를 설계하는 방법 정리
4주안전한 명령 실행 설계렌더러·가드·골든 테스트·저널을 묶어 “되돌릴 수 있는 변경 엔진” 최소판(Op 3개)을 직접 구현
5주서명과 공급망minisign 검증 흐름, 재현 빌드, apksigner 확인. 이 레포의 verify-release.sh를 읽고 각 단계가 막는 공격을 적기
6주Compose UI 테스트Robolectric+Roborazzi 골든 테스트가 어떻게 화면 변화를 잡는지 feature/setup/src/test에서 확인

10핵심 키워드 사전

코드에서 자주 만나는 말
키워드뜻
OpTV에 할 수 있는 일의 한 단위(sealed interface). 셸 문자열이 아니라 타입
Plan / StepOp들을 순서대로 묶은 실행 계획과 그 한 칸. hash로 승인한 계획과 실행할 계획이 같음을 확인
Prior변경 전 상태(켜짐/꺼짐, 설정값, 홈 등). 되돌릴 때 쓰는 근거
Snapshotprobe가 읽어 온 TV 상태. 계획은 특정 snapshot의 해시를 기반으로 만들어짐
ProbeTV 상태를 읽기 전용 명령으로 수집하는 단계
Profile특정 TV 모델에 맞춘 “끌 앱/끄면 안 되는 앱/특이사항” 목록. 업스트림 debloat.txt에서 변환
BrandSureSet모델 프로필이 없을 때 같은 브랜드 목록이 모두 동의하는 항목만 쓰는 보수적 목록
Denylist어떤 프로필도 덮어쓸 수 없는 보호 패키지 목록
GuardedSection홈 화면 교체처럼 실패 시 롤백이 필요한 구간. 워치독 무장→교체→확인으로 구성
WatchdogTV 셸에서 도는 데드맨 타이머. 확인 파일이 없으면 기본 런처를 복구
Curtain정리 중 TV 화면에 진행 상황을 보여 주는 별도 TV 앱
Restore point변경 전 상태 기록. 폰 안에 보관하고 원하면 restore.sh/restore.ps1로 내보냄
Module / Module index선택 설치 앱(예: Retro)과 그 목록. 목록은 minisign으로 서명됨
MinisignEd25519 기반 서명 도구·형식. 인덱스와 키 파일 검증에 사용
Flavour (full/play)같은 코드의 스토어별 빌드 변형. play는 GitHub 접속·업데이트 확인이 없음

11참고 링크

공식 자료와 이 글이 근거로 삼은 파일