From d722b3273bcf1ba3d12c15bf25e00722eedd5067 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Thu, 7 May 2026 15:22:28 +0800 Subject: [PATCH] =?UTF-8?q?[REFACTOR]=20=E5=84=AA=E5=8C=96=20Android=20MQT?= =?UTF-8?q?T=20=E9=80=9A=E8=A8=8A=E6=9E=B6=E6=A7=8B=E8=88=87=E5=95=86?= =?UTF-8?q?=E5=93=81=E5=90=8C=E6=AD=A5=E8=AA=9E=E7=BE=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 實作 MqttService 與 MqttManager 以優化 MQTT 生命週期管理與自動重連機制。 2. 新增 MQTT Payload 結構類別 (Command, Heartbeat, Ack),規範化 IoT 通訊格式。 3. 調整「商品同步」語義與 UI 流程,更名為「下載商品資料」並強化同步完成提示。 4. 修正 StarCloudAPI 同步邏輯,確保空貨道資訊正確上傳至後台。 5. 統一多語系字串(繁中/英文/日文),提升操作直覺性。 --- app/build.gradle | 10 + app/src/main/AndroidManifest.xml | 10 +- .../com/unibuy/smartdevice/MainActivity.java | 20 +- .../java/com/unibuy/smartdevice/MyApp.java | 22 ++ .../smartdevice/external/StarCloudAPI.java | 355 +++++++++++++----- .../external/mqtt/MqttManager.java | 246 ++++++++++++ .../smartdevice/service/MqttService.java | 131 +++++++ .../structure/mqtt/CommandAckPayload.java | 53 +++ .../structure/mqtt/CommandPayload.java | 33 ++ .../structure/mqtt/HeartbeatPayload.java | 27 ++ .../smartdevice/ui/ProductListActivity.java | 89 +++-- .../ui/devs/vmc/VmcSlotListActivity.java | 56 ++- .../smartdevice/ui/tools/ImageGlide.java | 8 +- .../main/res/layout/activity_product_list.xml | 4 +- .../res/layout/activity_vmc_slot_list.xml | 54 ++- app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values-ja/strings.xml | 6 + app/src/main/res/values/strings.xml | 10 + 18 files changed, 1007 insertions(+), 130 deletions(-) create mode 100644 app/src/main/java/com/unibuy/smartdevice/external/mqtt/MqttManager.java create mode 100644 app/src/main/java/com/unibuy/smartdevice/service/MqttService.java create mode 100644 app/src/main/java/com/unibuy/smartdevice/structure/mqtt/CommandAckPayload.java create mode 100644 app/src/main/java/com/unibuy/smartdevice/structure/mqtt/CommandPayload.java create mode 100644 app/src/main/java/com/unibuy/smartdevice/structure/mqtt/HeartbeatPayload.java diff --git a/app/build.gradle b/app/build.gradle index ef2b811..f151477 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bb0b055..34074c9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,10 +11,13 @@ + + + + diff --git a/app/src/main/java/com/unibuy/smartdevice/MainActivity.java b/app/src/main/java/com/unibuy/smartdevice/MainActivity.java index 5d0ada2..92da9c5 100644 --- a/app/src/main/java/com/unibuy/smartdevice/MainActivity.java +++ b/app/src/main/java/com/unibuy/smartdevice/MainActivity.java @@ -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(); } diff --git a/app/src/main/java/com/unibuy/smartdevice/MyApp.java b/app/src/main/java/com/unibuy/smartdevice/MyApp.java index 3c74523..210306c 100644 --- a/app/src/main/java/com/unibuy/smartdevice/MyApp.java +++ b/app/src/main/java/com/unibuy/smartdevice/MyApp.java @@ -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)); diff --git a/app/src/main/java/com/unibuy/smartdevice/external/StarCloudAPI.java b/app/src/main/java/com/unibuy/smartdevice/external/StarCloudAPI.java index 4d328b3..050c11f 100644 --- a/app/src/main/java/com/unibuy/smartdevice/external/StarCloudAPI.java +++ b/app/src/main/java/com/unibuy/smartdevice/external/StarCloudAPI.java @@ -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 getAuthHeader() { - Map 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(); } /** diff --git a/app/src/main/java/com/unibuy/smartdevice/external/mqtt/MqttManager.java b/app/src/main/java/com/unibuy/smartdevice/external/mqtt/MqttManager.java new file mode 100644 index 0000000..9902a41 --- /dev/null +++ b/app/src/main/java/com/unibuy/smartdevice/external/mqtt/MqttManager.java @@ -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); + } +} diff --git a/app/src/main/java/com/unibuy/smartdevice/service/MqttService.java b/app/src/main/java/com/unibuy/smartdevice/service/MqttService.java new file mode 100644 index 0000000..53442cc --- /dev/null +++ b/app/src/main/java/com/unibuy/smartdevice/service/MqttService.java @@ -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); + } + } + } +} diff --git a/app/src/main/java/com/unibuy/smartdevice/structure/mqtt/CommandAckPayload.java b/app/src/main/java/com/unibuy/smartdevice/structure/mqtt/CommandAckPayload.java new file mode 100644 index 0000000..9b77e23 --- /dev/null +++ b/app/src/main/java/com/unibuy/smartdevice/structure/mqtt/CommandAckPayload.java @@ -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; + } +} diff --git a/app/src/main/java/com/unibuy/smartdevice/structure/mqtt/CommandPayload.java b/app/src/main/java/com/unibuy/smartdevice/structure/mqtt/CommandPayload.java new file mode 100644 index 0000000..dce9c49 --- /dev/null +++ b/app/src/main/java/com/unibuy/smartdevice/structure/mqtt/CommandPayload.java @@ -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; + } +} diff --git a/app/src/main/java/com/unibuy/smartdevice/structure/mqtt/HeartbeatPayload.java b/app/src/main/java/com/unibuy/smartdevice/structure/mqtt/HeartbeatPayload.java new file mode 100644 index 0000000..2995afb --- /dev/null +++ b/app/src/main/java/com/unibuy/smartdevice/structure/mqtt/HeartbeatPayload.java @@ -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; + } +} diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/ProductListActivity.java b/app/src/main/java/com/unibuy/smartdevice/ui/ProductListActivity.java index e5b5daa..e51d62d 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/ProductListActivity.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/ProductListActivity.java @@ -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 diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/devs/vmc/VmcSlotListActivity.java b/app/src/main/java/com/unibuy/smartdevice/ui/devs/vmc/VmcSlotListActivity.java index b7785da..af59e47 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/devs/vmc/VmcSlotListActivity.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/devs/vmc/VmcSlotListActivity.java @@ -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 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() { @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()); } } } \ No newline at end of file diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/tools/ImageGlide.java b/app/src/main/java/com/unibuy/smartdevice/ui/tools/ImageGlide.java index e97d0f9..cc027b5 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/tools/ImageGlide.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/tools/ImageGlide.java @@ -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) diff --git a/app/src/main/res/layout/activity_product_list.xml b/app/src/main/res/layout/activity_product_list.xml index c7b361c..631cf44 100644 --- a/app/src/main/res/layout/activity_product_list.xml +++ b/app/src/main/res/layout/activity_product_list.xml @@ -69,7 +69,7 @@ android:layout_weight="1" android:backgroundTint="@color/black" android:textSize="28dp" - android:text="返回" /> + android:text="@string/back" />