[FEAT] 重構 OTA 升級管理器以改用 DownloadManager 支援背景續傳與進度回報
1. 升級 `app/build.gradle` 頂部修補版號為 10 (verPatch = 10)。 2. 重構 `OtaManager.java`: - 捨棄舊版自架 Thread 下載,改用系統 `DownloadManager` 進行背景下載,防止 App 重啟被殺中斷。 - 下載期間定期透過 MQTT 發送進度 ACK (`result="progress"`)。 - 語意修正:不再一開始就回報 `success`,僅在終態回報成敗。因靜默安裝成功會重啟 App,故於新版啟動後透過 `resumeIfPending` 比對預期版本並回報 `success`。 - 新增閒置安裝機制,若機台忙碌中則持續等待並定期重試。 - 提供無 `DownloadManager` 時的舊版直連下載退回方案。 3. 修改 `MqttManager.java`:在 MQTT 連線就緒後呼叫 `OtaManager.resumeIfPending` 接手未完成的 OTA 任務,避免狀態遺失。 4. 修改 `MqttService.java`:解析 MQTT `update_app` 指令 payload 中的 `version` 欄位並帶入 `OtaManager` 進行版本確認。
This commit is contained in:
parent
272164e0ed
commit
6dfadbe246
@ -10,7 +10,7 @@ plugins {
|
||||
// 避免不同尺寸機台間版本號產生衝突。
|
||||
// =========================================================================
|
||||
def verMinor = 2
|
||||
def verPatch = 9
|
||||
def verPatch = 10
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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 靜默覆蓋安裝管理。
|
||||
*
|
||||
* <p>下載改用系統 {@link DownloadManager},解決舊版「自架 Thread 下載」的兩個致命傷:
|
||||
* <ul>
|
||||
* <li>由系統程序負責下載,<b>App 重啟/被殺不會中斷</b>,並支援自動續傳與重試;</li>
|
||||
* <li>下載期間定期回報<b>進度 ACK(result="progress")</b>,後台不視為終態,看得到實際進度。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>回報語意修正:舊版一「開始下載」就回 {@code result=success},導致後台立刻把指令標成完成。
|
||||
* 新版只在<b>最終成敗</b>時回 {@code success}/{@code failed};下載/安裝過程一律走 {@code progress}。
|
||||
* 由於 {@code pm install -r} 成功後本程序會被新版替換,<b>「安裝成功」的終態 ACK 改由新版啟動後
|
||||
* 透過 {@link #resumeIfPending(Context)} 比對版本回報</b>,確保後台狀態與機台實際版本一致。
|
||||
*/
|
||||
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,依目前狀態接手。
|
||||
* <ul>
|
||||
* <li>安裝階段(phase=installing):比對實際版本 → 回報 success;不符則重試安裝或回報 failed。</li>
|
||||
* <li>下載階段:依 DownloadManager 狀態 → 已完成則安裝;進行中則重掛監看;失敗則回報 failed。</li>
|
||||
* </ul>
|
||||
* 本方法可重入:無待辦時直接返回,重複呼叫不會重複監看或重複回報。
|
||||
*/
|
||||
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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user