상세 컨텐츠

본문 제목

크레마 카르타 G로 홈 디스플레이 만들기 (2) — 오래된 이북 리더에게 이미지 한 장 맡기기 (앱)

AI

by mingoon.com 2026. 11. 4. 15:11

본문

반응형

시리즈: 크레마 카르타 G로 홈 디스플레이 만들기
1편: 오늘의 정보를 이미지 한 장으로 — 서버
2편: 오래된 이북 리더에게 이미지 한 장 맡기기 — 앱 (이 글)

 

1편에서 서버가 매일 아침 e-ink용 PNG 한 장을 만들어 GitHub Pages에 올리는 과정을 다뤘다. 이번 편은 그 이미지를 받아 보여 주는 크레마 카르타 G 앱, crema-home-app 이야기다.

대상은 Android 4.4.2(API 19) 기반 e-ink 이북 리더다. 스토어에 올릴 앱도 아니고, 계속 만지는 앱도 아니다. 필요할 때 화면을 바꾸고, 나머지 시간엔 조용히 있으면 된다. 이 앱도 멀티 에이전트 하네스로 10월 24일부터 11월 1일까지 개발했다.

 

 

 


1. 이미지 한 장인데 왜 복잡해질까

처음 요구만 보면 타이머와 이미지 뷰어를 붙이면 끝날 것 같다. 그런데 하나씩 따져 보면 서로 다른 일이 섞여 있다.

  • 잠든 기기를 깨워 내려받는 일과, 화면을 깨워 보여 주는 일은 다르다.
  • 파일을 잘 받았다는 사실과 화면에 표시했다는 사실도 다르다.
  • 여기에 오래된 Android의 HTTPS 문제가 겹친다.

그래서 기능 목록보다 먼저 태도를 정했다.

  • 정해진 시각에만 이미지를 확인한다.
  • 내용이 같으면 그것 때문에 화면을 다시 깨우지 않는다.
  • 새 이미지를 쓸 수 없으면 마지막 정상 이미지를 지킨다.
  • 평소엔 이미지만 보이고, 문제가 생겼을 때만 원인을 덧붙인다.

네트워크가 잠깐 끊겼다고 멀쩡한 화면까지 잃으면, 집 안에 두는 디스플레이로는 곤란하다.


2. 설정을 만드는 대신, 설정할 일을 없애기

첫 버전엔 이미지 URL을 입력·저장하는 화면과 수동 새로고침 버튼이 있었다. 그런데 이 앱이 받을 주소는 결국 하나다. UI는 점점 줄었다.

날짜 변화
10/25 URL 입력 화면 + 수동 새로고침 버튼 + 예약 갱신
10/27 출처를 GitHub Pages 고정 주소로. URL 입력 제거
10/27 버튼과 설정 진입 제거. 앱을 새로 시작하면 캐시를 먼저 보여 주고 자동으로 갱신
10/31 이미지를 빠르게 두 번 탭하면 다시 불러오기

 

더블 탭은 버튼을 되살리는 대신 이미지 자체를 조작 대상으로 삼은 결과다. 별도 다운로드 코드를 만들지 않고 기존 갱신 경로를 그대로 부른다. 한 번 탭하거나 메뉴 키를 눌러도 아무 일도 일어나지 않는다.

화면이 단순해졌다고 내부 상태까지 사라지는 건 아니다. 버튼이 하던 설명을 이제 시작 조건과 복구 규칙이 대신 책임져야 한다.

 


3. 언제 일할 것인가: 30분 간격에서 아침 90분으로

처음 일정은 06:00~23:30 30분 간격과 00:00이었다. 단순히 "마지막 실행 + 30분"이 아니라 Schedule.next()가 현재보다 엄격히 미래인 슬롯을 계산해서 RTC_WAKEUP 알람을 하나씩 이어 건다. 재부팅이나 시각·시간대 변경 때도 다시 계산한다.

문제는 크레마의 60분 화면보호기였다. 60분 동안 조작이 없으면 화면보호기로 넘어간다. 10월 31일엔 이걸 피하려고 50분마다 화면을 잠깐 깨우는 예방 알람을 넣었다. 그런데 이 방식이 실제로 화면보호기 타이머를 리셋한다는 실기기 근거가 없었다. 앱을 전면으로 가져오지도 않았다.

그래서 11월 1일에 요구 자체를 다시 정리했다. 하루 종일 깨어 있을 필요는 없다. 아침에 보면 된다.

재설계 이후의 하루 — 07:00에 깨우고, 07:10까지 2분 간격으로 새로고침, 08:30까지 화면 유지

  • 07:00: 이미지 변경과 상관없이 화면을 깨우고 앱을 전면에 띄운다. 하루 한 번만 강제한다.
  • 07:00~07:10: 2분 간격(하루 6회) 새로고침. 이미지가 바뀌었을 때만 화면을 교체한다.
  • 07:00~08:30: 앱이 전면이면 FLAG_KEEP_SCREEN_ON으로 화면을 유지한다. 08:30 종료용 알람은 따로 두지 않고 ACTION_TIME_TICK으로 매분 다시 판정한다.
  • 그 밖의 시간: 앱이 스스로 깨우지 않는다. 기존 30분 갱신과 50분 예방 기상은 제거했다.

