2026/05/05

오늘의 이야기


#billcorea #운동동아리관리앱
🏸 Schneedle, aplikasi yang wajib dimiliki oleh klub bulu tangkis!
👉 Match Play – Rekam Skor & Temukan Lawan 🎉
Sempurna untuk di mana saja, sendirian, bersama teman, atau di klub! 🤝
Jika Anda suka bulu tangkis, cobalah

Buka aplikasi 👉 https://play.google.com/store/apps/details?id=com.billcorea.matchplay




오늘의 이야기

<!doctype html>


 


🧭 Android | 위치 수신·히스토리 저장 및 Vico 그래프 NaN 크래시 대응


앱의 그래프 그리기



 


개요 (Intro)



  • 오늘의 목표 / 배경: 폰앱에서 위치(및 걸음수)를 영구 저장(히스토리)하고, Setting 화면에는 마지막 정보만 노출, 메인 화면의 Vico 그래프에 시간축으로 X/Y/Z와 걸음수 시리즈를 표기(색상 구분) — 동시에 발생한 런타임 크래시(NaN 관련)를 해결한다.

  • 해결하려던 문제: Vico 라인 차트에서 마커/툴팁 계산 중 NaN 값이 투입되어 앱이 크래시 나는 문제. 또한 걸음수가 그래프에 보이지 않는 문제와 설정 화면의 측정 상태가 앱 재진입 시 유지되지 않는 문제를 정리.

  • 사용한 기술 스택: Kotlin, Jetpack Compose, Vico chart, Health Connect API, Coroutines, Android Studio


📅 날짜: 2025.12.07
🎯 목표: 위치 히스토리 영구 저장, Setting 화면 최근값 표시, Vico 그래프에 X/Y/Z/Steps 표시(시간 축), NaN 크래시 방지
🧰 기술: Kotlin, Compose, Vico, Coroutines

문제 정의 (Problem / Motivation)


작업 중에 다음과 같은 런타임 예외가 발생했습니다:



java.lang.IllegalArgumentException: Cannot round NaN value.
at kotlin.math.MathKt__MathJVMKt.roundToInt(MathJVM.kt:1192)
at com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer.updateMarkerTargets(LineCartesianLayer.kt:471)
... (생략)

원인으로 의심되는 상황:



  • 시간별 배열을 FloatArray(24)로 만들고 기본값을 Float.NaN으로 두었음 — 일부 시간대에 값이 없으면 NaN이 남아 있음.

  • Vico 차트에 여러 시리즈(예: X, Y, Z, Steps)를 정의하는 레이어는 고정된 시리즈 수를 기대하거나, 마커 계산 시 시리즈 내부 포인트가 없는 경우를 안전하게 처리하지 못함.

  • lineSeries 빌더에서 조건부로 시리즈를 추가하면 레이어 쪽 series 컬러 매핑과 시리즈 개수가 맞지 않아 인덱스 문제/NaN 전파가 발생할 수 있음.


// 문제를 일으킨 코드 개요 (발생 사례 일부)
val locXHourly = FloatArray(24) { Float.NaN }
// ... 최근 위치를 시간별로 채움
// 일부 시간대는 NaN으로 남아 있을 수 있음
// 이후 Vico에 시리즈를 만들 때 NaN이 남아 있는 값 때문에 마커 계산이 NaN을 전달받아 roundToInt 에러 발생

해결 과정 (How I Solved It)


접근한 순서:



  1. 원인 분석 — 로그와 스택트레이스에서 NaN이 roundToInt로 전달된 것을 확인.

  2. 데이터 포맷/시리즈 구성 점검 — Vico 레이어가 기대하는 시리즈 수와 순서를 맞추도록 수정 방향 결정.

  3. 안전성 확보 — NaN이 마커 계산으로 전달되지 않도록 시리즈를 일관된 길이로 만들거나, 마커를 비활성화/무시하도록 보호 코드 추가.

  4. 걸음수 표시 문제 해결 — 시간 축 기준으로 시간별 합계를 구하고 그래프 범위/스케일을 조정하여 눈에 띄게 함.

  5. Setting 화면 상태 영속화는 DataStore/SharedPreferences로 저장하도록 계획(이번 변경에서는 로그와 그래프 중심 수정에 초점).


핵심 수정 아이디어(예시 코드)


아래 코드는 Vico 시리즈를 생성할 때 안전하게 NaN을 처리하고, 항상 레이어에 정의된 시리즈 개수를 맞추기 위한 예시입니다. 초보자도 이해하기 쉽도록 주석을 상세히 달았습니다.


// 안전한 시리즈 생성 예시 (MainScreen.kt의 modelProducer 구성부에서 사용)
// 1) 공통 X축: hours (0..23)
val hours = (0..23).toList()

// 2) 시간별 값들을 미리 계산 (값이 없으면 null로 둠)
val xValuesNullable: List<Float?> = hours.map { h -> /* 값이 있으면 실수, 없으면 null */ null }
// 실제 코드에서는 recentLocations를 순회해서 채움

// 3) Vico에 넣을 때는 레이어가 기대하는 시리즈 개수(예: 4)에 맞춰 항상 series를 추가
// 값이 없는 포인트는 0f로 대체하거나, 이전 값으로 보간하거나, 마커를 끄는 방식 중 선택
val xValuesForChart = xValuesNullable.map { it ?: 0f } // 비어있을 때 0으로 대체 (안전한 방법)
val yValuesForChart = /* 동일 처리 */ hours.map { 0f }
val zValuesForChart = /* 동일 처리 */ hours.map { 0f }
val stepsValuesForChart = /* 시간별 steps, 스케일 조정 */ hours.map { h -> (stepsHourly[h] / 10f) }

