Skip to content

[AN][feat]: iOS 건강 앱 연결#1176

Open
hwannow wants to merge 22 commits intodevelop-anfrom
feat/1174
Open

[AN][feat]: iOS 건강 앱 연결#1176
hwannow wants to merge 22 commits intodevelop-anfrom
feat/1174

Conversation

@hwannow
Copy link
Contributor

@hwannow hwannow commented Feb 27, 2026

🔗 관련 이슈

안드로이드처럼 WorkManager를 사용하는 것이 불가능해서, Calorie의 변동 사항이 있으면 자동으로 함수가 호출되는 로직입니다...

따라서 swift 내 자동으로 호출되는 함수에서 직접 서버 요청을 할 수 있도록 KoinHelper를 만들어 봤어요

📸 스크린샷 (Optional)

ScreenRecording_02-27-2026.17-35-27_1.mov

🔗 관련 이슈

📝 작업 내용

iOS 기기의 건강 앱에서 활동 에너지(칼로리) 변화를 감지하면 자동 호출되는 Swift 함수가 서버에 요청할 수 있도록 iOS HealthKit 통합을 구현했습니다. Android의 WorkManager와 달리 iOS에서는 자동 트리거 함수가 직접 서버 통신을 수행해야 하므로, Kotlin/Swift 상호운영성을 지원하는 KoinHelper를 도입하여 Swift 코드에서 Kotlin의 저장소(Repository) 로직에 접근할 수 있게 했습니다.

주요 변경사항

  1. iOS HealthKit 통합 계층 신규 구현

    • HealthKitManager.swift: HKHealthStore를 활용해 활동 에너지 권한 요청, 시간 단위 배경 업데이트 활성화, 데이터 변화 감지 옵저버 구현
    • 칼로리 값이 임계값(exerciseDetectionThreshold) 이상이면 KoinHelper를 통해 postActiveCaloriesBurned 콜백 호출
    • 권한 거부 시 iOS 설정 앱으로 이동 처리
  2. Kotlin/Swift 상호운영성 레이어 재설계

    • 기존 HelperKt.doInitKoin()의 정적 함수 호출에서 KoinHelper 클래스의 인스턴스 메서드 방식으로 변경
    • KoinHelper에 postActiveCaloriesBurned(kcal: Double) 메서드 추가: Swift에서 호출하면 Dispatchers.IO 스레드에서 비동기로 서버 요청 수행
    • 이를 통해 Swift의 자동 트리거 함수가 Kotlin 저장소에 접근 가능하게 구현
  3. iOS 기존 Placeholder 구현 완전 제거

    • CalorieCheckerImpl.kt: TODO 주석만 있던 iOS 전용 스텁 삭제
    • HealthRepositoryImpl.kt: 더미 반환값(0.0, false)만 제공하던 iOS 저장소 구현 삭제
    • RepositoryModule, CheckerModule: 위 구현들의 DI 바인딩 제거
    • iOS에서 Android처럼 공통 저장소 인터페이스를 사용하지 않고 Swift 네이티브 HealthKitManager로 처리하는 구조로 전환
  4. iOS 앱 설정 및 권한 구성

    • Info.plist: NSHealthShareUsageDescription(한글 설명) 추가, UIBackgroundModes에 healthkit 추가
    • iosApp.entitlements, iosAppDebug.entitlements: HealthKit 관련 entitlement 추가 (com.apple.developer.healthkit, com.apple.developer.healthkit.background-delivery)
  5. UI 계층 지원

    • HealthKitPlatform.kt: isAvailable()을 HKHealthStore.isHealthDataAvailable()로 구현, navigateToHealthConnect()에서 x-apple-health:// 스키마로 건강 앱 연결 시도
    • HealthSection.ios.kt: iOS용 UI 완전 구현 (TODO 제거, 클릭 가능한 Row 컴포넌트 추가)
    • strings.xml: "헬스 키트" 관련 리소스 문자열 추가
  6. 부수적 개선

    • HomeViewModel.kt: isFirstLaunch 타입을 SharedFlow에서 StateFlow으로 변경하여 상태 흐름 강화
    • CupsTopAppBar.kt: 컬러 설정을 centerAlignedTopAppBarColors에서 topAppBarColors로 통일

📊 변경 효과 요약

  • 코드 라인 추가: 267줄 (주로 HealthKitManager 신규 구현 +112줄, HealthSection UI 구현 +52줄, KoinHelper 기능 추가 +30줄)
  • 코드 라인 삭제: 57줄 (iOS placeholder 구현 제거 -25줄, DI 모듈 정리)
  • 변경된 파일: 18개 (iOS 앱 계층 13개, Kotlin 멀티플랫폼 공유 계층 5개)
  • DI 모듈 정리: 정적 함수 기반 초기화 → 인스턴스 클래스 기반으로 변경, 이를 통해 Swift에서 런타임 중 서버 호출 가능하게 구조화
  • 테스트, 빌드 단계에서의 정량 지표는 측정하지 않음

@github-actions github-actions bot changed the title Feat/1174 [AN][feat]: iOS 건강 앱 연결 Feb 27, 2026
@github-actions github-actions bot added the AN 안드로이드 관련 이슈 label Feb 27, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 27, 2026

Note

.coderabbit.yaml has unrecognized properties

CodeRabbit is using all valid settings from your configuration. Unrecognized properties (listed below) have been ignored and may indicate typos or deprecated fields that can be removed.

⚠️ Parsing warnings (1)
Validation error: Unrecognized key(s) in object: 'tools'
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 98d99d13-8d35-427b-a6c0-5583f991b872

📥 Commits

Reviewing files that changed from the base of the PR and between 402e84e and 985446f.

📒 Files selected for processing (4)
  • mobile/androidApp/src/main/java/com/mulkkam/ui/util/extensions/ContextExtensions2.kt
  • mobile/androidApp/src/main/java/com/mulkkam/ui/util/extensions/StringExtensions2.kt
  • mobile/iosApp/iosApp/Health/HealthKitManager.swift
  • mobile/shared/src/iosMain/kotlin/com/mulkkam/ui/setting/bioinfo/HealthKitPlatform.kt
🚧 Files skipped from review as they are similar to previous changes (2)
  • mobile/shared/src/iosMain/kotlin/com/mulkkam/ui/setting/bioinfo/HealthKitPlatform.kt
  • mobile/iosApp/iosApp/Health/HealthKitManager.swift

Walkthrough

iOS 앱에 HealthKit 통합을 추가합니다. HealthKitManager 싱글톤을 구현하여 활동 에너지 데이터를 읽고, 시간별 백그라운드 업데이트를 활성화하며, 칼로리 임계값을 초과할 때 콜백을 실행합니다. 기존 iOS 특화 CalorieChecker와 HealthRepository 스텁 구현을 제거하고 DI 모듈을 재구성합니다. 엔타이틀먼트 파일에 HealthKit 권한을 추가하고 Info.plist에 사용 설명을 추가합니다. SwiftUI 초기화 흐름에서 HealthKit 권한 요청을 통합합니다.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

상세 코드 리뷰

주요 우려 사항

1. HealthKitManager에서의 순환 참조 위험

HealthKitManager.swift의 requestAuthorization 메서드에서 클로저 내부에서 self를 직접 사용하는 부분이 있습니다. [weak self] 캡처를 일관되게 사용했더라도, 콜백 체인 중간의 옵셔널 언래핑이 명확하지 않습니다.