2분 간격이 겹치지 않는지도 따졌다. 갱신 작업의 제한 시간을 합치면 최악의 경우가 2분보다 짧아야 한다.

항목 제한
Wi-Fi 연결 대기 25초 (0.5초 간격 확인)
다운로드 전체 (리디렉션 포함) 60초
연결·읽기 1회 최대 8초
서비스 CPU wake lock / Wi-Fi lock 150초 / 120초

 

Wi-Fi 대기와 다운로드를 합쳐 최악 약 85초로 2분 안에 들어간다. 앱은 Wi-Fi를 켜고 저장된 네트워크를 기다릴 뿐, SSID나 암호를 대신 설정하거나 작업 후 Wi-Fi를 끄지는 않는다.

남은 위험도 적어 뒀다. GitHub Pages 응답엔 cache-control: max-age=600이 붙어 있어서, 07:09에 올라간 이미지가 07:10 새로고침에 안 잡힐 수 있다. 서버 cron이 06:00이라 여유는 있지만 기억해 둘 부분이다.


4. 같은 이미지인가, 이미 보여 준 이미지인가

캐시에는 서로 다른 질문이 두 개 있다.

  1. 새로 받은 이미지의 픽셀이 이전과 같은가?
  2. 지금 캐시를 Activity가 표시했는가?

둘을 성공 플래그 하나로 합치면 빈틈이 생긴다. 다운로드는 성공했는데 Activity 실행이 실패한 경우다. 다음 응답이 같은 픽셀이면 "동일 이미지니까 아무것도 안 함"으로 끝나 버리고, 화면에는 끝내 새 이미지가 안 뜬다.

픽셀 비교는 Bitmap.sameAs, 표시 수락은 캐시 PNG의 SHA-256 — 미수락이면 같은 이미지라도 다시 표시한다

  • 첫 번째 질문은 RefreshService의 Bitmap.sameAs()가 맡는다. 파일 인코딩이 달라도 픽셀이 같으면 같은 이미지다. 1편에서 말한 "Git엔 변경, 앱엔 동일"이 여기서 걸러진다.
  • 두 번째 질문은 ImageDisplayState가 맡는다. 캐시 PNG 파일의 SHA-256을 식별자로 쓰고, Activity가 표시를 마치면 그 식별자를 "수락"으로 기록한다.
  • Activity는 표시 뒤 읽었던 식별자가 지금 캐시와 같은지 다시 확인한다. 오래된 이미지를 읽은 Activity가 더 새 캐시까지 수락했다고 기록하는 걸 막기 위해서다.

물론 이 수락은 앱 안의 표시 경로가 끝났다는 뜻이다. e-ink 패널에 실제로 그려졌는지 확인하는 API는 쓰지 않는다. 제조사 전용 e-ink 리프레시 API 없이 일반 ImageView로 그린다.


5. 실패한 새 이미지보다 살아 있는 이전 이미지

다운로드 응답은 최대 4 MiB, 디코딩 이미지는 최대 200만 픽셀·한 변 4096px로 제한한다. 검증한 비트맵을 대상 옆 임시 PNG로 쓰고, fsync 후 rename으로 교체한다. 다운로드·검증·저장 중 어디서 실패해도 기존 정상 캐시가 먼저 망가지지 않는다.

화면도 같은 원칙이다. 새 캐시 디코딩이나 회전 사본 생성이 실패하면 지금 보이는 비트맵을 그대로 둔다. 디스크 캐시를 지키는 것과 메모리에 올라온 마지막 화면을 지키는 것은 별개의 방어선이다.

오류 문구를 지우는 조건도 까다롭게 잡았다. 결과가 "성공"이나 "동일"이어도 실제로 캐시를 다시 읽는 데 성공해야 오류를 지운다. 파일이 사라졌는데 이전 비트맵이 남아 있다는 이유로 "복구됐다"고 말하지 않는다.

10월 28일 API 35 에뮬레이터 QA에서 이 경계를 일부러 건드렸다. 실행 중에 캐시를 잘못된 바이트로 바꾸자 이전 비트맵과 오류가 함께 남았고, 정상 파일로 되돌리자 오류가 지워졌다. TCP 443을 실제로 막은 콜드 스타트 실패에서도 원본 캐시와 오류가 함께 표시됐다.

 

 


6. 화면에 맞추되, 원본은 돌리지 않기

서버 이미지는 1448×1072 가로형이고, 크레마는 세로로 세워 둔다. 10월 28일에 표시 방향을 시계 반대 방향 90°로 돌리고 CENTER_CROP을 적용했다.

