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

aerial-tv 딥다이브
— 리모컨 한 번 뒤에 숨은 Android 시스템 설계

TV 앱

빠른 판단

무엇
Android TV의 앱 타일·배경화면·화면보호기를 구성하는 Kotlin·Compose 홈 런처
이럴 때
보유 TV의 홈 화면을 단순하게 바꿀 때
앱과 배경화면만 남긴 조용한 TV 홈 화면은 어떻게 만들까요? aerial-tv는 Xiaomi·TCL 등 중국 시장용 TV를 겨냥한 Kotlin·Jetpack Compose 런처입니다. 예쁜 화면 아래에는 리모컨 입력 처리, 제조사별 HOME 복귀, 약한 GPU를 위한 사전 렌더링, 휴대폰 설정 서버가 있습니다. 커밋 12ca103을 기준으로 그 설계를 살펴봅니다. 저장소 생성 2026-10-20 · 스타 3,403 · 포크 248 · MIT · 최신 릴리스 v1.37. 저장소 정보는 2026-10-23 GitHub 기준입니다.
목차
  1. 프로젝트 한줄 요약
  2. 왜 주목받는가
  3. 기술 스택 전체 지도
  4. 아키텍처 심화 분석
  5. 디렉토리 구조 해부
  6. 학습 포인트 (기술별)
  7. 하드웨어 / 시스템 요구사항
  8. 직접 해볼 수 있는 실습 과제
  9. 관련 기술 심화 학습 로드맵
  10. 핵심 키워드 사전
  11. 참고 링크

1프로젝트 한줄 요약

TV 홈 화면 하나를 통해 UI와 운영체제의 경계를 배운다

aerial-tv는 Android TV의 홈 화면을 tvOS에서 영감을 받은 앱 타일·배경화면·화면보호기로 바꾸는 오픈소스 앱입니다. 설치된 동영상 서비스를 대신 재생하는 서비스가 아니라, 그 앱을 찾아 실행하는 출발점입니다. Android 패키지 이름은 app.orchard.tv이고, 사용자에게 보이는 이름은 Aerial입니다.

이 저장소의 가장 좋은 공부거리는 화면을 그리는 코드와 TV 펌웨어의 제약을 다루는 코드가 한 제품 안에서 만나는 과정입니다. Kotlin 파일 51개, 주석·빈 줄을 포함해 11,644줄이므로 작은 예제보다는 크지만, 처음부터 여러 서버를 띄워야 하는 서비스보다 전체 흐름을 따라가기 쉽습니다.

용어
런처 (launcher)
다른 앱을 실행하는 홈 화면 앱입니다. Android에서는 Intent라는 실행 요청으로 다른 앱의 화면을 엽니다. 다만 TV 제조사가 자기 홈 화면을 강제로 선택하는 펌웨어라면, 런처를 설치하는 것과 기본 홈 화면으로 쓰는 것은 서로 다른 문제입니다.
한 문장 비유

가게를 바꾸는 대신, 복잡한 쇼핑몰의 안내 데스크를 바꾼다

영상 앱은 각각의 가게이고 Aerial은 입구의 안내 데스크입니다. 가게 안의 구독·로그인·영상 목록은 그대로 두고, 입구에서 어느 가게로 들어갈지 고르는 경험을 바꿉니다.

일부 TV에서는 건물 관리자인 펌웨어가 기존 데스크를 자꾸 다시 세웁니다. 그래서 이 프로젝트에는 안내 화면뿐 아니라, 사용자가 HOME을 눌렀을 때 돌아올 위치를 관리하는 장치도 필요합니다.

1-1. 사용자 행동을 코드에 대응시키기

사용자가 하는 일주요 구현배울 개념
앱 목록 보기app/src/main/java/app/orchard/tv/apps/Apps.kt의 AppCatalog.load()운영체제에 등록된 실행 진입점 조회
앱 이동·숨김·폴더 정리app/src/main/java/app/orchard/tv/apps/LayoutStore.kt의 arrange(), move()식별자 기반 데이터 모델·영속화
리모컨 확인 버튼 누르기app/src/main/java/app/orchard/tv/ui/components/Pressable.kt의 Pressable()포커스·누름·뗌의 상태 전이
HOME으로 돌아오기app/src/main/java/app/orchard/tv/guard/HomeGuardService.kt시스템 이벤트와 제조사 정책
휴대폰으로 사진 보내기app/src/main/java/app/orchard/tv/wallpaper/PhoneServer.ktTV 내부 HTTP 서버·파일 업로드
항공 영상 화면보호기 보기app/src/main/java/app/orchard/tv/wallpaper/AerialStream.kt스트리밍·버퍼·부분 캐시

파일 경로는 저장소 루트 기준입니다. 주요 Kotlin 구현은 app/src/main/java/app/orchard/tv/ 아래에 있으며, app/src/main/java/app/orchard/tv/apps/Apps.kt는 앱 검색과 타일 구성을 담당합니다.

읽는 순서
화면 → 상태 → 데이터 → 시스템 순서로 범위를 넓히세요

처음에는 app/src/main/java/app/orchard/tv/MainActivity.kt, app/src/main/java/app/orchard/tv/HomeViewModel.kt, app/src/main/java/app/orchard/tv/apps/LayoutStore.kt만 읽어도 됩니다. 서비스 복구와 제조사 예외 처리는 UI의 기본 흐름을 이해한 다음 읽으면 훨씬 덜 복잡합니다.

2왜 주목받는가

분명한 불편을 해결하고, 설치 이후의 문제까지 다룬다

2-1. 선정 사실과 인기의 원인에 대한 해석을 구분하기

선정 근거는 TrendShift Daily #12이며 분석일은 2026-10-23입니다. 2026-10-23 GitHub 기준, 2026-10-20에 생성된 저장소의 스타는 3,403개, 포크는 248개입니다. 생성일은 공개 저장소의 생성 시점이며, 전체 개발 기간이나 모든 코드가 사흘 만에 작성됐다는 증거는 아닙니다.

관심을 끄는 이유로는 광고·추천 피드가 없는 단순한 홈 화면, 익숙한 tvOS 계열 시각 언어, 중국 시장용 TV의 설치·대기 복귀 문제를 다루는 실용성을 들 수 있습니다. 이는 README의 제품 설명과 소스 구조를 바탕으로 한 분석자의 해석입니다. 방문자 유입 경로나 스타를 누른 사람의 동기를 측정한 결과는 아닙니다.

제품의 매력은 간결한 화면에 있고, 학습 가치는 그 간결함을 유지하기 위해 처리한 시스템 예외에 있습니다.

2-2. 어떤 대안과 비교해야 할까?

선택지장점·목적Aerial과 비교할 지점
TV 기본 홈 화면제조사 콘텐츠·설정·입력 소스와 밀접하게 연결README가 지적하는 추천·회원권 중심 화면 대신 앱 타일 중심 화면을 제공
일반 HOME 앱표준 Android의 기본 런처 선택 절차 활용Aerial은 표준 등록 외에 HOME 등록이 막힌 펌웨어용 guard 빌드도 제공
화면보호기 전용 앱유휴 시간에 사진·영상 표시Aerial은 앱 실행·정리와 화면보호기를 한 홈 화면 경험으로 연결
Apple TV 기기별도 기기에서 tvOS 자체를 사용Aerial은 기존 Android TV에서 UI를 바꾸는 앱이며 tvOS나 Apple 서비스 호환 계층은 아님

위 표는 제품 범주와 Aerial의 구현 범위를 비교합니다. 다른 런처의 성능·가격·전체 기능을 조사한 순위표는 아닙니다. Aerial의 구체적인 차별점은 app/build.gradle.kts의 두 빌드 변형과 app/src/main/java/app/orchard/tv/guard/의 복귀 로직에서 확인할 수 있습니다.

2-3. “광고 없음”의 정확한 범위

README는 광고와 추천 피드가 없는 홈 화면을 내세웁니다. 소스에서도 홈 UI는 설치 앱·입력 소스·시계·배경화면을 중심으로 구성되고, 의존성 목록에는 광고 SDK가 선언되어 있지 않습니다. 그러나 TV 펌웨어가 띄우는 부팅 광고까지 제거한다는 뜻은 아닙니다. README도 부팅 광고가 남는다고 명시합니다.

