[FEAT] 新增遠端 MQTT 應用程式 OTA 更新功能與雙版號動態計算機制

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 的建置組態。
This commit is contained in:
sky121113 2026-05-22 16:04:52 +08:00
parent 465125f614
commit e15d0209c6
7 changed files with 238 additions and 8 deletions

View File

@ -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`),以便日後追溯特定現場機台的版本程式碼。

View File

@ -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 (如有連接設備)

2
.gitignore vendored
View File

@ -9,3 +9,5 @@
.cxx
local.properties
/outputs
/.antigravitycli/

View File

@ -1,3 +1,3 @@
{
"java.configuration.updateBuildConfiguration": "interactive"
"java.configuration.updateBuildConfiguration": "automatic"
}

View File

@ -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"
}
}

View File

@ -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");

View File

@ -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) {}
}
}
}