diff --git a/app/build.gradle b/app/build.gradle index bc1149d..e22e0b5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,7 +10,7 @@ plugins { // 避免不同尺寸機台間版本號產生衝突。 // ========================================================================= def verMinor = 2 -def verPatch = 9 +def verPatch = 10 diff --git a/app/src/main/java/com/unibuy/smartdevice/external/mqtt/MqttManager.java b/app/src/main/java/com/unibuy/smartdevice/external/mqtt/MqttManager.java index ecb3356..5965b65 100644 --- a/app/src/main/java/com/unibuy/smartdevice/external/mqtt/MqttManager.java +++ b/app/src/main/java/com/unibuy/smartdevice/external/mqtt/MqttManager.java @@ -102,6 +102,14 @@ public class MqttManager { publishStatus("online"); subscribeToCommandTopic(); startHeartbeatScheduler(); + // 連線就緒後接手未完成的 OTA(續傳監看 / 安裝後版本確認回報), + // 確保下載被重啟打斷時能續傳完成,且終態 ACK 不會因尚未連線而遺失。 + try { + com.unibuy.smartdevice.tools.OtaManager.resumeIfPending( + com.unibuy.smartdevice.MyApp.getInstance().getApplicationContext()); + } catch (Exception e) { + logs.info("OTA resume on connect failed: " + e.getMessage()); + } }) .buildAsync(); diff --git a/app/src/main/java/com/unibuy/smartdevice/service/MqttService.java b/app/src/main/java/com/unibuy/smartdevice/service/MqttService.java index 7cc8507..717258e 100644 --- a/app/src/main/java/com/unibuy/smartdevice/service/MqttService.java +++ b/app/src/main/java/com/unibuy/smartdevice/service/MqttService.java @@ -452,19 +452,23 @@ public class MqttService extends Service { logs.info("Executing remote app OTA update command..."); try { String apkUrl = null; + String targetVersion = null; if (command.getPayload() != null && command.getPayload().isJsonObject()) { com.google.gson.JsonObject payloadObj = command.getPayload().getAsJsonObject(); if (payloadObj.has("url")) { apkUrl = payloadObj.get("url").getAsString(); } + if (payloadObj.has("version") && !payloadObj.get("version").isJsonNull()) { + targetVersion = payloadObj.get("version").getAsString(); + } } if (apkUrl == null || apkUrl.isEmpty()) { throw new Exception("Missing 'url' parameter in update_app payload."); } - // 啟動背景 OTA 更新管理器 - com.unibuy.smartdevice.tools.OtaManager.startUpdate(getApplicationContext(), apkUrl, cmdId); + // 啟動背景 OTA 更新管理器(帶目標版本,供安裝後比對回報終態) + com.unibuy.smartdevice.tools.OtaManager.startUpdate(getApplicationContext(), apkUrl, cmdId, targetVersion); } catch (Exception e) { logs.info("Failed to start remote app update: " + e.getMessage()); diff --git a/app/src/main/java/com/unibuy/smartdevice/tools/OtaManager.java b/app/src/main/java/com/unibuy/smartdevice/tools/OtaManager.java index ed1542f..8448049 100644 --- a/app/src/main/java/com/unibuy/smartdevice/tools/OtaManager.java +++ b/app/src/main/java/com/unibuy/smartdevice/tools/OtaManager.java @@ -1,110 +1,525 @@ package com.unibuy.smartdevice.tools; +import android.app.DownloadManager; +import android.content.BroadcastReceiver; import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; + +import com.unibuy.smartdevice.exception.ErrorCode; import com.unibuy.smartdevice.exception.Logs; import com.unibuy.smartdevice.external.mqtt.MqttManager; -import java.io.BufferedInputStream; + import java.io.DataOutputStream; import java.io.File; -import java.io.FileOutputStream; -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.net.URL; /** - * 負責遠端 APK 的背景下載與 Root 靜默覆蓋安裝管理 + * 負責遠端 APK 的背景下載與 Root 靜默覆蓋安裝管理。 + * + *

下載改用系統 {@link DownloadManager},解決舊版「自架 Thread 下載」的兩個致命傷: + *

+ * + *

回報語意修正:舊版一「開始下載」就回 {@code result=success},導致後台立刻把指令標成完成。 + * 新版只在最終成敗時回 {@code success}/{@code failed};下載/安裝過程一律走 {@code progress}。 + * 由於 {@code pm install -r} 成功後本程序會被新版替換,「安裝成功」的終態 ACK 改由新版啟動後 + * 透過 {@link #resumeIfPending(Context)} 比對版本回報,確保後台狀態與機台實際版本一致。 */ public class OtaManager { - private static final String TAG = "OtaManager"; private static final Logs logs = new Logs(OtaManager.class); - private static final int DOWNLOAD_CONNECT_TIMEOUT_MS = 30000; - private static final int DOWNLOAD_READ_TIMEOUT_MS = 120000; + + private static final String PREFS = "ota_state"; + private static final String KEY_DOWNLOAD_ID = "download_id"; + private static final String KEY_CMD_ID = "cmd_id"; + private static final String KEY_VERSION = "version"; + private static final String KEY_PHASE = "phase"; + private static final String KEY_INSTALL_ATTEMPTS = "install_attempts"; + + private static final String PHASE_DOWNLOADING = "downloading"; + private static final String PHASE_READY = "ready"; // 下載完成,等待機台閒置才安裝 + private static final String PHASE_INSTALLING = "installing"; + + /** 非終態回報結果值;後台 ProcessCommandAck 對 update_app 的此值只更新備註、保持 pending。 */ + private static final String RESULT_PROGRESS = "progress"; + private static final String RESULT_SUCCESS = "success"; + private static final String RESULT_FAILED = "failed"; + + private static final long PROGRESS_REPORT_INTERVAL_MS = 5000; + private static final int MAX_INSTALL_ATTEMPTS = 2; + private static final String APK_FILE_NAME = "update_ota.apk"; + + // 安裝閘門:下載完成後,需「不忙碌且閒置達門檻」才執行安裝(安裝會替換 App 並重啟, + // 不可在有人操作/交易中進行)。忙碌時持續等待、定期重試。 + private static final int INSTALL_REQUIRED_IDLE_SECONDS = 60; + private static final long IDLE_CHECK_INTERVAL_MS = 30000; + private static final int WAIT_NOTIFY_EVERY_N_CHECKS = 10; // 約每 5 分鐘回報一次等待中 + + // 防止重複監看同一筆下載(resume 會在每次 MQTT 連線時被呼叫) + private static volatile long monitoringDownloadId = -1; + private static volatile boolean awaitingIdleInstall = false; + private static BroadcastReceiver completionReceiver; + + /** 相容舊呼叫端:未帶版本資訊。 */ + public static void startUpdate(Context context, String apkUrl, String cmdId) { + startUpdate(context, apkUrl, cmdId, null); + } /** - * 啟動背景執行緒執行 APP OTA 更新 + * 啟動遠端 APP OTA 更新。 * - * @param context 應用程式 Context - * @param apkUrl 新版 APK 的下載位址 - * @param cmdId MQTT 指令 ID,用於回報執行結果 + * @param context 應用程式 Context + * @param apkUrl 新版 APK 的下載位址 + * @param cmdId MQTT 指令 ID,用於回報執行結果 + * @param expectedVersion 目標版本名(如 "21_02_9_R"),安裝後用於比對確認;可為 null */ - public static void startUpdate(Context context, String apkUrl, String cmdId) { - new Thread(new Runnable() { + public static void startUpdate(Context context, String apkUrl, String cmdId, String expectedVersion) { + Context appContext = context.getApplicationContext(); + MqttManager mqttManager = MqttManager.getInstance(); + try { + logs.info("Starting OTA update process. URL: " + apkUrl + " (cmdId: " + cmdId + + ", version: " + expectedVersion + ")"); + + // 先清掉前一筆殘留下載,避免重複下載/狀態混淆 + cancelExisting(appContext); + + DownloadManager dm = (DownloadManager) appContext.getSystemService(Context.DOWNLOAD_SERVICE); + if (dm == null) { + // 極少數 ROM 停用 DownloadManager:退回舊版直連下載(不可續傳,僅作保險) + logs.info("DownloadManager unavailable, falling back to legacy direct download."); + legacyDownloadAndInstall(appContext, apkUrl, cmdId, expectedVersion); + return; + } + + File target = new File(appContext.getExternalFilesDir(null), APK_FILE_NAME); + if (target.exists()) { + target.delete(); + } + + DownloadManager.Request request = new DownloadManager.Request(Uri.parse(apkUrl)); + request.setTitle("OTA Update"); + request.setDescription("Downloading APK " + (expectedVersion != null ? expectedVersion : "")); + request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN); + // 下載到 App 私有外部目錄,su (root) 可讀取進行安裝,免 WRITE_EXTERNAL_STORAGE 權限 + request.setDestinationInExternalFilesDir(appContext, null, APK_FILE_NAME); + request.setAllowedOverMetered(true); + request.setAllowedOverRoaming(true); + + long downloadId = dm.enqueue(request); + + // 持久化狀態:App 重啟後可由 resumeIfPending 接手(續傳監看 / 安裝 / 回報) + prefs(appContext).edit() + .putLong(KEY_DOWNLOAD_ID, downloadId) + .putString(KEY_CMD_ID, cmdId) + .putString(KEY_VERSION, expectedVersion) + .putString(KEY_PHASE, PHASE_DOWNLOADING) + .putInt(KEY_INSTALL_ATTEMPTS, 0) + .apply(); + + mqttManager.publishCommandAck(cmdId, RESULT_PROGRESS, "Downloading update APK..."); + + registerCompletionReceiver(appContext); + startProgressMonitor(appContext, downloadId, cmdId); + + } catch (Exception e) { + logs.error(ErrorCode.UNKNOWN_ERROR, "OTA Update failed to start: " + e.getMessage()); + mqttManager.publishCommandAck(cmdId, RESULT_FAILED, "OTA Update failed: " + e.getMessage()); + clearState(appContext); + } + } + + /** + * App 啟動且 MQTT 連線後呼叫:若有未完成的 OTA,依目前狀態接手。 + *

+ * 本方法可重入:無待辦時直接返回,重複呼叫不會重複監看或重複回報。 + */ + public static void resumeIfPending(Context context) { + Context appContext = context.getApplicationContext(); + SharedPreferences prefs = prefs(appContext); + long downloadId = prefs.getLong(KEY_DOWNLOAD_ID, -1); + if (downloadId < 0) { + return; + } + String cmdId = prefs.getString(KEY_CMD_ID, null); + String phase = prefs.getString(KEY_PHASE, PHASE_DOWNLOADING); + + // 安裝階段:新版已(應)啟動,比對版本回報終態 + if (PHASE_INSTALLING.equals(phase)) { + confirmAfterInstall(appContext, cmdId); + return; + } + + // 已下載完成、等待閒置安裝:重啟後重新掛上閒置等待迴圈(APK 仍在本機) + if (PHASE_READY.equals(phase)) { + File apkFile = new File(appContext.getExternalFilesDir(null), APK_FILE_NAME); + if (apkFile.exists()) { + scheduleInstallWhenIdle(appContext, apkFile, cmdId); + } else { + if (cmdId != null) { + MqttManager.getInstance().publishCommandAck(cmdId, RESULT_FAILED, + "Downloaded APK missing before install."); + } + clearState(appContext); + } + return; + } + + DownloadManager dm = (DownloadManager) appContext.getSystemService(Context.DOWNLOAD_SERVICE); + if (dm == null) { + return; + } + int status = queryStatus(dm, downloadId); + logs.info("OTA resume: pending download " + downloadId + " (cmdId: " + cmdId + ") status=" + status); + + switch (status) { + case DownloadManager.STATUS_SUCCESSFUL: + handleDownloadComplete(appContext, cmdId); + break; + case DownloadManager.STATUS_RUNNING: + case DownloadManager.STATUS_PENDING: + case DownloadManager.STATUS_PAUSED: + registerCompletionReceiver(appContext); + startProgressMonitor(appContext, downloadId, cmdId); + break; + case DownloadManager.STATUS_FAILED: + default: + if (cmdId != null) { + MqttManager.getInstance().publishCommandAck(cmdId, RESULT_FAILED, + "OTA download failed or record missing after restart."); + } + clearState(appContext); + break; + } + } + + // ==================================================================== + // 內部流程 + // ==================================================================== + + /** 註冊下載完成廣播(runtime)。重複呼叫會先解除舊的,避免洩漏。 */ + private static synchronized void registerCompletionReceiver(Context appContext) { + unregisterCompletionReceiver(appContext); + completionReceiver = new BroadcastReceiver() { @Override - public void run() { - MqttManager mqttManager = MqttManager.getInstance(); - try { - logs.info("Starting OTA update process. URL: " + apkUrl + " (cmdId: " + cmdId + ")"); - mqttManager.publishCommandAck(cmdId, "success", "Downloading update APK..."); - - // 1. 背景下載 APK 到 APP 私有快取空間 - File apkFile = downloadApk(context, apkUrl); - if (apkFile == null || !apkFile.exists()) { - throw new Exception("Downloaded APK file is null or does not exist."); - } - logs.info("APK downloaded successfully to: " + apkFile.getAbsolutePath()); - - // 2. 執行 Root 靜默安裝 - mqttManager.publishCommandAck(cmdId, "success", "Installing update APK..."); - boolean installSuccess = silentInstall(apkFile.getAbsolutePath()); - - if (installSuccess) { - logs.info("Silent installation triggered successfully. App should be restarted by BootReceiver soon."); - // 安裝成功後,系統會因為 ACTION_MY_PACKAGE_REPLACED 自動重啟主 APP。 - // 此處已不需要再做其他處理。 - } else { - throw new Exception("Silent installation command returned non-zero status."); - } - - } catch (Exception e) { - logs.error(com.unibuy.smartdevice.exception.ErrorCode.UNKNOWN_ERROR, "OTA Update failed: " + e.getMessage()); - mqttManager.publishCommandAck(cmdId, "failed", "OTA Update failed: " + e.getMessage()); + public void onReceive(Context ctx, Intent intent) { + long id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1); + long expected = prefs(appContext).getLong(KEY_DOWNLOAD_ID, -1); + if (id >= 0 && id == expected) { + String cmdId = prefs(appContext).getString(KEY_CMD_ID, null); + handleDownloadComplete(appContext, cmdId); } } - }).start(); + }; + IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + appContext.registerReceiver(completionReceiver, filter, Context.RECEIVER_EXPORTED); + } else { + appContext.registerReceiver(completionReceiver, filter); + } } - /** - * 從網路下載 APK 檔案並存入 APP 私有快取目錄中 - */ - private static File downloadApk(Context context, String apkUrl) throws Exception { - URL url = new URL(apkUrl); - HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - connection.setConnectTimeout(DOWNLOAD_CONNECT_TIMEOUT_MS); - connection.setReadTimeout(DOWNLOAD_READ_TIMEOUT_MS); - connection.connect(); - - if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) { - throw new Exception("Server returned HTTP " + connection.getResponseCode() - + " " + connection.getResponseMessage()); - } - - // 使用 APP 私有的外部快取目錄,免去 Android 12 的 WRITE_EXTERNAL_STORAGE 權限宣告與申請 - File cacheDir = context.getExternalCacheDir(); - if (cacheDir == null) { - cacheDir = context.getCacheDir(); - } - File apkFile = new File(cacheDir, "update_ota.apk"); - if (apkFile.exists()) { - apkFile.delete(); - } - - try (InputStream input = new BufferedInputStream(connection.getInputStream()); - FileOutputStream output = new FileOutputStream(apkFile)) { - - byte[] data = new byte[4096]; - int count; - while ((count = input.read(data)) != -1) { - output.write(data, 0, count); + private static synchronized void unregisterCompletionReceiver(Context appContext) { + if (completionReceiver != null) { + try { + appContext.unregisterReceiver(completionReceiver); + } catch (Exception ignored) { } - output.flush(); - } finally { - connection.disconnect(); + completionReceiver = null; } - return apkFile; + } + + /** 背景輪詢下載進度並節流回報,下載結束(非進行中)即停止。 */ + private static void startProgressMonitor(Context appContext, long downloadId, String cmdId) { + if (monitoringDownloadId == downloadId) { + return; // 已在監看,避免重複起執行緒 + } + monitoringDownloadId = downloadId; + new Thread(() -> { + DownloadManager dm = (DownloadManager) appContext.getSystemService(Context.DOWNLOAD_SERVICE); + if (dm == null) { + monitoringDownloadId = -1; + return; + } + int lastPercent = -1; + try { + while (true) { + Thread.sleep(PROGRESS_REPORT_INTERVAL_MS); + if (prefs(appContext).getLong(KEY_DOWNLOAD_ID, -1) != downloadId) { + break; // 狀態已被清除或換成新下載 + } + int[] info = queryProgress(dm, downloadId); // {status, percent} + int status = info[0]; + int percent = info[1]; + if (status == DownloadManager.STATUS_RUNNING || status == DownloadManager.STATUS_PENDING + || status == DownloadManager.STATUS_PAUSED) { + if (percent >= 0 && percent != lastPercent) { + lastPercent = percent; + MqttManager.getInstance().publishCommandAck(cmdId, RESULT_PROGRESS, + "Downloading update APK... " + percent + "%"); + } + } else { + break; // 成功/失敗皆交由完成廣播或 resume 處理 + } + } + } catch (InterruptedException ignored) { + } finally { + monitoringDownloadId = -1; + } + }, "ota-progress-monitor").start(); + } + + /** 下載完成:標記為「待安裝」並等待機台閒置後才安裝。 */ + private static synchronized void handleDownloadComplete(Context appContext, String cmdId) { + SharedPreferences prefs = prefs(appContext); + // 已進入待安裝/安裝階段就不重複觸發(完成廣播 + resume 可能同時到達) + String currentPhase = prefs.getString(KEY_PHASE, PHASE_DOWNLOADING); + if (PHASE_READY.equals(currentPhase) || PHASE_INSTALLING.equals(currentPhase)) { + return; + } + long downloadId = prefs.getLong(KEY_DOWNLOAD_ID, -1); + DownloadManager dm = (DownloadManager) appContext.getSystemService(Context.DOWNLOAD_SERVICE); + if (dm == null || queryStatus(dm, downloadId) != DownloadManager.STATUS_SUCCESSFUL) { + if (cmdId != null) { + MqttManager.getInstance().publishCommandAck(cmdId, RESULT_FAILED, "OTA download did not complete."); + } + clearState(appContext); + return; + } + unregisterCompletionReceiver(appContext); + File apkFile = new File(appContext.getExternalFilesDir(null), APK_FILE_NAME); + if (!apkFile.exists()) { + if (cmdId != null) { + MqttManager.getInstance().publishCommandAck(cmdId, RESULT_FAILED, "Downloaded APK file missing."); + } + clearState(appContext); + return; + } + logs.info("APK downloaded successfully to: " + apkFile.getAbsolutePath()); + prefs.edit().putString(KEY_PHASE, PHASE_READY).apply(); + scheduleInstallWhenIdle(appContext, apkFile, cmdId); } /** - * 調用 Root 權限執行 pm install -r 靜默安裝 + * 等待機台閒置後才安裝:背景輪詢,僅在「不忙碌(無對話框/NFC 交易)且閒置達門檻」時 + * 觸發安裝;忙碌則持續等待並定期回報「等待閒置中」。可重入:已在等待則直接返回。 + */ + private static void scheduleInstallWhenIdle(Context appContext, File apkFile, String cmdId) { + if (awaitingIdleInstall) { + return; + } + awaitingIdleInstall = true; + MqttManager.getInstance().publishCommandAck(cmdId, RESULT_PROGRESS, + "Update downloaded, waiting for machine idle to install..."); + new Thread(() -> { + int checks = 0; + try { + while (true) { + // 狀態已被清除(如安裝完成/失敗)或不再是待安裝階段 → 結束等待 + if (!PHASE_READY.equals(prefs(appContext).getString(KEY_PHASE, ""))) { + break; + } + String busyReason = com.unibuy.smartdevice.MyApp.getInstance().getBusyReason(); + long idle = com.unibuy.smartdevice.MyApp.getInstance().getIdleSeconds(); + if (busyReason.isEmpty() && idle >= INSTALL_REQUIRED_IDLE_SECONDS) { + logs.info("OTA install gate passed (idle " + idle + "s). Installing now."); + installNow(appContext, apkFile, cmdId); + break; + } + if (checks % WAIT_NOTIFY_EVERY_N_CHECKS == 0) { + String detail = !busyReason.isEmpty() ? busyReason : ("idle " + idle + "s"); + logs.info("OTA install deferred, machine in use (" + detail + "). Waiting..."); + MqttManager.getInstance().publishCommandAck(cmdId, RESULT_PROGRESS, + "Downloaded, waiting for machine idle to install (" + detail + ")..."); + } + checks++; + Thread.sleep(IDLE_CHECK_INTERVAL_MS); + } + } catch (InterruptedException ignored) { + } finally { + awaitingIdleInstall = false; + } + }, "ota-install-idle-gate").start(); + } + + /** + * 執行靜默安裝。安裝成功(exit 0)後本程序即將被新版替換,終態 success 由 + * {@link #confirmAfterInstall} 於新版啟動後回報;安裝指令失敗則就地回報 failed。 + */ + private static void installNow(Context appContext, File apkFile, String cmdId) { + prefs(appContext).edit().putString(KEY_PHASE, PHASE_INSTALLING).apply(); + MqttManager.getInstance().publishCommandAck(cmdId, RESULT_PROGRESS, "Installing update APK..."); + boolean triggered = silentInstall(apkFile.getAbsolutePath()); + if (!triggered) { + // pm install 回傳非 0(無 root / 簽章不符等),此時 App 未被替換,可直接回報失敗 + logs.error(ErrorCode.UNKNOWN_ERROR, "Silent install command returned non-zero status."); + if (cmdId != null) { + MqttManager.getInstance().publishCommandAck(cmdId, RESULT_FAILED, + "Silent install failed (root/signature?)."); + } + clearState(appContext); + } else { + logs.info("Silent installation triggered. Awaiting package replacement & restart for confirmation."); + } + } + + /** 新版啟動後比對實際版本,回報最終成敗。 */ + private static void confirmAfterInstall(Context appContext, String cmdId) { + SharedPreferences prefs = prefs(appContext); + String expected = prefs.getString(KEY_VERSION, null); + String current = currentVersionName(appContext); + logs.info("OTA confirm after install. expected=" + expected + " current=" + current); + + boolean ok = (expected == null) || expected.equals(current); + if (ok) { + if (cmdId != null) { + MqttManager.getInstance().publishCommandAck(cmdId, RESULT_SUCCESS, + "App updated to " + (current != null ? current : "(unknown)") + "."); + } + clearState(appContext); + return; + } + + // 版本未變:可能上次安裝被中途打斷。APK 還在就重試一次,否則回報失敗。 + int attempts = prefs.getInt(KEY_INSTALL_ATTEMPTS, 0); + File apkFile = new File(appContext.getExternalFilesDir(null), APK_FILE_NAME); + if (apkFile.exists() && attempts < MAX_INSTALL_ATTEMPTS) { + prefs.edit().putInt(KEY_INSTALL_ATTEMPTS, attempts + 1).apply(); + logs.info("OTA confirm: version mismatch, retrying install (attempt " + (attempts + 1) + ")."); + installNow(appContext, apkFile, cmdId); + } else { + if (cmdId != null) { + MqttManager.getInstance().publishCommandAck(cmdId, RESULT_FAILED, + "Install did not result in expected version " + expected + + " (current " + current + ")."); + } + clearState(appContext); + } + } + + // ==================================================================== + // 工具 + // ==================================================================== + + private static SharedPreferences prefs(Context appContext) { + return appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + private static void clearState(Context appContext) { + prefs(appContext).edit().clear().apply(); + monitoringDownloadId = -1; + awaitingIdleInstall = false; + unregisterCompletionReceiver(appContext); + } + + /** 取消並移除先前殘留的下載任務。 */ + private static void cancelExisting(Context appContext) { + long prev = prefs(appContext).getLong(KEY_DOWNLOAD_ID, -1); + if (prev >= 0) { + DownloadManager dm = (DownloadManager) appContext.getSystemService(Context.DOWNLOAD_SERVICE); + if (dm != null) { + try { + dm.remove(prev); + } catch (Exception ignored) { + } + } + } + clearState(appContext); + } + + private static int queryStatus(DownloadManager dm, long downloadId) { + return queryProgress(dm, downloadId)[0]; + } + + /** @return {status, percent};查無紀錄回 {STATUS_FAILED, -1}。 */ + private static int[] queryProgress(DownloadManager dm, long downloadId) { + Cursor c = null; + try { + c = dm.query(new DownloadManager.Query().setFilterById(downloadId)); + if (c != null && c.moveToFirst()) { + int status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)); + long done = c.getLong(c.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)); + long total = c.getLong(c.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)); + int percent = (total > 0) ? (int) (done * 100 / total) : -1; + return new int[]{status, percent}; + } + } catch (Exception e) { + logs.info("OTA queryProgress error: " + e.getMessage()); + } finally { + if (c != null) c.close(); + } + return new int[]{DownloadManager.STATUS_FAILED, -1}; + } + + private static String currentVersionName(Context appContext) { + try { + return appContext.getPackageManager() + .getPackageInfo(appContext.getPackageName(), 0).versionName; + } catch (Exception e) { + return null; + } + } + + /** + * 退回方案:DownloadManager 不可用時的直連下載(背景 Thread,不可續傳)。 + * 下載完成後一樣走 {@link #installNow} 與安裝後版本確認流程。 + */ + private static void legacyDownloadAndInstall(Context appContext, String apkUrl, String cmdId, String expectedVersion) { + prefs(appContext).edit() + .putLong(KEY_DOWNLOAD_ID, 0) // 佔位,使 resume 視為有待辦(安裝階段) + .putString(KEY_CMD_ID, cmdId) + .putString(KEY_VERSION, expectedVersion) + .putString(KEY_PHASE, PHASE_DOWNLOADING) + .putInt(KEY_INSTALL_ATTEMPTS, 0) + .apply(); + MqttManager.getInstance().publishCommandAck(cmdId, RESULT_PROGRESS, "Downloading update APK..."); + new Thread(() -> { + try { + File apkFile = new File(appContext.getExternalFilesDir(null), APK_FILE_NAME); + if (apkFile.exists()) apkFile.delete(); + + java.net.HttpURLConnection conn = + (java.net.HttpURLConnection) new java.net.URL(apkUrl).openConnection(); + conn.setConnectTimeout(30000); + conn.setReadTimeout(120000); + conn.connect(); + if (conn.getResponseCode() != java.net.HttpURLConnection.HTTP_OK) { + throw new Exception("Server returned HTTP " + conn.getResponseCode()); + } + try (java.io.InputStream in = new java.io.BufferedInputStream(conn.getInputStream()); + java.io.FileOutputStream out = new java.io.FileOutputStream(apkFile)) { + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + } + out.flush(); + } finally { + conn.disconnect(); + } + logs.info("APK downloaded (legacy) to: " + apkFile.getAbsolutePath()); + prefs(appContext).edit().putString(KEY_PHASE, PHASE_READY).apply(); + scheduleInstallWhenIdle(appContext, apkFile, cmdId); + } catch (Exception e) { + logs.error(ErrorCode.UNKNOWN_ERROR, "Legacy OTA download failed: " + e.getMessage()); + MqttManager.getInstance().publishCommandAck(cmdId, RESULT_FAILED, + "OTA Update failed: " + e.getMessage()); + clearState(appContext); + } + }, "ota-legacy-download").start(); + } + + /** + * 調用 Root 權限執行 pm install -r 靜默安裝。 + * + * @return 安裝指令是否成功觸發(exit code 0) */ private static boolean silentInstall(String apkPath) { Process process = null; @@ -128,7 +543,8 @@ public class OtaManager { try { if (os != null) os.close(); if (process != null) process.destroy(); - } catch (Exception ignored) {} + } catch (Exception ignored) { + } } } }