// 4) lineSeries에 항상 4개의 시리즈를 추가 (레이어와 일치)
lineSeries {
series(hours, xValuesForChart)
series(hours, yValuesForChart)
series(hours, zValuesForChart)
series(hours, stepsValuesForChart)
}

// 주석: 0으로 대체하면 그래프 수치 왜곡 가능성이 있으므로, 시각적으로는
// 값이 없음을 별도로 표시(예: 투명도 낮게 그리거나 범례에 설명 추가)하는 것이 좋습니다.

또한 마커 계산 시점에 안전 장치를 추가합니다:


// 마커 사용 시 안전 체크 예시
val marker = rememberMarker()
// modelProducer에 데이터가 아예 없을 땐 marker를 null처럼 취급하거나
// marker가 포인트 좌표를 계산할 때 NaN을 만나면 아무것도 그리지 않도록 구현
// (이 부분은 vico의 marker 콜백 구현 시점에 방어 코드 추가 필요)

결과 (Result)


✅ NaN으로 인한 라인 차트 마커 크래시의 원인을 파악했습니다. (NaN이 roundToInt로 전달되어 예외 발생)

적용한 전략의 장점:



  • 시리즈 개수와 레이어 정의를 일치시켜 인덱스 불일치 문제를 제거했습니다.

  • 빈 시간대 처리(0 또는 보간)는 크래시를 방지합니다. 시각적 정확성은 추가 보완 필요(예: 투명도/점선 처리).

  • 걸음수는 시간별로 합산해 별도 시리즈로 추가했으며, 그래프 상에서 보이도록 스케일을 조정했습니다(예: /10 표기).


느낀 점 / 회고 (Reflection)



  • 데이터 시각화 라이브러리는 데이터 포맷에 민감합니다. 특히 여러 시리즈를 그릴 때 길이와 인덱스 정합성을 반드시 맞춰야 합니다.

  • 디버깅 시 스택트레이스가 가리키는 함수 내부에서 어떤 입력값이 NaN/무효값인지 역추적하는 것이 중요했습니다. 로그와 작은 재현 데이터셋을 만들어 원인을 좁혔습니다.

  • 완벽한 해결을 위해선 다음 작업이 필요합니다: (1) SettingScreen의 측정 시작 상태 영속화(DataStore), (2) 빈값 처리 시 시각적 표시 개선(투명도/점선/툴팁에서 ‘데이터 없음’ 표기), (3) 마커가 NaN을 만나면 안전하게 무시하도록 vico 콜백 방어 코드 강화.


참고자료 (References)



  • Vico 공식 문서 및 예제 (GitHub) - https://github.com/patrykandpatrick/vico

  • Kotlin stdlib - Float.isNaN(), roundToInt() 동작 참고

  • Android Developers - Data persistence (DataStore) 가이드





오늘의 이야기

 



🦾 Android | 워치앱 빌드 오류 수정과 UI/국제화 개선 정리


카드서비스



 


개요 (Intro)



  • 오늘의 목표 / 배경: 워치앱(MainActivity) 빌드 오류를 해결하고, 스크롤바/버튼 텍스트 레이아웃/국제화(strings.xml) 개선

  • 어떤 문제를 해결하려 했는지: 미선언 변수(dayKey)로 인한 컴파일 오류, 텍스트 줄임 표시, 스크롤바 가시성, 하드코딩 문자열 정리

  • 사용한 기술 스택: Kotlin, Jetpack Compose, Wear OS, Coroutine, Hilt


📅 날짜: 2025.12.11
🎯 목표: 워치앱 컴파일 오류 제거 + UI/국제화 개선
🧰 기술: Kotlin, Android Studio, Compose, Wearable APIs

문제 정의 (Problem / Motivation)



  • 워치앱의 MainActivity.kt에서 Unresolved reference 'dayKey' 빌드 에러 발생

  • 타일/메인 화면 일부 텍스트가 길어 줄임표(...)로 보이는 문제

  • 스크롤바가 배경과 유사해 가시성이 떨어짐

  • 하드코딩된 한글 문자열의 국제화(strings.xml) 필요


// 컴파일 에러 예시 (요약)
// e: MainActivity.kt: Unresolved reference 'dayKey'
// 자정 체크 로직에서 dayKey 상태가 정의되어 있지 않음

해결 과정 (How I Solved It)



  • dayKey 상태 추가: 자정 변경 감지를 위해 remember { mutableStateOf(...) }로 dayKey를 선언

  • 스크롤바 색 개선: 배경색의 보색(Complementary color)로 스크롤바 트랙/썸을 그려 가시성 상승

  • 텍스트 레이아웃 개선: 여백 조정 및 항목 분리로 줄임표 빈도 감소, 향후 Chip/버튼 폭/높이 조정 계획 반영

  • 국제화: 화면에 보이는 주요 한글 문자열은 stringResource(...)로 대체하고 리소스로 이동(추가 정리 예정)


// 핵심 수정 예시: dayKey 상태 추가 및 자정 리셋 로직 유지
@Composable
fun WearApp() {
// ... 기존 상태들 ...
// 일자 키(자정 교체 감지용)
var dayKey by remember {
mutableStateOf(
java.time.ZonedDateTime.now()
.format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"))
)
}

// 자정 변경 감지: 1분마다 체크하여 dayKey 달라지면 스텝/PDR 리셋
LaunchedEffect(Unit) {
while (true) {
val current = java.time.ZonedDateTime.now()
.format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"))
if (current != dayKey) {
dayKey = current
// 스텝 리셋 및 PDR 리셋
passiveStepsManager.stop(); passiveStepsManager.start()
pdr.reset()
}
kotlinx.coroutines.delay(60_000L)
}
}
}

