Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ concurrency:
jobs:
build-test:
name: Build & Test (iOS Simulator)
runs-on: macos-14
# Google Mobile Ads 13.x 헤더가 Xcode 16 이상에서만 컴파일된다(NS_SWIFT_SENDING).
runs-on: macos-15
timeout-minutes: 30

env:
PROJECT_PATH: Controllers/Controllers.xcodeproj
SCHEME: Controllers
DESTINATION: platform=iOS Simulator,name=iPhone 15
DESTINATION: generic/platform=iOS Simulator

steps:
- name: Checkout
Expand Down
56 changes: 56 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
| 컨트롤러 페어링·입력 | `GameController` 프레임워크 (ControllerKit으로 래핑) | [GameControllerManager.swift](Controllers/ControllerKit/Sources/Controller/GameControllerManager.swift) |
| 백그라운드 알림 | `BGAppRefreshTask` + 로컬 알림 + 사용자 설정 기준값 | [FetchGameControllerOperation.swift](Controllers/Controllers/Sources/Models/FetchGameControllerOperation.swift) |
| 잠금화면 노출 | ActivityKit Live Activity (iOS 16.1+) | [LiveActivityManager.swift](Controllers/Controllers/Sources/Models/LiveActivityManager.swift) |
| 광고 이용권 | AdMob 보상형 광고 → 기기 안에서 시간제 Pro | [RewardedPassManager.swift](Controllers/Controllers/Sources/Models/RewardedPassManager.swift) |
| 비구독자 광고·Pro 유도 | 앱 오프닝 광고(복귀 시) + 컨트롤러 연결 시 Pro 팝업 | [AppOpenAdManager.swift](Controllers/Controllers/Sources/Models/AppOpenAdManager.swift), [ProPromotionManager.swift](Controllers/Controllers/Sources/Models/ProPromotionManager.swift) |

---

Expand Down Expand Up @@ -269,6 +271,60 @@ GCController 알림 (.GCControllerDidConnect / DidDisconnect)

---

## 결정 #12 — 광고 이용권: AdMob 보상형 광고 + 기기 안 만료 시각

**결정**: 구독하지 않은 사용자도 보상형 광고를 끝까지 보면 `RewardedPassManager.passDuration` 동안 유료 기능(백그라운드 충전 알림, Live Activity)을 쓴다. 유료 기능 판정은 `PremiumAccess.isActive`(구독 중 ‖ 이용권 남음) 한 곳으로 모은다.

