[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:
parent
465125f614
commit
e15d0209c6
52
.agents/rules/versioning.md
Normal file
52
.agents/rules/versioning.md
Normal 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`),以便日後追溯特定現場機台的版本程式碼。
|
||||
@ -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
2
.gitignore
vendored
@ -9,3 +9,5 @@
|
||||
.cxx
|
||||
local.properties
|
||||
/outputs
|
||||
/.antigravitycli/
|
||||
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -1,3 +1,3 @@
|
||||
{
|
||||
"java.configuration.updateBuildConfiguration": "interactive"
|
||||
"java.configuration.updateBuildConfiguration": "automatic"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
132
app/src/main/java/com/unibuy/smartdevice/tools/OtaManager.java
Normal file
132
app/src/main/java/com/unibuy/smartdevice/tools/OtaManager.java
Normal 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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user