// 스크롤바 가시성 개선: 배경색 보색 사용
val animatedBg by animateColorAsState(targetValue = bgColor, animationSpec = tween(150))
val scrollbarColor = Color(1f - animatedBg.red, 1f - animatedBg.green, 1f - animatedBg.blue)

Canvas(modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 2.dp)
.height(100.dp)
.width(3.dp)) {
val contentHeight = scrollState.maxValue.toFloat() + size.height
if (contentHeight > size.height + 1f) {
val ratio = size.height / contentHeight
val thumbHeight = (size.height * ratio).coerceAtLeast(12f)
val scrollY = (scrollState.value / scrollState.maxValue.toFloat()).coerceIn(0f, 1f)
val thumbTop = (size.height - thumbHeight) * scrollY
drawRect(color = scrollbarColor.copy(alpha = 0.2f), size = size) // 트랙
drawRect(color = scrollbarColor, topLeft = Offset(0f, thumbTop), size = Size(size.width, thumbHeight)) // 썸
}
}

// 국제화 적용 예시: 하드코딩 제거
Text(stringResource(R.string.steps_label) + ": $steps (Δ $lastDelta)")
Text(stringResource(R.string.altitude_barometer_label) + ": $altText")

결과 (Result)



  • 워치앱 컴파일 오류 해결 (dayKey 상태 추가)

  • 스크롤바가 배경과 대비되어 가시성 향상

  • 텍스트 줄임표 이슈 완화, 국제화 기반으로 문자열 관리 준비


✅ 워치앱이 정상 빌드/실행됨
📱 UI 가독성 개선, 유지보수성(국제화) 향상 기반 구축

느낀 점 / 회고 (Reflection)



  • 작은 상태 누락(dayKey)도 앱의 핵심 흐름(자정 리셋)에 영향을 크게 줄 수 있음

  • UI 색 대비는 접근성과 직결되므로 초기 단계부터 고려하는 게 중요

  • 국제화는 나중에 몰아서 하기보다, 화면 작업과 함께 병행해야 리스크가 줄어듦


참고자료 (References)






오늘의 이야기

<!doctype html>



🐾 Android | Kalman vs EMA — 고도/센서 데이터 필터링 비교와 적용기


센서 그래프 예시



 


개요 (Intro)



  • 오늘의 목표 / 배경: 위치·고도 데이터(바로미터 + PDR/IMU)를 안정적으로 시각화/동기화하기 위해 Kalman 필터와 EMA(지수평활)의 차이를 비교 분석하고, 프로젝트에 적용할 방향을 정리한다.

  • 해결하려는 문제: 그래프의 NaN 크래시 방지, 스파이크(이상치)와 드리프트에 강한 필터링, 실시간성(응답속도) 유지 간의 균형.

  • 사용한 기술 스택: Kotlin, Jetpack Compose, Vico chart, Coroutines, Wear OS


📅 날짜: 2025.12.09
🎯 목표: Kalman/EMA 특성 비교 및 프로젝트 적용 가이드 정리
🧰 기술: Kotlin, Compose, Vico, Wear OS, Coroutines

문제 정의 (Problem / Motivation)



  • 바로미터(고도) 데이터는 환경/기압 변화에 민감해 노이즈와 스파이크가 잦음.

  • PDR/IMU는 누적 드리프트가 생기기 쉬움(특히 장시간).

  • 그래프에 NaN이 들어가면 마커/그리기 로직에서 크래시 위험(실제 NaN round 예외 경험).

  • 실시간 UI에서는 빠른 반응과 안정적 시각화 둘 다 필요.


// NaN 방어를 위해 앞값 채움(fill-forward)으로 시계열을 안전화한 부분
fun fillForward(arr: FloatArray): List<Float> {
val out = ArrayList<Float>(arr.size)
var last = 0f
var seen = false
for (i in arr.indices) {
val v = arr[i]
if (!v.isNaN()) { last = v; seen = true; out.add(v) }
else { out.add(if (seen) last else 0f) }
}
return out
}

해결 과정 (How I Solved It)


EMA(지수평활) — 간단·저비용 필터



  • 정의: y[n] = α·x[n] + (1-α)·y[n-1]

  • 특징: 구현 매우 쉬움, 레이턴시 낮음, 이상치에는 약함.


// 초보자도 이해하기 쉬운 EMA 필터 — 주석 상세 버전
class EmaFilter(private val alpha: Float) {
private var state: Float? = null // 이전 출력값(초기엔 없음)

// 새 입력값 x를 받아 필터링된 값 반환
fun update(x: Float): Float {
val s = state
val out = if (s == null) {
// 첫 샘플은 기준값으로 사용
x
} else {
// EMA 핵심 — 현재 입력과 이전 출력의 가중 평균
alpha * x + (1 - alpha) * s
}
state = out
return out
}

fun reset() { state = null }
}

Kalman — 상태공간 기반 최적 추정(선형/가우시안 가정)



  • 특징: 모델(A,H), 노이즈(Q,R)로 예측/갱신. 드리프트·노이즈 균형적으로 제어.

  • 장점: 통계적으로 최적(가정 하), 속도/가속 등 상태 추정 가능.

  • 단점: 튜닝 복잡, 계산량 EMA보다 큼.


