From c840e09885501c53ba282556778e7aafe1e1f72c Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 14 Jun 2026 17:03:10 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Android=20=ED=8F=B0=C2=B7=ED=83=9C?= =?UTF-8?q?=EB=B8=94=EB=A6=BF=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=C2=B7?= =?UTF-8?q?=EC=95=B1=20=EB=82=B4=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI에서 APK 버전 자동 bump, 앱 복귀 시 업데이트 확인·시스템 다운로드, 프론트 Android 전용 UI 정리(iPad는 향후). Co-authored-by: Cursor --- app/src/components/AppUpdateGate.tsx | 58 +++++++++-------- app/src/services/appUpdate.ts | 60 ++++++++++++++--- docs/mobile-tablet-phase1.md | 68 +++++++++++--------- frontend/public/install.html | 5 +- frontend/src/components/AppDownloadModal.tsx | 63 ++++-------------- scripts/bump-android-version.sh | 47 ++++++++++++++ scripts/jenkins-mobile-pipeline.sh | 13 ++++ scripts/server-setup-mobile-phase1.sh | 9 +-- 8 files changed, 193 insertions(+), 130 deletions(-) create mode 100755 scripts/bump-android-version.sh diff --git a/app/src/components/AppUpdateGate.tsx b/app/src/components/AppUpdateGate.tsx index 0b5452b..4d7550f 100644 --- a/app/src/components/AppUpdateGate.tsx +++ b/app/src/components/AppUpdateGate.tsx @@ -1,50 +1,52 @@ import React, { useEffect, useState } from 'react'; +import { App } from '@capacitor/app'; import { Capacitor } from '@capacitor/core'; -import { promptAppUpdateIfNeeded } from '../services/appUpdate'; +import { promptAppUpdate } from '../services/appUpdate'; type Props = { children: React.ReactNode; }; -/** Android 네이티브 — 시작 시 서버 APK 버전 확인 (무음 스킵, 이후 설정에서 수동 확인) */ +const isAndroidNative = () => + Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android'; + +/** Android — 시작·복귀 시 서버 APK 버전 확인 */ export default function AppUpdateGate({ children }: Props) { - const [ready, setReady] = useState(() => - !Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'android', - ); + const [ready, setReady] = useState(() => !isAndroidNative()); useEffect(() => { - if (ready) return; + if (!isAndroidNative()) return; + let cancelled = false; - void (async () => { + + const runCheck = async () => { try { - const { checkForAppUpdate, openApkDownload } = await import('../services/appUpdate'); - const check = await checkForAppUpdate(); - if (!cancelled && check.updateAvailable && check.remoteVersion) { - const dismissed = sessionStorage.getItem(`gc_skip_update_${check.remoteVersion}`); - if (!dismissed) { - const ok = window.confirm( - `GoldenChart v${check.remoteVersion} 업데이트가 있습니다.\n` - + `(현재 v${check.currentVersion})\n\n지금 APK를 다운로드하시겠습니까?`, - ); - if (ok) { - await openApkDownload(check.release); - } else { - sessionStorage.setItem(`gc_skip_update_${check.remoteVersion}`, '1'); - } - } - } + await promptAppUpdate({ skipIfDismissed: true }); } catch { /* 네트워크 오류 — 앱 진입 허용 */ - } finally { - if (!cancelled) setReady(true); } + }; + + void (async () => { + await runCheck(); + if (!cancelled) setReady(true); })(); - return () => { cancelled = true; }; - }, [ready]); + + const sub = App.addListener('appStateChange', ({ isActive }) => { + if (isActive && !cancelled) { + void runCheck(); + } + }); + + return () => { + cancelled = true; + void sub.then(h => h.remove()); + }; + }, []); if (!ready) { return
업데이트 확인…
; } return <>{children}; -} +}; diff --git a/app/src/services/appUpdate.ts b/app/src/services/appUpdate.ts index bd8eeae..4a6c50d 100644 --- a/app/src/services/appUpdate.ts +++ b/app/src/services/appUpdate.ts @@ -16,6 +16,8 @@ export interface AppUpdateCheckResult { release?: MobileAppReleaseInfoDto | null; } +const skipKey = (remote: string) => `gc_skip_update_${remote}`; + export async function getCurrentAppVersion(): Promise { if (!Capacitor.isNativePlatform()) { return import.meta.env.VITE_APP_VERSION ?? '0.0.0'; @@ -40,16 +42,51 @@ export async function checkForAppUpdate(): Promise { return { updateAvailable, currentVersion, remoteVersion, release }; } +/** Android — 시스템 브라우저/다운로드 매니저로 APK 받기 */ export async function openApkDownload(release?: MobileAppReleaseInfoDto | null): Promise { const info = release ?? await loadMobileAppReleaseInfo(); const url = info?.downloadUrl?.trim(); if (!url) return false; - window.location.href = url; + if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android') { + try { + await App.openUrl({ url }); + return true; + } catch { + /* fallback */ + } + } + + const a = document.createElement('a'); + a.href = url; + a.rel = 'noopener'; + a.download = info?.fileName ?? 'goldenchart-android.apk'; + document.body.appendChild(a); + a.click(); + a.remove(); return true; } -export async function promptAppUpdateIfNeeded(silent = false): Promise { +export function isUpdateDismissed(remoteVersion: string): boolean { + try { + return sessionStorage.getItem(skipKey(remoteVersion)) === '1'; + } catch { + return false; + } +} + +export function dismissUpdate(remoteVersion: string): void { + try { + sessionStorage.setItem(skipKey(remoteVersion), '1'); + } catch { + /* ignore */ + } +} + +/** 업데이트 확인 후 confirm → APK 다운로드. skipIfDismissed면 세션 내 거부 버전 생략 */ +export async function promptAppUpdate(options?: { + skipIfDismissed?: boolean; +}): Promise { if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'android') { return false; } @@ -59,22 +96,26 @@ export async function promptAppUpdateIfNeeded(silent = false): Promise return false; } - if (silent) { + if (options?.skipIfDismissed && isUpdateDismissed(check.remoteVersion)) { return false; } const ok = window.confirm( - `새 버전(v${check.remoteVersion})이 있습니다.\n` - + `현재: v${check.currentVersion}\n\n` - + 'APK를 다운로드하여 설치하시겠습니까?', + `GoldenChart v${check.remoteVersion} 업데이트가 있습니다.\n` + + `(현재 v${check.currentVersion})\n\n` + + 'APK를 다운로드하여 설치하시겠습니까?\n' + + '(다운로드 후 알림에서 설치를 완료하세요.)', ); - if (!ok) return false; + if (!ok) { + dismissUpdate(check.remoteVersion); + return false; + } await openApkDownload(check.release); return true; } -/** 설정 화면 등 — 수동 업데이트 확인 */ +/** 설정 화면 — 수동 업데이트 확인 */ export async function checkAndPromptAppUpdate(): Promise { const result = await checkForAppUpdate(); if (!result.updateAvailable) { @@ -82,7 +123,8 @@ export async function checkAndPromptAppUpdate(): Promise { return result; } const ok = window.confirm( - `새 버전 v${result.remoteVersion} 사용 가능.\nAPK를 다운로드하시겠습니까?`, + `새 버전 v${result.remoteVersion} 사용 가능.\n` + + `현재 v${result.currentVersion}\n\nAPK를 다운로드하시겠습니까?`, ); if (ok) { await openApkDownload(result.release); diff --git a/docs/mobile-tablet-phase1.md b/docs/mobile-tablet-phase1.md index 23e430a..5ef8b99 100644 --- a/docs/mobile-tablet-phase1.md +++ b/docs/mobile-tablet-phase1.md @@ -1,15 +1,12 @@ -# 모바일·태블릿 Phase 1 — Capacitor 확장 (A) +# Android 모바일·태블릿 (Phase 1) -## 스택 결정 +## 범위 -| 항목 | 선택 | +| 항목 | 지원 | |------|------| -| 단기 경로 | **1단계 A — Capacitor 확장** (`app/`) | -| 장기(선택) | 2단계 B — Tauri Mobile 이전 (미착수) | -| Android 패드 | 동일 APK (반응형 UI) | -| iPad | TestFlight 링크 (ipa 직접 다운로드·앱 내 updater 불가) | - -Phase 1 목표: 데스크톱과 동일한 **git push → CI 빌드 → 다운로드 허브** 흐름을 Android에 맞추고, PC 프로그램 탭에서 Android·iPad를 함께 제공한다. +| Android 휴대폰 | ✅ 동일 APK | +| Android 태블릿(패드) | ✅ 동일 APK (반응형 UI) | +| iPad | ⏸ 향후 (TestFlight) | ## 파이프라인 @@ -18,44 +15,53 @@ git push main → post-receive (deploy.sh) → trigger-mobile-jenkins-build.sh → jenkins-mobile-pipeline.sh + → bump-android-version.sh (CI patch + versionCode++) → build-mobile-apk.sh → data/mobile-releases/goldenchart-android.apk → GC_MOBILE_APP_VERSION (.env) + → backend 재시작 ``` ## 다운로드 UI -**PC 프로그램** 탭 (`AppDownloadModal`): +| 위치 | 내용 | +|------|------| +| **모바일** 탭 | QR + APK 다운로드 (Android 전용) | +| **PC 프로그램** 탭 | Android (패드·폰) 카드 | +| `/install.html` | QR 설치 페이지 | -- macOS (.dmg) — 기존 -- Windows (.exe) — 기존 -- Android 패드/폰 (.apk) — `/api/mobile-app/info` -- iPad (TestFlight) — `GC_MOBILE_APP_IOS_TESTFLIGHT_URL` 링크 +## 앱 내 자동 업데이트 (Android) -## Android 앱 내 업데이트 +1. `/api/mobile-app/info`의 `version` vs `@capacitor/app` `versionName` +2. 새 버전이면 시작·앱 복귀 시 confirm → `App.openUrl`로 APK 다운로드 +3. 설정 → **업데이트 확인** 수동 트리거 -- 시작 시 `/api/mobile-app/info` 버전 vs `@capacitor/app` `version` -- 새 버전이면 APK 다운로드 URL 열기 (재설치) - -## iPad / TestFlight - -1. Mac + Xcode + Apple Developer Program -2. `cd app && npm run cap:sync && npx cap open ios` -3. Archive → TestFlight 업로드 -4. 서버 `.env`: `GC_MOBILE_APP_IOS_TESTFLIGHT_URL=https://testflight.apple.com/join/...` - -## Jenkins 설치 (서버 1회) +## Jenkins (서버 1회) ```bash ./scripts/server-install-mobile-jenkins.sh -# .env 예시 -# GC_MOBILE_JENKINS_TRIGGER_TOKEN=goldenchart-mobile-build +./scripts/server-setup-mobile-phase1.sh # hook·env·검증 ``` ## 환경 변수 | 변수 | 설명 | |------|------| -| `GC_MOBILE_APP_VERSION` | 웹·API 표시 버전 | -| `GC_MOBILE_APP_IOS_TESTFLIGHT_URL` | iPad TestFlight 초대 URL | -| `GC_MOBILE_JENKINS_*` | Jenkins mobile job (desktop과 동일 패턴) | +| `GC_MOBILE_APP_VERSION` | API·앱 업데이트 비교용 버전 | +| `GC_MOBILE_JENKINS_*` | Jenkins mobile job | +| `GC_MOBILE_APP_PUBLIC_BASE_URL` | 다운로드 URL base (기본 `http://exdev.co.kr`) | + +## 수동 빌드 + +```bash +# 버전 bump 없이 +BUMP_VERSION=0 ./scripts/jenkins-mobile-pipeline.sh + +# bump 포함 (CI와 동일) +./scripts/jenkins-mobile-pipeline.sh +``` + +## API + +- `GET /api/mobile-app/info` +- `GET /api/mobile-app/download/android` diff --git a/frontend/public/install.html b/frontend/public/install.html index 20b2d19..9e05ab6 100644 --- a/frontend/public/install.html +++ b/frontend/public/install.html @@ -45,7 +45,7 @@

GoldenChart 앱 설치

-

가상매매·전략편집기·푸시 알림을 모바일에서 사용하세요.

+

Android 휴대폰·태블릿에서 가상매매·전략·푸시 알림을 사용하세요.

로딩 중…

@@ -73,7 +73,8 @@ 'APK 다운로드' + '
  1. 다운로드 후 APK 파일을 열어 설치
  2. ' + '
  3. 「알 수 없는 출처」 허용 필요 시 설정에서 활성화
  4. ' + - '
  5. 앱 실행 후 API URL 설정
'; + '
  • 앱은 서버(exdev.co.kr)에 자동 연결됩니다
  • ' + + '
  • 새 버전은 앱 시작·설정 → 업데이트 확인
  • '; QRCode.toCanvas(document.getElementById('qr'), url, { width: 200, margin: 1 }); } catch (e) { el.innerHTML = '

    앱 정보를 불러오지 못했습니다.

    '; diff --git a/frontend/src/components/AppDownloadModal.tsx b/frontend/src/components/AppDownloadModal.tsx index cadd167..8c7a2c5 100644 --- a/frontend/src/components/AppDownloadModal.tsx +++ b/frontend/src/components/AppDownloadModal.tsx @@ -107,12 +107,15 @@ const MobileTab: React.FC = () => {
    GC
    -

    GoldenChart Mobile

    +

    GoldenChart Android

    {info.version && v{info.version}} {info.sizeBytes != null && {formatBytes(info.sizeBytes)}} {info.updatedAt && {info.updatedAt}}

    +

    + 휴대폰·태블릿(패드) 동일 APK +

    @@ -123,7 +126,7 @@ const MobileTab: React.FC = () => {
    QR 생성 중…
    )}
    - 휴대폰 카메라로 QR 스캔 + 휴대폰·태블릿 카메라로 QR 스캔

    Android에서 APK 다운로드 페이지가 열립니다. 다운로드 후 설치하세요.

    {downloadUrl}

    @@ -138,23 +141,20 @@ const MobileTab: React.FC = () => {
    - 설치 방법 (Android) + 설치 방법 (Android · 패드·폰)
    1. QR 코드를 스캔하거나 위 버튼으로 APK를 다운로드합니다.
    2. 「출처를 알 수 없는 앱」 설치 허용이 필요할 수 있습니다.
    3. 다운로드 완료 후 APK 파일을 탭하여 설치합니다.
    4. +
    5. 앱 실행 후 설정 → 「업데이트 확인」으로 새 버전을 받을 수 있습니다.
    - -

    - iPad는 PC 프로그램 탭의 TestFlight 링크를 이용하세요. 이 탭의 QR·APK는 Android 전용입니다. -

    ); }; const PlatformCard: React.FC<{ - platform: 'mac' | 'windows' | 'android' | 'ipad'; + platform: 'mac' | 'windows' | 'android'; release?: DesktopAppPlatformReleaseDto; mobile?: MobileAppReleaseInfoDto | null; }> = ({ platform, release, mobile }) => { @@ -162,7 +162,6 @@ const PlatformCard: React.FC<{ mac: { label: 'macOS (Apple Silicon · Intel)', icon: '🍎', ext: '.dmg', isLink: false }, windows: { label: 'Windows (64-bit)', icon: '🪟', ext: '.exe', isLink: false }, android: { label: 'Android (패드 · 폰)', icon: '🤖', ext: '.apk', isLink: false }, - ipad: { label: 'iPad (TestFlight)', icon: '📱', ext: '', isLink: true }, } as const; const { label, icon, ext } = configs[platform]; @@ -203,43 +202,6 @@ const PlatformCard: React.FC<{ ); } - if (platform === 'ipad') { - const testFlightUrl = mobile?.iosTestFlightUrl?.trim(); - if (!mobile?.iosAvailable || !testFlightUrl) { - return ( -
    -
    {icon}
    -
    - {label} -

    TestFlight 링크 준비 중

    -
    -
    - ); - } - return ( -
    -
    {icon}
    -
    - {label} -

    - TestFlight 베타 -

    -

    - Apple TestFlight 초대 링크 -

    -
    - - TestFlight 열기 - -
    - ); - } - if (!release?.available || !release.downloadUrl) { return (
    @@ -471,8 +433,7 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop }) } }, [canBuildDesktop, buildBusy, isBuilding, reloadBuildStatus]); - const hasAny = info?.mac?.available || info?.windows?.available - || mobileInfo?.available || mobileInfo?.iosAvailable; + const hasAny = info?.mac?.available || info?.windows?.available || mobileInfo?.available; if (loading) return

    PC 프로그램 정보 불러오는 중…

    ; if (error) return

    {error}

    ; @@ -534,7 +495,7 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop })
    💻

    설치 파일 준비 중

    - macOS(.dmg), Windows(.exe), Android(.apk) 또는 iPad TestFlight 링크가 아직 준비되지 않았습니다. + macOS(.dmg), Windows(.exe) 또는 Android(.apk)가 아직 준비되지 않았습니다.
    {canBuildDesktop ? '우측 상단 「앱 빌드」 버튼으로 Jenkins 빌드를 시작하세요.' @@ -554,7 +515,6 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop }) -

    )} @@ -573,9 +533,8 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop })
  • Windows: setup.exe를 실행하고 안내에 따라 설치합니다.
  • Android (패드·폰): APK를 다운로드한 뒤 설치합니다. 패드와 폰은 동일 APK입니다.
  • -
  • iPad: TestFlight 링크로 베타 앱을 설치합니다 (App Store 정책상 ipa 직접 다운로드 불가).
  • 설치된 PC 앱은 메뉴 우측 상단 업데이트 버튼으로 자동 업데이트할 수 있습니다.
  • -
  • Android 앱은 시작 시 또는 설정에서 새 APK 버전을 안내합니다.
  • +
  • Android 앱은 시작·복귀 시 또는 설정 → 업데이트 확인으로 새 APK를 안내합니다.
  • 빌드는 Git main 최신 커밋 기준이며, 완료 후 설치 파일이 자동으로 배포됩니다.
  • diff --git a/scripts/bump-android-version.sh b/scripts/bump-android-version.sh new file mode 100755 index 0000000..3448144 --- /dev/null +++ b/scripts/bump-android-version.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# CI: .env GC_MOBILE_APP_VERSION 또는 build.gradle 기준 patch bump + versionCode++ +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORK_TREE="${WORK_TREE:-$ROOT}" +GRADLE="${GRADLE:-$ROOT/app/android/app/build.gradle}" +ENV_FILE="${ENV_FILE:-$WORK_TREE/.env}" + +if [[ ! -f "$GRADLE" ]]; then + echo "build.gradle 없음: $GRADLE" >&2 + exit 1 +fi + +ENV_VER="" +if [[ -f "$ENV_FILE" ]]; then + ENV_VER="$(grep '^GC_MOBILE_APP_VERSION=' "$ENV_FILE" 2>/dev/null | cut -d= -f2- | tr -d '\r' || true)" +fi + +node -e " +const fs = require('fs'); +const gradlePath = process.argv[1]; +const envVersion = (process.argv[2] || '').trim(); +let content = fs.readFileSync(gradlePath, 'utf8'); +const vc = content.match(/versionCode\\s+(\\d+)/); +const vn = content.match(/versionName\\s+\"([^\"]+)\"/); +if (!vc || !vn) { + console.error('versionCode/versionName 파싱 실패'); + process.exit(1); +} +let versionCode = parseInt(vc[1], 10); +let base = envVersion || vn[1]; +const parts = base.split('.').map(n => parseInt(n, 10) || 0); +while (parts.length < 2) parts.push(0); +if (parts.length === 2) { + parts[1] += 1; +} else { + while (parts.length < 3) parts.push(0); + parts[2] += 1; +} +const versionName = parts.join('.'); +versionCode += 1; +content = content.replace(/versionCode\\s+\\d+/, 'versionCode ' + versionCode); +content = content.replace(/versionName\\s+\"[^\"]+\"/, 'versionName \"' + versionName + '\"'); +fs.writeFileSync(gradlePath, content); +console.log(versionName); +" "$GRADLE" "$ENV_VER" diff --git a/scripts/jenkins-mobile-pipeline.sh b/scripts/jenkins-mobile-pipeline.sh index b520a07..5cca5b1 100755 --- a/scripts/jenkins-mobile-pipeline.sh +++ b/scripts/jenkins-mobile-pipeline.sh @@ -30,6 +30,19 @@ fi chmod +x "$ROOT/scripts/"*.sh 2>/dev/null || true +if [[ "${BUMP_VERSION:-}" == "0" ]]; then + log "version bump 스킵 (BUMP_VERSION=0)" +elif [[ -n "${BUILD_NUMBER:-}" && "${MOBILE_BUMP_ON_CI:-1}" == "1" ]]; then + ENV_FILE="$WORK_TREE/.env" + log "CI Android version bump (.env 기준)..." + NEW_VER="$("$ROOT/scripts/bump-android-version.sh")" + log "Android version → $NEW_VER" +elif [[ -n "${BUMP_VERSION:-}" ]]; then + log "CI Android version bump..." + NEW_VER="$("$ROOT/scripts/bump-android-version.sh")" + log "Android version → $NEW_VER" +fi + "$ROOT/scripts/build-mobile-apk.sh" VERSION_NAME="$(grep -E 'versionName[[:space:]]+"' "$ROOT/app/android/app/build.gradle" | head -1 | sed -E 's/.*versionName[[:space:]]+"([^"]+)".*/\1/' || true)" diff --git a/scripts/server-setup-mobile-phase1.sh b/scripts/server-setup-mobile-phase1.sh index 6286bfe..f46e03f 100755 --- a/scripts/server-setup-mobile-phase1.sh +++ b/scripts/server-setup-mobile-phase1.sh @@ -59,16 +59,9 @@ export GC_MOBILE_JENKINS_USER="${DESKTOP_USER:-}" export GC_MOBILE_JENKINS_TOKEN="${DESKTOP_TOKEN:-}" "$WORK_TREE/scripts/trigger-mobile-jenkins-build.sh" || log "[WARN] Mobile Jenkins 트리거 실패" -if command -v pod >/dev/null 2>&1 && [[ -f "$WORK_TREE/app/ios/App/Podfile" ]]; then - log "iOS pod install" - (cd "$WORK_TREE/app/ios/App" && pod install) || log "[WARN] pod install 실패" -else - log "CocoaPods 없음 — iOS 빌드는 brew install cocoapods 후 pod install" -fi - log "API 확인" sleep 4 curl -fsSL http://127.0.0.1:8080/api/mobile-app/info 2>/dev/null | python3 -m json.tool 2>/dev/null || \ curl -fsSL http://exdev.co.kr/api/mobile-app/info | python3 -m json.tool 2>/dev/null || true -log "완료" +log "완료 (Android APK · iPad는 향후 TestFlight)"