또한 MainActivity.idleBeforeScreensaver()는 제조사 화면보호기보다 먼저 앱의 화면보호기를 띄우도록 시간을 조정합니다. 이는 홈 화면에 있을 때의 동작이며, 모든 앱 위에서 제조사 광고 화면보호기를 전역 차단하는 구조는 아닙니다.

2-4. “설치 성공” 이후까지 이어지는 설계

비유

새 현관문을 다는 일은 문짝을 만드는 것으로 끝나지 않습니다. 기존 열쇠가 맞는지, 정전 후에도 열리는지, 고장 났을 때 원래 문으로 돌아갈 수 있는지도 봐야 합니다. 이 저장소의 설치·복구·진단 코드가 그런 역할을 합니다.

3기술 스택 전체 지도

웹 서비스의 세 칸에 억지로 맞추지 않고, 실제 실행 위치를 본다

3-1. 프론트엔드: 네이티브 TV UI와 작은 웹 페이지

주 화면은 브라우저가 아니라 Jetpack Compose로 그립니다. MainActivity.onCreate()에서 setContent를 호출해 HomeScreen()을 올리고, HomeViewModel이 앱 목록과 오버레이 상태를 보유합니다. 휴대폰 설정 화면만 app/src/main/assets/web/index.html이라는 HTML 자산입니다.

기술·의존성선언 버전소스에서 맡는 일
Kotlin·Compose 플러그인2.2.10루트 build.gradle.kts에서 두 플러그인 버전 지정
Compose BOM2025.11.01ui·foundation·animation 버전 정렬
Activity Compose1.10.1Activity 안에 Compose 화면 연결
Lifecycle ViewModel Compose2.9.2화면 상태 보유자와 UI 연결
AndroidX Core KTX1.16.0Android API를 Kotlin에서 편하게 사용
Media3 3개 모듈1.8.1exoplayer·datasource·database로 영상 재생·캐시

표의 버전은 app/build.gradle.kts의 선언값입니다. 이 파일의 주석은 해당 BOM이 Compose 1.9.5를 사용하며 Android 5.0 지원을 유지하기 위한 선택이라고 설명합니다. BOM 선언값과 소스 주석의 개별 버전 설명은 구분해 읽어야 합니다.

용어
BOM과 빌드 변형 (product flavor)
BOM은 관련 라이브러리들이 함께 쓸 버전을 맞춰 주는 기준표입니다. product flavor는 공통 코드를 공유하면서 설정·리소스·Manifest 일부를 달리해 앱을 만드는 방법입니다. 이 저장소에서는 HOME 선언 유무를 나누는 데 사용합니다.

3-2. 백엔드: TV 프로세스 안의 설정용 서버

독립적인 클라우드 API 서버나 회원 DB는 없습니다. 대신 PhoneServer가 TV 안에서 NanoHTTPD 2.3.1을 이용해 포트 8790의 HTTP 요청을 처리합니다. ZXing Core 3.5.3은 이 서버로 가는 QR 코드를 만듭니다. 서버를 직접 호스팅할 별도 PC는 필요하지 않습니다.

관심사실제 구현공부할 내용
라우팅PhoneServer.serve()의 경로 분기페이지·JSON·업로드를 응답으로 구분하기
상태 저장LayoutStore·AerialSettings의 SharedPreferences순서·숨김·설정을 로컬 키와 값으로 저장
이미지 저장Wallpapers·CustomIcons와 앱 전용 파일사용자 사진과 재생성 가능한 캐시 구분
외부 데이터Open-Meteo·IP 위치 조회·Bing·Apple 영상 서버타임아웃·실패 복구·서비스별 네트워크 의존성

저장소에는 package.json, Cargo.toml, pyproject.toml, go.mod, Dockerfile이 없습니다. 저장소의 중심은 단일 Android :app 모듈입니다. site/의 배포용 웹 페이지와 TV가 제공하는 휴대폰 페이지는 위치와 수명이 다른 화면입니다.

3-3. 인프라·빌드: 오래된 TV 지원이 버전을 결정한다

항목설정값확인 파일
Android Gradle Plugin8.12.0루트 build.gradle.kts
Gradle Wrapper8.14.3gradle/wrapper/gradle-wrapper.properties
JDK·JVM 타깃17app/build.gradle.kts와 README 개발 환경
SDKcompileSdk = 36, targetSdk = 34, minSdk = 21app/build.gradle.kts
앱 버전versionName = "1.37", versionCode = 39app/build.gradle.kts
구형 API 보완desugar_jdk_libs:2.1.5java.time 등 일부 API의 구형 OS 지원
릴리스 최적화isMinifyEnabled = true, isShrinkResources = true코드·리소스 축소 설정
빌드 JVM 힙-Xmx4ggradle.properties, TV RAM 요구량이 아님
// app/build.gradle.kts의 핵심 설정 발췌
android {
    compileSdk = 36
    defaultConfig {
        minSdk = 21
        targetSdk = 34
        versionCode = 39
        versionName = "1.37"
    }
    flavorDimensions += "home"
    productFlavors {
        create("standard") { dimension = "home" }
        create("guard") {
            dimension = "home"
            versionNameSuffix = "-guard"
        }
    }
}

의존성 버전은 최신이라는 이유만으로 올리는 것이 아니라, 제품이 지원해야 할 가장 오래된 운영체제와 함께 결정해야 합니다. 이 저장소의 빌드 주석과 docs/HANDOFF.md는 Android 5.0 지원 때문에 Compose·Media3 업그레이드에 주의하라고 설명합니다.

웹 배포는 wrangler.jsonc의 assets.directory = "./site"를 사용하는 Cloudflare Worker 정적 자산 방식입니다. tools/deploy-site.sh는 릴리스 APK와 도구를 복사한 뒤 npx wrangler@4 deploy를 호출합니다. 여기서 @4는 정확한 패치 버전 고정이 아닙니다. 현재 .github/에는 이슈 템플릿이 있고 .github/workflows/의 CI 설정은 없습니다.

릴리스 서명은 local/signing.properties가 있으면 그 키를 사용하고, 없으면 로컬 debug 키로 대체합니다. 키 파일은 저장소에 없습니다. 이 설정만으로 공개 APK의 실제 서명 키를 확인할 수는 없으며, 같은 패키지에 덮어쓰려면 서명 연속성이 필요합니다.

4아키텍처 심화 분석

네 가지 설계 패턴으로 읽는 화면·입력·캐시·기기 적응

4-1. 전체 시스템 구조

Aerial의 주요 기능은 TV의 앱 프로세스 안에서 실행됩니다. Android 시스템이 Activity·Service·Receiver로 이벤트를 전달하고, UI는 로컬 앱 목록과 저장된 배치를 결합합니다. 휴대폰 설정은 같은 프로세스의 서버로 들어오는 별도 입력 통로입니다.

[리모컨·터치] [휴대폰 브라우저] | 확인·방향 | QR로 접속 v v +-------------------- TV: Aerial 프로세스 --------------------+ | MainActivity PhoneServer :8790 | | | 생명주기·키 이벤트 | 토큰 경로·JSON·사진 업로드 | | v v | | HomeScreen <-- 상태 -- HomeViewModel Wallpapers / 설정 | | | | | | Pressable AppCatalog.load() --> TileDiskCache | | | | 앱 목록 + 고정 입력 | | +-- 실행 요청 +-- LayoutStore.arrange() | | | 폴더·순서·숨김 적용 | | | | HomeGuardService <-- 창 변경·HOME·켜짐 이벤트 | | | 복귀 AerialStream / ExoPlayer | | v | | | MainActivity 부분 캐시 + 영상 재생 | +------------------------------------------------------------+ | | v v [Android PackageManager] [Apple 영상·Bing·날씨 API] [시스템 설치기·설정·입력] 기능별 외부 네트워크 요청 [알림 리스너·동기화 재바인딩] | +-- RevivalListener / RevivalSync --> GuardSetup 복구

주목할 점은 UI 계층이 모든 일을 직접 하지 않는다는 것입니다. MainActivity는 생명주기와 앱 실행을, HomeViewModel은 표시할 상태와 사용자 의도를, AppCatalog·LayoutStore는 목록과 배치를 담당합니다. 다만 별도의 domain 모듈이나 DI 프레임워크로 엄격하게 분리된 구조는 아닙니다.

4-2. 패턴 ① 상태를 올려 두고 화면은 그 상태를 읽는다

app/src/main/java/app/orchard/tv/HomeViewModel.kt에는 apps, hiddenApps, folderApps, overlay, movingKey가 있습니다. mutableStateOf로 만든 관찰 가능한 상태를 HomeScreen()이 읽습니다. 화면 조작은 moveBy(), hide(), onHomePressed() 같은 함수로 이어집니다.

이 구조를 MVVM 계열의 상태 분리로 이해할 수 있습니다. Compose의 상태와 단방향 데이터 흐름에 대한 일반 원리는 Android 공식 상태 문서에서도 설명합니다. 이 저장소에서는 ViewModel 외에 Wallpapers 같은 전역 객체도 상태를 보유하므로, 모든 상태가 한 곳에 모인 구조라고 일반화하면 안 됩니다.

// app/src/main/java/app/orchard/tv/HomeViewModel.kt의 실제 선언 일부
sealed interface Overlay {
    data object None : Overlay
    data class ControlCenter(val page: CcPage = CcPage.Main) : Overlay
    data class Folder(val id: String) : Overlay
    data object HiddenApps : Overlay
    data object Wallpaper : Overlay
}

// 목록 이동의 흐름을 줄여 표현한 설명용 의사코드
사용자_방향입력(delta)
    → HomeViewModel.moveBy(delta)
    → LayoutStore.move(key, target)
    → arrange()
    → apps 상태 변경
    → HomeScreen이 새 순서 표시

Overlay에는 이 발췌 외에 앱 메뉴와 PIN 입력 상태도 있습니다. 여러 개의 참·거짓 변수로 패널 열림을 표현하는 대신, 하나의 sealed interface 값으로 현재 패널의 종류와 필요한 데이터를 함께 표현합니다. “제어판도 열려 있고 폴더도 열린” 조합을 별도로 조정할 부담이 줄어듭니다.

용어
상태 끌어올리기와 재구성 (state hoisting·recomposition)
여러 화면 조각이 공유하는 값을 상위 상태 보유자에 두는 것이 상태 끌어올리기입니다. 그 값을 읽던 Compose 함수가 변경된 값으로 UI를 다시 계산하는 것이 재구성입니다. 재구성이 일어났다는 말이 화면의 모든 픽셀을 무조건 새로 그린다는 뜻은 아닙니다.

reload()는 이전 loadJob을 취소하고 새 작업을 시작합니다. AppCatalog.load()는 Dispatchers.Default에서 실행되고, 입력 소스 조회도 withContext(Dispatchers.Default)로 옮깁니다. 이어 arrange()가 설치 앱과 고정 입력 소스를 합쳐 사용자 배치에 적용합니다.

LayoutStore.arrange()는 앱의 표시 이름 대신 패키지 기반 key로 순서를 유지합니다. 폴더에는 folder:, 입력 소스에는 input: 접두사를 써 앱 패키지와 구분합니다. 업데이트 중 앱이 잠깐 목록에서 사라져도 저장된 키는 남겨, 복귀했을 때 원래 위치를 찾도록 설계했습니다.

4-3. 패턴 ② 리모컨 입력을 작은 상태 기계로 다룬다

Pressable()은 단순히 “확인 키를 받으면 실행”하지 않습니다. PressState에 focused, pressed, byKey를 보관하고, 이 컨트롤에서 시작한 키 누름만 키를 뗄 때 클릭으로 인정합니다. 키 반복 이벤트는 최초 누름과 구분합니다.

// app/src/main/java/app/orchard/tv/ui/components/Pressable.kt의 핵심 로직 발췌
KeyEventType.KeyDown -> {
    if (e.nativeKeyEvent.repeatCount == 0) {
        state.pressed = true
        state.byKey = true
    }
    true
}
KeyEventType.KeyUp -> {
    val was = state.pressed && state.byKey
    state.pressed = false
    state.byKey = false
    if (was) {
        Sounds.select()
        onClick()
    }
    true
}

앱 타일을 길게 눌러 메뉴를 열었다고 상상해 보세요. 키를 뗄 때 포커스는 이미 메뉴 첫 항목으로 옮겨 있을 수 있습니다. 새 항목이 키를 뗐다는 사실만 보고 클릭하면 메뉴가 열리는 순간 원치 않는 명령까지 실행됩니다. 시작한 누름의 소유권을 확인하면 이런 중복 동작을 막을 수 있습니다.

onFocusChanged는 포커스가 빠질 때 누름 상태를 초기화합니다. 터치 입력은 detectTapGestures로 처리하고, byKey는 손가락의 해제가 리모컨 누름 상태를 잘못 끝내지 않도록 구분합니다. 리모컨·마우스·터치가 함께 있는 기기에서 특히 유용한 사고방식입니다.

비유

엘리베이터 버튼을 누른 손을 다른 층의 버튼 위에서 뗐다고 해서 그 층까지 선택하면 곤란합니다. “뗀 위치”뿐 아니라 “누름이 시작된 주체”를 기억해야 합니다. 입력 처리는 이벤트 하나보다 이벤트 사이의 관계가 중요합니다.

MainActivity.dispatchKeyEvent()에도 같은 원칙이 보입니다. 화면보호기를 끝낸 키의 KeyDown을 소비하고 swallowKeyUp에 키 코드를 저장해 뒤따르는 KeyUp도 소비합니다. 깨어나려고 누른 한 번의 확인 키가 뒤의 앱까지 실행하지 않게 만드는 것입니다.

4-4. 패턴 ③ 비싼 그림은 미리 만들고, 재사용 조건을 키로 적는다

app/src/main/java/app/orchard/tv/apps/Apps.kt의 entry()는 메모리 캐시를 먼저 보고, 없으면 TileDiskCache.read()를 시도합니다. 둘 다 없거나 조건이 달라졌을 때만 타일을 새로 만듭니다. 생성한 그림은 메모리와 디스크에 저장해 다음 실행에 재사용합니다.

캐시 종류키·검사 조건줄이려는 비용
앱 타일 디스크 캐시앱·사용자 이미지 수정 시각, 타일 너비, 아이콘 스타일, 언어, 앱 버전아이콘 분석·합성·타일 그리기
홈 배경 캐시벽지 ID·수정 시각·화면 크기·앱 버전, 크기 확인원본 디코딩·명암 처리·흐림 계산
연속 곡률 마스크모양 종류·너비·높이·반지름프레임마다 복잡한 경로 처리
항공 영상 도입부영상 ID와 화질, 필요한 구간 보유 여부첫 프레임 전 네트워크 왕복 대기
// TileDiskCache.key()가 표현하는 유효성 조건을 풀어 쓴 도식
타일 키 =
  stamp
  | TileArtFactory.W
  | TileArtFactory.unified
  | Locale.getDefault().toLanguageTag()
  | BuildConfig.VERSION_CODE

// stamp는 app/src/main/java/app/orchard/tv/apps/Apps.kt에서 두 수정 시각을 더한 값
stamp = 패키지의 lastUpdateTime + CustomIcons.stamp(pkg)

// 타일 파일이 완성된 뒤 최종 이름으로 바꾼다
임시 파일에 key·label·그림 기록
    → 출력 스트림 닫기
    → renameTo(최종 파일)
    → 다음 시작에서 key가 같을 때만 읽기

TileDiskCache.write()는 SupervisorJob() + Dispatchers.IO의 별도 코루틴에서 PNG와 메타데이터를 기록합니다. read()가 손상된 파일을 읽다 실패하면 null로 돌아가 새 그림을 만들 수 있게 합니다. 캐시는 정답의 유일한 저장소가 아니라 다시 만들 수 있는 결과를 보관하는 곳입니다.

