TV 앱
12ca103을 기준으로 그 설계를 살펴봅니다. 저장소 생성 2026-10-20 · 스타 3,403 · 포크 248 · MIT · 최신 릴리스 v1.37. 저장소 정보는 2026-10-23 GitHub 기준입니다.aerial-tv는 Android TV의 홈 화면을 tvOS에서 영감을 받은 앱 타일·배경화면·화면보호기로 바꾸는 오픈소스 앱입니다. 설치된 동영상 서비스를 대신 재생하는 서비스가 아니라, 그 앱을 찾아 실행하는 출발점입니다. Android 패키지 이름은 app.orchard.tv이고, 사용자에게 보이는 이름은 Aerial입니다.
이 저장소의 가장 좋은 공부거리는 화면을 그리는 코드와 TV 펌웨어의 제약을 다루는 코드가 한 제품 안에서 만나는 과정입니다. Kotlin 파일 51개, 주석·빈 줄을 포함해 11,644줄이므로 작은 예제보다는 크지만, 처음부터 여러 서버를 띄워야 하는 서비스보다 전체 흐름을 따라가기 쉽습니다.
Intent라는 실행 요청으로 다른 앱의 화면을 엽니다. 다만 TV 제조사가 자기 홈 화면을 강제로 선택하는 펌웨어라면, 런처를 설치하는 것과 기본 홈 화면으로 쓰는 것은 서로 다른 문제입니다.영상 앱은 각각의 가게이고 Aerial은 입구의 안내 데스크입니다. 가게 안의 구독·로그인·영상 목록은 그대로 두고, 입구에서 어느 가게로 들어갈지 고르는 경험을 바꿉니다.
일부 TV에서는 건물 관리자인 펌웨어가 기존 데스크를 자꾸 다시 세웁니다. 그래서 이 프로젝트에는 안내 화면뿐 아니라, 사용자가 HOME을 눌렀을 때 돌아올 위치를 관리하는 장치도 필요합니다.
| 사용자가 하는 일 | 주요 구현 | 배울 개념 |
|---|---|---|
| 앱 목록 보기 | 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.kt | TV 내부 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의 기본 흐름을 이해한 다음 읽으면 훨씬 덜 복잡합니다.
선정 근거는 TrendShift Daily #12이며 분석일은 2026-10-23입니다. 2026-10-23 GitHub 기준, 2026-10-20에 생성된 저장소의 스타는 3,403개, 포크는 248개입니다. 생성일은 공개 저장소의 생성 시점이며, 전체 개발 기간이나 모든 코드가 사흘 만에 작성됐다는 증거는 아닙니다.
관심을 끄는 이유로는 광고·추천 피드가 없는 단순한 홈 화면, 익숙한 tvOS 계열 시각 언어, 중국 시장용 TV의 설치·대기 복귀 문제를 다루는 실용성을 들 수 있습니다. 이는 README의 제품 설명과 소스 구조를 바탕으로 한 분석자의 해석입니다. 방문자 유입 경로나 스타를 누른 사람의 동기를 측정한 결과는 아닙니다.
제품의 매력은 간결한 화면에 있고, 학습 가치는 그 간결함을 유지하기 위해 처리한 시스템 예외에 있습니다.
| 선택지 | 장점·목적 | 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/의 복귀 로직에서 확인할 수 있습니다.
README는 광고와 추천 피드가 없는 홈 화면을 내세웁니다. 소스에서도 홈 UI는 설치 앱·입력 소스·시계·배경화면을 중심으로 구성되고, 의존성 목록에는 광고 SDK가 선언되어 있지 않습니다. 그러나 TV 펌웨어가 띄우는 부팅 광고까지 제거한다는 뜻은 아닙니다. README도 부팅 광고가 남는다고 명시합니다.
또한 MainActivity.idleBeforeScreensaver()는 제조사 화면보호기보다 먼저 앱의 화면보호기를 띄우도록 시간을 조정합니다. 이는 홈 화면에 있을 때의 동작이며, 모든 앱 위에서 제조사 광고 화면보호기를 전역 차단하는 구조는 아닙니다.
HomeViewModel.offerSetupOnce()가 기본 홈 상태와 권한을 보고 설정 안내를 제안합니다.RevivalListener와 RevivalSync가 시스템 재바인딩을 이용하는 복구 경로를 제공합니다.Updates.install()이 APK를 받아 검사를 거친 뒤 TV의 시스템 설치기를 엽니다.app/src/main/java/app/orchard/tv/system/Diagnostics.kt와 기기별 문서로 환경 차이를 기록합니다.새 현관문을 다는 일은 문짝을 만드는 것으로 끝나지 않습니다. 기존 열쇠가 맞는지, 정전 후에도 열리는지, 고장 났을 때 원래 문으로 돌아갈 수 있는지도 봐야 합니다. 이 저장소의 설치·복구·진단 코드가 그런 역할을 합니다.
주 화면은 브라우저가 아니라 Jetpack Compose로 그립니다. MainActivity.onCreate()에서 setContent를 호출해 HomeScreen()을 올리고, HomeViewModel이 앱 목록과 오버레이 상태를 보유합니다. 휴대폰 설정 화면만 app/src/main/assets/web/index.html이라는 HTML 자산입니다.
| 기술·의존성 | 선언 버전 | 소스에서 맡는 일 |
|---|---|---|
| Kotlin·Compose 플러그인 | 2.2.10 | 루트 build.gradle.kts에서 두 플러그인 버전 지정 |
| Compose BOM | 2025.11.01 | ui·foundation·animation 버전 정렬 |
| Activity Compose | 1.10.1 | Activity 안에 Compose 화면 연결 |
| Lifecycle ViewModel Compose | 2.9.2 | 화면 상태 보유자와 UI 연결 |
| AndroidX Core KTX | 1.16.0 | Android API를 Kotlin에서 편하게 사용 |
| Media3 3개 모듈 | 1.8.1 | exoplayer·datasource·database로 영상 재생·캐시 |
표의 버전은 app/build.gradle.kts의 선언값입니다. 이 파일의 주석은 해당 BOM이 Compose 1.9.5를 사용하며 Android 5.0 지원을 유지하기 위한 선택이라고 설명합니다. BOM 선언값과 소스 주석의 개별 버전 설명은 구분해 읽어야 합니다.
독립적인 클라우드 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가 제공하는 휴대폰 페이지는 위치와 수명이 다른 화면입니다.
| 항목 | 설정값 | 확인 파일 |
|---|---|---|
| Android Gradle Plugin | 8.12.0 | 루트 build.gradle.kts |
| Gradle Wrapper | 8.14.3 | gradle/wrapper/gradle-wrapper.properties |
| JDK·JVM 타깃 | 17 | app/build.gradle.kts와 README 개발 환경 |
| SDK | compileSdk = 36, targetSdk = 34, minSdk = 21 | app/build.gradle.kts |
| 앱 버전 | versionName = "1.37", versionCode = 39 | app/build.gradle.kts |
| 구형 API 보완 | desugar_jdk_libs:2.1.5 | java.time 등 일부 API의 구형 OS 지원 |
| 릴리스 최적화 | isMinifyEnabled = true, isShrinkResources = true | 코드·리소스 축소 설정 |
| 빌드 JVM 힙 | -Xmx4g | gradle.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의 실제 서명 키를 확인할 수는 없으며, 같은 패키지에 덮어쓰려면 서명 연속성이 필요합니다.
Aerial의 주요 기능은 TV의 앱 프로세스 안에서 실행됩니다. Android 시스템이 Activity·Service·Receiver로 이벤트를 전달하고, UI는 로컬 앱 목록과 저장된 배치를 결합합니다. 휴대폰 설정은 같은 프로세스의 서버로 들어오는 별도 입력 통로입니다.
주목할 점은 UI 계층이 모든 일을 직접 하지 않는다는 것입니다. MainActivity는 생명주기와 앱 실행을, HomeViewModel은 표시할 상태와 사용자 의도를, AppCatalog·LayoutStore는 목록과 배치를 담당합니다. 다만 별도의 domain 모듈이나 DI 프레임워크로 엄격하게 분리된 구조는 아닙니다.
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 값으로 현재 패널의 종류와 필요한 데이터를 함께 표현합니다. “제어판도 열려 있고 폴더도 열린” 조합을 별도로 조정할 부담이 줄어듭니다.
reload()는 이전 loadJob을 취소하고 새 작업을 시작합니다. AppCatalog.load()는 Dispatchers.Default에서 실행되고, 입력 소스 조회도 withContext(Dispatchers.Default)로 옮깁니다. 이어 arrange()가 설치 앱과 고정 입력 소스를 합쳐 사용자 배치에 적용합니다.
LayoutStore.arrange()는 앱의 표시 이름 대신 패키지 기반 key로 순서를 유지합니다. 폴더에는 folder:, 입력 소스에는 input: 접두사를 써 앱 패키지와 구분합니다. 업데이트 중 앱이 잠깐 목록에서 사라져도 저장된 키는 남겨, 복귀했을 때 원래 위치를 찾도록 설계했습니다.
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도 소비합니다. 깨어나려고 누른 한 번의 확인 키가 뒤의 앱까지 실행하지 않게 만드는 것입니다.
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 기기의 측정값입니다.
캐시 설계의 핵심은 저장 자체보다, 무엇이 바뀌었을 때 결과를 다시 만들어야 하는지 명시하는 데 있습니다.
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의 일반 실행 진입점만 사용하고, 권한과 설정이 갖춰졌을 때 접근성 서비스를 통한 복귀를 시도합니다.
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 카테고리를 붙여 새 작업이 생기고 포커스가 초기화되던 문제를 피하려는 설계입니다.
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)이라 전체 영상을 재생하면서 계속 저장하지 않습니다.
홈 복귀는 다른 앱이나 입력 화면을 가리지 않아야 하고, 화면보호기 해제 키는 앱을 열지 않아야 합니다. 캐시는 조건이 달라지면 쓰지 않아야 합니다. 기능의 완성도는 이런 경계 조건에서 드러납니다.
아래 트리는 저장소 루트 기준의 핵심 경로를 보여 줍니다. Android 앱의 구현과 자산은 app/에 모여 있습니다. local/은 문서와 빌드에서 참조하는 비공개 환경 경로입니다.
| 바꾸고 싶은 것 | 먼저 읽을 파일 | 함께 확인할 곳 |
|---|---|---|
| 타일 위치·격자 | app/src/main/java/app/orchard/tv/ui/home/HomeScreen.kt | GridMetrics, HomeViewModel.moveBy() |
| 앱 순서·숨김 보존 | app/src/main/java/app/orchard/tv/apps/LayoutStore.kt | arrange(), save(), 폴더·신규 앱 처리 |
| 앱 그림·라벨 | app/src/main/java/app/orchard/tv/apps/Apps.kt | app/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.kt | MainActivity.dispatchKeyEvent() |
| HOME·켜짐 후 복귀 | app/src/main/java/app/orchard/tv/guard/HomeGuardService.kt | app/src/main/java/app/orchard/tv/guard/GuardSetup.kt, 해당 docs/devices/ 문서 |
| 설정 페이지의 새 기능 | app/src/main/java/app/orchard/tv/wallpaper/PhoneServer.kt | app/src/main/assets/web/index.html과 실제 상태 보유자 |
| 릴리스·다운로드 | app/build.gradle.kts | app/src/main/java/app/orchard/tv/update/Updates.kt, tools/deploy-site.sh |
site/만 고쳐서는 휴대폰 설정 화면이 바뀌지 않습니다. 반대로 app/src/main/assets/web/index.html을 고쳐도 배포 홈페이지는 바뀌지 않습니다. 파일 확장자가 같다는 이유로 같은 화면 계층이라고 생각하지 않는 연습이 필요합니다.
| 확장자 | 파일 수 | 줄 수 | 범위 |
|---|---|---|---|
Kotlin .kt | 51 | 11,644 | Android 앱 구현 |
Gradle Kotlin .kts | 3 | 123 | 루트·설정·앱 빌드 |
| XML | 13 | 678 | Manifest·문자열·설정 등 |
| HTML | 4 | 1,229 | 휴대폰 설정·홈페이지·업데이트 안내 |
| JavaScript·CSS | 각 1 | 135·221 | 배포 웹사이트의 동작·스타일 |
| Shell | 5 | 527 | 설치·복원·배포·진단 도구 |
| Python | 3 | 248 | 도시 데이터·브랜드 자산·T-Cast 도구 |
| Swift | 1 | 13 | 스크린샷 QR 읽기 보조 도구 |
| YAML | 5 | 247 | GitHub 이슈 양식·설정 |
표는 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개 항목이 있습니다. 데이터 행 수와 구현 코드 크기를 합쳐 프로젝트가 더 복잡한 것처럼 표현하지 않는 것이 좋습니다.
AppEntry는 일반 앱·입력 소스·폴더를 하나의 타일 모델로 나타냅니다. source: TvSource?와 folder: Folder?는 해당 종류가 아닐 때 null입니다. 반면 Overlay는 가능한 패널 종류를 sealed interface로 나눕니다. 같은 프로젝트 안에서 두 모델링 방식을 비교할 수 있습니다.
읽을 질문: launchIntent()는 입력 소스가 있을 때와 일반 앱일 때 어떻게 갈라질까요? folderEntry()의 패키지 필드가 실제 앱 패키지가 아닌 폴더 키인 이유는 무엇일까요? 이름보다 필드가 실제로 쓰이는 위치를 추적해 보세요.
작은 실습: “일반 앱 / 입력 소스 / 폴더”를 각각 하나씩 종이에 만들고 key, source, folder, 실행 경로를 표로 적으세요. 다음 단계로 별도 학습 프로젝트에서 이를 sealed interface Tile로 바꾼 모델과 비교해 볼 수 있습니다.
Pressable() 안의 누름 상태는 remember로 보관합니다. 홈 전체에서 쓰는 앱 목록은 ViewModel에, 앱 재실행 뒤에도 남아야 하는 순서는 LayoutStore의 설정 저장소에 있습니다. 이 세 수명을 구별하면 “화면이 다시 계산되자 값이 초기화됐다”는 문제를 더 정확히 설명할 수 있습니다.
작은 실습: focused, movingKey, 앱 순서, 배경 사진 파일을 각각 “컴포넌트가 있는 동안 / 화면 상태 보유자 동안 / 프로세스 재시작 뒤”로 분류하세요. ViewModel이 프로세스 종료까지 자동으로 버텨 주는 저장소라고 생각하지 않는 것이 핵심입니다.
app/src/main/java/app/orchard/tv/ui/home/HomeScreen.kt의 격자는 COLUMNS = 5이고, GridMetrics.slot()이 나머지와 몫으로 열·행을 계산합니다. 스마트폰의 터치 좌표와 달리 TV는 “현재 포커스에서 다음에 갈 곳”이 사용성의 중심이므로 마지막 열·마지막 행·앱 제거 후 포커스를 함께 생각해야 합니다.
HomeViewModel.reload()는 이전 로딩을 취소합니다. Wallpapers.rebuildBackdrop()도 이전 배경 작업을 취소하고 새 배경을 계산합니다. 사용자가 빠르게 선택을 바꿀 때 오래된 결과가 늦게 도착해 새 선택을 덮지 않도록 설계할 때 살펴볼 사례입니다.
| 도구 | 여기서 읽을 위치 | 학습 질문 |
|---|---|---|
viewModelScope | HomeViewModel.reload() | 화면 상태 보유자가 사라지면 작업은 어떻게 끝나는가? |
withContext | 입력 조회·배경 계산·업데이트 다운로드 | 무거운 작업과 UI 상태 반영을 어느 스레드에서 하는가? |
snapshotFlow | 사용자 아이콘 버전·입력 소스 모양 관찰 | 이미 가지고 있는 상태의 변경을 어떻게 작업으로 연결하는가? |
SupervisorJob | TileDiskCache·Updates | 개별 작업 실패와 전체 범위의 수명을 어떻게 나누는가? |
runBlocking | PhoneServer.onMain() | HTTP 처리 스레드가 기다리는 동안 메인 스레드가 할 일은 무엇인가? |
작은 실습: 사진 선택 A → 사진 선택 B → A 계산 완료라는 시간선을 그리세요. 취소가 어떤 시점에 확인되는지, CPU 계산이 협조적으로 취소되는지까지 점검하는 것이 다음 단계입니다. cancel() 호출만으로 이미 시작한 모든 처리가 즉시 멈춘다고 가정하면 안 됩니다.
공통 Manifest에는 접근성 서비스, 알림 리스너, 부팅 수신기, DreamService가 등록되어 있습니다. 그렇다고 등록만으로 권한이 활성화되거나 기본 HOME이 되는 것은 아닙니다. SystemProbe.homeStatus()는 실제 기본 HOME 패키지와 가드 실행 여부를 보고 Default, Guarded, NotDefault를 구분합니다.
작은 실습: “APK 설치됨 / 일반 앱 목록에 아이콘 있음 / HOME 필터 선언됨 / 기본 HOME으로 해석됨 / 가드 서비스 실행 중”을 각각 다른 체크 항목으로 작성하세요. README의 “APK만 USB로 설치하면 홈 복귀는 여전히 기존 런처로 간다”는 설명을 이 구분으로 풀어 보세요.
RevivalListener는 알림 본문 처리 콜백이 아니라 onListenerConnected()를 복구 계기로 사용합니다. RevivalSync도 로그인 서비스가 아닌 로컬 동기화 어댑터를 복구 경로로 씁니다. 이런 방식은 제조사 정책에 의존하므로, 모든 Android 앱의 일반적인 백그라운드 실행 해법으로 외우기보다 해결한 조건을 함께 읽어야 합니다.
canvasDensity()는 실제 화면 크기를 기준으로 960×540 설계 좌표계를 확대합니다. 1920×1080 UI이면 배율이 2가 됩니다. 물리 패널이 4K여도 TV가 앱 UI에는 1080p를 보고할 수 있어, UI 해상도와 영상 디코더 능력은 따로 봐야 합니다.
smoothMask()는 크기별 마스크를 미리 만들고, Bitmap.frozen()은 수정 불가 비트맵 복사본을 만듭니다. 배경 명암과 흐림도 Wallpapers.rebuildBackdrop()에서 준비합니다. 이 코드를 통해 CPU 계산·GPU 업로드·프레임당 합성이 서로 다른 비용임을 배울 수 있습니다.
작은 실습: “타일을 그리는 일”을 원본 읽기 → 이미지 분석 → 마스크 생성 → 합성 → 화면 이동으로 나누세요. 어떤 결과는 캐시에 넣을 수 있고 어떤 값은 입력마다 바뀌는지 표시하면 최적화 후보가 보입니다.
PhoneServer는 인터넷 계정 대신 주소에 포함된 임시 토큰을 사용합니다. installed()는 사용자 아이콘을 적용할 대상을 실제 앱·고정 입력 목록에서 확인합니다. 경로 검사, 입력값 검증, 이미지 크기 처리, 처리 시간은 서로 다른 방어 층이라는 것을 배울 수 있습니다.
AerialFormats.resolve()는 화질 선택과 디코더 지원을 조합해 실제 화질을 고르고, lower()는 실패 시 내려갈 화질을 정합니다. AerialStream에서는 버퍼·타임아웃·캐시를 따로 설정합니다. “네트워크가 느리다”는 문제를 연결 지연·첫 화면 지연·재생 중 고갈로 나누어 생각해 보세요.
초보자가 이 저장소에서 얻을 가장 큰 습관은 기능마다 상태의 소유자, 작업의 수명, 실패 뒤의 경로를 함께 적어 보는 것입니다.
① 입력은 무엇인가 ② 어떤 상태나 파일이 바뀌는가 ③ 어느 스레드·생명주기에서 실행되는가 ④ 실패·취소·다음 입력이 오면 어떻게 되는가. 이 네 질문이면 코드 읽기가 요약문 복사에서 설계 분석으로 바뀝니다.
| 항목 | 확인된 조건 | 해석 |
|---|---|---|
| TV OS | minSdk = 21, Android 5.0 이상 | 빌드가 선언하는 하한이며 모든 기종의 완전 작동 보장은 아님 |
| 입력 장치 | 방향·확인·뒤로 키, 터치·마우스 처리도 존재 | 터치스크린 필수 기능은 Manifest에서 false |
| 화면 | 가로 화면, 960×540 기준 좌표계 | 실제 UI 크기로 확대; 이 값이 물리 패널 해상도 요구는 아님 |
| RAM·CPU | 정식 최소 사양표 없음 | 특정 메모리 용량을 지원 보장 수치로 만들 수 없음 |
| 저장 공간 | 앱·사용자 사진·그림 캐시·영상 도입부 캐시 | 48MiB는 영상 캐시 한 종류의 상한이며 전체 사용량 상한이 아님 |
| 휴대폰 설정 | 같은 LAN에서 TV의 8790 포트에 접근 가능 | 클라이언트 격리·게스트 Wi-Fi 설정에 따라 연결이 막힐 수 있음 |
| 개발 PC | README 기준 JDK 17·Android SDK 36 | Gradle 빌드용 힙 -Xmx4g와 OS·IDE 메모리를 별도로 고려 |
| ADB | 제조사별 활성화·권한 부여 절차 | 시스템 연동 설정에 사용; 코드 읽기에는 불필요 |
minSdk는 설치 가능한 최소 API이고 targetSdk는 앱이 대상으로 선언한 플랫폼 동작 기준입니다. compileSdk는 컴파일 시 참조하는 API 범위입니다. Android 공식 SDK 선언 문서와 빌드 파일을 같이 읽으면 “SDK 36으로 빌드하니 Android 36 기기만 된다”는 오해를 피할 수 있습니다.
AerialFormats.probe()는 Android 7.0 이상에서 HEVC의 3840×2160·30fps 지원을 조회하고, Dolby Vision은 추가로 특정 디코더 프로파일과 디스플레이 HDR 지원을 확인합니다. 자동 선택 후에도 실제 재생 오류가 생길 수 있어 하향 경로가 필요합니다. LiveWallpaper는 Dolby Vision의 영상 설정이 홈 타일에까지 적용되는 문제를 피하려고 SDR을 사용한다고 소스 주석에서 설명합니다.
| 영상 | 소스 주석의 대략 전송률 | 주의할 의미 |
|---|---|---|
| 1080p H.264 | 약 10Mbps | 저장소가 기본 호환 경로로 취급 |
| 4K SDR HEVC | 약 12Mbps | 4K 패널 이름만으로 디코더 지원을 판단하지 않음 |
| 4K Dolby Vision | 약 24Mbps | 프로파일·패널·네트워크 조건 모두 필요 |
이는 app/src/main/java/app/orchard/tv/wallpaper/Aerials.kt 주석의 추정 전송률이며 최저 회선 요구량을 보장하는 수치는 아닙니다. 영상 구간·서버 응답·Wi-Fi 상태에 따라 여유가 필요합니다. 내장 배경과 로컬 사진을 쓰는 홈 화면 학습에는 항공 영상 재생이 필수가 아닙니다.
HOME 등록 거부, 접근성 설정 누락, 대기 중 강제 종료, 비공개 HDMI 매핑은 별개의 제약입니다. 일반 Android TV 에뮬레이터에서 화면이 보이더라도, 제조사 TV의 부팅·HOME·입력 전환은 기종과 펌웨어별로 따로 검증해야 합니다.
아래 과제는 별도 학습 사본에서 설계 원리를 익히기 위한 연습입니다. 첫 과제는 개발 도구나 TV 없이도 할 수 있습니다.
목표: 입력이 화면 상태 변경 또는 앱 실행으로 이어지는 경로를 설명합니다.
app/src/main/java/app/orchard/tv/ui/components/Pressable.kt에서 isSelect()가 인정하는 키 코드를 찾습니다.KeyDown·KeyUp 및 포커스 변경이 바꾸는 세 필드를 표로 정리합니다.HomeScreen()의 실행 콜백과 MainActivity.launch()를 연결합니다.완료 기준: 일반 클릭·길게 눌러 메뉴 열기·화면보호기 해제의 세 시간선을 그리고, 각 이벤트를 소비하는 주체를 적습니다. “클릭 함수가 실행된다” 대신 언제·왜 실행되는지 설명해야 합니다.
목표: 화면상의 위치와 영구 식별자를 분리합니다. 시작 파일은 app/src/main/java/app/orchard/tv/apps/LayoutStore.kt입니다.
[A, B, C, D]에서 move(B, D)와 move(D, B)의 결과를 손으로 계산합니다.완료 기준: 정방향·역방향·무효 입력·일시 누락의 예상 결과를 먼저 적고 구현과 비교합니다. 일시 누락된 앱의 키를 제거하면 어떤 사용자 경험이 달라지는지 한 문단으로 설명합니다.
# 과제 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]
목표: Pressable의 핵심 원칙을 UI 없이 검증합니다.
대기, 키 누름 중, 취소 상태와 이벤트 전이표를 만듭니다.완료 기준: 정상 클릭은 정확히 한 번, 키 뗌만 받으면 0번, 포커스 상실 뒤에는 0번 실행되어야 합니다. UI를 띄우지 않고도 이 기대를 검사할 수 있는 모델이 결과물입니다.
목표: TileDiskCache와 배경 캐시의 유효성 규칙을 비교합니다.
stamp의 구성을 적습니다.완료 기준: 오래된 그림을 쓸 수 있는 조건과 다시 만들어야 하는 조건을 구별하고, 캐시 삭제가 사용자 사진 삭제와 달라야 하는 이유를 설명합니다. 성능 수치는 측정 환경을 정하기 전에는 붙이지 않습니다.
목표: 실기기 권한을 바꾸지 않고 시스템 경계의 의사결정을 모델링합니다.
SystemProbe에서 쓰는 입력·설정·음량 기능을 인터페이스 초안으로 추출합니다.AerialStream의 연결 시간 초과·캐시 없음·디코더 실패를 가짜 이벤트로 만듭니다.완료 기준: 각 실패가 UI 상태, 대체 경로, 로그에 어떻게 나타나야 하는지 표로 제시합니다. 가짜 환경에서 통과한 결과와 실제 TV에서 따로 확인할 항목을 명확히 구분합니다.
좋은 실습 결과물은 기능을 하나 늘린 화면뿐 아니라, 그 기능이 잘못 작동하면 안 되는 조건을 설명하는 작은 모델입니다.
주당 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, 배포 도구 | 요청 흐름도와 실패 대응표, 작은 학습 프로젝트 |
data class, object, sealed interface, ?., ?:부터 정리합니다.mutableStateOf를 쓰는 값 하나를 정하고 읽는 함수와 쓰는 함수를 연결합니다.delay, 시스템 콜백을 서로 다른 줄에 놓고 시간선을 그립니다.같은 집을 배관공·전기기사·가구 디자이너의 눈으로 번갈아 보는 공부입니다. 한 번에 집 전체를 외우지 않아도, 물이 흐르는 길과 전기가 흐르는 길을 따로 이해하면 나중에 두 경로가 만나는 곳이 보입니다.
별도 프로젝트에서 “앱처럼 생긴 가짜 타일 10개를 정렬하는 화면”을 만들어 보세요. 실제 앱 조회나 HOME 권한 없이도 상태 모델·리모컨 입력·이동·숨김·폴더·캐시 키를 연습할 수 있습니다. 그 다음 실제 Android 패키지 조회를 붙이면 시스템 의존성을 분리하는 이유를 체감할 수 있습니다.
전체 Aerial을 복제하는 것이 목표는 아닙니다. 선택한 설계 하나를 다른 맥락에서 설명하고 재현할 수 있다면, 그 부분을 배운 것입니다. 기기별 가드 코드는 마지막 확장 주제로 남겨 두어도 됩니다.
| 용어 | 쉬운 설명 | 이 저장소의 예 |
|---|---|---|
| Launcher | 다른 앱으로 들어가는 홈 화면 앱 | MainActivity와 HOME 선언 |
| Intent | Android에 화면 실행이나 동작을 요청하는 메시지 | 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 | 기기 부팅 후 사용자 저장소 잠금 해제 전에도 일부 동작을 허용하는 체계 | 가드의 잠금 상태 확인·해제 후 처리 |
| DreamService | Android 시스템 화면보호기용 서비스 | 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 |
저장소 메타데이터는 2026-10-23 GitHub 기준이며, 버전 설정·구조·코드 규모·테스트·CI 항목은 커밋 12ca103 기준입니다.
| 항목 | 확인 내용 | 근거 |
|---|---|---|
| 저장소·주 언어 | pengchujin/aerial-tv · Kotlin | 2026-10-23 GitHub 기준·소스 구성 |
| 스타 / 포크 | 3,403 / 248 | 2026-10-23 GitHub 기준 |
| 생성일 | 2026-10-20 | 2026-10-23 GitHub 기준 |
| 최근 push | 2026-10-23 | 2026-10-23 GitHub 기준 |
| 최신 릴리스 | v1.37 · 2026-10-23 | 2026-10-23 GitHub 기준·앱 버전 설정 |
| 최근 커밋 | 12ca103 · 2026-10-23 · 1.37: versionCode 39 | 커밋 12ca103의 정보 |
| 앱 식별자 | app.orchard.tv | app/build.gradle.kts |
| 빌드 변형 | standard / guard, versionCode 39 | 빌드 설정·standard Manifest |
| 코드 규모 | Kotlin 51파일·11,644줄, 빌드 Kotlin 3파일·123줄 | 커밋 12ca103의 추적 파일 |
| 테스트·CI | app/src/test·app/src/androidTest 디렉토리와 CI 워크플로 파일 없음 | 커밋 12ca103의 파일 목록·HANDOFF의 자동 테스트 제안 |
| 라이선스 | MIT · Copyright 2026 pengchujin | 루트 LICENSE |
| 서드파티 자산 | Inter는 OFL, 도시 데이터는 GeoNames 출처, 항공 영상은 Apple 서버 | README·FONT-LICENSE-OFL.txt·소스 |
| 문서의 설명 | 현재 코드에서 확인한 것 | 읽는 법 |
|---|---|---|
| HANDOFF: 항공 영상 로컬 캐시 없음 | AerialStream.prepareOpener()에 부분 캐시 존재 | 전체 영상 저장은 없지만 앞·뒤 구간은 미리 저장 |
| ARCHITECTURE: 패널을 열 때마다 토큰 재생성 | PhoneServer.start()는 실행 중 서버 재사용 | 새 서버 생성 때 토큰 생성, 5분 유예 중에는 재사용 가능 |
| 오래된 Xiaomi 기기 문서: 원래 런처 방문 중만 키 필터 | updateKeyFilter()는 OS·기본 HOME·통과 대상 조건 사용 | 이전 설계 설명을 현재 조건으로 오인하지 않기 |
| Hisense 기기 문서: 입력 패널 연동을 계획으로 설명 | SystemProbe.openInputPicker()에 Hisense 서비스 경로 존재 | 구현 존재와 모든 Hisense 기기 검증 완료는 별개 |
변경 속도가 빠른 저장소에서는 설명 문서가 현재 코드보다 늦을 수 있습니다. 반대로 소스에 분기가 있다는 사실만으로 그 기기에서 실제로 작동했다고 단정할 수도 없습니다. 의도는 문서에서, 현재 조건은 코드에서, 검증 범위는 기기별 기록에서 따로 확인하는 습관이 필요합니다.
코드 설명은 커밋 12ca103 기준입니다. 기기별 성공 사례와 성능 수치는 README·인수인계·기기 문서에 기록된 모델과 펌웨어를 기준으로 읽어야 합니다.
모든 TV에서 기본 HOME·자동 시작·밝기·HDMI가 된다는 보장은 없습니다. 원래 시스템 런처를 비활성화하는 실습은 피해야 합니다. 특히 저장소는 Xiaomi의 com.mitv.tvhome을 끄면 검은 화면에 갇힐 수 있다고 기록합니다.
휴대폰 설정 페이지는 TLS 없는 LAN HTTP와 임의 토큰을 사용합니다. 주소를 아는 장치가 설정 요청을 보낼 수 있으므로 공용 인터넷에 노출할 관리 서버로 다루지 마세요. 구형 TV의 인증서 호환을 위해 일부 외부 서비스에는 HTTP 대체 경로도 있습니다.
MIT는 저장소 코드의 라이선스입니다. README가 별도로 밝힌 폰트·도시 데이터·Apple 영상·Bing 이미지의 출처와 이용 조건까지 하나로 바꾸는 선언으로 읽으면 안 됩니다. 화면보호기용 영상과 배경 이미지는 외부 서버의 제공 상태에도 의존합니다.