[REFACTOR] 優化 Android MQTT 通訊架構與商品同步語義
1. 實作 MqttService 與 MqttManager 以優化 MQTT 生命週期管理與自動重連機制。 2. 新增 MQTT Payload 結構類別 (Command, Heartbeat, Ack),規範化 IoT 通訊格式。 3. 調整「商品同步」語義與 UI 流程,更名為「下載商品資料」並強化同步完成提示。 4. 修正 StarCloudAPI 同步邏輯,確保空貨道資訊正確上傳至後台。 5. 統一多語系字串(繁中/英文/日文),提升操作直覺性。
This commit is contained in:
parent
a73c618360
commit
d722b3273b
@ -56,6 +56,12 @@ android {
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += ['META-INF/INDEX.LIST', 'META-INF/io.netty.versions.properties']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@ -77,4 +83,8 @@ dependencies {
|
||||
implementation libs.tsnackBar
|
||||
|
||||
implementation 'com.blankj:utilcodex:1.31.1'
|
||||
implementation 'com.hivemq:hivemq-mqtt-client:1.3.3'
|
||||
implementation 'io.netty:netty-codec-http:4.1.94.Final'
|
||||
implementation 'io.netty:netty-handler:4.1.94.Final'
|
||||
implementation 'com.google.code.gson:gson:2.10.1'
|
||||
}
|
||||
|
||||
@ -11,10 +11,13 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.USB_PERMISSION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:name=".MyApp"
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@drawable/cpu"
|
||||
@ -89,6 +92,11 @@
|
||||
android:name=".MainActivity"
|
||||
android:exported="false">
|
||||
</activity>
|
||||
<service
|
||||
android:name=".service.MqttService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
<queries>
|
||||
<package android:name="com.xyshj.machine" />
|
||||
|
||||
@ -551,7 +551,25 @@ public class MainActivity extends AppCompatActivityAbstract {
|
||||
dialog.dismiss();
|
||||
}
|
||||
}).create();
|
||||
// dialog.getWindow().setFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE);
|
||||
|
||||
// 🛠️ Debug Backdoor: 長按對話框標題跳過登入
|
||||
dialog.setOnShowListener(d -> {
|
||||
int titleId = context.getResources().getIdentifier("alertTitle", "id", "android");
|
||||
View titleView = dialog.findViewById(titleId);
|
||||
if (titleView != null) {
|
||||
titleView.setOnLongClickListener(v -> {
|
||||
Toast.makeText(context, "🛠️ Debug Mode: 跳過登入", Toast.LENGTH_SHORT).show();
|
||||
MyApp.getInstance().setSuAccess("DEBUG_MODE");
|
||||
if (pendingAction != null) {
|
||||
pendingAction.run();
|
||||
pendingAction = null;
|
||||
}
|
||||
dialog.dismiss();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ package com.unibuy.smartdevice;
|
||||
import android.os.Build;
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.view.WindowManager;
|
||||
|
||||
@ -389,6 +390,25 @@ public class MyApp extends Application {
|
||||
);
|
||||
}
|
||||
|
||||
public void initMqtt() {
|
||||
String machineId = MyApp.getInstance().getMachine().getMachineID();
|
||||
String apiKey = MyApp.getInstance().getMachine().getApiKey();
|
||||
|
||||
if (machineId != null && !machineId.isEmpty() && apiKey != null && !apiKey.isEmpty()) {
|
||||
logs.info("Starting MQTT Service for machine: " + machineId);
|
||||
|
||||
Intent serviceIntent = new Intent(this, com.unibuy.smartdevice.service.MqttService.class);
|
||||
serviceIntent.putExtra("machineId", machineId);
|
||||
serviceIntent.putExtra("apiToken", apiKey);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(serviceIntent);
|
||||
} else {
|
||||
startService(serviceIntent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void initialSettingData() {
|
||||
SettingsDao settingsDao = new SettingsDao(getApplicationContext());
|
||||
|
||||
@ -401,6 +421,8 @@ public class MyApp extends Application {
|
||||
}
|
||||
}
|
||||
|
||||
initMqtt();
|
||||
|
||||
if (MyApp.getInstance().getGreenInvoice().getGreenMerchantID().isEmpty()) {
|
||||
SettingStructure setting = settingsDao.getOne(MyApp.getInstance().getGreenInvoice().getClass().getSimpleName());
|
||||
MyApp.getInstance().getGreenInvoice().setGreenMerchantID(setting.getData(0));
|
||||
|
||||
@ -2,8 +2,7 @@ package com.unibuy.smartdevice.external;
|
||||
|
||||
import android.util.Log;
|
||||
import com.unibuy.smartdevice.MyApp;
|
||||
import com.unibuy.smartdevice.tools.FormGetMainScheduler;
|
||||
import com.unibuy.smartdevice.tools.FormPostMainScheduler;
|
||||
// Removed unused Scheduler imports
|
||||
import com.unibuy.smartdevice.tools.HandlerMain;
|
||||
import com.unibuy.smartdevice.tools.HttpConnect;
|
||||
import org.json.JSONArray;
|
||||
@ -12,9 +11,14 @@ import org.json.JSONObject;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import com.unibuy.smartdevice.MyApp;
|
||||
import com.unibuy.smartdevice.R;
|
||||
import com.unibuy.smartdevice.database.ProductsDao;
|
||||
import com.unibuy.smartdevice.database.SettingsDao;
|
||||
import com.unibuy.smartdevice.structure.ProductStructure;
|
||||
import com.unibuy.smartdevice.structure.ReportSlotListStructure;
|
||||
import com.unibuy.smartdevice.structure.SlotStructure;
|
||||
import com.unibuy.smartdevice.devices.SlotField;
|
||||
import com.unibuy.smartdevice.structure.SettingStructure;
|
||||
import java.net.InetAddress;
|
||||
import java.net.NetworkInterface;
|
||||
@ -114,13 +118,8 @@ public class StarCloudAPI {
|
||||
JSONObject res = new JSONObject(finalReceive);
|
||||
if (res.optBoolean("success", false)) {
|
||||
MyApp.getInstance().setSuAccess(res.optString("Su_Account", account));
|
||||
String token = res.optString("token", "");
|
||||
if (!token.isEmpty()) {
|
||||
MyApp.getInstance().getMachine().setApiKey(token);
|
||||
Log.i(TAG, "Login success, persisting token: " + token);
|
||||
// 登入成功立即持久化,防止重啟失效
|
||||
persistCurrentSettings();
|
||||
}
|
||||
// 移除覆蓋 ApiKey 的邏輯,因為 B000 回傳的可能是 Admin Token,但我們需要保留 Machine Token
|
||||
Log.i(TAG, "Login success for user: " + account);
|
||||
handlerMain.start("StarCloudAPI", 200, "登入成功");
|
||||
} else {
|
||||
handlerMain.start("StarCloudAPI", 401,
|
||||
@ -146,60 +145,92 @@ public class StarCloudAPI {
|
||||
* 不需要 ApiKey 即可呼叫,僅需機器序號。
|
||||
*/
|
||||
public void downloadSetting() {
|
||||
String machineId = MyApp.getInstance().getMachine().getMachineID();
|
||||
final String machineId = MyApp.getInstance().getMachine().getMachineID();
|
||||
if (machineId.isEmpty()) {
|
||||
Log.e(TAG, "Machine ID is empty, cannot bind.");
|
||||
handlerMain.start("StarCloudAPI", 400, "請先輸入機器序號");
|
||||
return;
|
||||
}
|
||||
|
||||
String url = BASE_URL + "machine/setting/B014?machine=" + machineId;
|
||||
final String url = BASE_URL + "machine/setting/B014?machine=" + machineId;
|
||||
Log.i(TAG, "Starting B014 request to: " + url);
|
||||
|
||||
new FormGetMainScheduler(handlerMain) {
|
||||
new Thread(new Runnable() {
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() {
|
||||
return srcHandlerMain;
|
||||
}
|
||||
public void run() {
|
||||
int responseCode = -1;
|
||||
String receive = "";
|
||||
try {
|
||||
java.net.URL reqUrl = new java.net.URL(url);
|
||||
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) reqUrl.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
conn.setConnectTimeout(15000);
|
||||
conn.setReadTimeout(15000);
|
||||
|
||||
@Override
|
||||
protected Class<?> setCls() {
|
||||
return StarCloudAPI.class;
|
||||
}
|
||||
responseCode = conn.getResponseCode();
|
||||
java.io.InputStream is = (responseCode >= 200 && responseCode < 300)
|
||||
? conn.getInputStream() : conn.getErrorStream();
|
||||
if (is != null) {
|
||||
java.io.BufferedReader br = new java.io.BufferedReader(
|
||||
new java.io.InputStreamReader(is, "UTF-8"));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) sb.append(line);
|
||||
receive = sb.toString();
|
||||
}
|
||||
conn.disconnect();
|
||||
|
||||
@Override
|
||||
public void response(int responseCode, String receive, HandlerMain handlerMain) {
|
||||
Log.d(TAG, "B014 Response (" + responseCode + "): " + receive);
|
||||
if (responseCode == 200) {
|
||||
try {
|
||||
JSONObject res = new JSONObject(receive);
|
||||
if (res.optBoolean("success", false)) {
|
||||
// 配合後端更新:直接解析 data 物件
|
||||
JSONObject config = res.optJSONObject("data");
|
||||
if (config != null) {
|
||||
saveSettings(config);
|
||||
handlerMain.start("StarCloudAPI", 200, "綁定成功");
|
||||
} else {
|
||||
// 偵錯用:顯示收到的原始內容
|
||||
handlerMain.start("StarCloudAPI", 500, "設定內容為空,原始資料:" + receive);
|
||||
Log.d(TAG, "B014 Response (" + responseCode + "): " + receive);
|
||||
} catch (final Exception e) {
|
||||
Log.e(TAG, "B014 Network Error", e);
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
android.widget.Toast.makeText(MyApp.getInstance(),
|
||||
"綁定連線失敗: " + e.getMessage(), android.widget.Toast.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
handlerMain.start("StarCloudAPI", 500, "連線失敗: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
final int finalCode = responseCode;
|
||||
final String finalReceive = receive;
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (finalCode >= 200 && finalCode < 300) {
|
||||
try {
|
||||
JSONObject res = new JSONObject(finalReceive);
|
||||
if (res.optBoolean("success", false)) {
|
||||
JSONObject config = res.optJSONObject("data");
|
||||
if (config != null) {
|
||||
saveSettings(config);
|
||||
handlerMain.start("StarCloudAPI", 200, "綁定成功");
|
||||
} else {
|
||||
handlerMain.start("StarCloudAPI", 500, "設定內容為空,原始資料:" + finalReceive);
|
||||
}
|
||||
} else {
|
||||
handlerMain.start("StarCloudAPI", 401, "綁定失敗: " + res.optString("message"));
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
Log.e(TAG, "B014 Parse Error: " + e.getMessage());
|
||||
handlerMain.start("StarCloudAPI", 500, "設定解析錯誤: " + e.getMessage());
|
||||
}
|
||||
} else {
|
||||
handlerMain.start("StarCloudAPI", 401, "綁定失敗: " + res.optString("message"));
|
||||
try {
|
||||
JSONObject res = new JSONObject(finalReceive);
|
||||
String msg = res.optString("message", "網路錯誤: " + finalCode);
|
||||
handlerMain.start("StarCloudAPI", finalCode, msg);
|
||||
} catch (Exception e) {
|
||||
handlerMain.start("StarCloudAPI", finalCode, "網路錯誤: " + finalCode);
|
||||
}
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
Log.e(TAG, "B014 Parse Error: " + e.getMessage());
|
||||
handlerMain.start("StarCloudAPI", 500, "設定解析錯誤: " + e.getMessage());
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
JSONObject res = new JSONObject(receive);
|
||||
String msg = res.optString("message", "網路錯誤: " + responseCode);
|
||||
handlerMain.start("StarCloudAPI", responseCode, msg);
|
||||
} catch (Exception e) {
|
||||
handlerMain.start("StarCloudAPI", responseCode, "網路錯誤: " + responseCode);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}.start(url, null, null, null);
|
||||
}).start();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -207,55 +238,202 @@ public class StarCloudAPI {
|
||||
* 需要 Bearer Token 認證。
|
||||
*/
|
||||
public void downloadProductInfo() {
|
||||
String url = BASE_URL + "machine/products/B012";
|
||||
final String url = BASE_URL + "machine/products/B012";
|
||||
Log.i(TAG, "Starting B012 request to: " + url);
|
||||
|
||||
new FormGetMainScheduler(handlerMain) {
|
||||
new Thread(new Runnable() {
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() {
|
||||
return srcHandlerMain;
|
||||
}
|
||||
public void run() {
|
||||
int responseCode = -1;
|
||||
String receive = "";
|
||||
try {
|
||||
java.net.URL reqUrl = new java.net.URL(url);
|
||||
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) reqUrl.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
|
||||
// 加入機台 Token 驗證
|
||||
String machineToken = MyApp.getInstance().getMachine().getApiKey();
|
||||
if (machineToken != null && !machineToken.isEmpty() && !machineToken.equals("null")) {
|
||||
conn.setRequestProperty("Authorization", "Bearer " + machineToken);
|
||||
Log.i(TAG, "B012 Request with Token: Bearer " + machineToken.substring(0, Math.min(machineToken.length(), 10)) + "...");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<?> setCls() {
|
||||
return StarCloudAPI.class;
|
||||
}
|
||||
conn.setConnectTimeout(15000);
|
||||
conn.setReadTimeout(15000);
|
||||
|
||||
@Override
|
||||
public void response(int responseCode, String receive, HandlerMain handlerMain) {
|
||||
Log.d(TAG, "B012 Response (" + responseCode + "): " + receive);
|
||||
if (responseCode == 200) {
|
||||
try {
|
||||
JSONObject res = new JSONObject(receive);
|
||||
if (res.optBoolean("success")) {
|
||||
JSONArray products = res.getJSONArray("data");
|
||||
saveProducts(products);
|
||||
handlerMain.start("StarCloudAPI", 200, "商品同步成功");
|
||||
} else {
|
||||
handlerMain.start("StarCloudAPI", 401, "同步失敗: " + res.optString("message"));
|
||||
responseCode = conn.getResponseCode();
|
||||
java.io.InputStream is = (responseCode >= 200 && responseCode < 300)
|
||||
? conn.getInputStream() : conn.getErrorStream();
|
||||
if (is != null) {
|
||||
java.io.BufferedReader br = new java.io.BufferedReader(
|
||||
new java.io.InputStreamReader(is, "UTF-8"));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) sb.append(line);
|
||||
receive = sb.toString();
|
||||
}
|
||||
conn.disconnect();
|
||||
|
||||
Log.d(TAG, "B012 Response (" + responseCode + "): " + receive);
|
||||
} catch (final Exception e) {
|
||||
Log.e(TAG, "B012 Network Error", e);
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
android.widget.Toast.makeText(MyApp.getInstance(),
|
||||
"下載連線失敗: " + e.getMessage(), android.widget.Toast.LENGTH_LONG).show();
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
handlerMain.start("StarCloudAPI", 500, "商品解析錯誤");
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
JSONObject res = new JSONObject(receive);
|
||||
String msg = res.optString("message", "同步網路錯誤: " + responseCode);
|
||||
handlerMain.start("StarCloudAPI", responseCode, msg);
|
||||
} catch (Exception e) {
|
||||
handlerMain.start("StarCloudAPI", responseCode, "同步網路錯誤: " + responseCode);
|
||||
}
|
||||
});
|
||||
handlerMain.start("StarCloudAPI", 500, "連線失敗: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
final int finalCode = responseCode;
|
||||
final String finalReceive = receive;
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (finalCode >= 200 && finalCode < 300) {
|
||||
try {
|
||||
JSONObject res = new JSONObject(finalReceive);
|
||||
if (res.optBoolean("success", false)) {
|
||||
JSONArray products = res.getJSONArray("data");
|
||||
saveProducts(products);
|
||||
handlerMain.start("StarCloudAPI", 200, "商品下載成功");
|
||||
} else {
|
||||
handlerMain.start("StarCloudAPI", 401, "下載失敗: " + res.optString("message"));
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
handlerMain.start("StarCloudAPI", 500, "商品解析錯誤");
|
||||
}
|
||||
} else {
|
||||
String errorMsg;
|
||||
if (finalCode == 502) {
|
||||
errorMsg = "伺服器忙碌或維護中 (502),請稍後再試";
|
||||
} else {
|
||||
try {
|
||||
JSONObject res = new JSONObject(finalReceive);
|
||||
errorMsg = res.optString("message", "下載網路錯誤: " + finalCode);
|
||||
} catch (Exception e) {
|
||||
errorMsg = "下載網路錯誤: " + finalCode;
|
||||
}
|
||||
}
|
||||
handlerMain.start("StarCloudAPI", finalCode, errorMsg);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}.start(url, null, null, getAuthHeader());
|
||||
}).start();
|
||||
}
|
||||
|
||||
private Map<String, String> getAuthHeader() {
|
||||
Map<String, String> map = new HashMap<>();
|
||||
String token = MyApp.getInstance().getMachine().getApiKey();
|
||||
if (token != null && !token.isEmpty() && !token.equals("null")) {
|
||||
map.put("Authorization", "Bearer " + token);
|
||||
}
|
||||
return map;
|
||||
/**
|
||||
* B009: 貨道庫存即時回報
|
||||
* 需要 Bearer Token 認證。
|
||||
*/
|
||||
public void reportSlotList(final ReportSlotListStructure data) {
|
||||
final String url = BASE_URL + "products/supplementary/B009";
|
||||
Log.i(TAG, "Starting B009 request to: " + url);
|
||||
|
||||
new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
int responseCode = -1;
|
||||
String receive = "";
|
||||
try {
|
||||
JSONArray jsonArray = new JSONArray();
|
||||
for (SlotField slotField : SlotField.values()) {
|
||||
for (SlotStructure slotData : MyApp.getInstance().getSlotList(slotField.getField())) {
|
||||
// 判斷是否有商品(若無商品 ID 為 "?",則不傳送,觸發後台全量同步移除邏輯)
|
||||
if (slotData.getProduct() != null && !slotData.getProduct().getProductID().equals("?")) {
|
||||
JSONObject item = new JSONObject();
|
||||
item.put("tid", String.valueOf(slotData.getSlot())); // 貨道編號轉字串
|
||||
item.put("t060v00", slotData.getProduct().getProductID()); // 商品 ID
|
||||
item.put("num", String.valueOf(slotData.getCount())); // 實體庫存轉字串
|
||||
// 不送 status 參數(依據新文件,只送 tid, t060v00, num)
|
||||
jsonArray.put(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("data", jsonArray);
|
||||
json.put("account", data.getAccount());
|
||||
|
||||
Log.d(TAG, "B009 Request JSON: " + json.toString());
|
||||
|
||||
java.net.URL reqUrl = new java.net.URL(url);
|
||||
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) reqUrl.openConnection();
|
||||
conn.setRequestMethod("PUT"); // B009 使用 PUT
|
||||
conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
|
||||
// 加入機台 Token 驗證
|
||||
String machineToken = MyApp.getInstance().getMachine().getApiKey();
|
||||
if (machineToken != null && !machineToken.isEmpty() && !machineToken.equals("null")) {
|
||||
conn.setRequestProperty("Authorization", "Bearer " + machineToken);
|
||||
Log.i(TAG, "B009 Request with Token: Bearer " + machineToken.substring(0, Math.min(machineToken.length(), 10)) + "...");
|
||||
} else {
|
||||
Log.e(TAG, "B009 Request Failed: Machine Token is empty!");
|
||||
}
|
||||
|
||||
conn.setConnectTimeout(15000);
|
||||
conn.setReadTimeout(15000);
|
||||
conn.setDoOutput(true);
|
||||
|
||||
byte[] bodyBytes = json.toString().getBytes("UTF-8");
|
||||
conn.getOutputStream().write(bodyBytes);
|
||||
conn.getOutputStream().flush();
|
||||
|
||||
responseCode = conn.getResponseCode();
|
||||
java.io.InputStream is = (responseCode >= 200 && responseCode < 300)
|
||||
? conn.getInputStream() : conn.getErrorStream();
|
||||
if (is != null) {
|
||||
java.io.BufferedReader br = new java.io.BufferedReader(
|
||||
new java.io.InputStreamReader(is, "UTF-8"));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) sb.append(line);
|
||||
receive = sb.toString();
|
||||
}
|
||||
conn.disconnect();
|
||||
|
||||
if (responseCode >= 200 && responseCode < 300) {
|
||||
Log.i(TAG, "B009 Success: " + receive);
|
||||
} else {
|
||||
Log.e(TAG, "B009 Failed (" + responseCode + "): " + receive);
|
||||
}
|
||||
} catch (final Exception e) {
|
||||
Log.e(TAG, "B009 Network Error", e);
|
||||
handlerMain.start(TAG, 500, "同步失敗: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
final int finalCode = responseCode;
|
||||
final String finalReceive = receive;
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (finalCode >= 200 && finalCode < 300) {
|
||||
try {
|
||||
JSONObject res = new JSONObject(finalReceive);
|
||||
if (res.optBoolean("success", false)) {
|
||||
handlerMain.start(TAG, 200, handlerMain.getContext().getString(R.string.upload_success));
|
||||
} else {
|
||||
String errorMsg = res.optString("message", handlerMain.getContext().getString(R.string.upload_failed));
|
||||
handlerMain.start(TAG, finalCode, handlerMain.getContext().getString(R.string.upload_failed) + ": " + errorMsg);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "B009 Parse Error: " + e.getMessage());
|
||||
handlerMain.start(TAG, 500, "解析失敗: " + finalReceive);
|
||||
}
|
||||
} else {
|
||||
handlerMain.start(TAG, finalCode, "同步失敗 (" + finalCode + ") " + finalReceive);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -293,6 +471,7 @@ public class StarCloudAPI {
|
||||
|
||||
// 5. 重要:將所有設定同步回資料庫,防止 App 重啟後消失
|
||||
persistCurrentSettings();
|
||||
MyApp.getInstance().initMqtt();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
246
app/src/main/java/com/unibuy/smartdevice/external/mqtt/MqttManager.java
vendored
Normal file
246
app/src/main/java/com/unibuy/smartdevice/external/mqtt/MqttManager.java
vendored
Normal file
@ -0,0 +1,246 @@
|
||||
package com.unibuy.smartdevice.external.mqtt;
|
||||
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.util.Log;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.hivemq.client.mqtt.MqttClient;
|
||||
import com.hivemq.client.mqtt.MqttClientState;
|
||||
import com.hivemq.client.mqtt.datatypes.MqttQos;
|
||||
import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient;
|
||||
import com.unibuy.smartdevice.MyApp;
|
||||
import com.unibuy.smartdevice.controller.DevXinYuanController;
|
||||
import com.unibuy.smartdevice.structure.mqtt.CommandAckPayload;
|
||||
import com.unibuy.smartdevice.structure.mqtt.CommandPayload;
|
||||
import com.unibuy.smartdevice.structure.mqtt.HeartbeatPayload;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class MqttManager {
|
||||
private static final String TAG = "MqttManager";
|
||||
private static MqttManager instance;
|
||||
private Mqtt3AsyncClient client;
|
||||
private final Gson gson = new Gson();
|
||||
private String serialNo;
|
||||
private MqttCommandListener commandListener;
|
||||
private ScheduledExecutorService heartbeatExecutor;
|
||||
|
||||
private MqttManager() {}
|
||||
|
||||
public static synchronized MqttManager getInstance() {
|
||||
if (instance == null) {
|
||||
instance = new MqttManager();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void setCommandListener(MqttCommandListener listener) {
|
||||
this.commandListener = listener;
|
||||
}
|
||||
|
||||
public void connect(String machineId, String apiToken) {
|
||||
if (machineId == null || machineId.isEmpty() || apiToken == null || apiToken.isEmpty()) {
|
||||
Log.e(TAG, "Cannot connect to MQTT: machineId or apiToken is empty");
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已經連線或正在連線中,且 ID 沒變,就不重複執行
|
||||
if (client != null && serialNo != null && serialNo.equals(machineId)) {
|
||||
MqttClientState state = client.getState();
|
||||
if (state != MqttClientState.DISCONNECTED) {
|
||||
Log.i(TAG, "MQTT is already " + state + " for: " + machineId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.serialNo = machineId;
|
||||
String clientId = "SC_" + machineId;
|
||||
|
||||
String willTopic = "machine/" + machineId + "/status";
|
||||
String willPayload = "{\"status\":\"offline\"}";
|
||||
|
||||
// 如果舊的 client 存在但狀態不對,先關閉
|
||||
if (client != null) {
|
||||
try {
|
||||
client.disconnect();
|
||||
} catch (Exception e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
client = MqttClient.builder()
|
||||
.useMqttVersion3()
|
||||
.identifier(clientId)
|
||||
.serverHost("mqtt.setdomains.work")
|
||||
.serverPort(443)
|
||||
.useSslWithDefaultConfig()
|
||||
.webSocketConfig()
|
||||
.serverPath("/mqtt")
|
||||
.applyWebSocketConfig()
|
||||
.simpleAuth()
|
||||
.username(machineId)
|
||||
.password(apiToken.getBytes(StandardCharsets.UTF_8))
|
||||
.applySimpleAuth()
|
||||
.automaticReconnectWithDefaultConfig()
|
||||
.addConnectedListener(context -> {
|
||||
Log.i(TAG, "MQTT Connection established/recovered");
|
||||
publishStatus("online");
|
||||
subscribeToCommandTopic();
|
||||
startHeartbeatScheduler();
|
||||
})
|
||||
.buildAsync();
|
||||
|
||||
client.connectWith()
|
||||
.cleanSession(true)
|
||||
.keepAlive(30) // Broker 判斷斷線 = 30 × 1.5 = 45 秒,加快 LWT 觸發
|
||||
.willPublish()
|
||||
.topic(willTopic)
|
||||
.payload(willPayload.getBytes(StandardCharsets.UTF_8))
|
||||
.qos(MqttQos.AT_LEAST_ONCE)
|
||||
.retain(true)
|
||||
.applyWillPublish()
|
||||
.send()
|
||||
.whenComplete((connAck, throwable) -> {
|
||||
if (throwable != null) {
|
||||
Log.e(TAG, "MQTT Initial connection failed: " + throwable.getMessage(), throwable);
|
||||
} else {
|
||||
Log.i(TAG, "MQTT Initial connection successful");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void startHeartbeatScheduler() {
|
||||
if (heartbeatExecutor != null && !heartbeatExecutor.isShutdown()) {
|
||||
heartbeatExecutor.shutdownNow();
|
||||
}
|
||||
heartbeatExecutor = Executors.newSingleThreadScheduledExecutor();
|
||||
heartbeatExecutor.scheduleAtFixedRate(() -> {
|
||||
try {
|
||||
String ver = "0";
|
||||
try {
|
||||
PackageInfo packageInfo = MyApp.getInstance().getPackageManager().getPackageInfo(MyApp.getInstance().getPackageName(), 0);
|
||||
ver = packageInfo.versionName;
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
// ignore
|
||||
}
|
||||
int temperature = DevXinYuanController.getTemperature();
|
||||
publishHeartbeat(ver, temperature);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error in heartbeat scheduler", e);
|
||||
}
|
||||
}, 0, 60, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
if (heartbeatExecutor != null && !heartbeatExecutor.isShutdown()) {
|
||||
heartbeatExecutor.shutdownNow();
|
||||
}
|
||||
if (client != null && client.getState().isConnected()) {
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private void subscribeToCommandTopic() {
|
||||
if (client == null || serialNo == null) return;
|
||||
|
||||
String commandTopic = "machine/" + serialNo + "/command";
|
||||
client.subscribeWith()
|
||||
.topicFilter(commandTopic)
|
||||
.qos(MqttQos.AT_LEAST_ONCE)
|
||||
.callback(publish -> {
|
||||
String payloadString = new String(publish.getPayloadAsBytes(), StandardCharsets.UTF_8);
|
||||
Log.i(TAG, "Received command payload: " + payloadString);
|
||||
try {
|
||||
CommandPayload command = gson.fromJson(payloadString, CommandPayload.class);
|
||||
if (commandListener != null && command != null) {
|
||||
commandListener.onCommandReceived(command);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error parsing command payload", e);
|
||||
}
|
||||
})
|
||||
.send()
|
||||
.whenComplete((subAck, throwable) -> {
|
||||
if (throwable != null) {
|
||||
Log.e(TAG, "Failed to subscribe to command topic", throwable);
|
||||
} else {
|
||||
Log.i(TAG, "Subscribed to " + commandTopic);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void publishStatus(String status) {
|
||||
if (client == null || !client.getState().isConnected()) return;
|
||||
|
||||
String payload = "{\"status\":\"" + status + "\"}";
|
||||
String topic = "machine/" + serialNo + "/status";
|
||||
|
||||
client.publishWith()
|
||||
.topic(topic)
|
||||
.payload(payload.getBytes(StandardCharsets.UTF_8))
|
||||
.qos(MqttQos.AT_LEAST_ONCE)
|
||||
.retain(true)
|
||||
.send()
|
||||
.whenComplete((publishResult, throwable) -> {
|
||||
if (throwable != null) {
|
||||
Log.e(TAG, "Failed to publish status", throwable);
|
||||
} else {
|
||||
Log.d(TAG, "Status published: " + payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void publishHeartbeat(String firmwareVersion, int temperature) {
|
||||
if (client == null || !client.getState().isConnected()) return;
|
||||
|
||||
HeartbeatPayload payloadObj = new HeartbeatPayload(firmwareVersion, temperature);
|
||||
String payload = gson.toJson(payloadObj);
|
||||
String topic = "machine/" + serialNo + "/heartbeat";
|
||||
|
||||
client.publishWith()
|
||||
.topic(topic)
|
||||
.payload(payload.getBytes(StandardCharsets.UTF_8))
|
||||
.qos(MqttQos.AT_MOST_ONCE)
|
||||
.send()
|
||||
.whenComplete((publishResult, throwable) -> {
|
||||
if (throwable != null) {
|
||||
Log.e(TAG, "Failed to publish heartbeat", throwable);
|
||||
} else {
|
||||
Log.d(TAG, "Heartbeat published: " + payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void publishCommandAck(String commandId, String result, String message) {
|
||||
publishCommandAck(new CommandAckPayload(commandId, result, message));
|
||||
}
|
||||
|
||||
public void publishCommandAck(CommandAckPayload ack) {
|
||||
if (client == null || !client.getState().isConnected()) return;
|
||||
|
||||
String payload = gson.toJson(ack);
|
||||
String topic = "machine/" + serialNo + "/command/ack";
|
||||
|
||||
client.publishWith()
|
||||
.topic(topic)
|
||||
.payload(payload.getBytes(StandardCharsets.UTF_8))
|
||||
.qos(MqttQos.AT_LEAST_ONCE)
|
||||
.send()
|
||||
.whenComplete((publishResult, throwable) -> {
|
||||
if (throwable != null) {
|
||||
Log.e(TAG, "Failed to publish command ack", throwable);
|
||||
} else {
|
||||
Log.d(TAG, "Command ACK published: " + payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public interface MqttCommandListener {
|
||||
void onCommandReceived(CommandPayload command);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,131 @@
|
||||
package com.unibuy.smartdevice.service;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
import android.os.PowerManager;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
|
||||
import com.unibuy.smartdevice.HomeActivity;
|
||||
import com.unibuy.smartdevice.R;
|
||||
import com.unibuy.smartdevice.external.mqtt.MqttManager;
|
||||
|
||||
/**
|
||||
* Foreground Service,負責持有 WakeLock 並管理 MQTT 連線。
|
||||
* 確保販賣機台在關閉螢幕時也能維持通訊。
|
||||
* 斷線偵測統一由 MQTT LWT(遺囑)機制處理,不另設其他偵測邏輯。
|
||||
*/
|
||||
public class MqttService extends Service {
|
||||
private static final String TAG = "MqttService";
|
||||
private static final String CHANNEL_ID = "MqttServiceChannel";
|
||||
private PowerManager.WakeLock wakeLock;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
Log.i(TAG, "MqttService Created");
|
||||
|
||||
// 1. 建立 Notification Channel
|
||||
createNotificationChannel();
|
||||
|
||||
// 2. 啟動為前台服務(確保不被系統殺掉)
|
||||
Intent notificationIntent = new Intent(this, HomeActivity.class);
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(this,
|
||||
0, notificationIntent, PendingIntent.FLAG_IMMUTABLE);
|
||||
|
||||
Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("StarCloud MQTT Service")
|
||||
.setContentText("機台通訊監控中...")
|
||||
.setSmallIcon(R.drawable.cpu)
|
||||
.setContentIntent(pendingIntent)
|
||||
.build();
|
||||
|
||||
startForeground(1, notification);
|
||||
|
||||
// 3. 取得 WakeLock(確保關螢幕時 CPU 不休眠,心跳不中斷)
|
||||
PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE);
|
||||
if (powerManager != null) {
|
||||
wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "StarCloud:MqttWakeLock");
|
||||
wakeLock.acquire();
|
||||
Log.i(TAG, "WakeLock acquired");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
String machineId = intent != null ? intent.getStringExtra("machineId") : null;
|
||||
String apiToken = intent != null ? intent.getStringExtra("apiToken") : null;
|
||||
|
||||
Log.i(TAG, "MqttService onStartCommand with machineId: " + machineId);
|
||||
|
||||
if (machineId != null && apiToken != null) {
|
||||
MqttManager mqttManager = MqttManager.getInstance();
|
||||
mqttManager.setCommandListener(command -> {
|
||||
String cmd = command.getCommand();
|
||||
String cmdId = command.getCommand_id();
|
||||
Log.i(TAG, "Received MQTT command: " + cmd + " (ID: " + cmdId + ")");
|
||||
|
||||
try {
|
||||
if ("reboot".equals(cmd)) {
|
||||
Log.i(TAG, "Executing remote reboot command...");
|
||||
mqttManager.publishCommandAck(cmdId, "success", "Rebooting soon...");
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> {
|
||||
Intent restartIntent = new Intent(getApplicationContext(), com.unibuy.smartdevice.ui.RestartHostActivity.class);
|
||||
restartIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(restartIntent);
|
||||
}, 2000);
|
||||
} else {
|
||||
Log.w(TAG, "Unknown MQTT command: " + cmd);
|
||||
mqttManager.publishCommandAck(cmdId, "failed", "Unknown command");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error executing MQTT command: " + e.getMessage(), e);
|
||||
mqttManager.publishCommandAck(cmdId, "failed", "Error: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
mqttManager.connect(machineId, apiToken);
|
||||
}
|
||||
|
||||
// START_STICKY 確保 Service 被系統殺掉後會自動重啟
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
Log.i(TAG, "MqttService Destroyed");
|
||||
if (wakeLock != null && wakeLock.isHeld()) {
|
||||
wakeLock.release();
|
||||
Log.i(TAG, "WakeLock released");
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
private void createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel serviceChannel = new NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"MQTT Service Channel",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
);
|
||||
NotificationManager manager = getSystemService(NotificationManager.class);
|
||||
if (manager != null) {
|
||||
manager.createNotificationChannel(serviceChannel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
package com.unibuy.smartdevice.structure.mqtt;
|
||||
|
||||
public class CommandAckPayload {
|
||||
private String command_id;
|
||||
private String result;
|
||||
private String message;
|
||||
private String stock;
|
||||
|
||||
public CommandAckPayload(String command_id, String result, String message) {
|
||||
this.command_id = command_id;
|
||||
this.result = result;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public CommandAckPayload(String command_id, String result, String message, String stock) {
|
||||
this.command_id = command_id;
|
||||
this.result = result;
|
||||
this.message = message;
|
||||
this.stock = stock;
|
||||
}
|
||||
|
||||
public String getCommand_id() {
|
||||
return command_id;
|
||||
}
|
||||
|
||||
public void setCommand_id(String command_id) {
|
||||
this.command_id = command_id;
|
||||
}
|
||||
|
||||
public String getResult() {
|
||||
return result;
|
||||
}
|
||||
|
||||
public void setResult(String result) {
|
||||
this.result = result;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public String getStock() {
|
||||
return stock;
|
||||
}
|
||||
|
||||
public void setStock(String stock) {
|
||||
this.stock = stock;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package com.unibuy.smartdevice.structure.mqtt;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
|
||||
public class CommandPayload {
|
||||
private String command;
|
||||
private String command_id;
|
||||
private JsonElement payload;
|
||||
|
||||
public String getCommand() {
|
||||
return command;
|
||||
}
|
||||
|
||||
public void setCommand(String command) {
|
||||
this.command = command;
|
||||
}
|
||||
|
||||
public String getCommand_id() {
|
||||
return command_id;
|
||||
}
|
||||
|
||||
public void setCommand_id(String command_id) {
|
||||
this.command_id = command_id;
|
||||
}
|
||||
|
||||
public JsonElement getPayload() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
public void setPayload(JsonElement payload) {
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
package com.unibuy.smartdevice.structure.mqtt;
|
||||
|
||||
public class HeartbeatPayload {
|
||||
private String firmware_version;
|
||||
private int temperature;
|
||||
|
||||
public HeartbeatPayload(String firmware_version, int temperature) {
|
||||
this.firmware_version = firmware_version;
|
||||
this.temperature = temperature;
|
||||
}
|
||||
|
||||
public String getFirmware_version() {
|
||||
return firmware_version;
|
||||
}
|
||||
|
||||
public void setFirmware_version(String firmware_version) {
|
||||
this.firmware_version = firmware_version;
|
||||
}
|
||||
|
||||
public int getTemperature() {
|
||||
return temperature;
|
||||
}
|
||||
|
||||
public void setTemperature(int temperature) {
|
||||
this.temperature = temperature;
|
||||
}
|
||||
}
|
||||
@ -30,6 +30,7 @@ public class ProductListActivity extends AppCompatActivityAbstract {
|
||||
public enum Option {
|
||||
TOAST(0),
|
||||
RESET_DATA(1),
|
||||
IMAGE_RELOAD(2),
|
||||
;
|
||||
|
||||
private int option;
|
||||
@ -65,17 +66,38 @@ public class ProductListActivity extends AppCompatActivityAbstract {
|
||||
return new ToastHandlerMain(getCtx(), getLogs()) {
|
||||
@Override
|
||||
protected void execute(Context context, int commandCode, String message) {
|
||||
// 處理 StarCloudAPI 的成功回傳
|
||||
if (commandCode == 200) {
|
||||
// 立即刷新資料 (文字、價格等),讓使用者先看到資料更新
|
||||
runOnUiThread(() -> recyclerProductListAdpter.notifyDataSetChanged());
|
||||
new ImagePreloadTask(ProductListActivity.this.getHandlerMain()).start();
|
||||
return;
|
||||
}
|
||||
|
||||
Option option = optionMap.get(commandCode);
|
||||
switch (option) {
|
||||
case TOAST:
|
||||
super.execute(context, commandCode, message);
|
||||
break;
|
||||
case RESET_DATA:
|
||||
int size = MyApp.getInstance().getProductList().size();
|
||||
for (int index=0; index < size; index++) {
|
||||
recyclerProductListAdpter.notifyItemChanged(index);
|
||||
}
|
||||
break;
|
||||
if (option != null) {
|
||||
switch (option) {
|
||||
case TOAST:
|
||||
super.execute(context, commandCode, message);
|
||||
runOnUiThread(() -> {
|
||||
binding.progressLayout.setVisibility(View.GONE);
|
||||
binding.buttonBack.setVisibility(View.VISIBLE);
|
||||
});
|
||||
break;
|
||||
case RESET_DATA:
|
||||
recyclerProductListAdpter.notifyDataSetChanged();
|
||||
break;
|
||||
case IMAGE_RELOAD:
|
||||
new ImagePreloadTask(ProductListActivity.this.getHandlerMain()).start();
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// 發生錯誤 (如非 200 代碼),顯示訊息並恢復 UI
|
||||
super.execute(context, commandCode, message);
|
||||
runOnUiThread(() -> {
|
||||
binding.progressLayout.setVisibility(View.GONE);
|
||||
binding.buttonBack.setVisibility(View.VISIBLE);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -104,7 +126,30 @@ public class ProductListActivity extends AppCompatActivityAbstract {
|
||||
this.binding.recyclerProductList.setLayoutManager(new LinearLayoutManager(this));
|
||||
|
||||
binding.buttonBack.setOnClickListener(v -> finish());
|
||||
binding.buttonReload.setOnClickListener(new ButtonReloadOnClick(getHandlerMain()));
|
||||
binding.buttonReload.setOnClickListener(v -> {
|
||||
new android.app.AlertDialog.Builder(ProductListActivity.this)
|
||||
.setTitle(getCtx().getString(R.string.sync_product))
|
||||
.setMessage(getCtx().getString(R.string.sync_product_warning))
|
||||
.setPositiveButton(getCtx().getString(R.string.sure), (dialog, which) -> {
|
||||
com.unibuy.smartdevice.external.StarCloudAPI starCloudAPI = new com.unibuy.smartdevice.external.StarCloudAPI(getHandlerMain(), getHandlerMain());
|
||||
try {
|
||||
runOnUiThread(() -> {
|
||||
binding.buttonBack.setVisibility(View.GONE);
|
||||
binding.progressLayout.setVisibility(View.VISIBLE);
|
||||
updateProgressUI(0, getCtx().getString(R.string.downloading_product_data));
|
||||
});
|
||||
starCloudAPI.downloadProductInfo();
|
||||
} catch (Exception e) {
|
||||
getLogs().warning(e);
|
||||
runOnUiThread(() -> {
|
||||
binding.progressLayout.setVisibility(View.GONE);
|
||||
binding.buttonBack.setVisibility(View.VISIBLE);
|
||||
});
|
||||
}
|
||||
})
|
||||
.setNegativeButton(getCtx().getString(R.string.cancel), null)
|
||||
.show();
|
||||
});
|
||||
|
||||
if (MyApp.getInstance().getProductList().isEmpty()) {
|
||||
ProductsDao productsDao = new ProductsDao(this);
|
||||
@ -119,8 +164,8 @@ public class ProductListActivity extends AppCompatActivityAbstract {
|
||||
}
|
||||
}
|
||||
|
||||
private class ButtonReloadOnClick extends HandlerMainSchedulerOnClick {
|
||||
public ButtonReloadOnClick(HandlerMain handlerMain) {
|
||||
private class ImagePreloadTask extends HandlerMainSchedulerOnClick {
|
||||
public ImagePreloadTask(HandlerMain handlerMain) {
|
||||
super(handlerMain);
|
||||
}
|
||||
|
||||
@ -131,7 +176,7 @@ public class ProductListActivity extends AppCompatActivityAbstract {
|
||||
|
||||
@Override
|
||||
protected void close(HandlerMain handlerMain) {
|
||||
handlerMain.start(getClass().getSimpleName(), getCtx().getString(R.string.successfully_loaded) + MyApp.getInstance().getProductList().size() + "/" + MyApp.getInstance().getProductList().size() + getCtx().getString(R.string.product_information));//成功載入,筆商品圖片
|
||||
handlerMain.start(getClass().getSimpleName(), Option.TOAST.getOption(), getCtx().getString(R.string.sync_completed));
|
||||
handlerMain.start(getClass().getSimpleName(), Option.RESET_DATA.getOption(), "reset data");
|
||||
}
|
||||
|
||||
@ -143,10 +188,11 @@ public class ProductListActivity extends AppCompatActivityAbstract {
|
||||
binding.progressLayout.setVisibility(View.VISIBLE);
|
||||
});
|
||||
|
||||
handlerMain.start(getClass().getSimpleName(),getCtx().getString(R.string.start_downloading_product_images));//開始下載商品圖片
|
||||
|
||||
int adnum = MyApp.getInstance().getProductList().size();
|
||||
getLogs().info("商品數量: "+adnum);
|
||||
getLogs().info("ImagePreloadTask - 商品數量: " + adnum);
|
||||
if (adnum == 0) {
|
||||
getLogs().info("警告: 商品列表為空,無法預載圖片");
|
||||
}
|
||||
|
||||
for (int i = 0; i < MyApp.getInstance().getProductList().size(); i++) {
|
||||
ProductStructure product = MyApp.getInstance().getProductList().get(i);
|
||||
@ -158,12 +204,8 @@ public class ProductListActivity extends AppCompatActivityAbstract {
|
||||
getLogs().warning(e);
|
||||
}
|
||||
|
||||
if (i % 10 == 0) {
|
||||
handlerMain.start(getClass().getSimpleName(), getCtx().getString(R.string.successfully_loaded) + i + "/" + MyApp.getInstance().getProductList().size() + getCtx().getString(R.string.product_images));//成功載入,筆商品圖片
|
||||
}
|
||||
|
||||
int persent = (i * 100 / adnum);
|
||||
updateProgressUI(persent, "下載中...");
|
||||
int persent = ((i + 1) * 100 / adnum);
|
||||
updateProgressUI(persent, getCtx().getString(R.string.syncing) + (i + 1) + "/" + adnum);
|
||||
}
|
||||
|
||||
// 全部完成 → 隱藏進度框,顯示返回按鈕 (UI thread)
|
||||
@ -171,6 +213,7 @@ public class ProductListActivity extends AppCompatActivityAbstract {
|
||||
binding.progressLayout.setVisibility(View.GONE);
|
||||
binding.buttonBack.setVisibility(View.VISIBLE);
|
||||
});
|
||||
close(handlerMain);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@ -15,10 +15,13 @@ import com.unibuy.smartdevice.AppCompatActivityAbstract;
|
||||
import com.unibuy.smartdevice.MyApp;
|
||||
import com.unibuy.smartdevice.databinding.ActivityVmcSlotListBinding;
|
||||
import com.unibuy.smartdevice.devices.SlotField;
|
||||
import com.unibuy.smartdevice.exception.ErrorCode;
|
||||
import com.unibuy.smartdevice.exception.LogsParseException;
|
||||
import com.unibuy.smartdevice.exception.LogsSettingEmptyException;
|
||||
import com.unibuy.smartdevice.external.HttpAPI;
|
||||
import com.unibuy.smartdevice.external.StarCloudAPI;
|
||||
import com.unibuy.smartdevice.structure.ProductStructure;
|
||||
import com.unibuy.smartdevice.structure.ReportSlotListStructure;
|
||||
import com.unibuy.smartdevice.structure.SlotStructure;
|
||||
import com.unibuy.smartdevice.database.SlotsDao;
|
||||
import com.unibuy.smartdevice.tools.HandlerMain;
|
||||
@ -30,6 +33,7 @@ public class VmcSlotListActivity extends AppCompatActivityAbstract {
|
||||
private ActivityResultLauncher<Intent> resultLauncher;
|
||||
private RecyclerVmcSlotListAdpter recyclerVmcSlotListAdpter;
|
||||
private HttpAPI httpAPI;
|
||||
private StarCloudAPI starCloudAPI;
|
||||
|
||||
@Override
|
||||
protected Context setCtx() {
|
||||
@ -43,7 +47,18 @@ public class VmcSlotListActivity extends AppCompatActivityAbstract {
|
||||
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() {
|
||||
return new ToastHandlerMain(getCtx(), getLogs());
|
||||
return new ToastHandlerMain(getCtx(), getLogs()) {
|
||||
@Override
|
||||
protected void execute(Context context, int commandCode, String message) {
|
||||
// 無論成功或失敗,都隱藏進度條並恢復按鈕
|
||||
if (binding != null && binding.progressLayout != null) {
|
||||
binding.progressLayout.setVisibility(View.GONE);
|
||||
binding.buttonBack.setEnabled(true);
|
||||
binding.buttonSave.setEnabled(true);
|
||||
}
|
||||
super.execute(context, commandCode, message);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -53,6 +68,7 @@ public class VmcSlotListActivity extends AppCompatActivityAbstract {
|
||||
binding = ActivityVmcSlotListBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
httpAPI = new HttpAPI(getHandlerMain());
|
||||
starCloudAPI = new StarCloudAPI(getHandlerMain(), getHandlerMain());
|
||||
resultLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
|
||||
new ActivityResultCallback<ActivityResult>() {
|
||||
@Override
|
||||
@ -85,12 +101,29 @@ public class VmcSlotListActivity extends AppCompatActivityAbstract {
|
||||
binding.buttonBack.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
saveData();
|
||||
updateSlotListNum();
|
||||
// 如果有進度條在跑,不允許直接返回
|
||||
if (binding.progressLayout.getVisibility() == View.VISIBLE) {
|
||||
return;
|
||||
}
|
||||
finish();
|
||||
}
|
||||
});
|
||||
|
||||
binding.buttonSave.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
// 1. 本地儲存
|
||||
saveData();
|
||||
|
||||
// 2. 顯示進度條並開始雲端同步
|
||||
binding.progressLayout.setVisibility(View.VISIBLE);
|
||||
binding.buttonBack.setEnabled(false);
|
||||
binding.buttonSave.setEnabled(false);
|
||||
|
||||
updateSlotListNum();
|
||||
}
|
||||
});
|
||||
|
||||
binding.buttonReload.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
@ -189,14 +222,17 @@ public class VmcSlotListActivity extends AppCompatActivityAbstract {
|
||||
}
|
||||
|
||||
public void updateSlotListNum(){
|
||||
ReportSlotListStructure data = MyApp.getInstance().getReportSlotListStructure();
|
||||
data.setAccount(MyApp.getInstance().getSuAccess());
|
||||
// 如果 Intent 沒傳 vmcType,預設給 "U4"
|
||||
String vmcType = getIntent().getStringExtra("vmctype");
|
||||
if (vmcType == null || vmcType.isEmpty()) vmcType = "U4";
|
||||
data.setVmctype(vmcType);
|
||||
|
||||
try {
|
||||
MyApp.getInstance().getReportSlotListStructure().setAccount(MyApp.getInstance().getSuAccess());
|
||||
MyApp.getInstance().getReportSlotListStructure().setVmctype("U4");
|
||||
httpAPI.reportSlotList(MyApp.getInstance().getReportSlotListStructure());
|
||||
} catch (LogsParseException e) {
|
||||
throw new RuntimeException(e);
|
||||
} catch (LogsSettingEmptyException e) {
|
||||
throw new RuntimeException(e);
|
||||
this.starCloudAPI.reportSlotList(data);
|
||||
} catch (Exception e) {
|
||||
getLogs().error(ErrorCode.NETWORK_UNKNOWN_ERROR, "同步啟動失敗: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -76,7 +76,11 @@ public class ImageGlide {
|
||||
}
|
||||
}
|
||||
|
||||
public void preload(String urlString) throws LogsEmptyException{
|
||||
public void preload(String urlString) throws LogsEmptyException {
|
||||
preload(urlString, false);
|
||||
}
|
||||
|
||||
public void preload(String urlString, boolean force) throws LogsEmptyException {
|
||||
if (context instanceof Activity) {
|
||||
Activity activity = (Activity) context;
|
||||
if (activity.isFinishing() || activity.isDestroyed()) {
|
||||
@ -86,7 +90,7 @@ public class ImageGlide {
|
||||
|
||||
HttpConnect httpConnect = new HttpConnect();
|
||||
try {
|
||||
File imageFile = httpConnect.download(urlString, true);
|
||||
File imageFile = httpConnect.download(urlString, force);
|
||||
Glide.with(context.getApplicationContext()).load(imageFile)
|
||||
.placeholder(R.drawable.loading)
|
||||
.error(R.drawable.loading)
|
||||
|
||||
@ -69,7 +69,7 @@
|
||||
android:layout_weight="1"
|
||||
android:backgroundTint="@color/black"
|
||||
android:textSize="28dp"
|
||||
android:text="返回" />
|
||||
android:text="@string/back" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/buttonReload"
|
||||
@ -78,6 +78,6 @@
|
||||
android:layout_weight="1"
|
||||
android:backgroundTint="@color/black"
|
||||
android:textSize="28dp"
|
||||
android:text="重載圖片" />
|
||||
android:text="@string/sync_product" />
|
||||
</LinearLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@ -20,6 +20,7 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:visibility="gone"
|
||||
android:backgroundTint="@color/black"
|
||||
android:text="智販機"
|
||||
android:textSize="24dp" />
|
||||
@ -60,20 +61,67 @@
|
||||
|
||||
<Button
|
||||
android:id="@+id/buttonBack"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:backgroundTint="@color/black"
|
||||
android:text="儲存並返回"
|
||||
android:text="返回"
|
||||
android:textSize="28dp"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/buttonSave"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:backgroundTint="@color/black"
|
||||
android:text="@string/upload_to_backend"
|
||||
android:textSize="28dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/buttonReload"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:visibility="gone"
|
||||
android:backgroundTint="@color/black"
|
||||
android:text="清空" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 中央浮動進度區塊 (預設隱藏) -->
|
||||
<FrameLayout
|
||||
android:id="@+id/progressLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#80000000"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="400dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp"
|
||||
android:gravity="center"
|
||||
android:background="@drawable/progress_bg"
|
||||
android:layout_gravity="center">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
style="?android:attr/progressBarStyleLarge"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/progressText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="12dp"
|
||||
android:textSize="20sp"
|
||||
android:textColor="@color/black"
|
||||
android:text="@string/uploading_data" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@ -389,4 +389,7 @@
|
||||
<string name="there_is_something_at_the_pickup_port">There is something at the pickup port, please contact the service staff to handle it!</string>
|
||||
<string name="i_knew">knew</string>
|
||||
<string name="input_vehicle">Input Vehicle</string>
|
||||
<string name="reload_product">Reload Products</string>
|
||||
<string name="reload_product_warning">This will download all products from the cloud and overwrite any unsaved local inventory changes. Are you sure you want to continue?</string>
|
||||
<string name="downloading_product_data">Downloading product data...</string>
|
||||
</resources>
|
||||
@ -156,6 +156,7 @@
|
||||
<string name="get_sehicle">車両を入手する</string>
|
||||
<string name="unflashed_vehicles">フラッシュされていない車両</string>
|
||||
<string name="complete_purchase_within_40_seconds">40秒以内に購入を完了してください</string>
|
||||
<string name="back">戻る</string>
|
||||
<string name="cancel">キャンセル</string>
|
||||
<!-- ui/dialog/CheckoutCashDialog.java -->
|
||||
<string name="waiting_opening">開始を待っています</string>
|
||||
@ -388,4 +389,9 @@
|
||||
<string name="there_is_something_at_the_pickup_port">ピックアップポートに何かある場合は、サービススタッフに連絡して対応してください!</string>
|
||||
<string name="i_knew">知っていた</string>
|
||||
<string name="input_vehicle">スマホバーコード への入力</string>
|
||||
<string name="sync_product">商品データを同期</string>
|
||||
<string name="sync_product_warning">クラウドから商品データを同期しますか?これにより、すべての商品の名称、価格、画像が更新されます。</string>
|
||||
<string name="downloading_product_data">商品データを同期中...</string>
|
||||
<string name="syncing">同期中... </string>
|
||||
<string name="sync_completed">商品データの同期が完了しました</string>
|
||||
</resources>
|
||||
@ -85,6 +85,7 @@
|
||||
<string name="get_sehicle">取得載具</string>
|
||||
<string name="unflashed_vehicles">未刷載具</string>
|
||||
<string name="complete_purchase_within_40_seconds">請在40秒內完成加購動作</string>
|
||||
<string name="back">返回</string>
|
||||
<string name="cancel">取消</string>
|
||||
<!-- ui/dialog/CheckoutCashDialog.java -->
|
||||
<string name="waiting_opening">等待開啟</string>
|
||||
@ -300,4 +301,13 @@
|
||||
<string name="input_vehicle">輸入載具</string>
|
||||
<string name="member_card_pickup_title">領取確認</string>
|
||||
<string name="please_tap_member_card">請將員山卡靠近讀卡機</string>
|
||||
<string name="sync_product">下載商品資料</string>
|
||||
<string name="sync_product_warning">確定要從雲端下載商品資料嗎?這將會更新所有商品的品名、價格與圖片,並覆蓋目前的資訊。</string>
|
||||
<string name="downloading_product_data">正在下載商品資料...</string>
|
||||
<string name="syncing">下載中... </string>
|
||||
<string name="sync_completed">商品資料下載完成</string>
|
||||
<string name="upload_to_backend">上傳後台</string>
|
||||
<string name="uploading_data">資料上傳中...</string>
|
||||
<string name="upload_success">資料上傳成功</string>
|
||||
<string name="upload_failed">資料上傳失敗</string>
|
||||
</resources>
|
||||
Loading…
Reference in New Issue
Block a user