Wallpapers.loadBaked()는 마지막 배경을 시작 시 바로 보여 주고, 필요하면 최신 배경을 나중에 계산합니다. saveBaked()는 기존 키를 지우고 두 이미지 파일을 쓴 뒤 키를 마지막에 기록합니다. 중간에 기록이 끊긴 결과를 완성된 묶음으로 읽지 않게 하는 간단한 완성 표시입니다.

app/src/main/java/app/orchard/tv/ui/components/SmoothCorners.kt의 smoothMask()는 복잡한 곡선을 CPU에서 비트맵으로 만듭니다. baked()의 접근 순서 기반 캐시는 최대 32개 항목을 유지합니다. 이 파일의 GPU 처리 시간은 소스 주석에 기록된 특정 Xiaomi 기기의 측정값입니다.

캐시 설계의 핵심은 저장 자체보다, 무엇이 바뀌었을 때 결과를 다시 만들어야 하는지 명시하는 데 있습니다.

4-5. 패턴 ④ 표준 기능을 먼저 쓰고, 기기별 대체 경로를 둔다

app/src/main/java/app/orchard/tv/system/SystemState.kt의 SystemProbe.sources()는 표준 프레임워크 입력을 먼저 조회하고, 결과가 비어 있을 때 Konka → TCL → Whaley 경로를 차례로 시도합니다. firstResolvable()는 실행 가능한 첫 설정 화면을 고르며, 후보가 모두 없으면 마지막 후보를 반환합니다. 음량 조절은 adjustStreamVolume이 권한 예외로 실패하면 setStreamVolume을 시도합니다. 공통 API가 있다는 것과 모든 펌웨어에서 똑같이 동작한다는 것은 다릅니다.

기기 이름을 비교하는 DeviceProfile.current()도 존재합니다. 따라서 “모든 호환 처리를 자동 탐지한다”는 설명은 틀립니다. 이 저장소의 규칙은 가능한 기능 탐지를 우선하고, 탐지할 수 없는 하드웨어 매핑 등에 기종별 예외를 남기는 것입니다.

HOME 처리의 분기는 빌드 시점부터 시작합니다. app/src/standard/AndroidManifest.xml만 CATEGORY_HOME을 추가합니다. guard는 공통 Manifest의 일반 실행 진입점만 사용하고, 권한과 설정이 갖춰졌을 때 접근성 서비스를 통한 복귀를 시도합니다.

표준 런처 선택이 가능한 TV HOME 요청 → Android 기본 HOME 선택 → MainActivity 제조사 런처가 다시 나타나는 TV 창 변경 / HOME 관련 신호 → HomeGuardService가 현재 화면 판단 → 기본 런처 화면이고 허용된 방문이 아니면 goHome() → MainActivity.bringBackIntent()로 기존 작업 복귀 대기 중 프로세스가 종료되는 일부 TV 시스템의 리스너·동기화 재바인딩 → RevivalListener / RevivalSync → GuardSetup.ensureEnabled() → 서비스 재연결·복귀 조건 재검사

takeOverOnceSettled()는 SETTLE_MS = 1500 뒤에 복귀 여부를 확인합니다. 부팅·켜짐 직후 HDMI-CEC가 입력을 열고 있을 수 있으므로, 바로 홈을 덮으면 사용자가 보려던 외부 기기를 가리게 됩니다. goHome()은 화면 꺼짐과 특정 빠른 대기 설정도 확인합니다.

updateKeyFilter()의 실제 조건도 중요합니다. Android 11 이하이고, 해당 TV가 키 통과 대상이 아니며, Aerial이 기본 HOME이 아닐 때 필터를 요청합니다. Android 12 이상에서는 이 경로의 필터를 쓰지 않고 창 이벤트와 homekey 사유의 브로드캐스트를 활용합니다. 일부 오래된 기기 문서에 있는 “기존 런처 방문 중에만 필터” 설명보다 현재 함수 조건이 더 정확합니다.

MainActivity.bringBackIntent()에는 CATEGORY_HOME을 넣지 않습니다. ACTION_MAIN과 작업 플래그를 사용해 기존 작업을 복귀시키며, 공통 Manifest의 launchMode="singleTask"와 함께 동작합니다. 제조사 TV에서 HOME 카테고리를 붙여 새 작업이 생기고 포커스가 초기화되던 문제를 피하려는 설계입니다.

4-6. 휴대폰 설정과 영상 재생을 흐름으로 검증하기

PhoneServer.start()는 서버가 없을 때 SecureRandom으로 8자리 토큰을 만들고, QR에는 TV 주소·8790 포트·토큰 경로를 넣습니다. serve()는 토큰 접두사를 검사한 뒤 api/state, api/upload, api/icon 등의 요청을 분기합니다. 설정 반영은 runBlocking(Dispatchers.Main)으로 메인 스레드에서 마친 뒤 응답합니다.

WallpaperSheet()는 패널이 열릴 때 서버를 시작하고, 닫힌 뒤 5분이 지나면 정지시킵니다. 아직 실행 중인 서버를 다시 쓰면 토큰도 재사용하므로, 아키텍처 문서의 “패널을 열 때마다 새 토큰” 설명과는 차이가 있습니다. 업로드의 MAX_UPLOAD는 200MiB이지만, 현재 사전 검사는 선언된 Content-Length를 기준으로 합니다. 이를 모든 전송 방식에 대한 강제 스트리밍 제한으로 해석해서는 안 됩니다.

AerialStream.player()는 무음·반복 재생, 연결 5초·읽기 8초 타임아웃, 재시도 정책과 버퍼 목표를 설정합니다. 기본 계열 24MiB, 4K 32MiB, Dolby Vision 48MiB는 버퍼 목표값이며 프로세스 전체 메모리 상한이 아닙니다. Media3 공식 문서의 구성 요소 교체 방식이 이 코드에서 어떻게 쓰이는지 비교해 볼 수 있습니다.

prepareOpener()는 다음 영상 후보의 앞 8MiB와 뒤 1MiB를 미리 받습니다. 뒤쪽에는 MP4 인덱스가 있다는 소스 설명에 따른 최적화입니다. 재생 중인 영상을 제외한 후보에서 준비된 도입부가 3개 미만이면 하나를 추가로 준비하며, 디스크 캐시는 48MiB LRU로 제한합니다. 재생 경로는 setCacheWriteDataSinkFactory(null)이라 전체 영상을 재생하면서 계속 저장하지 않습니다.

코드를 읽는 질문
“무엇을 한다” 다음에 “언제 하지 않는가”를 찾으세요

홈 복귀는 다른 앱이나 입력 화면을 가리지 않아야 하고, 화면보호기 해제 키는 앱을 열지 않아야 합니다. 캐시는 조건이 달라지면 쓰지 않아야 합니다. 기능의 완성도는 이런 경계 조건에서 드러납니다.

5디렉토리 구조 해부

UI, 앱 목록, 시스템 호환성, 배포 도구가 어디서 갈리는가

아래 트리는 저장소 루트 기준의 핵심 경로를 보여 줍니다. Android 앱의 구현과 자산은 app/에 모여 있습니다. local/은 문서와 빌드에서 참조하는 비공개 환경 경로입니다.