// 1D 고도용 아주 간단한 Kalman — 주석 상세
class SimpleKalman1D(
private var q: Float, // 프로세스(모델) 노이즈 공분산 — 모델 불확실성
private var r: Float // 측정 노이즈 공분산 — 센서 불확실성
) {
private var x: Float = 0f // 상태(고도)
private var p: Float = 1f // 상태 오차 공분산

fun init(initial: Float, initialP: Float = 1f) {
x = initial
p = initialP
}

// 예측 단계 — 간단 모델(고정)에서는 p만 늘려 불확실성 반영
fun predict() {
p += q
}

// 갱신 단계 — 관측 z 반영
fun update(z: Float): Float {
val k = p / (p + r) // 칼만 이득(측정 신뢰도 vs 상태 신뢰도 비율)
x = x + k * (z - x) // 상태 보정
p = (1 - k) * p // 오차 공분산 업데이트
return x
}
}

상보(Complementary) 융합 — 간단 융합법



  • 예: fusedZ = α·pdrZ + (1-α)·baroZ

  • 장점: 직관적인 튜닝, 구현 쉬움.

  • 단점: 노이즈 통계 반영 없음, 이상치 처리 필요.


// 프로젝트에서 사용 중인 융합 아이디어(유사): baro와 ENU zUp을 비율로 합성
val fusedZ = altitudeFusionRatio * enu.zUp + (1 - altitudeFusionRatio) * baro

결과 (Result)



  • 그래프 입력의 NaN 제거(앞값 채움)로 마커 크래시 방지.

  • EMA/상보/칼만의 트레이드오프를 정리해 적용 방향을 확립.

  • 실시간 UI 요구가 큰 경우: EMA 또는 상보 필터부터 적용하고, 필요 시 칼만으로 단계적 고도화.


✅ 실시간성(EMA/상보)과 안정성(칼만)의 균형을 프로젝트 요구에 맞게 선택할 수 있도록 기준을 수립.

느낀 점 / 회고 (Reflection)



  • NaN 같은 ‘형식적 오류’도 시각화 라이브러리에서 큰 크래시 원인이 된다 — 입력 정규화가 최우선.

  • EMA는 UI엔 매우 좋은 출발점. 칼만은 모델·노이즈 튜닝이 핵심 — 로그 기반 정량 튜닝이 필요.

  • 상보 필터는 간단하지만 이상치 방어(스파이크 클리핑/중앙값 필터)와 함께 쓰면 체감 품질이 훨씬 좋아진다.


참고자료 (References)



  • Kalman Filter — Greg Welch & Gary Bishop, "An Introduction to the Kalman Filter"

  • Signal Processing Stack Exchange — EMA/Moving Average 비교 토론

  • Android Sensor Docs — Barometer, SensorManager Delay 등

  • Vico Chart — https://github.com/patrykandpatrick/vico


다음 단계 제안

  1. 바로미터 측정에 중앙값(Median) 또는 MAD 기반 이상치 제거를 추가.

  2. 상보 필터 계수(altitudeFusionRatio)를 프로파일(배터리/표준/고속)에 따라 자동 조정.

  3. 간단한 1D 칼만(고도+속도)로 샘플 러닝 테스트 — Q/R 튜닝 로그(innovation) 기록.






오늘의 이야기



#스치니1000프로젝트 #재미 #행운기원 #Compose #Firebase

🎯 야 너 토요일마다 로또 확인하냐?
나도 맨날 “혹시나~” 하면서 봤거든 ㅋㅋ

근데 이제는 그냥 안 해
AI한테 맡겼어 🤖✨

그것도 구글 Gemini로다가!

그래서 앱 하나 만들었지
👉 “로또 예상번호 by Gemini” 🎱

AI가 분석해서 번호 딱! 뽑아줌
그냥 보고 참고만 하면 됨

재미로 해도 좋고…
혹시 모르는 거잖아? 😏


https://play.google.com/store/apps/details?id=com.billcorea.gptlotto1127




오늘의 이야기


#스하리1000명프로젝트

오늘 내가 만든앱 하나 알려주고 싶어, 이 앱은 알림수집기 라고 이름을 붙였는 데,
내 폰에 표시 되는 알림을 읽어서 내가 지정한 단어가 들어 있고, 지출기록을 남겨야 하는 알림이
있으면 수집하고, 카카오톡으로 친구에게 전달해 주는 기능을 구현해 줄꺼야. 📲

이번 패치에서는 하루 한번 지정한 시간에 나에게 알림(노티) 하도록 기능을 추가 했어. 🙏
한번 써보고 불편한 거 있으면 말해줘.

앱 바로가기
👉 https://play.google.com/store/apps/details?id=com.nari.notify2kakao





오늘의 이야기

 


🪄 Wear OS | Complication 탭 액션 구현 및 타일/칩 UI 정리


wear os 화면



개요 (Intro)


오늘의 목표는 Wear OS 관련 컴포넌트에서 사용자 인터랙션(특히 콤플리케이션 탭)을 통해 앱을 열 수 있도록 안정적인 동작을 구현하고, 타일(Tile) 및 칩(Chip) UI를 정리해 원형 기기에서 잘리지 않도록 개선하는 것이었습니다.


해결하려던 문제는 콤플리케이션을 탭했을 때 앱(MainActivity)으로 안전하게 진입하도록 PendingIntent와 Intent의 플래그/extra를 적절히 설정하고, 타일에서 불필요하게 전체 목록을 노출하던 부분을 3줄(앱 이름/완료수·대상수/앱으로 가기 버튼)으로 간소화하는 것입니다.


📅 날짜: 2025.11.19
🎯 목표: Complication 탭 액션(앱 진입) 안정화, Tile/Chip UI 간소화 및 원형 컷오프 방지
🧰 기술: Kotlin, Wear OS Tiles/Complications, Android PendingIntent, DataLayer

문제 정의 (Problem / Motivation)


프로젝트의 Wear 모듈에서 다음과 같은 문제가 있었습니다:



  • 콤플리케이션을 탭했을 때 앱으로 넘어가는 동작이 불분명하거나 extras가 안정적으로 전달되지 않아 앱 쪽에서 출처를 알기 어려운 경우가 있음.

  • 타일에서 습관 전체 목록을 나열하면 UI가 복잡해지고, 원형 기기에서는 칩이 가장자리에서 잘리는 현상이 관찰됨.


특히 콤플리케이션의 tapAction은 PendingIntent를 통해 앱을 여는 방식이지만, PendingIntent의 플래그나 Intent의 설정(FLAG_UPDATE_CURRENT, FLAG_IMMUTABLE, 추가 extras 등)에 따라 동작이 달라질 수 있어 이를 명확히 구현할 필요가 있었습니다.


// 예: MainComplicationService에서 PendingIntent 생성 부분
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
putExtra("from_complication", true)
action = "com.billcorea.habit1007.ACTION_OPEN_FROM_COMPLICATION"
}
val pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val pendingIntent = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags)

해결 과정 (How I Solved It)


시도한 접근법과 적용한 변경 사항은 다음과 같습니다.



  1. 콤플리케이션 탭 시 앱으로 진입하는 Intent에 출처 표시를 위한 extras를 추가했습니다. (putExtra("from_complication", true))

  2. Intent에 고유 action을 넣어 앱 내에서 호출 출처를 더 명확히 구분할 수 있게 했습니다. (action = "com.billcorea.habit1007.ACTION_OPEN_FROM_COMPLICATION")

  3. PendingIntent 생성 시 FLAG_UPDATE_CURRENT와 FLAG_IMMUTABLE를 조합해 기존 PendingIntent를 최신화하고 보안성을 보장했습니다.

  4. 타일(`MainTileService`)은 전체 습관 목록을 노출하는 대신 요약 정보(완료 건수 / 대상 건수)와 앱으로 가기 버튼(Chip)만 표시하도록 간소화했습니다.

  5. 원형 기기에서의 칩 잘림 문제는 칩 너비/높이를 화면 크기 기반으로 보수적으로 계산하고 내부 패딩/아이콘 크기 조정, 항목 간 간격을 늘려 대응했습니다.


// 핵심: Complication 탭 액션에 Intent extras + 안전한 PendingIntent 플래그 적용
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
putExtra("from_complication", true)
action = "com.billcorea.habit1007.ACTION_OPEN_FROM_COMPLICATION"
}
val pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val pendingIntent = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags)
ShortTextComplicationData.Builder(...).setTapAction(pendingIntent).build()

이유: extras와 action을 넣으면 앱(예: MainActivity)에서 Intent를 검사해 "콤플리케이션에서 왔음"을 판별하고, 필요 시 특정 화면으로 바로 이동시키는 로직을 실행할 수 있기 때문입니다. FLAG_UPDATE_CURRENT는 PendingIntent 갱신을 보장하고, FLAG_IMMUTABLE은 보안 권장사항입니다.


결과 (Result)


적용 결과는 다음과 같습니다.



  • 콤플리케이션 탭 시 앱이 안정적으로 열리고, Intent extra("from_complication") 또는 action으로 호출 출처를 앱에서 판단할 수 있게 되었습니다.

  • 타일은 더 간결해져 정보 전달이 명확해졌고, 원형 화면에서 칩이 잘리는 문제를 완화할 수 있는 구조로 수정했습니다.


✅ Complication 탭에서 MainActivity로 안전하게 진입하도록 구현 완료
✅ 타일 레이아웃을 3줄(앱 이름/완료수·대상수/앱으로 가기 버튼)로 간소화 완료

느낀 점 / 회고 (Reflection)



  • Wear OS에서는 각 플랫폼 컴포넌트(Complication/Tile)의 동작 방식을 정확히 이해하는 것이 중요합니다. 특히 PendingIntent와 Intent 플래그, action/extras 관리가 핵심입니다.

  • 작은 UI 요소(칩, 패딩)는 원형 기기에서 큰 영향을 줍니다 — 항상 원형 안전 영역을 고려해 크기와 패딩을 설계해야 합니다.

  • 다음 개선: 앱 쪽(MainActivity)에서 Intent의 action과 extras를 핸들링해 "콤플리케이션에서 진입했을 때 특정 화면(예: 오늘의 습관 상세)로 바로 이동"하는 로직을 추가할 계획입니다.


참고자료 (References)






오늘의 이야기


#스하리1000명프로젝트,
Nawala sa Korea? Kahit na hindi ka nagsasalita ng Korean, tinutulungan ka ng app na ito na madaling makalibot.
Sabihin lang ang iyong wika—ito ay nagsasalin, naghahanap, at nagpapakita ng mga resulta pabalik sa iyong wika.
Mahusay para sa mga manlalakbay! Sinusuportahan ang 10+ wika kabilang ang English, Japanese, Chinese, Vietnamese, at higit pa.
Subukan ito ngayon!
https://play.google.com/store/apps/details?id=com.billcoreatech.opdgang1127




2026/05/04

오늘의 이야기

 



⌚ Wear OS | 센서 수명주기·권한·동기화 연결로 기본 데이터 파이프 완성


워치, 모바일



 


개요 (Intro)



  • 오늘의 목표: Wear OS에서 걸음 수/헤딩/고도/위치를 안정적으로 수집하고, 폰과 동기화까지 이어지는 파이프를 작동 상태로 만들기

  • 배경: 기존 코드에 센서 시작/중지, 런타임 권한, 폰-웨어 메시지 등록이 일부 누락되어 실사용에 불편함

  • 사용 기술: Kotlin, Jetpack Compose, Hilt, Google Play Services (Wearable/Location), SensorManager