현재 상황의 한계:

  • 클로저가 self를 강하게 캡처할 경우, HealthKitManager가 메모리에서 해제되지 않을 수 있습니다
  • 특히 fetchBurnedCalories에서 반복적으로 옵저버 쿼리가 등록되면서 다중 참조가 누적될 가능성이 있습니다

개선 방향:
스코프 내 모든 비동기 작업(클로저, DispatchQueue, 옵저버)에 대해 명시적으로 [weak self] 캡처를 적용하고, 옵셔널 언래핑 후 가드문으로 조기 반환하는 패턴을 권장합니다. 예를 들어:

HealthKitStore.shared.execute(query) { [weak self] query, results, error in
  guard let self = self else { return }
  // 작업 진행
}

2. 에러 처리의 미흡함

HealthKitManager에서 Authorization 실패 시 단순히 앱 설정(Settings)을 열도록만 구현되어 있습니다. 사용자가 설정을 변경하지 않으면 권한 없이 앱이 계속 실행됩니다.

현재 상황의 한계:

  • 권한 거부 상태를 추적하는 메커니즘이 없어, 다음 실행 시에도 동일한 권한 요청이 반복될 수 있습니다
  • 백그라운드 배달 활성화 실패에 대한 처리가 누락되어 있습니다
  • 네트워크 오류(postActiveCaloriesBurned 실패)를 캐치하지 않습니다

개선 방향:
권한 거부 상태를 UserDefaults나 앱의 상태 관리 시스템에 기록하고, 재요청 로직에 제한을 두는 것을 권장합니다. 또한 백그라운드 배달 실패나 네트워크 오류 발생 시 로깅 및 재시도 메커니즘을 추가하면 운영 중 문제를 더 쉽게 파악할 수 있습니다.

3. KoinHelper 중복 정의

mobile/shared/src/iosMain/kotlin/com/mulkkam/di/Helper.ktmobile/shared/src/iosMain/kotlin/com/mulkkam/di/KoinHelper.kt에서 거의 동일한 KoinHelper 클래스가 정의되어 있습니다.

현재 상황의 한계:

  • 같은 기능을 두 파일에서 관리하면 유지보수 비용이 증가합니다
  • 향후 수정 시 한쪽만 업데이트될 가능성이 높습니다
  • 빌드 시 컴파일 충돌이 발생할 수 있습니다

개선 방향:
두 파일 중 하나를 선택하여 통합하거나, 한 파일을 삭제하는 것을 권장합니다. 만약 Helper.kt가 상위 수준의 호환성을 위해 필요하다면, KoinHelper를 한 곳에만 정의하고 Helper.kt에서는 래퍼 함수로 제공하는 방식을 고려해보세요.

4. 임계값(exerciseDetectionThreshold)의 하드코딩

HealthKitManager에서 calorie 임계값을 클래스 상수로 고정했습니다. 이는 런타임 시 동적으로 조정할 수 없습니다.

현재 상황의 한계:

  • 서버에서 임계값을 동적으로 변경하려 해도 앱 재배포가 필요합니다
  • A/B 테스트나 점진적 롤아웃 시 유연성이 떨어집니다
  • 테스트 환경에서 값을 임시로 조정하기 어렵습니다

개선 방향:
다음과 같은 선택지들을 고려해보세요:

  • 원격 설정 활용: 서버에서 임계값을 내려받아 메모리에 캐싱
  • 런타임 주입: 초기화 시 외부에서 임계값을 전달받도록 변경
  • 점진적 접근: 로컬 기본값을 제공하되, 앱 시작 시 서버에서 업데이트 시도

각 방식마다 구현 복잡도와 유지보수 난이도가 다르니, 프로젝트의 설정 관리 전략에 맞춰 선택하면 좋겠습니다.

5. 다국어 지원 고려 부족

Info.plist와 strings.xml에 추가된 HealthKit 설명이 한국어만 포함되어 있습니다.

현재 상황의 한계:

  • 앱이 한국어 이외의 언어를 지원한다면, 사용자에게 영문(또는 해당 언어)으로 권한 설명이 표시되지 않습니다
  • iOS의 NSHealthShareUsageDescription은 단일 문자열이므로 다국어 처리가 쉽지 않습니다

개선 방향:

  • Info.plist의 NSHealthShareUsageDescription은 기본 언어(예: 영어)로 작성하고, 각 지역화 파일(*.lproj)에서 적절한 언어로 재정의하는 것을 권장합니다
  • Compose Resources의 strings.xml은 이미 다국어 지원 구조가 있으니, 함께 제공되는 다른 언어 파일들에도 health_kit 관련 문자열을 추가하세요