개념도 — 캐시 파일은 그대로 두고, 화면 크기에 맞춰 다운샘플한 메모리 사본만 회전한다

핵심은 캐시 파일 자체를 회전해서 다시 저장하지 않는다는 점이다. 그래야 4장의 SHA-256 식별자가 흔들리지 않는다. 샘플링 판단도 회전 후의 가로·세로 크기를 기준으로 한다. 대신 중앙 크롭은 비율이 안 맞으면 가장자리를 자르므로, 서버 쪽에서도 중요한 내용을 가장자리에 두지 않는 게 좋다.


7. HTTPS는 되게 하되, 검증은 없애지 않기

오래된 Android에서 HTTPS 이미지를 받는 건 URL 하나로 끝나지 않는다. TLS 버전, 신뢰 루트, 암호군을 각각 봐야 한다. 10월 31일 TLS 통합 뒤에 Conscrypt 번들 보완이 바로 이어진 것도 그래서다. TLS 1.2라는 이름이 같아도 기기 암호화 엔진이 서버와 맞는 암호군을 못 고를 수 있었다.

TLS의 범위와 검증의 경계 — 우회는 넣지 않았고, 실기기 핸드셰이크는 아직 미검증이다

  • API 19를 지원하는 Conscrypt 2.5.2를 APK에 넣고, 연결별로 TLS 1.2를 쓴다. 앱 전체 기본 제공자는 바꾸지 않는다.
  • 고정 호스트에는 ISRG Root X1만 신뢰 루트로 쓴다. 다른 HTTPS 호스트로 리디렉션되면 시스템 신뢰 저장소를 쓴다.
  • 호스트명 검증은 기본 동작 그대로. 모든 인증서를 믿게 하거나 HTTP로 내리는 우회는 넣지 않았다.

이 선택은 유지보수 의무도 남긴다. 기기 시계가 틀리거나 서버 CA 체인이 바뀌면 실패한다. Conscrypt 2.5.2는 오래된 BoringSSL을 담고 있다. 호환성을 보완했다고 지원 끝난 OS가 안전한 최신 환경이 되는 건 아니다.


8. Gradle 없는 빌드, 그리고 말할 수 있는 범위

이 저장소는 Gradle 프로젝트가 아니다. Java 17과 Python 3로 API 19를 직접 컴파일하는 커스텀 빌드다. scripts/check.sh 하나가 다음을 돌린다.

  • 호스트 JVM 검사: 예약 시각, 시간대 경계, 표시 세션, HTTP 응답, 캐시 식별·수락 (assertion 수천 개)
  • 더블 탭 회귀 검사: 일부러 깨뜨린 코드 변형을 거부하는지
  • API 19 컴파일 → DEX → APK 서명·정렬 → Conscrypt JNI 4개 ABI 포함 확인

테스트가 늘수록 "어디까지 확인했는가"를 더 정확히 말해야 했다. 에뮬레이터 QA는 API 35에서 했고, 그날 버전의 증거일 뿐이다. 크레마 실기기에서의 TLS 핸드셰이크, 화면보호기 차단, 화면보호기 상태에서의 07:00 기상은 아직 미검증이다.

그래서 마지막으로 만든 건 기능이 아니라 시험 앱이다. 크레마엔 adb를 쓸 수 없어서, 기기 안에서 스스로 기록하는 별도 패키지 com.crema.home.probe를 만들었다. 1분 간격으로 시각·화면 상태·전면 앱을 기록해서, 화면보호기가 Android 표준 타임아웃인지 제조사 자체 타이머인지부터 가려낼 계획이다. 본 앱도 timing-<날짜>.log에 알람·새로고침·화면 유지 변경을 7일분 남긴다.

 

 


9. 정리

이미지 한 장을 띄우는 앱에서 배운 건 기능을 많이 넣는 법이 아니었다. 다음을 서로 다른 사실로 다루는 법이었다.

  • 내려받았다 ≠ 저장했다 ≠ 표시 경로를 마쳤다 ≠ 패널에 그려졌다
  • 바이트가 바뀌었다 ≠ 픽셀이 바뀌었다
  • 호스트 테스트 통과 ≠ 실기기 동작

서버(1편)가 "실패 뒤에 무엇을 남길지"를 정하는 일이었다면, 앱은 "성공이라는 말을 어디까지 써도 되는지"를 정하는 일이었다. 오래된 기기에 일을 맡길수록 그 선이 구현만큼 중요해진다. 실기기 시험 결과가 나오면 후기로 다시 정리할 생각이다.


이전 글: 크레마 카르타 G로 홈 디스플레이 만들기 (1) — 서버

 

 

※ 이 글은 AI의 도움을 받아 작성했고, 작성자가 내용을 직접 검토한 뒤 올렸다.

 

관련글 더보기

댓글 영역