📅 날짜: 2025.12.05
🎯 목표: 센서 수명주기 연결 + 권한 요청 + 동기화 작동 상태 만들기
🧰 기술: Kotlin, Compose, Hilt, Wearable Data Layer, SensorManager

문제 정의 (Problem / Motivation)



  • 센서 매니저(PassiveSteps/Heading/Altitude)가 start()/stop()로 생명주기에 연결되지 않음

  • WearDataSyncManager의 register()/unregister() 미호출로 메시지 수신/전송 상태 반영이 어려움

  • 걸음 업데이트와 PDR(보행자 추정항법)·스텝 전송의 연계가 빠져 있음

  • 런타임 권한(특히 ACTIVITY_RECOGNITION) 요청 흐름이 없어 첫 실행 경험이 매끄럽지 않음


// 예시: LiveData가 아니라 Compose state를 쓰고 있으나, 문제의 본질은 동일
// "센서 시작/중지"와 "동기화 리스너"가 Activity 수명주기에 묶여야 한다는 점.
val passiveStepsManager = remember(context) { PassiveStepsManager(context, scope) { total, delta ->
// 1) UI 업데이트
steps = total
lastDelta = delta
// 2) PDR 누적 (현재 헤딩 반영)
pdr.onStep(delta, headingRad)
// 3) 폰으로 스텝 동기화 (내부 스로틀 보유)
syncManager.sendSteps(total)
} }

해결 과정 (How I Solved It)



  1. 런타임 권한 요청을 Activity에 추가: 위치 + 활동 인식 분리 요청

  2. 센서·동기화 수명주기 연결: Compose에서 DisposableEffect로 start/stop, register/unregister 보장

  3. 걸음 → PDR → 동기화 파이프: onStepsUpdate에서 PDR 누적과 sendSteps 호출

  4. 위치 스트리밍은 임계치/간격/enable 토글 기준으로 collect하며 폰으로 전송