aerial-tv/ ├── build.gradle.kts 플러그인 버전 ├── settings.gradle.kts 저장소 설정·단일 :app 모듈 ├── gradle.properties 빌드 JVM·캐시 설정 ├── gradle/wrapper/ Gradle 배포 버전 ├── app/ │ ├── build.gradle.kts SDK·의존성·서명·flavor │ └── src/ │ ├── standard/ │ │ └── AndroidManifest.xml HOME 카테고리 추가 │ └── main/ │ ├── AndroidManifest.xml 공통 컴포넌트·권한 │ ├── java/app/orchard/tv/ │ │ ├── MainActivity.kt │ │ ├── HomeViewModel.kt │ │ ├── apps/ 앱 검색·배치·타일·폴더 │ │ ├── ui/ 홈·제어판·컴포넌트·테마 │ │ ├── guard/ HOME 복귀·부팅·프로세스 복구 │ │ ├── wallpaper/ 벽지·사진·영상·휴대폰 서버 │ │ ├── weather/ 날씨·도시 조회 │ │ ├── system/ 입력·음량·밝기·진단 │ │ ├── update/ 업데이트 확인·설치 요청 │ │ ├── settings/ 사용자 설정·PIN │ │ └── dream/ Android DreamService 연동 │ ├── assets/ │ │ ├── aerials.json 영상 114개의 메타데이터 │ │ ├── cities-cn.tsv 중국 도시·현 이름 데이터 │ │ └── web/index.html 휴대폰 설정 페이지 │ └── res/ 언어·테마·소리·아이콘·XML ├── docs/ │ ├── ARCHITECTURE.md 설계 설명 │ ├── HANDOFF.md 인수인계·변경·검증 기록 │ └── devices/ 기종별 현상·제약·대응 ├── tools/ 설치·복원·진단·자산 도구 ├── site/ 배포·다운로드 안내 웹사이트 ├── wrangler.jsonc 정적 웹사이트 배포 설정 └── .github/ISSUE_TEMPLATE/ 버그·기기·기능·작동 보고 양식

5-1. 폴더 이름보다 변경 이유를 먼저 본다

바꾸고 싶은 것먼저 읽을 파일함께 확인할 곳
타일 위치·격자app/src/main/java/app/orchard/tv/ui/home/HomeScreen.ktGridMetrics, HomeViewModel.moveBy()
앱 순서·숨김 보존app/src/main/java/app/orchard/tv/apps/LayoutStore.ktarrange(), save(), 폴더·신규 앱 처리
앱 그림·라벨app/src/main/java/app/orchard/tv/apps/Apps.ktapp/src/main/java/app/orchard/tv/apps/TileArt.kt, app/src/main/java/app/orchard/tv/apps/TileRecipes.kt, app/src/main/java/app/orchard/tv/apps/TileDiskCache.kt
리모컨 클릭 오동작app/src/main/java/app/orchard/tv/ui/components/Pressable.ktMainActivity.dispatchKeyEvent()
HOME·켜짐 후 복귀app/src/main/java/app/orchard/tv/guard/HomeGuardService.ktapp/src/main/java/app/orchard/tv/guard/GuardSetup.kt, 해당 docs/devices/ 문서
설정 페이지의 새 기능app/src/main/java/app/orchard/tv/wallpaper/PhoneServer.ktapp/src/main/assets/web/index.html과 실제 상태 보유자
릴리스·다운로드app/build.gradle.ktsapp/src/main/java/app/orchard/tv/update/Updates.kt, tools/deploy-site.sh

site/만 고쳐서는 휴대폰 설정 화면이 바뀌지 않습니다. 반대로 app/src/main/assets/web/index.html을 고쳐도 배포 홈페이지는 바뀌지 않습니다. 파일 확장자가 같다는 이유로 같은 화면 계층이라고 생각하지 않는 연습이 필요합니다.

5-2. 추적 파일 기준 코드 규모

확장자파일 수줄 수범위
Kotlin .kt5111,644Android 앱 구현
Gradle Kotlin .kts3123루트·설정·앱 빌드
XML13678Manifest·문자열·설정 등
HTML41,229휴대폰 설정·홈페이지·업데이트 안내
JavaScript·CSS각 1135·221배포 웹사이트의 동작·스타일
Shell5527설치·복원·배포·진단 도구
Python3248도시 데이터·브랜드 자산·T-Cast 도구
Swift113스크린샷 QR 읽기 보조 도구
YAML5247GitHub 이슈 양식·설정

표는 git ls-files에 나오는 추적 파일 중 UTF-8 텍스트의 물리적 줄 수입니다. 주석과 빈 줄을 포함하며 이미지·폰트·바이너리·.git은 제외합니다. HTML 안의 내장 JavaScript는 HTML에 포함되므로 위 수치는 언어 파서 기준의 순수 코드 줄 수가 아닙니다.

app/src/main/assets/cities-cn.tsv는 데이터 3,940줄이고, app/src/main/assets/aerials.json은 한 줄로 압축된 JSON이며 videos 배열에 114개 항목이 있습니다. 데이터 행 수와 구현 코드 크기를 합쳐 프로젝트가 더 복잡한 것처럼 표현하지 않는 것이 좋습니다.

6학습 포인트 (기술별)

파일을 읽고 끝내지 말고, 입력과 결과를 작은 사례로 적어 본다

6-1. Kotlin: 널 가능성과 닫힌 상태 모델

AppEntry는 일반 앱·입력 소스·폴더를 하나의 타일 모델로 나타냅니다. source: TvSource?와 folder: Folder?는 해당 종류가 아닐 때 null입니다. 반면 Overlay는 가능한 패널 종류를 sealed interface로 나눕니다. 같은 프로젝트 안에서 두 모델링 방식을 비교할 수 있습니다.

읽을 질문: launchIntent()는 입력 소스가 있을 때와 일반 앱일 때 어떻게 갈라질까요? folderEntry()의 패키지 필드가 실제 앱 패키지가 아닌 폴더 키인 이유는 무엇일까요? 이름보다 필드가 실제로 쓰이는 위치를 추적해 보세요.

작은 실습: “일반 앱 / 입력 소스 / 폴더”를 각각 하나씩 종이에 만들고 key, source, folder, 실행 경로를 표로 적으세요. 다음 단계로 별도 학습 프로젝트에서 이를 sealed interface Tile로 바꾼 모델과 비교해 볼 수 있습니다.

6-2. Compose: 상태의 수명과 포커스

Pressable() 안의 누름 상태는 remember로 보관합니다. 홈 전체에서 쓰는 앱 목록은 ViewModel에, 앱 재실행 뒤에도 남아야 하는 순서는 LayoutStore의 설정 저장소에 있습니다. 이 세 수명을 구별하면 “화면이 다시 계산되자 값이 초기화됐다”는 문제를 더 정확히 설명할 수 있습니다.

작은 실습: focused, movingKey, 앱 순서, 배경 사진 파일을 각각 “컴포넌트가 있는 동안 / 화면 상태 보유자 동안 / 프로세스 재시작 뒤”로 분류하세요. ViewModel이 프로세스 종료까지 자동으로 버텨 주는 저장소라고 생각하지 않는 것이 핵심입니다.

app/src/main/java/app/orchard/tv/ui/home/HomeScreen.kt의 격자는 COLUMNS = 5이고, GridMetrics.slot()이 나머지와 몫으로 열·행을 계산합니다. 스마트폰의 터치 좌표와 달리 TV는 “현재 포커스에서 다음에 갈 곳”이 사용성의 중심이므로 마지막 열·마지막 행·앱 제거 후 포커스를 함께 생각해야 합니다.

6-3. 코루틴: 취소·실행 스레드·순서 보장

HomeViewModel.reload()는 이전 로딩을 취소합니다. Wallpapers.rebuildBackdrop()도 이전 배경 작업을 취소하고 새 배경을 계산합니다. 사용자가 빠르게 선택을 바꿀 때 오래된 결과가 늦게 도착해 새 선택을 덮지 않도록 설계할 때 살펴볼 사례입니다.

도구여기서 읽을 위치학습 질문
viewModelScopeHomeViewModel.reload()화면 상태 보유자가 사라지면 작업은 어떻게 끝나는가?
withContext입력 조회·배경 계산·업데이트 다운로드무거운 작업과 UI 상태 반영을 어느 스레드에서 하는가?
snapshotFlow사용자 아이콘 버전·입력 소스 모양 관찰이미 가지고 있는 상태의 변경을 어떻게 작업으로 연결하는가?
SupervisorJobTileDiskCache·Updates개별 작업 실패와 전체 범위의 수명을 어떻게 나누는가?
runBlockingPhoneServer.onMain()HTTP 처리 스레드가 기다리는 동안 메인 스레드가 할 일은 무엇인가?

작은 실습: 사진 선택 A → 사진 선택 B → A 계산 완료라는 시간선을 그리세요. 취소가 어떤 시점에 확인되는지, CPU 계산이 협조적으로 취소되는지까지 점검하는 것이 다음 단계입니다. cancel() 호출만으로 이미 시작한 모든 처리가 즉시 멈춘다고 가정하면 안 됩니다.

