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 下載」的兩個致命傷:
+ *
+ * - 由系統程序負責下載,App 重啟/被殺不會中斷,並支援自動續傳與重試;
+ * - 下載期間定期回報進度 ACK(result="progress"),後台不視為終態,看得到實際進度。
+ *
+ *
+ * 回報語意修正:舊版一「開始下載」就回 {@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,依目前狀態接手。
+ *
+ * - 安裝階段(phase=installing):比對實際版本 → 回報 success;不符則重試安裝或回報 failed。
+ * - 下載階段:依 DownloadManager 狀態 → 已完成則安裝;進行中則重掛監看;失敗則回報 failed。
+ *
+ * 本方法可重入:無待辦時直接返回,重複呼叫不會重複監看或重複回報。
+ */
+ 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) {
+ }
}
}
}