// 1) 권한 요청 런처(위치 + 활동 인식)
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions()
) { result ->
val locGranted = result[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
result[Manifest.permission.ACCESS_COARSE_LOCATION] == true
val actGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
result[Manifest.permission.ACTIVITY_RECOGNITION] == true else true
hasLocationPermission = locGranted
hasActivityRecognitionPermission = actGranted
}

// 2) 센서와 동기화 수명주기 연결 (Activity 내 Composable)
DisposableEffect(Unit) {
// 센서 시작
headingProvider.start() // 회전벡터 → heading
altitudeMonitor.startMonitoring() // 기압 → 고도
passiveStepsManager.start() // 스텝 카운터

// 동기화 리스너 등록 + 현지 스텝 제공자 설정
syncManager.localStepsProvider = { steps }
syncManager.register()

// 컴포저블이 사라질 때 정리
onDispose {
passiveStepsManager.stop()
headingProvider.stop()
altitudeMonitor.stopMonitoring()
syncManager.unregister()
}
}

// 3) 걸음 업데이트 -> PDR 누적 및 스텝 전송
val passiveStepsManager = remember(context) {
PassiveStepsManager(context, scope) { total, delta ->
steps = total
lastDelta = delta
pdr.onStep(delta, headingRad) // 보행자 경로 누적
syncManager.sendSteps(total) // 폰으로 전송(스로틀 내장)
}
}

// 4) 위치 ENU 흐름을 폰으로 전송 (임계/간격/토글 기준)
LaunchedEffect(enuFlow, xyzIntervalMs, streamEnabled) {
if (enuFlow == null || !streamEnabled) return@LaunchedEffect
var lastSentX = Double.NaN
var lastSentY = Double.NaN
var lastSentZ = Double.NaN
var lastSentHeading = Double.NaN
var lastSentAt = 0L
val minIntervalMs = 2000L
val posThresh = 0.3
val headingThreshDeg = 5.0

enuFlow.collect { enu ->
val baro = altitude
val fusedZ = baro?.let { b -> altitudeFusionRatio * enu.zUp + (1 - altitudeFusionRatio) * b } ?: enu.zUp
val headingDegNow = Math.toDegrees(headingRad)
val now = System.currentTimeMillis()
val shouldSend = now - lastSentAt >= minIntervalMs ||
lastSentX.isNaN() || kotlin.math.abs(enu.xEast - lastSentX) > posThresh ||
lastSentY.isNaN() || kotlin.math.abs(enu.yNorth - lastSentY) > posThresh ||
lastSentZ.isNaN() || kotlin.math.abs(fusedZ - lastSentZ) > posThresh ||
lastSentHeading.isNaN() || kotlin.math.abs(headingDegNow - lastSentHeading) > headingThreshDeg
if (shouldSend) {
val payload = LocationPayload(
xEast = enu.xEast,
yNorth = enu.yNorth,
zFused = fusedZ,
headingDeg = headingDegNow,
pdrX = pdrState.value.xEast,
pdrY = pdrState.value.yNorth,
timestamp = now,
deviceId = Build.MODEL ?: "wear",
source = "wear"
)
syncManager.sendLocation(payload)
lastSentX = enu.xEast; lastSentY = enu.yNorth; lastSentZ = fusedZ
lastSentHeading = headingDegNow; lastSentAt = now
}
}
}

결과 (Result)



  • 실시간 센서 → UI 반응: Steps/Heading/Altitude가 1–2초 내 반응

  • 폰-웨어 동기화 가시화: /steps/push, /steps/state, /location/update 정상 송수신

  • 권한 거부 시에도 크래시 없이 안내 UI 유지

  • 스트리밍 프로파일(배터리/일반/고속)을 메시지로 수신해 샘플링 주기 동적 변경


✅ 기본 데이터 파이프(센서→처리→동기화) 작동 확인
📱 폰 로그에서 메시지 수신 경로 동작 검증(/steps/*, /location/*)

느낀 점 / 회고 (Reflection)



  • Compose 환경에서도 DisposableEffect로 수명주기 제어가 간결하고 확실했다.

  • 데이터 클래스는 공통 모듈로 분리하면 스키마 드리프트 사고를 줄일 수 있겠다.

  • Fused Location으로 전환하면 실내 가용성과 배터리가 더 좋아질 듯. 다음 스프린트에 포함.


참고자료 (References)



본 문서는 자동 생성된 개발일기이며, 예시 코드는 초보자도 이해할 수 있도록 상세 주석을 포함합니다.





오늘의 이야기

 



🩺 Android | Health Connect 걸음 수 집계 캐시 & 상단바 최소 높이 적용


앱 이미지



 


개요 (Intro)



  • 오늘의 목표: 만보계 핵심 로직(걸음 수 집계/캐시) 안정화 + 메인 화면 상단바 UI 컴팩트화

  • 배경: 기존 raw StepsRecord 합산 방식은 성능/정확도 측면 한계. TopAppBar 기본 높이 과도.

  • 해결하려는 문제: 중복 데이터 합산 리스크, 빈번한 집계 호출로 인한 UI 지연, 화면 상단 낭비 공간

  • 사용 기술: Kotlin, Jetpack Compose, Health Connect, MVVM, Coroutine, Flow


📅 날짜: 2025.11.18
🎯 목표: Health Connect 걸음 수 Aggregate + 캐시 적용 & 상단바 높이 24dp로 축소
🧰 기술: Kotlin, Android Studio, Compose, Health Connect, MVVM, Coroutines, Flow



문제 정의 (Problem / Motivation)



  • 걸음 수 계산을 raw StepsRecord 반복 합산 → 중복/성능 저하 가능성.

  • 집계를 화면 갱신마다 수행 → 불필요한 I/O 증가.

  • TopAppBar 기본 높이로 인해 실제 콘텐츠 표시 영역 감소.

  • DistanceRecord 읽기 함수에서 plus() 결과 미반영 버그 존재(기존 리스트 누적 실패).


발견된 버그 및 개선 필요 코드:


// 기존 (버그): distance 누적 실패
val rValue : List<DistanceRecord> = emptyList()
for (record in response.records) {
rValue.plus(record) // 결과를 재할당하지 않아 누락됨
}

// 기존: Steps 합산 (중복 가능성 & 비효율)
val response = client.readRecords(ReadRecordsRequest(StepsRecord::class, ...))
var total = 0L
for (r in response.records) total += r.count



해결 과정 (How I Solved It)



  1. DistanceRecord 누락 수정: MutableList로 변환 후 add()로 확정 저장.

  2. Aggregate API 도입: StepsRecord.COUNT_TOTAL 메트릭 사용.

  3. 캐시 레이어 추가: 30초 TTL + 자정 경계 무효화. getTodaySteps(forceRefresh) 제공.

  4. 세션 종료 시간 매핑 수정: endTime 잘못된 startTime 재사용 → 실제 endTime 적용.

  5. UI 축소: TopAppBar 제거 → 24dp Box + statusBarsPadding() 로 최소 높이 상단바.

  6. CombinedUiState 도입: 여러 StateFlow를 하나로 합쳐 recomposition 감소.


핵심 적용 코드:


// Distance 누락 수정
val recordsAccum = mutableListOf<DistanceRecord>()
for (record in response.records) {
recordsAccum.add(record)
}

// Aggregate + 캐시 (HealthConnectManager)
private data class StepCountCache(val dayEpoch: Long, val timestampMs: Long, val value: Long)
private var stepCountCache: StepCountCache? = null
private val STEP_CACHE_TTL_MS = 30_000L

private suspend fun computeTodayStepsAggregate(): Long {
val startZdt = ZonedDateTime.now().truncatedTo(ChronoUnit.DAYS)
val endZdt = startZdt.plusDays(1)
val agg = healthConnectClient.aggregate(
AggregateRequest(
metrics = setOf(StepsRecord.COUNT_TOTAL),
timeRangeFilter = TimeRangeFilter.between(startZdt.toInstant(), endZdt.toInstant())
)
)
return agg[StepsRecord.COUNT_TOTAL] ?: 0L
}

suspend fun getTodaySteps(forceRefresh: Boolean = false): Long {
val now = System.currentTimeMillis()
val todayEpoch = ZonedDateTime.now().truncatedTo(ChronoUnit.DAYS).toInstant().epochSecond
val cached = stepCountCache
val valid = cached != null && !forceRefresh &&
cached.dayEpoch == todayEpoch && (now - cached.timestampMs) < STEP_CACHE_TTL_MS
if (valid) return cached.value
val fresh = computeTodayStepsAggregate()
stepCountCache = StepCountCache(todayEpoch, now, fresh)
return fresh
}

// ViewModel 사용
_stepsTotal.value = healthConnectManager.getTodaySteps(forceRefresh = false)

// 상단바 최소 높이 UI
Box(
modifier = Modifier
.fillMaxWidth()
.height(24.dp) // status bar height target
.background(Color(0xFF5E82FC))
.statusBarsPadding()
) { /* Icon + Title */ }



결과 (Result)



  • 걸음 수 집계 호출 빈도 ↓ (30초 캐시로 반복 쿼리 방지).

  • UI 상단 높이 축소로 세로 가시 영역 증가.

  • 세션 종료 시간 표시 정확도 개선.

  • Distance 데이터 정상 누적 (차트/리스트 값 일치).


✅ Aggregate 기반 일일 걸음 수 정확도 상승
⚡ 집계 호출 체감 대기시간 단축 & 불필요 로딩 감소
🖼 상단바 24dp 적용으로 콘텐츠 노출 영역 확대



느낀 점 / 회고 (Reflection)



  • Health Connect Aggregate API를 우선적으로 사용하는 설계가 유지보수성과 정확성을 동시에 확보.

  • 캐시 TTL 결정(30초)은 사용자 즉각 반응성과 자원 절약 타협점으로 적절.

  • UI 영역은 초기 템플릿 의존보다 실제 사용 시나리오 측정 후 최소화하는 것이 좋음.

  • 다음 개선: WorkManager로 백그라운드 자동 재동기화, 캐시 만료 시 알림형 업데이트.




참고자료 (References)





다음 로그 예정: 백그라운드 differential changes token 주기 처리 + Room 캐시 Layer 도입.





오늘의 이야기

 



🛠 Android | Coupang API + Hilt DI + AdsScreen UX/포맷 개선 작업 기록


Ktor API 연동 페이지 샘플



개요 (Intro)



  • Ktor 기반 Coupang Affiliate API 통합, Hilt DI 구조 확립, AdsScreen UI/가격 포맷 개선

  • 네트워크 권한 및 키 로딩 안정화, 로깅/리트라이 및 브라우저 딥링크 UX 추가


📅 날짜: 2025.11.25
🎯 목표: 안정적인 외부 API 호출 + Hilt 의존성 주입 + 상품 리스트 화면 UX 향상
🧰 기술: Kotlin, Jetpack Compose, Ktor, Hilt, Coil, Gradle Kotlin DSL

문제 정의 (Problem / Motivation)



  • API 키가 빈 문자열(Empty Key)로 주입되는 문제 → local.properties 및 Gradle providers 처리 필요

  • INTERNET 권한 누락으로 OkHttp DNS SecurityException 발생

  • 가격 포맷 단순/정수 처리로 소수점 금액 누락

  • 이미지 클릭 가능성 낮음 → 링크 이동 인지 UX 부족

  • 랜덤 카테고리/재시도 로직 필요 및 불안정한 일회성 오류 대응


SecurityException: Permission denied (missing INTERNET permission?)
[CoupangKeys@Gradle] access.len=0, secret.len=0 (초기 진단 시)

해결 과정 (How I Solved It)



  • DI Hilt 모듈(NetworkModule, AppModule) 추가 → HttpClient(Ktor) & CoupangAffiliateClient 주입

  • Network INTERNET / ACCESS_NETWORK_STATE 권한 Manifest에 선언

  • Keys Gradle providers + local.properties 폴백, 길이 로그 출력 & 마스킹

  • Logging HMAC 서명 입력/시그니처 앞 8자리, 응답 status 로그

  • Retry Ktor 호출 3회 백오프 재시도 로직(ViewModel)

  • Enum 카테고리 enum화 + 랜덤 선택 기능 추가(Category.random())

  • UI AdsScreen 분리, 이미지/가격 Row 모두 브라우저 딥링크 클릭 처리

  • Format 가격 소수점 & 천단위 포맷: DecimalFormat("#,##0.########원")

  • UX OpenInNew 아이콘 + 안내 문구 후 Row 간소화(아이콘+가격) + 전체 클릭


// Hilt Module (AppModule) 일부
@Provides @Singleton
fun provideCoupangAffiliateClient(ctx: Context, http: HttpClient) = CoupangAffiliateClient(
accessKey = ctx.getString(R.string.cupang_access_key).trim(),
secretKey = ctx.getString(R.string.cupang_secret_key).trim(),
httpClient = http
)

// Retry 로직
while (attempt < maxRetries) {
try { /* API 호출 */ break } catch (e: Exception) { delay(300L * attempt++) }
}

// 가격 포맷
private fun formatPrice(raw: String?): String = DecimalFormat("#,##0.########")
.format(raw?.replace(Regex("[^0-9.]"), "")?.toBigDecimalOrNull() ?: return "-") + "원"

결과 (Result)



  • API 키 정상 주입(Gradle 로그: access.len=36, secret.len=40)

  • 네트워크 호출 정상 응답 (SecurityException 해소)

  • 상품 리스트: 이미지/가격 Row 모두 딥링크 동작, 가격 소수 유지

  • 랜덤 카테고리 새로고침으로 테스트 다양화, 오류 시 재시도 안정성 향상

  • 로그로 진단 용이(HMAC, status, key 길이)


✅ 키 주입/권한/로그 안정화 완료
🔄 재시도 + 랜덤 카테고리로 API 안정성 개선
🖼 UX: 클릭 영역 확대(이미지 + 가격 Row), 명확한 브라우저 이동 인지
💰 가격 포맷: 소수점 유지 + 천단위 표시

느낀 점 / 회고 (Reflection)



  • 권한/리소스/Gradle 구성 로그를 초기에 넣어두면 진단 속도가 크게 향상됨

  • 백오프 재시도는 간단하지만 API 레이트/에러코드 기반 세밀화 여지 있음

  • UI 클릭 affordance(아이콘, 텍스트 최소화)로 사용자 행동 유도 효과 확인

  • 가격 포맷은 BigDecimal 변환 단계에서 예외를 미리 필터링해 안정성 확보 중요


참고자료 (References)






오늘의 이야기

  🕹️ Android | Wear Compose UI 레이아웃 정리와 중앙정렬, 문자열 리소스화 앱 구성중   개요 (Intro) 오늘의 목표 / 배경: Wear OS 앱의 메인 ...