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" />
+ android:text="@string/sync_product" />
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_vmc_slot_list.xml b/app/src/main/res/layout/activity_vmc_slot_list.xml
index 9233fc3..64174df 100644
--- a/app/src/main/res/layout/activity_vmc_slot_list.xml
+++ b/app/src/main/res/layout/activity_vmc_slot_list.xml
@@ -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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index 991e3e0..91e9487 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -389,4 +389,7 @@
There is something at the pickup port, please contact the service staff to handle it!
knew
Input Vehicle
+ Reload Products
+ This will download all products from the cloud and overwrite any unsaved local inventory changes. Are you sure you want to continue?
+ Downloading product data...
\ No newline at end of file
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index e598b2c..25cc620 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -156,6 +156,7 @@
車両を入手する
フラッシュされていない車両
40秒以内に購入を完了してください
+ 戻る
キャンセル
開始を待っています
@@ -388,4 +389,9 @@
ピックアップポートに何かある場合は、サービススタッフに連絡して対応してください!
知っていた
スマホバーコード への入力
+ 商品データを同期
+ クラウドから商品データを同期しますか?これにより、すべての商品の名称、価格、画像が更新されます。
+ 商品データを同期中...
+ 同期中...
+ 商品データの同期が完了しました
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ede4414..1742f4f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -85,6 +85,7 @@
取得載具
未刷載具
請在40秒內完成加購動作
+ 返回
取消
等待開啟
@@ -300,4 +301,13 @@
輸入載具
領取確認
請將員山卡靠近讀卡機
+ 下載商品資料
+ 確定要從雲端下載商品資料嗎?這將會更新所有商品的品名、價格與圖片,並覆蓋目前的資訊。
+ 正在下載商品資料...
+ 下載中...
+ 商品資料下載完成
+ 上傳後台
+ 資料上傳中...
+ 資料上傳成功
+ 資料上傳失敗
\ No newline at end of file