6-4. Android 시스템: 등록·권한·실행 결과는 별개

공통 Manifest에는 접근성 서비스, 알림 리스너, 부팅 수신기, DreamService가 등록되어 있습니다. 그렇다고 등록만으로 권한이 활성화되거나 기본 HOME이 되는 것은 아닙니다. SystemProbe.homeStatus()는 실제 기본 HOME 패키지와 가드 실행 여부를 보고 Default, Guarded, NotDefault를 구분합니다.

작은 실습: “APK 설치됨 / 일반 앱 목록에 아이콘 있음 / HOME 필터 선언됨 / 기본 HOME으로 해석됨 / 가드 서비스 실행 중”을 각각 다른 체크 항목으로 작성하세요. README의 “APK만 USB로 설치하면 홈 복귀는 여전히 기존 런처로 간다”는 설명을 이 구분으로 풀어 보세요.

RevivalListener는 알림 본문 처리 콜백이 아니라 onListenerConnected()를 복구 계기로 사용합니다. RevivalSync도 로그인 서비스가 아닌 로컬 동기화 어댑터를 복구 경로로 씁니다. 이런 방식은 제조사 정책에 의존하므로, 모든 Android 앱의 일반적인 백그라운드 실행 해법으로 외우기보다 해결한 조건을 함께 읽어야 합니다.

6-5. 그래픽: 매 프레임 계산과 한 번 계산의 차이

canvasDensity()는 실제 화면 크기를 기준으로 960×540 설계 좌표계를 확대합니다. 1920×1080 UI이면 배율이 2가 됩니다. 물리 패널이 4K여도 TV가 앱 UI에는 1080p를 보고할 수 있어, UI 해상도와 영상 디코더 능력은 따로 봐야 합니다.

smoothMask()는 크기별 마스크를 미리 만들고, Bitmap.frozen()은 수정 불가 비트맵 복사본을 만듭니다. 배경 명암과 흐림도 Wallpapers.rebuildBackdrop()에서 준비합니다. 이 코드를 통해 CPU 계산·GPU 업로드·프레임당 합성이 서로 다른 비용임을 배울 수 있습니다.

작은 실습: “타일을 그리는 일”을 원본 읽기 → 이미지 분석 → 마스크 생성 → 합성 → 화면 이동으로 나누세요. 어떤 결과는 캐시에 넣을 수 있고 어떤 값은 입력마다 바뀌는지 표시하면 최적화 후보가 보입니다.

6-6. HTTP와 미디어: 한정된 자원을 명시하기

PhoneServer는 인터넷 계정 대신 주소에 포함된 임시 토큰을 사용합니다. installed()는 사용자 아이콘을 적용할 대상을 실제 앱·고정 입력 목록에서 확인합니다. 경로 검사, 입력값 검증, 이미지 크기 처리, 처리 시간은 서로 다른 방어 층이라는 것을 배울 수 있습니다.

AerialFormats.resolve()는 화질 선택과 디코더 지원을 조합해 실제 화질을 고르고, lower()는 실패 시 내려갈 화질을 정합니다. AerialStream에서는 버퍼·타임아웃·캐시를 따로 설정합니다. “네트워크가 느리다”는 문제를 연결 지연·첫 화면 지연·재생 중 고갈로 나누어 생각해 보세요.

초보자가 이 저장소에서 얻을 가장 큰 습관은 기능마다 상태의 소유자, 작업의 수명, 실패 뒤의 경로를 함께 적어 보는 것입니다.

학습 기록 양식
함수 하나를 읽고 네 줄만 남기세요

① 입력은 무엇인가 ② 어떤 상태나 파일이 바뀌는가 ③ 어느 스레드·생명주기에서 실행되는가 ④ 실패·취소·다음 입력이 오면 어떻게 되는가. 이 네 질문이면 코드 읽기가 요약문 복사에서 설계 분석으로 바뀝니다.

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

명시된 최소 버전과 기기별 작동 보장은 구분한다

7-1. 실행 환경과 개발 환경

항목확인된 조건해석
TV OSminSdk = 21, Android 5.0 이상빌드가 선언하는 하한이며 모든 기종의 완전 작동 보장은 아님
입력 장치방향·확인·뒤로 키, 터치·마우스 처리도 존재터치스크린 필수 기능은 Manifest에서 false
화면가로 화면, 960×540 기준 좌표계실제 UI 크기로 확대; 이 값이 물리 패널 해상도 요구는 아님
RAM·CPU정식 최소 사양표 없음특정 메모리 용량을 지원 보장 수치로 만들 수 없음
저장 공간앱·사용자 사진·그림 캐시·영상 도입부 캐시48MiB는 영상 캐시 한 종류의 상한이며 전체 사용량 상한이 아님
휴대폰 설정같은 LAN에서 TV의 8790 포트에 접근 가능클라이언트 격리·게스트 Wi-Fi 설정에 따라 연결이 막힐 수 있음
개발 PCREADME 기준 JDK 17·Android SDK 36Gradle 빌드용 힙 -Xmx4g와 OS·IDE 메모리를 별도로 고려
ADB제조사별 활성화·권한 부여 절차시스템 연동 설정에 사용; 코드 읽기에는 불필요

minSdk는 설치 가능한 최소 API이고 targetSdk는 앱이 대상으로 선언한 플랫폼 동작 기준입니다. compileSdk는 컴파일 시 참조하는 API 범위입니다. Android 공식 SDK 선언 문서와 빌드 파일을 같이 읽으면 “SDK 36으로 빌드하니 Android 36 기기만 된다”는 오해를 피할 수 있습니다.

7-2. 영상 화질은 패널·디코더·네트워크를 함께 본다

AerialFormats.probe()는 Android 7.0 이상에서 HEVC의 3840×2160·30fps 지원을 조회하고, Dolby Vision은 추가로 특정 디코더 프로파일과 디스플레이 HDR 지원을 확인합니다. 자동 선택 후에도 실제 재생 오류가 생길 수 있어 하향 경로가 필요합니다. LiveWallpaper는 Dolby Vision의 영상 설정이 홈 타일에까지 적용되는 문제를 피하려고 SDR을 사용한다고 소스 주석에서 설명합니다.

영상소스 주석의 대략 전송률주의할 의미
1080p H.264약 10Mbps저장소가 기본 호환 경로로 취급
4K SDR HEVC약 12Mbps4K 패널 이름만으로 디코더 지원을 판단하지 않음
4K Dolby Vision약 24Mbps프로파일·패널·네트워크 조건 모두 필요

이는 app/src/main/java/app/orchard/tv/wallpaper/Aerials.kt 주석의 추정 전송률이며 최저 회선 요구량을 보장하는 수치는 아닙니다. 영상 구간·서버 응답·Wi-Fi 상태에 따라 여유가 필요합니다. 내장 배경과 로컬 사진을 쓰는 홈 화면 학습에는 항공 영상 재생이 필수가 아닙니다.

7-3. 기기 문서에서 확인되는 지원의 층위

실기기 범위
“Android니까 된다”는 설치 전략이 아닙니다

HOME 등록 거부, 접근성 설정 누락, 대기 중 강제 종료, 비공개 HDMI 매핑은 별개의 제약입니다. 일반 Android TV 에뮬레이터에서 화면이 보이더라도, 제조사 TV의 부팅·HOME·입력 전환은 기종과 펌웨어별로 따로 검증해야 합니다.

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

읽기 과제에서 작은 모델 실험으로, 마지막에 실패 조건 설계까지

아래 과제는 별도 학습 사본에서 설계 원리를 익히기 위한 연습입니다. 첫 과제는 개발 도구나 TV 없이도 할 수 있습니다.

과제 1 · 입문

확인 버튼 한 번의 여행 지도 만들기 예상 30~45분

목표: 입력이 화면 상태 변경 또는 앱 실행으로 이어지는 경로를 설명합니다.

  1. app/src/main/java/app/orchard/tv/ui/components/Pressable.kt에서 isSelect()가 인정하는 키 코드를 찾습니다.
  2. KeyDown·KeyUp 및 포커스 변경이 바꾸는 세 필드를 표로 정리합니다.
  3. HomeScreen()의 실행 콜백과 MainActivity.launch()를 연결합니다.
  4. 화면보호기에서 같은 키를 누르면 왜 앱 실행까지 이어지지 않는지 비교합니다.