| 항목 | 구현 |
|---|---|
| 광고 SDK | Google Mobile Ads 13.x + UMP 3.x (SPM) |
| 광고 요청 순서 | UMP 동의 갱신·필요 시 동의 화면 → ATT 요청 → `MobileAds.start` → `RewardedAd.load` → 표시. 동의·ATT는 `AdConsentManager`가 맡고 앱 시작 때도 묻는다(결정 #13) |
| 이용권 저장 | 만료 시각을 `UserDefaults.standard`(`StringKey.PRO_PASS_EXPIRATION`)에 저장. 시청 중에는 다시 받을 수 없음(누적 없음) |
| 유료 기능 판정 | BGTask 실행·예약, Live Activity 시작·갱신, 앱 활성화 시 Live Activity 정리 모두 `PremiumAccess.isActive` |
| 광고 단위 | DEBUG는 Google 테스트 단위, Release는 AdMob 'pro_pass_rewarded'. 앱 ID는 Info.plist `GADApplicationIdentifier` |
| EU 동의 | AdMob '아차 충전 iOS - 유럽 규정' 메시지. 동의가 필요한 지역이면 구매 화면에 '광고 개인정보 설정' 링크 표시 |

**근거**:
- 결제하지 않는 사용자에게도 유료 기능을 맛보게 하고 광고 수익을 얻음. 가이드라인 3.2.2(x)는 광고 시청에 대한 보상을 허용
- 판정을 기기 안에서 하는 이유는 결정 #7의 서버 검증 미사용과 같음 (유료 기능이 기기 안에서만 동작)
- ATT 허용 여부와 관계없이 보상은 같음 → 허용은 맞춤 광고 단가에만 영향

**트레이드오프**:
- 광고 SDK가 기기 식별자·광고 데이터·대략적 위치·진단 정보를 수집 → App Store 개인정보 라벨(추적 포함)과 개인정보처리방침(dy-solution.kr)을 함께 갱신해야 함
- 기기 시계를 바꾸면 이용권을 늘릴 수 있음 (서버 시간 검증 없음)
- 이용권이 끝나도 실행 중인 Live Activity는 다음 갱신이나 앱 활성화 때 닫힘
- AdMob 앱 인증은 App Store의 개발자 웹사이트(마케팅 URL) 도메인에 있는 app-ads.txt로 확인됨 → 마케팅 URL이 비어 있으면 광고 게재가 제한됨

**재검토 기준**: 구독 전환율이 떨어지면 `passDuration`을 줄이거나 하루 시청 횟수를 제한. 광고 수익이 의미 없으면 SDK째 제거(개인정보 라벨·방침도 되돌림).

---

## 결정 #13 — 비구독자 광고: 앱 오프닝 광고 + 컨트롤러 연결 시 Pro 팝업

**결정**: 유료 기능을 쓰지 않는 사용자(`!PremiumAccess.isActive`)에게 두 가지를 보여 준다. 구독자·광고 이용권 사용자에게는 보이지 않으며, 이로써 "광고 제거"가 Pro 혜택이 된다.

| 항목 | 구현 |
|---|---|
| 앱 시작 흐름 | `SceneDelegate.runLaunchFlow` (실행당 1회): 알림 권한 답(최대 10초 대기) → 비구독자면 UMP 동의·ATT·SDK 시작 → 앱 오프닝 광고 미리 로드 → Pro 팝업 허용 |
| 앱 오프닝 광고 | 백그라운드에서 돌아올 때만(`sceneDidBecomeActive`에서 직전에 `sceneDidEnterBackground`를 거쳤는지로 판단). 마지막 노출 후 15분(`AppOpenAdManager.minimumInterval`, `UserDefaults.standard`에 저장), 불러온 지 4시간 지난 광고는 버림 |
| 앱 오프닝 광고를 건너뛰는 경우 | 약관 링크·광고 클릭으로 앱이 사용자를 밖으로 보낸 직후 1회(`skipNextAd()`), 다른 광고·팝업(`UIAlertController`)이 떠 있을 때 |
| Pro 팝업 | 앱 실행 후 첫 컨트롤러 연결 때 1회(날짜와 무관, 메모리 플래그). 앱 시작 흐름이 끝나고 다른 광고·팝업이 없을 때 표시. 구매 화면을 보고 있으면 생략 |
| 광고 단위 | 앱 오프닝 'app_open_foreground'. DEBUG는 Google 테스트 단위 |

**근거**:
- AdMob 정책상 앱 로드·복귀 시점에 전면(interstitial) 광고는 금지. 이 시점용 형식이 앱 오프닝 광고
- 컨트롤러를 연결한 순간이 백그라운드 충전 알림이 가장 와닿는 때. 기존에는 Pro 진입점이 설정 탭 한 곳뿐이었음
- 알림 권한 팝업이 떠 있으면 앱이 비활성이라 ATT 팝업이 무시됨 → 순서를 코드로 강제

**트레이드오프**:
- 배터리만 잠깐 확인하고 나가는 사용자에게 광고가 끼어듦 → 간격 15분으로 완화
- Pro 팝업은 컨트롤러를 켠 채 앱을 실행할 때마다 뜸(연결 이벤트가 실행 직후 옴)
- 알림 권한 팝업이 10초 안에 뜨지 않으면 다음 단계로 넘어감. 그 뒤에 뜨더라도 `AdConsentManager.prepare()`가 앱이 다시 활성화될 때까지 기다리므로 ATT와 겹치지 않음

**재검토 기준**: 리뷰·별점에서 광고 불만이 늘면 간격을 늘리거나 앱 오프닝 광고를 끔. 팝업이 성가시다는 의견이 많으면 하루 1회로 제한.

---

## 알려진 부채 / 향후 결정 후보

다음 항목은 의사결정이 보류되었거나 미해결 상태입니다. 작업이 시작될 때 신규 "결정 #N"으로 승격하세요.
Expand Down
31 changes: 24 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@

**AchaCharge** ("아차! 충전 까먹었다")는 Game Controller의 배터리 충전 시기를 알려주는 iOS 앱입니다. 내부 Xcode 프로젝트명은 `Controllers`이며, 홈 화면 표시 이름은 ko `아차 충전 !` / en `Ah Charge!` / ja `忘れないで!` 입니다.

- **핵심 기능**: 컨트롤러 배터리 모니터링 · 버튼 입력 테스터 · 백그라운드 저전력 알림 · 홈 화면 위젯 · Live Activity · 월 구독(IAP)
- **핵심 기능**: 컨트롤러 배터리 모니터링 · 버튼 입력 테스터 · 백그라운드 저전력 알림 · 홈 화면 위젯 · Live Activity · 월 구독(IAP) · 광고 시청 Pro 이용권
- **지원 OS**: 앱/위젯 iOS 15.0 이상 (1.1.0부터 — Xcode 27이 지원하는 최소값). 테스트 타깃 16.4, Live Activity 16.1+ 런타임 가드. macOS 타깃(14.1)은 템플릿만 존재
- **외부 의존성**: [SwiftyStoreKit](https://github.com/bizz84/SwiftyStoreKit) 0.16.4 (SPM) 1개 + 로컬 패키지 `ControllerKit`
- **외부 의존성**: [SwiftyStoreKit](https://github.com/bizz84/SwiftyStoreKit) 0.16.4, Google Mobile Ads 13.x, Google UMP 3.x (모두 SPM) + 로컬 패키지 `ControllerKit`
- **공식 지원 컨트롤러**: Sony PlayStation DualSense Wireless Controller

상세 슬로건·외부 소개는 [README.md](README.md) 참조.
Expand Down Expand Up @@ -47,7 +47,7 @@ xcodebuild build -project Controllers/Controllers.xcodeproj -scheme Controllers

### CI

[.github/workflows/ios.yml](.github/workflows/ios.yml) — `main`/`develop` push·PR 시 `macos-14`에서 `Controllers` scheme **빌드만** 수행. test 단계는 위 TestAction 문제로 비활성화.
[.github/workflows/ios.yml](.github/workflows/ios.yml) — `main`/`develop` push·PR 시 `macos-15`(Xcode 16 이상 — Google Mobile Ads 13 요구)에서 `Controllers` scheme **빌드만** 수행. test 단계는 위 TestAction 문제로 비활성화.

### 주요 식별자

Expand Down Expand Up @@ -109,6 +109,10 @@ xcodebuild build -project Controllers/Controllers.xcodeproj -scheme Controllers
- `GameControllerManager.shared` (ControllerKit) — 연결 감지·배터리 조회·입력 매핑
- `StoreKitManager.shared` — 구독 여부, 상품 ID 로드
- `LiveActivityManager.shared` — Live Activity 시작/갱신/종료
- `RewardedPassManager.shared` — 보상형 광고(로드·표시)와 광고 이용권
- `AdConsentManager.shared` — 광고 요청 전 UMP 동의·ATT·SDK 시작
- `AppOpenAdManager.shared` — 포어그라운드 복귀 시 앱 오프닝 광고
- `ProPromotionManager.shared` — 컨트롤러 연결 시 Pro 소개 팝업
- 새 Manager 추가 시 같은 패턴 유지

### ControllerKit (로컬 패키지)
Expand All @@ -117,7 +121,7 @@ xcodebuild build -project Controllers/Controllers.xcodeproj -scheme Controllers

### 영속화
- **`UserDefaults.shared`** (App Group `group.arex.achaCharge`) — 컨트롤러 연결 여부, 배터리, 알림 기준값 등 위젯과 공유하는 값
- **`UserDefaults.standard`** — 구독 여부(`StringKey.IS_SUBSCRIBED`)만 저장. 위젯에서 읽을 수 없음
- **`UserDefaults.standard`** — 구독 여부(`StringKey.IS_SUBSCRIBED`), 광고 이용권 만료 시각(`StringKey.PRO_PASS_EXPIRATION`), 마지막 앱 오프닝 광고 시각(`StringKey.LAST_APP_OPEN_AD_DATE`). 위젯에서 읽을 수 없음
- 헬퍼: [UserDefaults+.swift](Controllers/Controllers/Sources/Extensions/UserDefaults+.swift) (`batteryNotificationThreshold` 등 계산 프로퍼티)
- 키는 `StringKey` 네임스페이스에 상수로 정의 — 새 키 추가 시 동일 패턴
- CoreData/Realm/Keychain 미사용. 도입 시 ARCHITECTURE.md에 의사결정 기록 필요
Expand All @@ -128,11 +132,11 @@ xcodebuild build -project Controllers/Controllers.xcodeproj -scheme Controllers
- 위젯이 읽는 키: `StringKey.CONTROLLER_CONNECTED`, `StringKey.BATTERY_LEVEL`
- 위젯에서 Push를 보내던 코드는 제거됨 (커밋 `ba362d5`). 위젯 → 앱 통신을 다시 넣지 마세요

### 백그라운드 알림 (구독자 전용)
- `AppDelegate`가 BGTask를 등록 → `SceneDelegate.sceneDidEnterBackground`에서 구독자일 때만 `scheduleAppRefresh()`
### 백그라운드 알림 (구독자·광고 이용권 사용자 전용)
- `AppDelegate`가 BGTask를 등록 → `SceneDelegate.sceneDidEnterBackground`에서 `PremiumAccess.isActive`일 때만 `scheduleAppRefresh()`
- 작업 실행 시 [FetchGameControllerOperation](Controllers/Controllers/Sources/Models/FetchGameControllerOperation.swift)이 배터리를 읽어 **기준값(기본 20%) 이하일 때만** 로컬 알림 발송

### Live Activity (iOS 16.1+, 구독자 전용)
### Live Activity (iOS 16.1+, 구독자·광고 이용권 사용자 전용)
- `ControllerActivityAttributes`는 **앱 + 위젯 양쪽 타깃 멤버십** 필요
- ActivityKit 코드는 전부 `@available(iOS 16.1, *)` + 런타임 `if #available` 가드. 앱 배포 타깃(15.0)은 올리지 않음
- 갱신은 로컬(ActivityKit)만 사용, Push 기반 갱신 없음
Expand All @@ -146,6 +150,19 @@ xcodebuild build -project Controllers/Controllers.xcodeproj -scheme Controllers
- 서버 검증은 쓰지 않습니다. 유료 기능이 기기 안에서만 동작해서, 서버 검증이 보안상 추가로 막는 게 없습니다
- `hotfix/1.0.12-iapverify`는 develop에 머지됐지만 그 검증 코드는 남아 있지 않습니다. 위 StoreKit 2 판정이 그 역할을 대신합니다
- 상품 ID는 절대 코드에 하드코딩 금지 — `ProductIDs.plist`에서 로드
- 유료 기능 여부는 `StoreKitManager.shared.isSubscribed`가 아니라 **`PremiumAccess.isActive`**(구독 ‖ 광고 이용권)로 판단

### 광고 (AdMob)
유료 기능을 쓰지 않는 사용자(`!PremiumAccess.isActive`)에게만 광고가 나갑니다. 구독자와 광고 이용권 사용자는 광고를 보지 않습니다.

- **앱 시작 흐름** (`SceneDelegate.runLaunchFlow`, 실행당 1회): 알림 권한 답 기다림(최대 10초) → 구독자가 아니면 [AdConsentManager](Controllers/Controllers/Sources/Models/AdConsentManager.swift)가 UMP 동의 → ATT → SDK 시작 → 앱 오프닝 광고 미리 로드 → Pro 팝업 허용. 시스템 팝업이 겹치지 않게 이 순서를 지키세요
- **보상형 → 광고 이용권** ([RewardedPassManager.swift](Controllers/Controllers/Sources/Models/RewardedPassManager.swift)): 구매 화면 버튼·Pro 팝업에서 시청. 끝까지 보면 `passDuration` 동안 이용권
- **앱 오프닝 광고** ([AppOpenAdManager.swift](Controllers/Controllers/Sources/Models/AppOpenAdManager.swift)): 백그라운드에서 돌아올 때만(콜드 스타트·제어 센터 제외), 마지막 노출 후 `minimumInterval`(15분)이 지나야 표시. AdMob 정책상 앱을 열거나 돌아오는 시점에는 전면(interstitial) 광고를 쓰면 안 됩니다
- **Pro 팝업** ([ProPromotionManager.swift](Controllers/Controllers/Sources/Models/ProPromotionManager.swift)): 앱 실행 후 첫 컨트롤러 연결 때 1회. 광고 이용권 받기 / 구매 화면 / 나중에
- 앱이 사용자를 밖으로 보내는 코드(`UIApplication.shared.open` 등)를 추가하면 그 직전에 `AppOpenAdManager.shared.skipNextAd()`를 부르세요. 안 그러면 돌아오자마자 광고가 뜹니다
- DEBUG 빌드는 Google 테스트 광고 단위를 씀. 실제 광고 단위로 개발 중 광고를 누르지 마세요(AdMob 무효 트래픽)
- AdMob 앱 ID `ca-app-pub-7469152743628808~7969925639`(Info.plist `GADApplicationIdentifier`), 보상형 `pro_pass_rewarded` `.../7469653173`, 앱 오프닝 `app_open_foreground` `.../3611967519`
- 광고 SDK가 데이터를 수집하므로 App Store 개인정보 라벨(추적 포함)과 dy-solution.kr 개인정보처리방침을 같이 관리. 근거는 ARCHITECTURE.md 결정 #12, #13

### 로컬라이제이션
- ko / en / ja `Localizable.strings` + `InfoPlist.strings`
Expand Down
Loading
Loading