feat(MOB): 인앱 업데이트 체크 기능 추가

- AppUpdateChecker: 서버 버전 확인 → 다이얼로그 → APK 다운로드 → 설치
- 강제 업데이트 시 "나중에" 버튼 없음, 닫으면 앱 종료
- REQUEST_INSTALL_PACKAGES 권한 추가
- file_paths.xml에 external-files-path 추가
- MainActivity.onCreate()에서 업데이트 체크 호출

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 19:53:15 +09:00
parent 71e3031519
commit 98647461f5
4 changed files with 231 additions and 0 deletions

View File

@@ -38,4 +38,5 @@
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
</manifest>

View File

@@ -0,0 +1,226 @@
package com.codebridgex.webapp;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.DownloadManager;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageInfo;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.util.Log;
import androidx.core.content.FileProvider;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class AppUpdateChecker {
private static final String TAG = "AppUpdateChecker";
private static final String API_BASE_URL = "https://api.codebridge-x.com";
private static final String API_KEY = "sam-api-key-2025";
private final Activity activity;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
public AppUpdateChecker(Activity activity) {
this.activity = activity;
}
/**
* 서버에서 최신 버전 확인
*/
public void checkForUpdate() {
executor.execute(() -> {
try {
int currentVersionCode = getCurrentVersionCode();
String urlStr = API_BASE_URL + "/api/v1/app/version?platform=android&current_version_code=" + currentVersionCode;
URL url = new URL(urlStr);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("X-API-KEY", API_KEY);
conn.setRequestProperty("Accept", "application/json");
conn.setConnectTimeout(10000);
conn.setReadTimeout(10000);
int responseCode = conn.getResponseCode();
if (responseCode != 200) {
Log.w(TAG, "Version check failed: HTTP " + responseCode);
return;
}
BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream()));
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
reader.close();
conn.disconnect();
JSONObject json = new JSONObject(sb.toString());
if (!json.optBoolean("success", false)) return;
JSONObject data = json.getJSONObject("data");
if (!data.optBoolean("has_update", false)) {
Log.d(TAG, "No update available");
return;
}
JSONObject latestVersion = data.getJSONObject("latest_version");
String versionName = latestVersion.getString("version_name");
String releaseNotes = latestVersion.optString("release_notes", "");
boolean forceUpdate = latestVersion.optBoolean("force_update", false);
String downloadUrl = latestVersion.getString("download_url");
activity.runOnUiThread(() -> showUpdateDialog(versionName, releaseNotes, forceUpdate, downloadUrl));
} catch (Exception e) {
Log.e(TAG, "Update check error", e);
}
});
}
/**
* 업데이트 다이얼로그 표시
*/
private void showUpdateDialog(String versionName, String releaseNotes, boolean forceUpdate, String downloadUrl) {
if (activity.isFinishing() || activity.isDestroyed()) return;
StringBuilder message = new StringBuilder();
message.append("새 버전 v").append(versionName).append("이 있습니다.\n");
if (releaseNotes != null && !releaseNotes.isEmpty()) {
message.append("\n").append(releaseNotes);
}
AlertDialog.Builder builder = new AlertDialog.Builder(activity)
.setTitle("업데이트 알림")
.setMessage(message.toString())
.setCancelable(!forceUpdate)
.setPositiveButton("업데이트", (dialog, which) -> {
dialog.dismiss();
startDownload(downloadUrl, versionName);
});
if (forceUpdate) {
// 강제 업데이트: "나중에" 버튼 없음, 뒤로가기/외부 터치로 닫을 수 없음
builder.setOnCancelListener(dialog -> activity.finishAffinity());
} else {
builder.setNegativeButton("나중에", (dialog, which) -> dialog.dismiss());
}
builder.show();
}
/**
* DownloadManager로 APK 다운로드
*/
private void startDownload(String downloadUrl, String versionName) {
try {
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(downloadUrl));
request.addRequestHeader("X-API-KEY", API_KEY);
request.setTitle("SAM 업데이트 v" + versionName);
request.setDescription("앱 업데이트를 다운로드하고 있습니다...");
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
String fileName = "sam-v" + versionName + ".apk";
request.setDestinationInExternalFilesDir(activity, Environment.DIRECTORY_DOWNLOADS, fileName);
DownloadManager dm = (DownloadManager) activity.getSystemService(Context.DOWNLOAD_SERVICE);
long downloadId = dm.enqueue(request);
// 다운로드 완료 감지
registerDownloadReceiver(downloadId, fileName);
} catch (Exception e) {
Log.e(TAG, "Download start error", e);
}
}
/**
* 다운로드 완료 시 설치 화면 표시
*/
private void registerDownloadReceiver(long downloadId, String fileName) {
BroadcastReceiver receiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
long id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
if (id != downloadId) return;
try {
activity.unregisterReceiver(this);
} catch (Exception ignored) {}
installApk(fileName);
}
};
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity.registerReceiver(receiver,
new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
Context.RECEIVER_NOT_EXPORTED);
} else {
activity.registerReceiver(receiver,
new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE));
}
}
/**
* APK 설치 Intent 실행
*/
private void installApk(String fileName) {
try {
File apkFile = new File(activity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), fileName);
if (!apkFile.exists()) {
Log.e(TAG, "APK file not found: " + apkFile.getAbsolutePath());
return;
}
Uri apkUri = FileProvider.getUriForFile(
activity,
activity.getPackageName() + ".fileprovider",
apkFile
);
Intent installIntent = new Intent(Intent.ACTION_VIEW);
installIntent.setDataAndType(apkUri, "application/vnd.android.package-archive");
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
installIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
activity.startActivity(installIntent);
} catch (Exception e) {
Log.e(TAG, "Install APK error", e);
}
}
/**
* 현재 앱 버전 코드
*/
private int getCurrentVersionCode() {
try {
PackageInfo pInfo = activity.getPackageManager()
.getPackageInfo(activity.getPackageName(), 0);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
return (int) pInfo.getLongVersionCode();
} else {
return pInfo.versionCode;
}
} catch (Exception e) {
Log.e(TAG, "Get version code error", e);
return 0;
}
}
}

View File

@@ -26,6 +26,9 @@ public class MainActivity extends BridgeActivity {
super.onCreate(savedInstanceState);
createNotificationChannels();
// 인앱 업데이트 체크
new AppUpdateChecker(this).checkForUpdate();
// WebView 줌 설정 (핀치 줌 활성화)
getBridge().getWebView().getSettings().setSupportZoom(true);
getBridge().getWebView().getSettings().setBuiltInZoomControls(true);

View File

@@ -2,4 +2,5 @@
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
<external-files-path name="apk_downloads" path="Download/" />
</paths>