완료 기준: 일반 클릭·길게 눌러 메뉴 열기·화면보호기 해제의 세 시간선을 그리고, 각 이벤트를 소비하는 주체를 적습니다. “클릭 함수가 실행된다” 대신 언제·왜 실행되는지 설명해야 합니다.

과제 2 · 입문 → 중급

앱 정렬 규칙을 종이와 작은 함수로 재현하기 예상 1~2시간

목표: 화면상의 위치와 영구 식별자를 분리합니다. 시작 파일은 app/src/main/java/app/orchard/tv/apps/LayoutStore.kt입니다.

  1. 순서 [A, B, C, D]에서 move(B, D)와 move(D, B)의 결과를 손으로 계산합니다.
  2. 별도 연습 파일에서 문자열 목록만 받는 순수 함수로 이동 규칙을 옮깁니다.
  3. 같은 키 이동·없는 키·숨긴 앱이 중간에 있는 경우를 추가합니다.
  4. 앱 C가 잠시 사라졌다가 다시 생길 때 저장 순서와 표시 목록을 따로 계산합니다.

완료 기준: 정방향·역방향·무효 입력·일시 누락의 예상 결과를 먼저 적고 구현과 비교합니다. 일시 누락된 앱의 키를 제거하면 어떤 사용자 경험이 달라지는지 한 문단으로 설명합니다.

# 과제 2의 기대 결과: 설명용 사례
처음: [A, B, C, D]
B를 D 위치로 이동: [A, C, D, B]
D를 B 위치로 이동: [A, D, B, C]
B를 B 위치로 이동: [A, B, C, D]
없는 X를 B 위치로 이동: [A, B, C, D]

# “표시에서 빠짐”과 “저장 순서에서 삭제”를 분리한다
저장 순서: [A, B, C, D]
현재 설치: [A, B, D]
표시 결과: [A, B, D]
C 재등장 뒤 표시: [A, B, C, D]
과제 3 · 중급

잘못된 클릭을 막는 입력 상태 기계 만들기 예상 2~3시간

목표: Pressable의 핵심 원칙을 UI 없이 검증합니다.

  1. 대기, 키 누름 중, 취소 상태와 이벤트 전이표를 만듭니다.
  2. 최초 누름·반복 누름·키 뗌·포커스 상실을 입력 이벤트로 둡니다.
  3. 길게 눌러 새 메뉴가 생기고 그 메뉴가 키 뗌만 받는 시나리오를 추가합니다.
  4. 터치와 리모컨을 동시에 쓰는 경우에는 입력 소유자를 어떻게 표현할지 적습니다.

완료 기준: 정상 클릭은 정확히 한 번, 키 뗌만 받으면 0번, 포커스 상실 뒤에는 0번 실행되어야 합니다. UI를 띄우지 않고도 이 기대를 검사할 수 있는 모델이 결과물입니다.

과제 4 · 중급 → 고급

캐시가 틀리는 조건부터 설계하기 예상 2~4시간

목표: TileDiskCache와 배경 캐시의 유효성 규칙을 비교합니다.

  1. 타일 키에 들어가는 다섯 요소와 stamp의 구성을 적습니다.
  2. 언어 변경·앱 업데이트·화면 크기 변경·Aerial 버전 변경의 영향 표를 만듭니다.
  3. 키 불일치, 파일 누락, 중간에 잘린 PNG를 각각 캐시 실패로 처리하는 모델을 만듭니다.
  4. 임시 파일 교체와 “키를 마지막에 기록” 방식이 보호하는 실패 구간을 비교합니다.

완료 기준: 오래된 그림을 쓸 수 있는 조건과 다시 만들어야 하는 조건을 구별하고, 캐시 삭제가 사용자 사진 삭제와 달라야 하는 이유를 설명합니다. 성능 수치는 측정 환경을 정하기 전에는 붙이지 않습니다.

과제 5 · 고급

가짜 기기·가짜 네트워크로 실패 대응표 작성하기 예상 반나절

목표: 실기기 권한을 바꾸지 않고 시스템 경계의 의사결정을 모델링합니다.

  1. SystemProbe에서 쓰는 입력·설정·음량 기능을 인터페이스 초안으로 추출합니다.
  2. 표준 기능 정상·기능 없음·권한 예외의 세 가짜 응답을 준비합니다.
  3. AerialStream의 연결 시간 초과·캐시 없음·디코더 실패를 가짜 이벤트로 만듭니다.
  4. 홈 복귀 대기 중 HDMI 화면 등장, 업데이트 중 앱 종료 같은 순서 문제를 추가합니다.

완료 기준: 각 실패가 UI 상태, 대체 경로, 로그에 어떻게 나타나야 하는지 표로 제시합니다. 가짜 환경에서 통과한 결과와 실제 TV에서 따로 확인할 항목을 명확히 구분합니다.

좋은 실습 결과물은 기능을 하나 늘린 화면뿐 아니라, 그 기능이 잘못 작동하면 안 되는 조건을 설명하는 작은 모델입니다.

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

6주 동안 한 저장소를 여러 관점으로 다시 읽기

주당 4~6시간을 쓰는 학습 계획입니다. 이미 아는 내용은 건너뛰어도 됩니다. “51개 파일을 모두 읽기”보다 매주 하나의 설명 가능한 결과물을 만드는 것이 목표입니다.

주차배울 기술읽을 코드남길 결과물
1주차Kotlin 기본 문법·널 가능성·컬렉션·데이터 모델AppEntry, Overlay, LayoutStore.move()세 종류 타일의 데이터 표와 앱 이동 규칙
2주차Compose 상태·재구성·포커스·이벤트HomeScreen, Pressable, HomeViewModel화면 입력 세 가지의 시간선
3주차코루틴·취소·작업 범위·로컬 영속화reload(), rebuildBackdrop(), LayoutStore.save()상태별 수명표와 느린 이전 작업의 처리 규칙
4주차Android 컴포넌트·Intent·권한·flavor두 Manifest, HomeGuardService, SystemProbe표준 TV와 제조사 TV의 HOME 경로 비교
5주차비트맵·캐시·Media3·부분 요청TileDiskCache, SmoothCorners, AerialStream캐시 키·무효화·첫 화면 비용의 비교표
6주차HTTP·업데이트·진단·배포PhoneServer, Updates, Diagnostics, 배포 도구요청 흐름도와 실패 대응표, 작은 학습 프로젝트

9-1. 기초가 막힐 때 되돌아갈 순서

  1. 문법이 낯설다: data class, object, sealed interface, ?., ?:부터 정리합니다.
  2. UI가 왜 바뀌는지 모르겠다: mutableStateOf를 쓰는 값 하나를 정하고 읽는 함수와 쓰는 함수를 연결합니다.
  3. 실행 순서가 헷갈린다: 동기 호출, 코루틴 시작, delay, 시스템 콜백을 서로 다른 줄에 놓고 시간선을 그립니다.
  4. 기기 예외가 많다: 표준 Android 경로를 먼저 설명한 다음 예외가 들어오는 위치만 덧붙입니다.
  5. 최적화가 어렵다: 원본 읽기·계산·업로드·화면 합성을 나눈 뒤 재사용 가능한 결과부터 표시합니다.
비유

같은 집을 배관공·전기기사·가구 디자이너의 눈으로 번갈아 보는 공부입니다. 한 번에 집 전체를 외우지 않아도, 물이 흐르는 길과 전기가 흐르는 길을 따로 이해하면 나중에 두 경로가 만나는 곳이 보입니다.

9-2. 마지막 주의 확장 프로젝트

별도 프로젝트에서 “앱처럼 생긴 가짜 타일 10개를 정렬하는 화면”을 만들어 보세요. 실제 앱 조회나 HOME 권한 없이도 상태 모델·리모컨 입력·이동·숨김·폴더·캐시 키를 연습할 수 있습니다. 그 다음 실제 Android 패키지 조회를 붙이면 시스템 의존성을 분리하는 이유를 체감할 수 있습니다.

전체 Aerial을 복제하는 것이 목표는 아닙니다. 선택한 설계 하나를 다른 맥락에서 설명하고 재현할 수 있다면, 그 부분을 배운 것입니다. 기기별 가드 코드는 마지막 확장 주제로 남겨 두어도 됩니다.

10핵심 키워드 사전

코드에서 만나는 용어를 쉬운 설명과 읽을 위치로 연결
용어쉬운 설명이 저장소의 예
Launcher다른 앱으로 들어가는 홈 화면 앱MainActivity와 HOME 선언
IntentAndroid에 화면 실행이나 동작을 요청하는 메시지AppEntry.launchIntent()
Manifest앱의 화면·서비스·권한·시스템 진입점을 선언하는 파일공통·standard의 두 Manifest
Product flavor공통 구현으로 설정이 다른 앱 변형을 만드는 빌드 기능standard, guard
Jetpack Compose현재 상태가 어떤 화면이어야 하는지 함수로 기술하는 UI 도구HomeScreen()
Recomposition관찰하던 값이 바뀌면 필요한 UI 함수를 다시 계산하는 과정vm.apps 변경 반영
ViewModel화면의 데이터와 동작을 보유하는 상태 담당자HomeViewModel
Sealed interface허용된 종류를 제한해 상태를 구분하는 Kotlin 선언Overlay
Focus현재 리모컨 입력을 받을 대상FocusRequester, PressState.focused
Coroutine대기·취소·비동기 흐름을 표현하는 작업 단위viewModelScope.launch
Dispatcher코루틴이 어느 실행 스레드 자원을 사용할지 정하는 정책Main, Default, IO
SharedPreferences설정을 작은 키와 값으로 남기는 Android 저장소layout의 순서·숨김·폴더
Cache invalidation보관한 결과가 더는 맞지 않을 때 재사용을 중단하는 규칙TileDiskCache.key()
LRU오래 사용하지 않은 항목부터 내보내는 캐시 정책영상 캐시의 LeastRecentlyUsedCacheEvictor
Bitmap픽셀 배열로 저장한 그림타일·배경·모서리 마스크
사전 렌더링복잡한 그림을 미리 계산해 화면에서는 재사용하는 방식smoothMask(), 배경 흐림 처리
AccessibilityService승인된 접근성 동작·이벤트를 받는 Android 서비스창 변경을 보는 HomeGuardService
Direct Boot기기 부팅 후 사용자 저장소 잠금 해제 전에도 일부 동작을 허용하는 체계가드의 잠금 상태 확인·해제 후 처리
DreamServiceAndroid 시스템 화면보호기용 서비스app/src/main/java/app/orchard/tv/dream/AerialDream.kt
Capability detection기종명만 믿지 않고 실제 기능·서비스·실행 가능 여부를 확인하는 방식firstResolvable(), 디코더 조회
HTTP 토큰주소를 아는 요청을 허용하기 위한 임의의 식별 문자열휴대폰 설정 QR의 8자리 경로
Buffer재생이 끊기지 않도록 먼저 받아 메모리에 둔 데이터DefaultLoadControl
MP4 moov영상의 트랙·시간·데이터 위치를 설명하는 인덱스 영역영상 뒤쪽을 미리 받는 도입부 최적화
APK 서명앱 배포자의 키로 패키지의 연속성을 확인하는 장치Updates.sameSigner()
FileProvider앱 전용 파일을 허가된 다른 앱에 URI로 전달하는 도구시스템 설치기로 APK 전달
Desugaring일부 언어·라이브러리 기능을 구형 Android에서도 쓰도록 보완하는 빌드 처리desugar_jdk_libs

11참고 링크

지금 읽은 커밋과 제품의 이후 변화를 구분해서 추적하기

11-1. 공식 저장소와 분석한 소스

11-2. 개념을 확장할 공식 문서

11-3. 현재 상태 (2026-10-23 확인)

저장소 메타데이터는 2026-10-23 GitHub 기준이며, 버전 설정·구조·코드 규모·테스트·CI 항목은 커밋 12ca103 기준입니다.

항목확인 내용근거
저장소·주 언어pengchujin/aerial-tv · Kotlin2026-10-23 GitHub 기준·소스 구성
스타 / 포크3,403 / 2482026-10-23 GitHub 기준
생성일2026-10-202026-10-23 GitHub 기준
최근 push2026-10-232026-10-23 GitHub 기준
최신 릴리스v1.37 · 2026-10-232026-10-23 GitHub 기준·앱 버전 설정
최근 커밋12ca103 · 2026-10-23 · 1.37: versionCode 39커밋 12ca103의 정보
앱 식별자app.orchard.tvapp/build.gradle.kts
빌드 변형standard / guard, versionCode 39빌드 설정·standard Manifest
코드 규모Kotlin 51파일·11,644줄, 빌드 Kotlin 3파일·123줄커밋 12ca103의 추적 파일
테스트·CIapp/src/test·app/src/androidTest 디렉토리와 CI 워크플로 파일 없음커밋 12ca103의 파일 목록·HANDOFF의 자동 테스트 제안
라이선스MIT · Copyright 2026 pengchujin루트 LICENSE
서드파티 자산Inter는 OFL, 도시 데이터는 GeoNames 출처, 항공 영상은 Apple 서버README·FONT-LICENSE-OFL.txt·소스

11-4. 설명 문서와 현재 구현이 어긋나는 곳

문서의 설명현재 코드에서 확인한 것읽는 법
HANDOFF: 항공 영상 로컬 캐시 없음AerialStream.prepareOpener()에 부분 캐시 존재전체 영상 저장은 없지만 앞·뒤 구간은 미리 저장
ARCHITECTURE: 패널을 열 때마다 토큰 재생성PhoneServer.start()는 실행 중 서버 재사용새 서버 생성 때 토큰 생성, 5분 유예 중에는 재사용 가능
오래된 Xiaomi 기기 문서: 원래 런처 방문 중만 키 필터updateKeyFilter()는 OS·기본 HOME·통과 대상 조건 사용이전 설계 설명을 현재 조건으로 오인하지 않기
Hisense 기기 문서: 입력 패널 연동을 계획으로 설명SystemProbe.openInputPicker()에 Hisense 서비스 경로 존재구현 존재와 모든 Hisense 기기 검증 완료는 별개

변경 속도가 빠른 저장소에서는 설명 문서가 현재 코드보다 늦을 수 있습니다. 반대로 소스에 분기가 있다는 사실만으로 그 기기에서 실제로 작동했다고 단정할 수도 없습니다. 의도는 문서에서, 현재 조건은 코드에서, 검증 범위는 기기별 기록에서 따로 확인하는 습관이 필요합니다.

사용 전에 알아둘 한계
소스 분석과 내 TV의 작동 보장은 다릅니다

코드 설명은 커밋 12ca103 기준입니다. 기기별 성공 사례와 성능 수치는 README·인수인계·기기 문서에 기록된 모델과 펌웨어를 기준으로 읽어야 합니다.

모든 TV에서 기본 HOME·자동 시작·밝기·HDMI가 된다는 보장은 없습니다. 원래 시스템 런처를 비활성화하는 실습은 피해야 합니다. 특히 저장소는 Xiaomi의 com.mitv.tvhome을 끄면 검은 화면에 갇힐 수 있다고 기록합니다.

휴대폰 설정 페이지는 TLS 없는 LAN HTTP와 임의 토큰을 사용합니다. 주소를 아는 장치가 설정 요청을 보낼 수 있으므로 공용 인터넷에 노출할 관리 서버로 다루지 마세요. 구형 TV의 인증서 호환을 위해 일부 외부 서비스에는 HTTP 대체 경로도 있습니다.

MIT는 저장소 코드의 라이선스입니다. README가 별도로 밝힌 폰트·도시 데이터·Apple 영상·Bing 이미지의 출처와 이용 조건까지 하나로 바꾸는 선언으로 읽으면 안 됩니다. 화면보호기용 영상과 배경 이미지는 외부 서버의 제공 상태에도 의존합니다.