From e15d0209c6261e68603c1c7b56b9e108005f429a Mon Sep 17 00:00:00 2001 From: sky121113 Date: Fri, 22 May 2026 16:04:52 +0800 Subject: [PATCH] =?UTF-8?q?[FEAT]=20=E6=96=B0=E5=A2=9E=E9=81=A0=E7=AB=AF?= =?UTF-8?q?=20MQTT=20=E6=87=89=E7=94=A8=E7=A8=8B=E5=BC=8F=20OTA=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=8A=9F=E8=83=BD=E8=88=87=E9=9B=99=E7=89=88?= =?UTF-8?q?=E8=99=9F=E5=8B=95=E6=85=8B=E8=A8=88=E7=AE=97=E6=A9=9F=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增 OtaManager.java:實現背景下載 APK 檔案至應用程式私有快取空間,並透過 Root 權限 (su 與 pm install -r) 進行靜默覆蓋安裝的完整邏輯。 2. 修改 MqttService.java:支援 update_app MQTT 指令,解析 payload 中的 url 參數並啟動 OtaManager 進行 OTA 更新。 3. 修改 app/build.gradle:實作雙版號動態計算機制,定義 verMajor、verMinor、verPatch,以符合實體機台的 OTA 覆蓋安裝規範。 4. 新增 .agents/rules/versioning.md:制訂專案版號與發版規範。 5. 修改 .agents/workflows/compiler-apk.md:調整編譯工作流,在編譯前自動檢查程式碼變更與遞增版號,並支援動態尋找編譯產出的最新 APK。 6. 修改 .gitignore:排除 .antigravitycli/ 工具產生的目錄。 7. 修改 .vscode/settings.json:設定自動更新 Java 的建置組態。 --- .agents/rules/versioning.md | 52 +++++++ .agents/workflows/compiler-apk.md | 18 ++- .gitignore | 2 + .vscode/settings.json | 2 +- app/build.gradle | 18 ++- .../smartdevice/service/MqttService.java | 22 +++ .../unibuy/smartdevice/tools/OtaManager.java | 132 ++++++++++++++++++ 7 files changed, 238 insertions(+), 8 deletions(-) create mode 100644 .agents/rules/versioning.md create mode 100644 app/src/main/java/com/unibuy/smartdevice/tools/OtaManager.java diff --git a/.agents/rules/versioning.md b/.agents/rules/versioning.md new file mode 100644 index 0000000..67e50d5 --- /dev/null +++ b/.agents/rules/versioning.md @@ -0,0 +1,52 @@ +--- +trigger: always_on +--- + +# TaiwanStar 專案版號與發版規範 + +本規範定義了本專案(販賣機機台 App)的版號命名、自動化計算、實體機台 OTA 更新原則,以確保後續開發與發版時版本管理高度穩定。 + +## 1. 雙版號動態計算機制 (Double Versioning Scheme) + +本專案採用三段式版號變數進行自動化計算,所有配置皆於 `app/build.gradle` 頂部定義。 + +> [!IMPORTANT] +> **開發人員與 AI 助手在修改版號時,嚴禁直接寫死 `versionCode` 或 `versionName`,必須透過修改 Gradle 頂部的變數來進行。** + +* `verMajor` (大版號):代表重大架構變更或大型系統改版(例如 `10`)。 +* `verMinor` (次版號):代表新增商業功能、硬體支援,但向下相容(例如 `8`)。 +* `verPatch` (修補版號):代表 Bug 修復、小優化(例如 `5`)。 + +### 計算公式: +* **`versionCode`** = `verMajor * 10000 + verMinor * 100 + verPatch` +* **`versionName`** = `"${verMajor}_0${verMinor}_${verPatch}_R"` + +--- + +## 2. 實體機台 OTA 自動更新鐵律 + +因為本 App 部署於實體販賣機,更新主要透過後台進行遠端自動下載覆蓋安裝 (OTA)。 + +> [!CAUTION] +> **每次釋出任何新版 APK 至生產環境,`versionCode` 必須嚴格大於前一次發佈的數值!** +> * 若 `versionCode` 相同,即便 `versionName` 不同(例如從 `10_08_2_R` 改為 `10_08_5_R`),機台下載後也會因 `INSTALL_FAILED_ALREADY_EXISTS` 錯誤而**拒絕覆蓋安裝**,導致遠端自動升級完全失效。 + +--- + +## 3. 機台套件與產品風味 (Product Flavors) 命名定義 + +* **命名結構**:`[螢幕尺寸]_[Android版本]_[主板與機型規格]` +* **現行標準 Flavor**:`S_12_XY_U_Standard_` + * `S`:代表 Small 尺寸螢幕(一般 S 號)。 + * `12`:代表針對 Android 12 系統優化之版本。 + * `XY_U_Standard_`:代表 XY 主板、U 規格標準版。 +* **擴充原則**:未來引進新硬體時,必須新增 Flavor 並遵循此命名格式(例如:`M_12_XY_U_Standard_`)。 + +--- + +## 4. Git 發版配合流程 + +1. 自 `develop` 拉出 `release/10.8.5` 發版分支。 +2. 遞增 `app/build.gradle` 中的 `verPatch` 變數並 Commit。 +3. 測試無誤後,合併回 `main` 與 `develop`。 +4. **必須在 `main` 合併節點打上 Git Tag**,格式為 `v[Major].[Minor].[Patch]`(例如 `v10.8.5`),以便日後追溯特定現場機台的版本程式碼。 diff --git a/.agents/workflows/compiler-apk.md b/.agents/workflows/compiler-apk.md index 8a2b18b..57881b1 100644 --- a/.agents/workflows/compiler-apk.md +++ b/.agents/workflows/compiler-apk.md @@ -6,6 +6,12 @@ description: 自動編譯 Android APK、輸出至專屬目錄並安裝至實體 這個 Workflow 主要是協助您一鍵完成 Android 專案的編譯、匯出與安裝。當您呼叫 `/compiler-apk` 時,AI 會自動為您執行以下步驟: +## 步驟零:自動檢查變更與升級版號 +在開始編譯之前,AI 助手會自動檢查是否有程式碼變更,以落實實體機台的 OTA 升級規範: +1. AI 助手將檢查 `git status --porcelain app/` 的輸出,以偵測是否有未提交的程式碼修改。 +2. 若偵測到變更,且本次任務尚未增加版號,AI 助手將自動讀取 `app/build.gradle` 並將其中的 `verPatch` 變數數值遞增 1(例如由 `5` 變更為 `6`)。 +3. 遞增完成後,向使用者呈報最新的版本號資訊,接著繼續執行步驟一。 + ## 步驟一:設定環境並編譯 Debug APK 使用 Java 17 與 Gradle 編譯專屬 Flavor (`S_12_XY_U_Standard_Debug`) 的程式碼。 @@ -29,11 +35,17 @@ cd /home/mama/projects/TaiwanStar_general_size_s_Chengwai // turbo cd /home/mama/projects/TaiwanStar_general_size_s_Chengwai mkdir -p outputs -APK_SOURCE="app/build/outputs/apk/S_12_XY_U_Standard_/debug/app-S_12_XY_U_Standard_-10_08_2_R-debug.apk" +# 動態尋找編譯產出的最新 APK 檔案,避免因版號變更導致寫死路徑失效 +APK_SOURCE=$(find app/build/outputs/apk/S_12_XY_U_Standard_/debug/ -name "app-S_12_XY_U_Standard_-*-debug.apk" | head -n 1) APK_DEST="outputs/TaiwanStar_debug.apk" -cp $APK_SOURCE $APK_DEST -echo "✅ APK 已經複製到 outputs 資料夾,路徑為:$APK_DEST" +if [ -n "$APK_SOURCE" ]; then + cp "$APK_SOURCE" "$APK_DEST" + echo "✅ APK 已經複製到 outputs 資料夾,路徑為:$APK_DEST (原始檔名: $(basename $APK_SOURCE))" +else + echo "❌ 找不到編譯產出的 APK 檔案,複製失敗!" + exit 1 +fi ``` ## 步驟三:安裝並啟動 App (如有連接設備) diff --git a/.gitignore b/.gitignore index 96d1b1f..110d88f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ .cxx local.properties /outputs +/.antigravitycli/ + diff --git a/.vscode/settings.json b/.vscode/settings.json index c5f3f6b..e0f15db 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "java.configuration.updateBuildConfiguration": "interactive" + "java.configuration.updateBuildConfiguration": "automatic" } \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index f151477..64d825f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,6 +2,16 @@ plugins { alias(libs.plugins.android.application) } +// ========================================================================= +// 核心版號與自動連動定義 +// 每次發佈新版本給機台時,只需遞增 verPatch (修補版號)。 +// 例如:從 5 改為 6,對應的 versionName 就會自動變成 "10_08_6_R", +// versionCode 就會自動變大為 100806,以正確觸發實體機台的 OTA 自動更新。 +// ========================================================================= +def verMajor = 10 +def verMinor = 8 +def verPatch = 6 + android { namespace 'com.unibuy.smartdevice' compileSdk 35 @@ -10,8 +20,8 @@ android { applicationId "com.unibuy.smartdevice" minSdk 21 targetSdk 34 - versionCode 12 - versionName "1_1_${versionCode}" + versionCode verMajor * 10000 + verMinor * 100 + verPatch + versionName "${verMajor}_0${verMinor}_${verPatch}_R" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -22,8 +32,8 @@ android { productFlavors { S_12_XY_U_Standard_ { dimension "app_type" - versionCode 8 - versionName "10_0${versionCode}_2_R" + versionCode verMajor * 10000 + verMinor * 100 + verPatch + versionName "${verMajor}_0${verMinor}_${verPatch}_R" } } 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 f2489c7..6fcd5e9 100644 --- a/app/src/main/java/com/unibuy/smartdevice/service/MqttService.java +++ b/app/src/main/java/com/unibuy/smartdevice/service/MqttService.java @@ -448,6 +448,28 @@ public class MqttService extends Service { logs.info("Failed to parse or start dispense command: " + e.getMessage()); mqttManager.publishCommandAck(cmdId, "failed", "Error: " + e.getMessage()); } + } else if ("update_app".equals(cmd)) { + logs.info("Executing remote app OTA update command..."); + try { + String apkUrl = 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 (apkUrl == null || apkUrl.isEmpty()) { + throw new Exception("Missing 'url' parameter in update_app payload."); + } + + // 啟動背景 OTA 更新管理器 + com.unibuy.smartdevice.tools.OtaManager.startUpdate(getApplicationContext(), apkUrl, cmdId); + + } catch (Exception e) { + logs.info("Failed to start remote app update: " + e.getMessage()); + mqttManager.publishCommandAck(cmdId, "failed", "Error: " + e.getMessage()); + } } else { logs.info("Unknown MQTT command: " + cmd); mqttManager.publishCommandAck(cmdId, "failed", "Unknown command"); diff --git a/app/src/main/java/com/unibuy/smartdevice/tools/OtaManager.java b/app/src/main/java/com/unibuy/smartdevice/tools/OtaManager.java new file mode 100644 index 0000000..a6c19d2 --- /dev/null +++ b/app/src/main/java/com/unibuy/smartdevice/tools/OtaManager.java @@ -0,0 +1,132 @@ +package com.unibuy.smartdevice.tools; + +import android.content.Context; +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 靜默覆蓋安裝管理 + */ +public class OtaManager { + private static final String TAG = "OtaManager"; + private static final Logs logs = new Logs(OtaManager.class); + + /** + * 啟動背景執行緒執行 APP OTA 更新 + * + * @param context 應用程式 Context + * @param apkUrl 新版 APK 的下載位址 + * @param cmdId MQTT 指令 ID,用於回報執行結果 + */ + public static void startUpdate(Context context, String apkUrl, String cmdId) { + new Thread(new Runnable() { + @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()); + } + } + }).start(); + } + + /** + * 從網路下載 APK 檔案並存入 APP 私有快取目錄中 + */ + private static File downloadApk(Context context, String apkUrl) throws Exception { + URL url = new URL(apkUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setConnectTimeout(15000); + connection.setReadTimeout(15000); + 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); + } + output.flush(); + } finally { + connection.disconnect(); + } + return apkFile; + } + + /** + * 調用 Root 權限執行 pm install -r 靜默安裝 + */ + private static boolean silentInstall(String apkPath) { + Process process = null; + DataOutputStream os = null; + try { + logs.info("Executing silent install command via su: pm install -r " + apkPath); + process = Runtime.getRuntime().exec("su"); + os = new DataOutputStream(process.getOutputStream()); + os.writeBytes("pm install -r " + apkPath + "\n"); + os.writeBytes("exit\n"); + os.flush(); + + int exitValue = process.waitFor(); + logs.info("Silent install process exit value: " + exitValue); + return exitValue == 0; + } catch (Exception e) { + logs.info("Failed to execute silent install su command: " + e.getMessage()); + logs.warning(e); + return false; + } finally { + try { + if (os != null) os.close(); + if (process != null) process.destroy(); + } catch (Exception ignored) {} + } + } +}