긍정적인 측면

  • 일관된 흐름: HealthKit 권한 요청에서 데이터 수집, 네트워크 전송까지 흐름이 명확하게 구성되어 있습니다
  • 배경 작업 활성화: hourly background delivery 설정으로 사용자가 앱을 열지 않아도 데이터 수집이 가능합니다
  • 메모리 안전성 고려: 대부분의 비동기 작업에서 [weak self]를 사용하려는 의도가 보입니다
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive Description includes the related issue (#1174) and explains the implementation approach, though the main work content and key changes sections are incomplete per the template. Please complete the 작업 내용 (Work Content) and 주요 변경사항 (Key Changes) sections following the template structure for clarity.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed Title clearly describes the main change: iOS HealthKit integration. It directly relates to the primary objective of connecting the iOS Health app.
Linked Issues check ✅ Passed The PR implements iOS HealthKit integration with automatic calorie update notifications, matching issue #1174's objective to enable server requests from auto-invoked Swift functions.
Out of Scope Changes check ✅ Passed All changes align with iOS HealthKit integration: HealthKitManager implementation, entitlements setup, UI components, and KoinHelper for server communication are all in scope.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/1174
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

CodeRabbit can generate a title for your PR based on the changes with custom instructions.

Set the reviews.auto_title_instructions setting to generate a title for your PR based on the changes in the PR with custom instructions.

@hwannow hwannow removed the request for review from devfeijoa February 27, 2026 08:44
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (6)
mobile/shared/src/commonMain/kotlin/com/mulkkam/ui/setting/cups/component/CupsTopAppBar.kt (1)

50-55: CenterAlignedTopAppBartopAppBarColors() 간의 API 불일치

현재 컴포넌트는 CenterAlignedTopAppBar(Line 32)를 사용하고 있지만, 색상 설정에는 TopAppBarDefaults.topAppBarColors()를 사용하고 있습니다.

Material 3 API 설계상:

  • CenterAlignedTopAppBarTopAppBarDefaults.centerAlignedTopAppBarColors()
  • TopAppBar (standard) → TopAppBarDefaults.topAppBarColors()

두 함수 모두 TopAppBarColors 타입을 반환하므로 현재 동작에는 문제가 없습니다. 그러나 이러한 불일치는 다음과 같은 잠재적 문제를 야기할 수 있습니다:

  1. 코드 가독성 저하: 다른 개발자가 코드를 읽을 때 의도를 파악하기 어려움
  2. 향후 호환성: Material 3 라이브러리 업데이트 시 두 함수의 기본값이 달라질 경우 예상치 못한 UI 변경 발생 가능

의도적으로 표준 TopAppBar 색상을 적용하려는 것이 아니라면, 원래의 centerAlignedTopAppBarColors()를 유지하는 것이 권장됩니다.

♻️ API 일치를 위한 수정 제안
            colors =
-               TopAppBarDefaults.topAppBarColors(
+               TopAppBarDefaults.centerAlignedTopAppBarColors(
                    containerColor = White,
                    titleContentColor = Gray400,
                    navigationIconContentColor = Gray400,
                ),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@mobile/shared/src/commonMain/kotlin/com/mulkkam/ui/setting/cups/component/CupsTopAppBar.kt`
around lines 50 - 55, The component uses CenterAlignedTopAppBar but sets colors
via TopAppBarDefaults.topAppBarColors; change the colors call to
TopAppBarDefaults.centerAlignedTopAppBarColors(...) so the API intent matches
(keep the same parameters: containerColor = White, titleContentColor = Gray400,
navigationIconContentColor = Gray400) and update any import if needed; locate
the usage in CupsTopAppBar.kt where CenterAlignedTopAppBar is defined and
replace topAppBarColors with centerAlignedTopAppBarColors.
mobile/shared/src/iosMain/kotlin/com/mulkkam/ui/setting/bioinfo/component/HealthSection.ios.kt (1)

41-49: 클릭 영역에 접근성 역할(role) 명시를 권장합니다.

현재 noRippleClickable이 접근성 semantics를 충분히 전달하지 않으면 VoiceOver에서 버튼으로 읽히지 않을 수 있습니다.
대안:

  • Modifier.clickable(role = Role.Button, ...) 사용: 기본 접근성 지원이 명확함(장점), 기존 커스텀 효과와 동작 차이가 날 수 있음(단점).
  • noRippleClickable 유지 + semantics { role = Role.Button } 추가: 기존 UX 유지(장점), 확장 함수에 의존해 일관성 관리 필요(단점).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@mobile/shared/src/iosMain/kotlin/com/mulkkam/ui/setting/bioinfo/component/HealthSection.ios.kt`
around lines 41 - 49, The Row currently uses Modifier.noRippleClickable(onClick
= { onClick() }) which may not expose a button role to VoiceOver; update the
modifier to explicitly declare accessibility role by either replacing
noRippleClickable with Modifier.clickable(role = Role.Button, onClick = {
onClick() }) or keep noRippleClickable and append semantics { role = Role.Button
} to the Modifier chain so the Row (and its onClick handler) is announced as a
button to assistive technologies.
mobile/shared/src/iosMain/kotlin/com/mulkkam/di/Helper.kt (1)

16-23: startKoin 재호출 방어를 추가하는 편이 안전합니다.

Line 20의 startKoin은 앱 재초기화 경로(예: 프리뷰/특정 재진입)에서 중복 호출 시 예외로 이어질 수 있습니다.
GlobalContext.getOrNull() 체크 후 미초기화일 때만 startKoin하는 방식이 안정적입니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mobile/shared/src/iosMain/kotlin/com/mulkkam/di/Helper.kt` around lines 16 -
23, The initKoin function currently calls startKoin unconditionally; to prevent
exceptions on re-initialization, check GlobalContext.getOrNull() and only call
startKoin when it returns null. Update the initKoin implementation (function
initKoin) to guard the startKoin call using GlobalContext.getOrNull(),
preserving the modules(iosSharedModule(baseUrl, isDebug)) behavior when actually
initializing.
mobile/iosApp/iosApp/Health/HealthKitManager.swift (1)

48-64: ObserverQuery 중복 등록 방어를 권장합니다.

requestAuthorization이 다시 호출되면 Line 63에서 query가 중첩 실행될 수 있습니다.
isObserving 플래그 또는 query 참조를 보관해 중복 실행을 막으면, 중복 콜백/중복 서버 호출 리스크를 줄일 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mobile/iosApp/iosApp/Health/HealthKitManager.swift` around lines 48 - 64,
startObserverQuery currently creates and executes a new HKObserverQuery every
time it runs, which can cause duplicate observer callbacks when
requestAuthorization is called again; add a guard using an isObserving Bool or
store the HKObserverQuery instance (e.g., a private var observerQuery:
HKObserverQuery?) and check it before creating/executing a new query in
startObserverQuery, set isObserving = true (or assign observerQuery) after
successfully executing via healthStore.execute(query), and clear/reset these
when you stop observing or on deinit to prevent duplicate registrations.
mobile/iosApp/iosApp/ContentView.swift (1)

33-35: 매번 새로운 KoinHelper() 인스턴스를 생성하는 것은 비효율적입니다.

onBurnedCalorieUpdated 콜백은 HealthKit 백그라운드 업데이트가 발생할 때마다 호출될 수 있습니다. 현재 구현에서는 매 호출 시 새로운 KoinHelper 인스턴스를 생성하고 있는데, 이는 불필요한 객체 할당을 유발합니다.

개선 방안:

  • KoinHelper 인스턴스를 ComposeView 프로퍼티로 캐싱하거나, KoinHelper를 Kotlin 측에서 object (싱글톤)로 변경하는 것을 권장합니다.
♻️ 개선 예시
 struct ComposeView: UIViewControllerRepresentable {
     let loginPlatform = LoginPlatform()
     let appVersion = (Bundle.main.infoDictionary?["MARKETING_VERSION"] as? String) ?? ""
+    let koinHelper = KoinHelper()
     
     func makeUIViewController(context: Context) -> UIViewController {
         // ...
                 onBurnedCalorieUpdated: { kcal in
-                    KoinHelper().postActiveCaloriesBurned(kcal: kcal)
+                    self.koinHelper.postActiveCaloriesBurned(kcal: kcal)
                 },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mobile/iosApp/iosApp/ContentView.swift` around lines 33 - 35, 현재
onBurnedCalorieUpdated 콜백에서 매번 새로운 KoinHelper()를 생성하고 있어 불필요한 객체 할당이 발생합니다; 해결책은
KoinHelper 인스턴스를 ComposeView(또는 ContentView) 프로퍼티로 캐싱하거나 KoinHelper를 Kotlin 측에서
싱글톤(object)으로 변경하는 것입니다; 구체적으로 ContentView.swift의 onBurnedCalorieUpdated 콜백 호출부를
찾아 KoinHelper() 호출을 제거하고 미리 생성해 둔 프로퍼티(예: private let koinHelper =
KoinHelper())를 사용하거나, Kotlin에서 object KoinHelper로 변경해 호출을
KoinHelper.postActiveCaloriesBurned(kcal:)로 바꿔 적용하세요.
mobile/shared/src/iosMain/kotlin/com/mulkkam/ui/setting/bioinfo/HealthKitPlatform.kt (1)

23-25: Health 앱을 열 수 없을 때 알림 설정으로 이동하는 것은 사용자 경험상 혼란을 줄 수 있습니다.

canOpenURL(healthUrl)false를 반환하는 경우는 주로 Health 앱이 기기에서 사용 불가능한 상황(예: 시뮬레이터, 특정 기기)입니다. 이때 알림 설정 화면으로 이동하는 것은 사용자가 기대하는 동작이 아닐 수 있습니다.

대안을 고려해 보세요:

  1. 아무 동작도 하지 않고 사용자에게 안내 메시지 표시 - 가장 명확한 사용자 경험을 제공하지만, 현재 구조에서 UI 피드백 전달 방법이 필요합니다.
  2. 시스템 설정의 Health 섹션으로 이동 - App-Prefs:root=HEALTH URL 스킴 사용 (단, 비공개 API라 App Store 심사에서 문제될 수 있음).
  3. 현재 방식 유지 - 만약 이 fallback이 의도적으로 "권한 관련 설정으로 안내"하는 목적이라면 주석으로 의도를 명시해 주세요.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@mobile/shared/src/iosMain/kotlin/com/mulkkam/ui/setting/bioinfo/HealthKitPlatform.kt`
around lines 23 - 25, In HealthKitPlatform, when canOpenURL(healthUrl) returns
false, don't silently fall back to openAppNotificationSettings(); instead
implement a clear, explicit fallback: either call a new helper like
showHealthUnavailableAlert() to present a user-facing message (or propagate an
error callback) indicating Health app is unavailable, or if you intend the
notification-settings fallback to be a deliberate permission-related guide,
leave the fallback but add a clarifying comment above
openAppNotificationSettings() describing that intent; update references in
HealthKitPlatform (the canOpenURL(healthUrl) branch) accordingly and add any
small helper (e.g., showHealthUnavailableAlert or an error callback) so the UI
can show feedback rather than unexpectedly opening notification settings.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@mobile/iosApp/iosApp/Health/HealthKitManager.swift`:
- Around line 16-37: Add a pre-check for HealthKit availability before calling
healthStore.requestAuthorization by using HKHealthStore.isHealthDataAvailable()
in requestAuthorization() (returning completion(false) immediately if
unavailable) so you don’t prompt unsupported devices; also update
enableBackgroundDelivery() to handle its completion callback (surface/log errors
and consider retry behavior) instead of ignoring result/error so background
delivery failures are observable; locate these changes around the
requestAuthorization(), enableBackgroundDelivery(), and startObserverQuery()
calls to implement the availability guard and proper error handling.
- Around line 40-45: enableBackgroundDelivery의
enableBackgroundDelivery(completion:) 콜백에서 success와 error를 무시하고 있어 실패를 알 수 없으니,
healthStore.enableBackgroundDelivery(for:activeEnergyType, frequency: .hourly,
withCompletion:)의 completion 파라미터에서 success와 error를 검사하도록 변경하고 실패 시(1) OSLog 또는
분석 서비스로 실패 원인(error.localizedDescription 포함)을 기록하고, (2) 등록이 실패했으면 바로
startObserverQuery()를 호출하지 않도록 분기 처리하며, 필요하면 사용자에게 설정 확인을 유도하거나(알림/alert) 재시도
로직을 트리거하도록 구현하세요; 대상 식별자는 enableBackgroundDelivery(), startObserverQuery(),
healthStore.enableBackgroundDelivery(...)입니다.

In `@mobile/iosApp/iosApp/iosApp.entitlements`:
- Around line 9-10: The entitlements currently only include
com.apple.developer.healthkit which enables basic HealthKit access but not
background delivery; update the entitlements to also include
com.apple.developer.healthkit.background-delivery (set to true) so
HKObserverQuery and enableBackgroundDelivery() will work, or enable "Background
Delivery" under Signing & Capabilities → HealthKit in Xcode to have Xcode add
the com.apple.developer.healthkit.background-delivery entitlement automatically.

In `@mobile/iosApp/iosApp/iosAppDebug.entitlements`:
- Around line 9-10: Add the HealthKit background delivery entitlement to the
debug entitlements so HKHealthStore.enableBackgroundDelivery() can succeed:
update iosAppDebug.entitlements to include the key
com.apple.developer.healthkit.background-delivery with a true value so when
HealthKitManager.enableBackgroundDelivery() is called immediately after
permission request it won’t fail at runtime; then rebuild the debug target to
ensure the entitlement is applied.

In `@mobile/shared/src/iosMain/kotlin/com/mulkkam/di/Helper.kt`:
- Around line 25-28: The current postActiveCaloriesBurned(kcal: Double) creates
a new CoroutineScope(Dispatchers.IO) per call which prevents proper lifecycle
and error handling; instead either (A) add a class-level val scope =
CoroutineScope(SupervisorJob() + Dispatchers.IO) and use scope.launch {
notificationRepository.postActiveCaloriesBurned(kcal) } so cancellations/errors
are managed and you can cancel scope when the host is destroyed, or (B) make fun
postActiveCaloriesBurned a suspend function and delegate suspension to the
caller (Swift side) so callers control coroutine lifecycle; locate the function
postActiveCaloriesBurned and the usage of CoroutineScope(Dispatchers.IO) and
implement one of these approaches, ensuring
notificationRepository.postActiveCaloriesBurned is called from that managed
scope or from the suspend function.
- Line 6: Remove the invalid import "kotlinx.coroutines.IO" (it isn't a
top-level symbol) and rely on the already imported
kotlinx.coroutines.Dispatchers; ensure usages reference Dispatchers.IO (e.g.,
wherever Dispatchers.IO is used in Helper.kt) and keep only the Dispatchers
import to avoid the "Unresolved reference" compile error.

---

Nitpick comments:
In `@mobile/iosApp/iosApp/ContentView.swift`:
- Around line 33-35: 현재 onBurnedCalorieUpdated 콜백에서 매번 새로운 KoinHelper()를 생성하고 있어
불필요한 객체 할당이 발생합니다; 해결책은 KoinHelper 인스턴스를 ComposeView(또는 ContentView) 프로퍼티로 캐싱하거나
KoinHelper를 Kotlin 측에서 싱글톤(object)으로 변경하는 것입니다; 구체적으로 ContentView.swift의
onBurnedCalorieUpdated 콜백 호출부를 찾아 KoinHelper() 호출을 제거하고 미리 생성해 둔 프로퍼티(예: private
let koinHelper = KoinHelper())를 사용하거나, Kotlin에서 object KoinHelper로 변경해 호출을
KoinHelper.postActiveCaloriesBurned(kcal:)로 바꿔 적용하세요.

In `@mobile/iosApp/iosApp/Health/HealthKitManager.swift`:
- Around line 48-64: startObserverQuery currently creates and executes a new
HKObserverQuery every time it runs, which can cause duplicate observer callbacks
when requestAuthorization is called again; add a guard using an isObserving Bool
or store the HKObserverQuery instance (e.g., a private var observerQuery:
HKObserverQuery?) and check it before creating/executing a new query in
startObserverQuery, set isObserving = true (or assign observerQuery) after
successfully executing via healthStore.execute(query), and clear/reset these
when you stop observing or on deinit to prevent duplicate registrations.

In
`@mobile/shared/src/commonMain/kotlin/com/mulkkam/ui/setting/cups/component/CupsTopAppBar.kt`:
- Around line 50-55: The component uses CenterAlignedTopAppBar but sets colors
via TopAppBarDefaults.topAppBarColors; change the colors call to
TopAppBarDefaults.centerAlignedTopAppBarColors(...) so the API intent matches
(keep the same parameters: containerColor = White, titleContentColor = Gray400,
navigationIconContentColor = Gray400) and update any import if needed; locate
the usage in CupsTopAppBar.kt where CenterAlignedTopAppBar is defined and
replace topAppBarColors with centerAlignedTopAppBarColors.

In `@mobile/shared/src/iosMain/kotlin/com/mulkkam/di/Helper.kt`:
- Around line 16-23: The initKoin function currently calls startKoin
unconditionally; to prevent exceptions on re-initialization, check
GlobalContext.getOrNull() and only call startKoin when it returns null. Update
the initKoin implementation (function initKoin) to guard the startKoin call
using GlobalContext.getOrNull(), preserving the modules(iosSharedModule(baseUrl,
isDebug)) behavior when actually initializing.

In
`@mobile/shared/src/iosMain/kotlin/com/mulkkam/ui/setting/bioinfo/component/HealthSection.ios.kt`:
- Around line 41-49: The Row currently uses Modifier.noRippleClickable(onClick =
{ onClick() }) which may not expose a button role to VoiceOver; update the
modifier to explicitly declare accessibility role by either replacing
noRippleClickable with Modifier.clickable(role = Role.Button, onClick = {
onClick() }) or keep noRippleClickable and append semantics { role = Role.Button
} to the Modifier chain so the Row (and its onClick handler) is announced as a
button to assistive technologies.

In
`@mobile/shared/src/iosMain/kotlin/com/mulkkam/ui/setting/bioinfo/HealthKitPlatform.kt`:
- Around line 23-25: In HealthKitPlatform, when canOpenURL(healthUrl) returns
false, don't silently fall back to openAppNotificationSettings(); instead
implement a clear, explicit fallback: either call a new helper like
showHealthUnavailableAlert() to present a user-facing message (or propagate an
error callback) indicating Health app is unavailable, or if you intend the
notification-settings fallback to be a deliberate permission-related guide,
leave the fallback but add a clarifying comment above
openAppNotificationSettings() describing that intent; update references in
HealthKitPlatform (the canOpenURL(healthUrl) branch) accordingly and add any
small helper (e.g., showHealthUnavailableAlert or an error callback) so the UI
can show feedback rather than unexpectedly opening notification settings.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fe9b14b and 876d9a7.

📒 Files selected for processing (17)
  • mobile/iosApp/iosApp/ContentView.swift
  • mobile/iosApp/iosApp/Health/HealthKitManager.swift
  • mobile/iosApp/iosApp/Info.plist
  • mobile/iosApp/iosApp/MulKkamApp.swift
  • mobile/iosApp/iosApp/iosApp.entitlements
  • mobile/iosApp/iosApp/iosAppDebug.entitlements
  • mobile/shared/src/commonMain/composeResources/values/strings.xml
  • mobile/shared/src/commonMain/kotlin/com/mulkkam/ui/home/home/HomeViewModel.kt
  • mobile/shared/src/commonMain/kotlin/com/mulkkam/ui/setting/cups/component/CupsTopAppBar.kt
  • mobile/shared/src/iosMain/kotlin/com/mulkkam/data/checker/CalorieCheckerImpl.kt
  • mobile/shared/src/iosMain/kotlin/com/mulkkam/data/repository/HealthRepositoryImpl.kt
  • mobile/shared/src/iosMain/kotlin/com/mulkkam/di/CheckerModule.kt
  • mobile/shared/src/iosMain/kotlin/com/mulkkam/di/Helper.kt
  • mobile/shared/src/iosMain/kotlin/com/mulkkam/di/IosSharedModule.kt
  • mobile/shared/src/iosMain/kotlin/com/mulkkam/di/RepositoryModule.kt
  • mobile/shared/src/iosMain/kotlin/com/mulkkam/ui/setting/bioinfo/HealthKitPlatform.kt
  • mobile/shared/src/iosMain/kotlin/com/mulkkam/ui/setting/bioinfo/component/HealthSection.ios.kt
💤 Files with no reviewable changes (5)
  • mobile/shared/src/iosMain/kotlin/com/mulkkam/di/IosSharedModule.kt
  • mobile/shared/src/iosMain/kotlin/com/mulkkam/di/CheckerModule.kt
  • mobile/shared/src/iosMain/kotlin/com/mulkkam/data/checker/CalorieCheckerImpl.kt
  • mobile/shared/src/iosMain/kotlin/com/mulkkam/data/repository/HealthRepositoryImpl.kt
  • mobile/shared/src/iosMain/kotlin/com/mulkkam/di/RepositoryModule.kt

Comment on lines +16 to +37
func requestAuthorization(
onBurnedCalorieUpdated: @escaping (Double) -> Void,
completion: @escaping (Bool) -> Void
) {
self.onBurnedCalorieUpdated = onBurnedCalorieUpdated

healthStore.requestAuthorization(
toShare: nil,
read: read
) { success, error in
DispatchQueue.main.async {
if success {
self.enableBackgroundDelivery()
self.startObserverQuery()
} else {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
completion(success)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "HealthKitManager.swift" -type f

Repository: woowacourse-teams/2025-mul-kkam

Length of output: 127


🏁 Script executed:

cat -n ./mobile/iosApp/iosApp/Health/HealthKitManager.swift

Repository: woowacourse-teams/2025-mul-kkam

Length of output: 3623


HealthKit 가용성 체크가 필요합니다.

requestAuthorization() 호출 전에 HKHealthStore.isHealthDataAvailable() 가드가 없어서, HealthKit을 지원하지 않는 기기(예: 일부 iPad)에서 문제가 발생할 수 있습니다. 현재 코드는 인증 실패 시 설정 화면으로 유도하는데, 기기 미지원과 사용자 거부를 구분하지 못해 사용자 경험이 좋지 않습니다.

두 가지 개선 방안을 제안합니다:

  1. 사전 가드 방식 (권장): 16번 줄 앞에 if !HKHealthStore.isHealthDataAvailable() { completion(false); return } 추가

    • 장점: 지원하지 않는 기기인지 명확히 판단, 불필요한 권한 요청 생략
    • 단점: 분기 로직이 한 단계 증가
  2. 현재 유지: 추가 코드 없이 진행

    • 장점: 구현이 단순함
    • 단점: 실패 원인 구분 어려움, 지원하지 않는 기기에서도 설정 화면 유도

더불어 28번 줄의 enableBackgroundDelivery() 메서드(40-45줄)에서 완료 핸들러가 결과와 에러를 모두 무시하고 있습니다. 백그라운드 전달이 실패해도 알 수 없어 조용히 실패할 위험이 있으니, 최소한 에러 로깅이나 재시도 로직을 고려해 주세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mobile/iosApp/iosApp/Health/HealthKitManager.swift` around lines 16 - 37, Add
a pre-check for HealthKit availability before calling
healthStore.requestAuthorization by using HKHealthStore.isHealthDataAvailable()
in requestAuthorization() (returning completion(false) immediately if
unavailable) so you don’t prompt unsupported devices; also update
enableBackgroundDelivery() to handle its completion callback (surface/log errors
and consider retry behavior) instead of ignoring result/error so background
delivery failures are observable; locate these changes around the
requestAuthorization(), enableBackgroundDelivery(), and startObserverQuery()
calls to implement the availability guard and proper error handling.

Comment on lines +40 to +45
private func enableBackgroundDelivery() {
healthStore.enableBackgroundDelivery(
for: activeEnergyType,
frequency: .hourly,
withCompletion: { _, _ in }
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "HealthKitManager.swift" | head -5

Repository: woowacourse-teams/2025-mul-kkam

Length of output: 127


🏁 Script executed:

cat -n ./mobile/iosApp/iosApp/Health/HealthKitManager.swift

Repository: woowacourse-teams/2025-mul-kkam

Length of output: 3623


🌐 Web query:

HealthKit enableBackgroundDelivery error handling Swift

💡 Result:

What enableBackgroundDelivery’s completion means

HKHealthStore.enableBackgroundDelivery(...)’s completion handler only tells you whether registration succeeded (and returns an Error?). It does not deliver updates; updates come through an HKObserverQuery you’ve set up and executed. (stackoverflow.com)

Swift pattern: register + log errors (and don’t assume it worked)

let store = HKHealthStore()
let type = HKObjectType.quantityType(forIdentifier: .stepCount)!  // example

store.enableBackgroundDelivery(for: type, frequency: .hourly) { success, error in
    if success {
        // registration succeeded
    } else {
        // IMPORTANT: log this (device settings often explain failures)
        NSLog("enableBackgroundDelivery failed: \(error as Any)")
    }
}

A surprisingly common “error handling” improvement is simply persisting success + error somewhere (OSLog, analytics, your backend) so you can see what happens on real devices. (reddit.com)

In your observer query: always call completionHandler (even on error)

If your observer update handler has an error path that returns without calling completionHandler, HealthKit can eventually stop sending background updates. Use defer to make this foolproof. (developer.apple.com)

let query = HKObserverQuery(sampleType: type, predicate: nil) { _, completion, error in
    defer { completion() }          // ensures it’s called on every path

    if let error {
        NSLog("Observer error: \(error)")
        return
    }

    // Kick off your real fetch here (often an HKAnchoredObjectQuery),
    // keeping work short.
}
store.execute(query)

Practical “it didn’t fire” cases to handle (not code bugs)

  • Background App Refresh disabled can prevent/derail delivery; users may need to enable it in Settings. (stackoverflow.com)
  • Device locked can block HealthKit access/delivery for privacy (expect delivery when the device is unlocked again). (stackoverflow.com)
  • Some types (notably Step Count) are effectively throttled (often ~hourly) even if you request .immediate, so “no callback yet” isn’t necessarily an error. (stackoverflow.com)

If you paste the exact error you’re seeing (domain/code/userInfo), I can suggest the specific handling/recovery path for that case.

Citations:


백그라운드 전달 등록 실패를 무시하고 있어서 동기화 실패를 추적할 수 없습니다.

44줄의 completion 핸들러가 successerror를 버리면, 실제로 background delivery 등록이 실패한 상황을 감지하지 못합니다. 이후 바로 startObserverQuery()를 호출하지만, 등록이 실패했다면 백그라운드에서 갱신 콜백이 도착하지 않을 것입니다. 건강 데이터 누락으로 이어질 수 있는 침묵의 실패 경로입니다.

코드의 다른 부분들(5255줄, 8285줄)에서는 에러를 확인하는데, 이 메서드만 건너뛰는 것도 불일치합니다. 최소한 다음 중 하나를 권장합니다:

  • 로깅만: 실패 여부와 이유를 OSLog나 분석 서비스에 기록하면, 실제 기기에서 어떤 상황(Background App Refresh 비활성화, 프라이버시 설정 등)으로 실패하는지 추적할 수 있습니다.
  • 사용자 알림: 등록 실패 시 사용자에게 설정 확인을 유도하는 방식으로, 데이터 누락을 사전에 방지할 수 있습니다.
  • 재시도 로직: 초기 등록 실패 시 주기적으로 재시도하는 방식입니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mobile/iosApp/iosApp/Health/HealthKitManager.swift` around lines 40 - 45,
enableBackgroundDelivery의 enableBackgroundDelivery(completion:) 콜백에서 success와
error를 무시하고 있어 실패를 알 수 없으니,
healthStore.enableBackgroundDelivery(for:activeEnergyType, frequency: .hourly,
withCompletion:)의 completion 파라미터에서 success와 error를 검사하도록 변경하고 실패 시(1) OSLog 또는
분석 서비스로 실패 원인(error.localizedDescription 포함)을 기록하고, (2) 등록이 실패했으면 바로
startObserverQuery()를 호출하지 않도록 분기 처리하며, 필요하면 사용자에게 설정 확인을 유도하거나(알림/alert) 재시도
로직을 트리거하도록 구현하세요; 대상 식별자는 enableBackgroundDelivery(), startObserverQuery(),
healthStore.enableBackgroundDelivery(...)입니다.

Comment on lines +25 to +28
fun postActiveCaloriesBurned(kcal: Double) {
CoroutineScope(Dispatchers.IO).launch {
notificationRepository.postActiveCaloriesBurned(kcal)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

요청마다 새 CoroutineScope 생성은 수명 관리/에러 전파에 취약합니다.

Line 26 패턴은 호출 누적 시 취소 제어가 어렵고, 실패가 조용히 유실됩니다.
대안:

  • 클래스 단일 CoroutineScope(SupervisorJob() + Dispatchers.IO) 보유: 수명 제어/일관성 좋음(장점), 정리 지점 필요(단점).
  • postActiveCaloriesBurnedsuspend로 노출해 Swift 측에서 호출 제어: 호출부 책임이 명확(장점), Swift 연동 복잡도 증가(단점).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mobile/shared/src/iosMain/kotlin/com/mulkkam/di/Helper.kt` around lines 25 -
28, The current postActiveCaloriesBurned(kcal: Double) creates a new
CoroutineScope(Dispatchers.IO) per call which prevents proper lifecycle and
error handling; instead either (A) add a class-level val scope =
CoroutineScope(SupervisorJob() + Dispatchers.IO) and use scope.launch {
notificationRepository.postActiveCaloriesBurned(kcal) } so cancellations/errors
are managed and you can cancel scope when the host is destroyed, or (B) make fun
postActiveCaloriesBurned a suspend function and delegate suspension to the
caller (Swift side) so callers control coroutine lifecycle; locate the function
postActiveCaloriesBurned and the usage of CoroutineScope(Dispatchers.IO) and
implement one of these approaches, ensuring
notificationRepository.postActiveCaloriesBurned is called from that managed
scope or from the suspend function.

@2Jin1031
Copy link
Contributor

2Jin1031 commented Mar 3, 2026

ci 터졌어요

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
mobile/shared/src/iosMain/kotlin/com/mulkkam/domain/model/bio/HealthKitPlatform.kt (1)

11-11: 메서드명이 Android 플랫폼 용어를 사용하고 있습니다.

navigateToHealthConnect라는 이름은 Android의 Health Connect API를 연상시킵니다. iOS에서는 HealthKit을 사용하므로 navigateToHealthApp 또는 navigateToHealthKit과 같은 플랫폼 중립적이거나 iOS에 맞는 이름이 더 적절할 수 있습니다.

물론 인터페이스(HealthPlatform)에서 정의된 이름을 따라야 한다면 인터페이스 수준에서 이름을 재검토해보는 것도 좋겠습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@mobile/shared/src/iosMain/kotlin/com/mulkkam/domain/model/bio/HealthKitPlatform.kt`
at line 11, The method name navigateToHealthConnect uses Android-specific
terminology; rename it to a platform-appropriate name (e.g., navigateToHealthApp
or navigateToHealthKit) in this implementation (HealthKitPlatform) and update
the HealthPlatform interface and all other implementations/usages to match the
new name so signatures remain consistent; ensure you refactor references to
navigateToHealthConnect across the codebase (calls, overrides, and tests) to the
chosen new name.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@mobile/iosApp/iosApp/Health/HealthKitManager.swift`:
- Around line 51-67: The observer query is being re-registered each time
requestAuthorization() runs, causing duplicate HKObserverQuery handlers; modify
HealthKitManager to keep a stored reference (e.g., activeObserverQuery:
HKObserverQuery?) and before creating a new HKObserverQuery in
startObserverQuery() stop and/or remove the existing one via
healthStore.stop(_:) (or call existingQuery.stop()) and replace it with the new
query, or alternatively set a Bool flag (e.g., isObserverQueryStarted) to no-op
if already registered; update startObserverQuery() to use the stored symbol
(activeObserverQuery or isObserverQueryStarted) so only one active observer
exists and ensure the previous query is properly stopped before assigning a new
one.

In `@mobile/shared/src/iosMain/kotlin/com/mulkkam/di/KoinHelper.kt`:
- Around line 16-23: initKoin can throw KoinAppAlreadyStartedException if called
more than once; protect it by adding a companion-object boolean guard (e.g.,
isKoinStarted) checked at the start of initKoin and set to true after successful
startKoin, so subsequent calls return early; make the guard thread-safe by
wrapping the check/set in a synchronized block (or other mutex) to avoid races
when initKoin is called from multiple threads.

In
`@mobile/shared/src/iosMain/kotlin/com/mulkkam/domain/model/bio/HealthKitPlatform.kt`:
- Line 3: 도메인 레이어(com.mulkkam.domain.model.bio의 HealthKitPlatform.kt)가 UI 유틸인
openAppNotificationSettings를 직접 임포트하고 있어 의존성 방향이 역전되어 있으니, 이 호출을 도메인에서 제거하세요;
해결책으로는 1) openAppNotificationSettings를 common/ios 공통 유틸 모듈로 이동하여 도메인에서 UI 의존성을
없애거나 2) HealthPlatform(또는 HealthKitPlatform) 인터페이스에 openSettings 같은 추상 메서드를 추가하고
도메인에서는 이 추상 메서드만 호출한 뒤 실제 플랫폼별 구현체(앱 모듈)에서 openAppNotificationSettings를 호출하도록
변경해 주세요 so that HealthKitPlatform/HealthPlatform contains no UI imports and the
platform-specific implementation in the UI layer performs the actual settings
opening.

---

Nitpick comments:
In
`@mobile/shared/src/iosMain/kotlin/com/mulkkam/domain/model/bio/HealthKitPlatform.kt`:
- Line 11: The method name navigateToHealthConnect uses Android-specific
terminology; rename it to a platform-appropriate name (e.g., navigateToHealthApp
or navigateToHealthKit) in this implementation (HealthKitPlatform) and update
the HealthPlatform interface and all other implementations/usages to match the
new name so signatures remain consistent; ensure you refactor references to
navigateToHealthConnect across the codebase (calls, overrides, and tests) to the
chosen new name.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 853c4f93-df26-4db0-94e5-a88ca2681e46

📥 Commits

Reviewing files that changed from the base of the PR and between 876d9a7 and 70a043c.

📒 Files selected for processing (4)
  • mobile/iosApp/iosApp/Health/HealthKitManager.swift
  • mobile/shared/src/iosMain/kotlin/com/mulkkam/di/HealthPlatformModule.kt
  • mobile/shared/src/iosMain/kotlin/com/mulkkam/di/KoinHelper.kt
  • mobile/shared/src/iosMain/kotlin/com/mulkkam/domain/model/bio/HealthKitPlatform.kt

Comment on lines +51 to +67
private func startObserverQuery() {
let query = HKObserverQuery(
sampleType: activeEnergyType,
predicate: nil
) { [weak self] _, completionHandler, error in
if error != nil {
completionHandler()
return
}

self?.fetchBurnedCalories {
completionHandler()
}
}

healthStore.execute(query)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the HealthKitManager file and examine its full content
fd -t f "HealthKitManager.swift" mobile/iosApp

Repository: woowacourse-teams/2025-mul-kkam

Length of output: 125


🏁 Script executed:

#!/bin/bash
# Read the HealthKitManager file to understand the full context
cat -n mobile/iosApp/iosApp/Health/HealthKitManager.swift

Repository: woowacourse-teams/2025-mul-kkam

Length of output: 3643


🏁 Script executed:

#!/bin/bash
# Search for calls to startObserverQuery to understand the call patterns
rg -n "startObserverQuery|requestAuthorization|enableBackgroundDelivery" mobile/iosApp/iosApp/Health/ -A 2 -B 2

Repository: woowacourse-teams/2025-mul-kkam

Length of output: 2461


🏁 Script executed:

#!/bin/bash
# Check if requestAuthorization is called elsewhere or if there are other lifecycle entry points
rg -n "requestAuthorization|HealthKitManager" mobile/iosApp --type swift -B 1 -A 1 | head -40

Repository: woowacourse-teams/2025-mul-kkam

Length of output: 1948


🏁 Script executed:

#!/bin/bash
# Verify HKObserverQuery behavior patterns and typical iOS HealthKit usage
# Check if there's any cleanup or deinitialization in the class
rg -n "deinit|stop|invalidate" mobile/iosApp/iosApp/Health/HealthKitManager.swift

Repository: woowacourse-teams/2025-mul-kkam

Length of output: 57


Observer 쿼리가 누적 등록될 수 있습니다.

requestAuthorization()이 여러 번 호출될 때마다 새로운 HKObserverQuery를 실행하지만, 이전 쿼리를 중지하지 않습니다. HealthKitManager는 싱글톤으로 앱 생명주기 동안 유지되고, 저장된 쿼리 참조도 없어서 이전 인스턴스를 제어할 수 없습니다. 이렇게 되면 HealthKit 업데이트 1건이 등록된 쿼리 개수만큼 fetchBurnedCalories()를 반복 호출하고, 결국 onBurnedCalorieUpdated 콜백이 중복 실행되어 서버 전송도 여러 번 일어납니다.

해결 방안 — 두 가지 접근 모두 실용적입니다.

  1. 쿼리 교체 방식: 기존 쿼리를 stop()으로 정리한 뒤 새 쿼리를 등록합니다. 재권한 요청이나 재설정 상황에도 안전하며, 항상 정확히 1개의 활성 쿼리만 유지합니다.

  2. 이미 등록 여부 확인: startObserverQuery()를 처음 호출했는지 플래그로 추적해서, 이미 등록되었으면 no-op 처리합니다. 상태 관리가 더 단순하지만, 설정 변경 시나리오에서는 쿼리를 갱신할 수 없습니다.

현재 구조에서는 초기화가 한 번 더 발생하는 것만으로 중복 동작이 쉽게 누적되므로, 쿼리 생명주기를 여기서 명확히 관리하는 편이 안전합니다. 다음은 교체 방식의 예시입니다.

🔧 쿼리 교체 방식 예시
 final class HealthKitManager {
     static let shared = HealthKitManager()
     static let exerciseDetectionThreshold: Double = 100.0

     private init() {}
+    private var observerQuery: HKObserverQuery?
 
     private let activeEnergyType =
         HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!
     private let healthStore = HKHealthStore()
     private let read = Set([
         HKSampleType.quantityType(forIdentifier: .activeEnergyBurned)!
     ])

     private var onBurnedCalorieUpdated: ((Double) -> Void)?

     func requestAuthorization(
         onBurnedCalorieUpdated: `@escaping` (Double) -> Void,
         completion: `@escaping` (Bool) -> Void
     ) {
         self.onBurnedCalorieUpdated = onBurnedCalorieUpdated

         healthStore.requestAuthorization(
             toShare: nil,
             read: read
         ) { success, error in
             DispatchQueue.main.async {
                 if success {
                     self.enableBackgroundDelivery()
                     self.startObserverQuery()
                 } else {
                     if let url = URL(string: UIApplication.openSettingsURLString) {
                         UIApplication.shared.open(url)
                     }
                 }
                 completion(success)
             }
         }
     }

     private func startObserverQuery() {
+        if let observerQuery {
+            healthStore.stop(observerQuery)
+        }
+
         let query = HKObserverQuery(
             sampleType: activeEnergyType,
             predicate: nil
         ) { [weak self] _, completionHandler, error in
             if error != nil {
                 completionHandler()
                 return
             }

             self?.fetchBurnedCalories {
                 completionHandler()
             }
         }

+        observerQuery = query
         healthStore.execute(query)
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mobile/iosApp/iosApp/Health/HealthKitManager.swift` around lines 51 - 67, The
observer query is being re-registered each time requestAuthorization() runs,
causing duplicate HKObserverQuery handlers; modify HealthKitManager to keep a
stored reference (e.g., activeObserverQuery: HKObserverQuery?) and before
creating a new HKObserverQuery in startObserverQuery() stop and/or remove the
existing one via healthStore.stop(_:) (or call existingQuery.stop()) and replace
it with the new query, or alternatively set a Bool flag (e.g.,
isObserverQueryStarted) to no-op if already registered; update
startObserverQuery() to use the stored symbol (activeObserverQuery or
isObserverQueryStarted) so only one active observer exists and ensure the
previous query is properly stopped before assigning a new one.

Comment on lines +16 to +23
fun initKoin(
baseUrl: String,
isDebug: Boolean,
) {
startKoin {
modules(iosSharedModule(baseUrl, isDebug))
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Koin startKoin multiple calls re-initialization behavior

💡 Result:

  • startKoin { … } creates a global Koin container by registering it in GlobalContext. Because it’s global, it’s intended to be called once at the app entry point. [1]
  • If you call startKoin again while Koin is already started, Koin considers that an error (“can’t be called more than once”) and you typically get a KoinAppAlreadyStartedException. [2]
  • If your goal is to “reinitialize” Koin, the supported reset is to stop the global context via stopKoin() (then you can startKoin again). This is also the recommended approach between tests to reset state. [1] [2]
  • If your goal is not a full re-init, but just adding/removing definitions after startup, don’t call startKoin again—use loadKoinModules(...) / unloadKoinModules(...). [2]
  • If you truly need multiple independent Koin containers (SDK/library isolation, multiple instances, some testing setups), don’t use startKoin; use koinApplication { … } to create an isolated instance that does not touch GlobalContext. [1] [3]

Sources: Starting Koin (latest docs) [1], Start Koin (core reference; notes “can’t be called more than once”, load/unload modules, stopKoin) [2], Context Isolation (koinApplication for isolated contexts) [3].


🏁 Script executed:

# Search for usage of KoinHelper and initKoin in the codebase
find . -type f -name "*.kt" | head -20 | xargs grep -l "KoinHelper\|initKoin" 2>/dev/null | head -10

Repository: woowacourse-teams/2025-mul-kkam

Length of output: 57


🏁 Script executed:

# Look at the KoinHelper.kt file to understand its full context
cat -n mobile/shared/src/iosMain/kotlin/com/mulkkam/di/KoinHelper.kt

Repository: woowacourse-teams/2025-mul-kkam

Length of output: 1067


🏁 Script executed:

# Search for Koin imports and usage patterns in the iOS main source
grep -r "startKoin\|initKoin\|KoinHelper" mobile/shared/src/iosMain/ --include="*.kt" 2>/dev/null

Repository: woowacourse-teams/2025-mul-kkam

Length of output: 418


initKoin()은 중복 호출 시 런타임 예외를 방지하도록 보호해야 합니다.

Koin의 startKoin은 전역 컨테이너를 일회성으로 초기화하는 API입니다. 공식 문서에 따르면 두 번째 호출 시 KoinAppAlreadyStartedException이 발생합니다. 현재 구현에는 재호출을 막는 장치가 없어서, Swift 쪽에서 초기화 로직이 반복되거나 KoinHelper 인스턴스가 재생성되면 런타임 크래시로 이어질 수 있습니다.

이를 해결하는 두 가지 방법을 고려해볼 수 있습니다.

1) 헬퍼 내부에 초기화 가드를 두는 방법 (최소 변경)

  • 장점: 코드 변경이 작고, 여러 호출 지점에서도 안전하게 동작합니다.
  • 단점: 초기화 책임이 헬퍼에 분산되어 있어 의도가 명확하지 않을 수 있습니다.

2) 앱 진입점(App delegate)에서만 초기화하도록 제한하는 방법 (구조적 개선)

  • 장점: 초기화 시점과 책임이 명확해서 유지보수가 쉽습니다.
  • 단점: 호출 구조 변경이 필요합니다.

현재 상황에서는 방법 1을 권장합니다. 아래와 같이 companion object의 가드 플래그를 추가하면 충분합니다:

🔧 구현 예시
 class KoinHelper {
+    companion object {
+        private var isStarted = false
+    }
+
     fun initKoin(
         baseUrl: String,
         isDebug: Boolean,
     ) {
+        if (isStarted) return
         startKoin {
             modules(iosSharedModule(baseUrl, isDebug))
         }
+        isStarted = true
     }
 }

메인 스레드 외 경로에서도 호출될 가능성이 있다면, 이 가드에 synchronized 블록을 감싸는 것도 좋습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mobile/shared/src/iosMain/kotlin/com/mulkkam/di/KoinHelper.kt` around lines
16 - 23, initKoin can throw KoinAppAlreadyStartedException if called more than
once; protect it by adding a companion-object boolean guard (e.g.,
isKoinStarted) checked at the start of initKoin and set to true after successful
startKoin, so subsequent calls return early; make the guard thread-safe by
wrapping the check/set in a synchronized block (or other mutex) to avoid races
when initKoin is called from multiple threads.

Copy link
Contributor

@junseo511 junseo511 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA를 해드리고 싶었으나...! 로그인이 안되는군요 😭
궁금한 점이 있어 코멘트를 남겼습니다 :)
고생하셨어요 헬스!!!

static let shared = HealthKitManager()
static let exerciseDetectionThreshold: Double = 100.0

private init() {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3) 해당 함수가 하는 역할이 없어보입니다! 🙌

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

private init 이 없으면 외부에서 인스턴스 생성이 가능해서, 싱글톤 패턴에서는 필수라고 하더군요?

그래서 살짝 넣어 봤습니다

Comment on lines +13 to +15
val healthUrl = NSURL.Companion.URLWithString("x-apple-health://") ?: return

val app = UIApplication.Companion.sharedApplication
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3) 여기 Companion들 지우셔도 될 것 같아요!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onBurnedCalorieUpdated: @escaping (Double) -> Void,
completion: @escaping (Bool) -> Void
) {
self.onBurnedCalorieUpdated = onBurnedCalorieUpdated
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3) 궁금한 점이 있습니다! 혹시 100칼로리 이상 넘어가서 101, 102, 103 매번 보내지는 않는지요? onXxx 라는 함수명 때문에 우려가 되어서 혹시혹시 물어봐요!!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AN 안드로이드 관련 이슈

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants