feat: Android 폰·태블릿 다운로드·앱 내 업데이트 완료

CI에서 APK 버전 자동 bump, 앱 복귀 시 업데이트 확인·시스템 다운로드,
프론트 Android 전용 UI 정리(iPad는 향후).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Macbook
2026-06-14 17:03:10 +09:00
parent be45247dc9
commit c840e09885
8 changed files with 193 additions and 130 deletions
+30 -28
View File
@@ -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 <div className="loading-center"> </div>;
}
return <>{children}</>;
}
};
+51 -9
View File
@@ -16,6 +16,8 @@ export interface AppUpdateCheckResult {
release?: MobileAppReleaseInfoDto | null;
}
const skipKey = (remote: string) => `gc_skip_update_${remote}`;
export async function getCurrentAppVersion(): Promise<string> {
if (!Capacitor.isNativePlatform()) {
return import.meta.env.VITE_APP_VERSION ?? '0.0.0';
@@ -40,16 +42,51 @@ export async function checkForAppUpdate(): Promise<AppUpdateCheckResult> {
return { updateAvailable, currentVersion, remoteVersion, release };
}
/** Android — 시스템 브라우저/다운로드 매니저로 APK 받기 */
export async function openApkDownload(release?: MobileAppReleaseInfoDto | null): Promise<boolean> {
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<boolean> {
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<boolean> {
if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'android') {
return false;
}
@@ -59,22 +96,26 @@ export async function promptAppUpdateIfNeeded(silent = false): Promise<boolean>
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<AppUpdateCheckResult> {
const result = await checkForAppUpdate();
if (!result.updateAvailable) {
@@ -82,7 +123,8 @@ export async function checkAndPromptAppUpdate(): Promise<AppUpdateCheckResult> {
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);