From 40abc26f5dcf56dc3c4ce6eba1d7947d74dc78eb Mon Sep 17 00:00:00 2001 From: airkjw Date: Sat, 13 Jun 2026 22:49:43 +0900 Subject: [PATCH] build(release): Android signing, iOS privacy manifest, store assets (Phase 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Android: release keystore signing wired via gitignored key.properties (falls back to debug when absent). Verified: signed AAB built (signer CN=Block Seasons). - iOS: app PrivacyInfo.xcprivacy (ATT tracking flag, device-id/usage data types, UserDefaults+FileTimestamp required-reason APIs) registered in the Runner target. - Store: app-ads.txt (pub-5605900229781491), EN/KO listing copy, owner submission guide (privacy labels, app-ads hosting, upload/submit steps). Secrets (keystore, key.properties) are gitignored — owner backs them up. Co-Authored-By: Claude Fable 5 --- .gitignore | 8 ++ android/app/build.gradle.kts | 31 +++++++- docs/store/app-ads.txt | 1 + docs/store/phase7-submission-guide.md | 110 ++++++++++++++++++++++++++ docs/store/store-listing.md | 89 +++++++++++++++++++++ ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/PrivacyInfo.xcprivacy | 64 +++++++++++++++ 7 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 docs/store/app-ads.txt create mode 100644 docs/store/phase7-submission-guide.md create mode 100644 docs/store/store-listing.md create mode 100644 ios/Runner/PrivacyInfo.xcprivacy diff --git a/.gitignore b/.gitignore index d0e142f..ce2d446 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,11 @@ CLAUDE.md # Firebase Hosting (CLI deploy cache + generated content payload) .firebase/ /deploy/content/ + +# Android release signing — NEVER commit (owner backs these up out-of-band) +android/key.properties +*.jks +*.keystore + +# Kotlin/Gradle build caches +android/.kotlin/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5b8a1c1..21a3806 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,6 @@ +import java.io.FileInputStream +import java.util.Properties + plugins { id("com.android.application") // START: FlutterFire Configuration @@ -8,6 +11,15 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } +// Release signing is read from android/key.properties (gitignored). When that +// file is absent (CI, a fresh clone, another machine) the release build falls +// back to debug signing so `flutter build`/`flutter run --release` still works. +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + android { namespace = "com.airkjw.block_seasons" compileSdk = flutter.compileSdkVersion @@ -34,11 +46,24 @@ android { versionName = flutter.versionName } + signingConfigs { + create("release") { + if (keystorePropertiesFile.exists()) { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + signingConfig = if (keystorePropertiesFile.exists()) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } } } diff --git a/docs/store/app-ads.txt b/docs/store/app-ads.txt new file mode 100644 index 0000000..5343af7 --- /dev/null +++ b/docs/store/app-ads.txt @@ -0,0 +1 @@ +google.com, pub-5605900229781491, DIRECT, f08c47fec0942fa0 diff --git a/docs/store/phase7-submission-guide.md b/docs/store/phase7-submission-guide.md new file mode 100644 index 0000000..862149b --- /dev/null +++ b/docs/store/phase7-submission-guide.md @@ -0,0 +1,110 @@ +# Phase 7 — 릴리스 & 스토어 제출 가이드 (오너용) + +코드/빌드 쪽(서명, 프라이버시 매니페스트, app-ads.txt 내용, 스토어 카피)은 준비돼 +있습니다. 이 문서는 **오너가 콘솔/웹에서 할 일**과 **제출 순서**입니다. 헷갈리면 +Claude에게 화면 보여주면서 같이 하면 됩니다. + +--- + +## 0. 먼저 — 안드로이드 서명 키 백업 (가장 중요) + +`android/app/upload-keystore.jks` 파일과 비밀번호는 **이 앱의 영구 서명 키**입니다. +**잃어버리면 Play에서 이 앱을 영원히 업데이트할 수 없습니다.** +- `upload-keystore.jks` 파일을 안전한 곳(클라우드 드라이브 + 외장 등 2곳 이상)에 백업. +- 비밀번호도 비밀번호 관리자에 저장. (git에는 올라가지 않습니다 — gitignore됨) + +> 참고: Play **앱 서명(Play App Signing)**을 쓰면 이 키는 "업로드 키"가 되고 분실 시 +> 재설정이 가능하지만, 그래도 백업은 필수입니다. + +--- + +## 1. 개인정보처리방침(Privacy Policy) 페이지 — 양 스토어 필수 + +두 스토어 모두 **개인정보처리방침 URL**을 요구합니다. 무료로 GitHub Pages에 한 장 +올리면 됩니다. 내용에 최소한 다음을 명시: +- 수집 항목: **광고 식별자(IDFA/광고 ID)**, **사용 데이터(앱 이용 통계)**, **기기 정보**. +- 사용처: **광고 게재(AdMob)**, **분석(Firebase Analytics)**. +- 제3자: Google AdMob, Google Firebase (각 정책 링크). +- 추적: iOS에서 ATT 동의 시에만 맞춤 광고. 동의 거부해도 앱 정상 작동. +- 구매: "광고 제거" 인앱 구입(비소모성). +- 문의 이메일. + +--- + +## 2. app-ads.txt 호스팅 — 광고 수익 인증 + +- 파일 내용은 `docs/store/app-ads.txt`에 있습니다: + ``` + google.com, pub-5605900229781491, DIRECT, f08c47fec0942fa0 + ``` +- 이걸 **개발자 웹사이트 도메인 루트**에 올립니다 → `https://<도메인>/app-ads.txt`. +- ⚠️ 그 도메인은 **스토어 리스팅의 "마케팅/개발자 웹사이트" URL과 정확히 같아야** + AdMob이 인식합니다. (GitHub Pages 커스텀 도메인이면 그 도메인) +- 올린 뒤 AdMob → 앱 → app-ads.txt 상태가 며칠 내 "발견됨"으로 바뀝니다. + +--- + +## 3. 개인정보 라벨 — 콘솔 입력값 (그대로 답하면 됨) + +코드가 실제로 수집하는 것과 일치하는 답변입니다. 이 표대로 입력하세요. + +### App Store Connect → 앱 개인정보 보호 +| 데이터 유형 | 수집? | 사용 목적 | 사용자 연결 | 추적에 사용 | +|---|---|---|---|---| +| 식별자 → 기기 ID(광고 식별자) | 예 | 제3자 광고 | 아니요 | **예** | +| 사용 데이터 → 제품 상호작용 | 예 | 분석, 앱 기능 | 아니요 | 아니요 | +| 구매 → 구매 내역 | (Apple이 IAP 자동 처리, 별도 수집 안 함) | — | — | — | + +### Google Play → 데이터 보안(Data safety) +- 데이터 수집함: **예** +- 앱 활동(앱 상호작용) → 분석 목적, 사용자 연결 안 함. +- 기기 또는 기타 ID(광고 ID) → 광고/마케팅 목적, **공유함(Google)**. +- 전송 중 암호화: 예. 사용자가 삭제 요청 가능: 해당 시 명시. +- ⚠️ **"아동 대상 앱" 아니오** (Everyone/4+, 아동 타겟 아님으로 정확히). + +--- + +## 4. 안드로이드 — Play 업로드 + +1. **AAB 파일**: Claude가 빌드한 `build/app/outputs/bundle/release/app-release.aab`. + (없거나 갱신 필요하면 Claude에게 "AAB 다시 빌드" 요청) +2. Play Console → Block Seasons → **테스트 → 내부 테스트 → 새 버전 만들기**. +3. AAB 업로드. (첫 업로드 시 **Play 앱 서명** 사용에 동의 — 권장) +4. 업로드되면 **수익 창출 → 제품 → 인앱 상품**에서 `remove_ads` 상품 생성 가능해집니다 + (제품 ID는 **`remove_ads`** 정확히 — App Store와 동일). +5. **라이선스 테스터**(설정)에 본인 계정 추가 → 샌드박스 결제 테스트. +6. 데이터 보안·콘텐츠 등급·스토어 리스팅(아래 카피) 작성 → 내부 테스트 출시. + +## 5. iOS — App Store Connect 업로드 + +1. **빌드 업로드**: Xcode로 `Runner.xcworkspace` 열기 → 기기를 "Any iOS Device" → + Product ▸ Archive → Organizer에서 "Distribute App" → App Store Connect 업로드. + (또는 `flutter build ipa` 후 Transporter 앱으로 업로드. 인증서/프로비저닝은 + Xcode 자동 서명으로 처리.) +2. 업로드된 빌드가 App Store Connect → TestFlight에 나타납니다(처리 ~수십 분). +3. **IAP 마무리**: `remove_ads` 상품에 **표시 이름/설명(한·영)** + **리뷰용 스크린샷** + (설정 화면 캡처) 추가 → 상태가 "제출 준비됨"으로. 첫 IAP는 **앱 버전과 함께 제출**. +4. **유료 앱 계약**(Business → 계약/세금/뱅킹) 완료해야 IAP 판매·심사 가능. +5. 앱 개인정보(위 표) + ATT 사용 이유 + 스토어 카피 작성 → **심사 제출**. + +## 6. 스토어 카피 + +`docs/store/store-listing.md`의 EN/KO 카피를 각 콘솔에 붙여넣으세요. +스크린샷은 실기기/시뮬레이터에서 캡처(홈·시즌맵·플레이·승리·엔드리스). Claude가 +캡처를 도울 수 있습니다(시뮬레이터). iOS는 6.7"/6.5" 규격, Play는 폰 스크린샷. + +## 7. AdMob — 앱 연결 + +앱이 스토어에 **출시된 뒤**, AdMob → 앱 → "앱 스토어에 연결"로 실제 스토어 앱과 +연결하면 실광고 게재가 승인됩니다. (출시 전까지는 미연결이 정상) + +--- + +## 제출 전 최종 체크리스트 +- [ ] 키스토어 백업 완료 +- [ ] 개인정보처리방침 URL 게시 +- [ ] app-ads.txt 게시(개발자 웹사이트 도메인 = 스토어 URL) +- [ ] App Store: 빌드 업로드 + IAP(현지화·스크린샷) + 개인정보 라벨 + ATT 문구 + 카피 +- [ ] Play: AAB(내부 테스트) + remove_ads 상품 + 데이터 보안 + 콘텐츠 등급 + 카피 +- [ ] 유료 앱 계약(Apple)·결제 프로필(Google) 완료 +- [ ] 양 스토어 심사 제출 diff --git a/docs/store/store-listing.md b/docs/store/store-listing.md new file mode 100644 index 0000000..12b9cdf --- /dev/null +++ b/docs/store/store-listing.md @@ -0,0 +1,89 @@ +# Store Listing Copy — Block Seasons + +Paste these into App Store Connect (App Information / Version) and Google Play +Console (Main store listing). Character limits noted; both EN and KO provided. + +--- + +## App name +- **EN:** `Block Seasons` (13) +- **KO:** `블록 시즌즈` — or keep `Block Seasons` (영문 그대로도 무방) + +## Subtitle (iOS, ≤30) / Short description (Play, ≤80) +- **EN (iOS subtitle):** `Seasonal block puzzle bliss` (27) +- **KO (iOS subtitle):** `시즌마다 새로워지는 블록 퍼즐` (16) +- **EN (Play short, ≤80):** `Drop blocks, clear lines, and chase a fresh themed season every few weeks.` (73) +- **KO (Play short, ≤80):** `블록을 놓아 줄을 지우고, 몇 주마다 새 테마 시즌을 즐기세요. 광고 강요 없는 편안한 퍼즐.` (47) + +## Keywords (iOS, ≤100, comma-separated) +- **EN:** `block,puzzle,blocks,brain,grid,tetris,blast,relax,season,line,casual,offline,jewel,combo` +- **KO:** `블록,퍼즐,블럭,두뇌,그리드,테트리스,블라스트,힐링,시즌,라인,캐주얼,오프라인,콤보` + +## Promotional text (iOS, ≤170) +- **EN:** `New Season 2 "Summer Tide" is live — cool teal blocks and 30 fresh stages. No internet? No problem: Season 1 plays fully offline.` (131) +- **KO:** `새 시즌 2 "여름 파도" 공개 — 시원한 청록 블록과 30개 새 스테이지. 인터넷이 없어도 시즌 1은 완전 오프라인으로 즐길 수 있어요.` (66) + +## Description (long, ≤4000) + +### EN +``` +Block Seasons is a cozy, beautiful block puzzle you can actually relax with. + +Drag three pieces at a time onto an 8×8 board, fill rows and columns, and watch +them clear in a satisfying glossy burst. Easy to pick up, deep enough to chase +"just one more" — chase combos, beat your best, and feel the board breathe. + +WHAT MAKES IT DIFFERENT +• Seasons — every few weeks a brand-new themed season arrives with fresh stages + and its own look, no app update needed. +• A journey, not a grid — wind your way up an illustrated map, one stage at a time. +• Endless mode — no limits, no objectives, just you and your high score. +• Glossy, hand-tuned visuals and a calm, drifting season backdrop. +• Plays offline — Season 1 is built in, so you can play on a plane, a subway, + anywhere. + +FAIR BY DESIGN +• No forced video before every move. Ads are spaced out, and rescue/continue is + always your choice. +• One-time "Remove Ads" if you'd rather go quiet — reward ads still work so you + never lose a helping hand. + +Whether you have two minutes or twenty, Block Seasons is the puzzle that's always +in season. Download free and start your first season today. +``` + +### KO +``` +블록 시즌즈는 정말로 편안하게 즐길 수 있는, 예쁜 블록 퍼즐입니다. + +한 번에 세 조각을 8×8 보드에 드래그해서 가로·세로 줄을 채우고, 반짝이는 글로시 +연출과 함께 줄이 사라지는 쾌감을 느껴보세요. 시작은 쉽지만 "한 판만 더"를 부르는 +깊이가 있습니다 — 콤보를 노리고, 최고 점수를 갱신하세요. + +무엇이 다른가요 +• 시즌제 — 몇 주마다 새 테마의 시즌과 스테이지가 앱 업데이트 없이 도착합니다. +• 그리드가 아닌 여정 — 일러스트 맵을 따라 한 스테이지씩 올라갑니다. +• 엔드리스 모드 — 제한도 목표도 없이, 오직 최고 점수에 도전. +• 손으로 다듬은 글로시 비주얼과 잔잔하게 흐르는 시즌 배경. +• 오프라인 플레이 — 시즌 1이 내장돼 비행기·지하철 어디서든 즐길 수 있습니다. + +설계부터 공정하게 +• 매 수마다 강제 영상 광고 없음. 광고는 충분히 띄엄띄엄, 구조/계속하기는 항상 선택. +• 조용히 즐기고 싶다면 일회성 "광고 제거". 보상형 광고는 그대로라 도움은 늘 받을 수 있어요. + +2분이든 20분이든, 블록 시즌즈는 언제나 제철인 퍼즐입니다. 무료로 받고 첫 시즌을 +시작하세요. +``` + +--- + +## Category & age +- Primary category: **Games → Puzzle** (iOS) / **Puzzle** (Play). +- Age rating: **Everyone / 4+** — but answer the questionnaires honestly and DO + NOT mark the app as "directed at children" (it is not — this protects ad rates + and avoids child-privacy obligations). + +## Required URLs (owner) +- Support URL + Marketing URL: a simple page is fine (GitHub Pages works). +- **Privacy Policy URL: required by both stores** — a hosted privacy policy page. + See docs/store/phase7-submission-guide.md for what it must cover. diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 872c4b9..228c320 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; A1624C49AABB61D3BB6EBA00 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7F021B835BC4E346AE82B4C9 /* Pods_RunnerTests.framework */; }; + BC732790904D77939BB8C135 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = FE18481AC23043B44AB64814 /* PrivacyInfo.xcprivacy */; }; D444497F007A61A9102D174D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92F5ACA56D636C056F52DDE6 /* Pods_Runner.framework */; }; E746073DDE80B82D8D3C9659 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 43C03408D7DF6E3F6C4EC9C7 /* GoogleService-Info.plist */; }; /* End PBXBuildFile section */ @@ -67,6 +68,7 @@ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; B4B2233E92790E4E03907BD2 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; B9983A741CFB90A0857F31CD /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + FE18481AC23043B44AB64814 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -155,6 +157,7 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + FE18481AC23043B44AB64814 /* PrivacyInfo.xcprivacy */, ); path = Runner; sourceTree = ""; @@ -269,6 +272,7 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, E746073DDE80B82D8D3C9659 /* GoogleService-Info.plist in Resources */, + BC732790904D77939BB8C135 /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/PrivacyInfo.xcprivacy b/ios/Runner/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..36a3c0e --- /dev/null +++ b/ios/Runner/PrivacyInfo.xcprivacy @@ -0,0 +1,64 @@ + + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeDeviceID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeThirdPartyAdvertising + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeProductInteraction + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyAccessedAPITypes + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + +