[REFACTOR] 優化 Android MQTT 通訊架構與商品同步語義

1. 實作 MqttService 與 MqttManager 以優化 MQTT 生命週期管理與自動重連機制。
2. 新增 MQTT Payload 結構類別 (Command, Heartbeat, Ack),規範化 IoT 通訊格式。
3. 調整「商品同步」語義與 UI 流程,更名為「下載商品資料」並強化同步完成提示。
4. 修正 StarCloudAPI 同步邏輯,確保空貨道資訊正確上傳至後台。
5. 統一多語系字串(繁中/英文/日文),提升操作直覺性。
This commit is contained in:
sky121113 2026-05-07 15:22:28 +08:00
parent a73c618360
commit d722b3273b
18 changed files with 1007 additions and 130 deletions

View File

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

View File

@ -11,10 +11,13 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.USB_PERMISSION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:name=".MyApp"
android:allowBackup="true"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/cpu"
@ -89,6 +92,11 @@
android:name=".MainActivity"
android:exported="false">
</activity>
<service
android:name=".service.MqttService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
<queries>
<package android:name="com.xyshj.machine" />

View File

@ -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();
}

View File

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

View File

@ -2,8 +2,7 @@ package com.unibuy.smartdevice.external;
import android.util.Log;
import com.unibuy.smartdevice.MyApp;
import com.unibuy.smartdevice.tools.FormGetMainScheduler;
import com.unibuy.smartdevice.tools.FormPostMainScheduler;
// Removed unused Scheduler imports
import com.unibuy.smartdevice.tools.HandlerMain;
import com.unibuy.smartdevice.tools.HttpConnect;
import org.json.JSONArray;
@ -12,9 +11,14 @@ import org.json.JSONObject;
import java.util.HashMap;
import java.util.Map;
import com.unibuy.smartdevice.MyApp;
import com.unibuy.smartdevice.R;
import com.unibuy.smartdevice.database.ProductsDao;
import com.unibuy.smartdevice.database.SettingsDao;
import com.unibuy.smartdevice.structure.ProductStructure;
import com.unibuy.smartdevice.structure.ReportSlotListStructure;
import com.unibuy.smartdevice.structure.SlotStructure;
import com.unibuy.smartdevice.devices.SlotField;
import com.unibuy.smartdevice.structure.SettingStructure;
import java.net.InetAddress;
import java.net.NetworkInterface;
@ -114,13 +118,8 @@ public class StarCloudAPI {
JSONObject res = new JSONObject(finalReceive);
if (res.optBoolean("success", false)) {
MyApp.getInstance().setSuAccess(res.optString("Su_Account", account));
String token = res.optString("token", "");
if (!token.isEmpty()) {
MyApp.getInstance().getMachine().setApiKey(token);
Log.i(TAG, "Login success, persisting token: " + token);
// 登入成功立即持久化防止重啟失效
persistCurrentSettings();
}
// 移除覆蓋 ApiKey 的邏輯因為 B000 回傳的可能是 Admin Token但我們需要保留 Machine Token
Log.i(TAG, "Login success for user: " + account);
handlerMain.start("StarCloudAPI", 200, "登入成功");
} else {
handlerMain.start("StarCloudAPI", 401,
@ -146,60 +145,92 @@ public class StarCloudAPI {
* 不需要 ApiKey 即可呼叫僅需機器序號
*/
public void downloadSetting() {
String machineId = MyApp.getInstance().getMachine().getMachineID();
final String machineId = MyApp.getInstance().getMachine().getMachineID();
if (machineId.isEmpty()) {
Log.e(TAG, "Machine ID is empty, cannot bind.");
handlerMain.start("StarCloudAPI", 400, "請先輸入機器序號");
return;
}
String url = BASE_URL + "machine/setting/B014?machine=" + machineId;
final String url = BASE_URL + "machine/setting/B014?machine=" + machineId;
Log.i(TAG, "Starting B014 request to: " + url);
new FormGetMainScheduler(handlerMain) {
new Thread(new Runnable() {
@Override
protected HandlerMain setHandlerMain() {
return srcHandlerMain;
}
public void run() {
int responseCode = -1;
String receive = "";
try {
java.net.URL reqUrl = new java.net.URL(url);
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) reqUrl.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "application/json");
conn.setConnectTimeout(15000);
conn.setReadTimeout(15000);
@Override
protected Class<?> setCls() {
return StarCloudAPI.class;
}
responseCode = conn.getResponseCode();
java.io.InputStream is = (responseCode >= 200 && responseCode < 300)
? conn.getInputStream() : conn.getErrorStream();
if (is != null) {
java.io.BufferedReader br = new java.io.BufferedReader(
new java.io.InputStreamReader(is, "UTF-8"));
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line);
receive = sb.toString();
}
conn.disconnect();
@Override
public void response(int responseCode, String receive, HandlerMain handlerMain) {
Log.d(TAG, "B014 Response (" + responseCode + "): " + receive);
if (responseCode == 200) {
try {
JSONObject res = new JSONObject(receive);
if (res.optBoolean("success", false)) {
// 配合後端更新直接解析 data 物件
JSONObject config = res.optJSONObject("data");
if (config != null) {
saveSettings(config);
handlerMain.start("StarCloudAPI", 200, "綁定成功");
} else {
// 偵錯用顯示收到的原始內容
handlerMain.start("StarCloudAPI", 500, "設定內容為空,原始資料:" + receive);
Log.d(TAG, "B014 Response (" + responseCode + "): " + receive);
} catch (final Exception e) {
Log.e(TAG, "B014 Network Error", e);
new android.os.Handler(android.os.Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
android.widget.Toast.makeText(MyApp.getInstance(),
"綁定連線失敗: " + e.getMessage(), android.widget.Toast.LENGTH_LONG).show();
}
});
handlerMain.start("StarCloudAPI", 500, "連線失敗: " + e.getMessage());
return;
}
final int finalCode = responseCode;
final String finalReceive = receive;
new android.os.Handler(android.os.Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
if (finalCode >= 200 && finalCode < 300) {
try {
JSONObject res = new JSONObject(finalReceive);
if (res.optBoolean("success", false)) {
JSONObject config = res.optJSONObject("data");
if (config != null) {
saveSettings(config);
handlerMain.start("StarCloudAPI", 200, "綁定成功");
} else {
handlerMain.start("StarCloudAPI", 500, "設定內容為空,原始資料:" + finalReceive);
}
} else {
handlerMain.start("StarCloudAPI", 401, "綁定失敗: " + res.optString("message"));
}
} catch (JSONException e) {
Log.e(TAG, "B014 Parse Error: " + e.getMessage());
handlerMain.start("StarCloudAPI", 500, "設定解析錯誤: " + e.getMessage());
}
} else {
handlerMain.start("StarCloudAPI", 401, "綁定失敗: " + res.optString("message"));
try {
JSONObject res = new JSONObject(finalReceive);
String msg = res.optString("message", "網路錯誤: " + finalCode);
handlerMain.start("StarCloudAPI", finalCode, msg);
} catch (Exception e) {
handlerMain.start("StarCloudAPI", finalCode, "網路錯誤: " + finalCode);
}
}
} catch (JSONException e) {
Log.e(TAG, "B014 Parse Error: " + e.getMessage());
handlerMain.start("StarCloudAPI", 500, "設定解析錯誤: " + e.getMessage());
}
} else {
try {
JSONObject res = new JSONObject(receive);
String msg = res.optString("message", "網路錯誤: " + responseCode);
handlerMain.start("StarCloudAPI", responseCode, msg);
} catch (Exception e) {
handlerMain.start("StarCloudAPI", responseCode, "網路錯誤: " + responseCode);
}
}
});
}
}.start(url, null, null, null);
}).start();
}
/**
@ -207,55 +238,202 @@ public class StarCloudAPI {
* 需要 Bearer Token 認證
*/
public void downloadProductInfo() {
String url = BASE_URL + "machine/products/B012";
final String url = BASE_URL + "machine/products/B012";
Log.i(TAG, "Starting B012 request to: " + url);
new FormGetMainScheduler(handlerMain) {
new Thread(new Runnable() {
@Override
protected HandlerMain setHandlerMain() {
return srcHandlerMain;
}
public void run() {
int responseCode = -1;
String receive = "";
try {
java.net.URL reqUrl = new java.net.URL(url);
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) reqUrl.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "application/json");
// 加入機台 Token 驗證
String machineToken = MyApp.getInstance().getMachine().getApiKey();
if (machineToken != null && !machineToken.isEmpty() && !machineToken.equals("null")) {
conn.setRequestProperty("Authorization", "Bearer " + machineToken);
Log.i(TAG, "B012 Request with Token: Bearer " + machineToken.substring(0, Math.min(machineToken.length(), 10)) + "...");
}
@Override
protected Class<?> setCls() {
return StarCloudAPI.class;
}
conn.setConnectTimeout(15000);
conn.setReadTimeout(15000);
@Override
public void response(int responseCode, String receive, HandlerMain handlerMain) {
Log.d(TAG, "B012 Response (" + responseCode + "): " + receive);
if (responseCode == 200) {
try {
JSONObject res = new JSONObject(receive);
if (res.optBoolean("success")) {
JSONArray products = res.getJSONArray("data");
saveProducts(products);
handlerMain.start("StarCloudAPI", 200, "商品同步成功");
} else {
handlerMain.start("StarCloudAPI", 401, "同步失敗: " + res.optString("message"));
responseCode = conn.getResponseCode();
java.io.InputStream is = (responseCode >= 200 && responseCode < 300)
? conn.getInputStream() : conn.getErrorStream();
if (is != null) {
java.io.BufferedReader br = new java.io.BufferedReader(
new java.io.InputStreamReader(is, "UTF-8"));
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line);
receive = sb.toString();
}
conn.disconnect();
Log.d(TAG, "B012 Response (" + responseCode + "): " + receive);
} catch (final Exception e) {
Log.e(TAG, "B012 Network Error", e);
new android.os.Handler(android.os.Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
android.widget.Toast.makeText(MyApp.getInstance(),
"下載連線失敗: " + e.getMessage(), android.widget.Toast.LENGTH_LONG).show();
}
} catch (JSONException e) {
handlerMain.start("StarCloudAPI", 500, "商品解析錯誤");
}
} else {
try {
JSONObject res = new JSONObject(receive);
String msg = res.optString("message", "同步網路錯誤: " + responseCode);
handlerMain.start("StarCloudAPI", responseCode, msg);
} catch (Exception e) {
handlerMain.start("StarCloudAPI", responseCode, "同步網路錯誤: " + responseCode);
}
});
handlerMain.start("StarCloudAPI", 500, "連線失敗: " + e.getMessage());
return;
}
final int finalCode = responseCode;
final String finalReceive = receive;
new android.os.Handler(android.os.Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
if (finalCode >= 200 && finalCode < 300) {
try {
JSONObject res = new JSONObject(finalReceive);
if (res.optBoolean("success", false)) {
JSONArray products = res.getJSONArray("data");
saveProducts(products);
handlerMain.start("StarCloudAPI", 200, "商品下載成功");
} else {
handlerMain.start("StarCloudAPI", 401, "下載失敗: " + res.optString("message"));
}
} catch (JSONException e) {
handlerMain.start("StarCloudAPI", 500, "商品解析錯誤");
}
} else {
String errorMsg;
if (finalCode == 502) {
errorMsg = "伺服器忙碌或維護中 (502),請稍後再試";
} else {
try {
JSONObject res = new JSONObject(finalReceive);
errorMsg = res.optString("message", "下載網路錯誤: " + finalCode);
} catch (Exception e) {
errorMsg = "下載網路錯誤: " + finalCode;
}
}
handlerMain.start("StarCloudAPI", finalCode, errorMsg);
}
}
});
}
}.start(url, null, null, getAuthHeader());
}).start();
}
private Map<String, String> getAuthHeader() {
Map<String, String> map = new HashMap<>();
String token = MyApp.getInstance().getMachine().getApiKey();
if (token != null && !token.isEmpty() && !token.equals("null")) {
map.put("Authorization", "Bearer " + token);
}
return map;
/**
* B009: 貨道庫存即時回報
* 需要 Bearer Token 認證
*/
public void reportSlotList(final ReportSlotListStructure data) {
final String url = BASE_URL + "products/supplementary/B009";
Log.i(TAG, "Starting B009 request to: " + url);
new Thread(new Runnable() {
@Override
public void run() {
int responseCode = -1;
String receive = "";
try {
JSONArray jsonArray = new JSONArray();
for (SlotField slotField : SlotField.values()) {
for (SlotStructure slotData : MyApp.getInstance().getSlotList(slotField.getField())) {
// 判斷是否有商品若無商品 ID "?"則不傳送觸發後台全量同步移除邏輯
if (slotData.getProduct() != null && !slotData.getProduct().getProductID().equals("?")) {
JSONObject item = new JSONObject();
item.put("tid", String.valueOf(slotData.getSlot())); // 貨道編號轉字串
item.put("t060v00", slotData.getProduct().getProductID()); // 商品 ID
item.put("num", String.valueOf(slotData.getCount())); // 實體庫存轉字串
// 不送 status 參數依據新文件只送 tid, t060v00, num
jsonArray.put(item);
}
}
}
JSONObject json = new JSONObject();
json.put("data", jsonArray);
json.put("account", data.getAccount());
Log.d(TAG, "B009 Request JSON: " + json.toString());
java.net.URL reqUrl = new java.net.URL(url);
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) reqUrl.openConnection();
conn.setRequestMethod("PUT"); // B009 使用 PUT
conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
conn.setRequestProperty("Accept", "application/json");
// 加入機台 Token 驗證
String machineToken = MyApp.getInstance().getMachine().getApiKey();
if (machineToken != null && !machineToken.isEmpty() && !machineToken.equals("null")) {
conn.setRequestProperty("Authorization", "Bearer " + machineToken);
Log.i(TAG, "B009 Request with Token: Bearer " + machineToken.substring(0, Math.min(machineToken.length(), 10)) + "...");
} else {
Log.e(TAG, "B009 Request Failed: Machine Token is empty!");
}
conn.setConnectTimeout(15000);
conn.setReadTimeout(15000);
conn.setDoOutput(true);
byte[] bodyBytes = json.toString().getBytes("UTF-8");
conn.getOutputStream().write(bodyBytes);
conn.getOutputStream().flush();
responseCode = conn.getResponseCode();
java.io.InputStream is = (responseCode >= 200 && responseCode < 300)
? conn.getInputStream() : conn.getErrorStream();
if (is != null) {
java.io.BufferedReader br = new java.io.BufferedReader(
new java.io.InputStreamReader(is, "UTF-8"));
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line);
receive = sb.toString();
}
conn.disconnect();
if (responseCode >= 200 && responseCode < 300) {
Log.i(TAG, "B009 Success: " + receive);
} else {
Log.e(TAG, "B009 Failed (" + responseCode + "): " + receive);
}
} catch (final Exception e) {
Log.e(TAG, "B009 Network Error", e);
handlerMain.start(TAG, 500, "同步失敗: " + e.getMessage());
return;
}
final int finalCode = responseCode;
final String finalReceive = receive;
new android.os.Handler(android.os.Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
if (finalCode >= 200 && finalCode < 300) {
try {
JSONObject res = new JSONObject(finalReceive);
if (res.optBoolean("success", false)) {
handlerMain.start(TAG, 200, handlerMain.getContext().getString(R.string.upload_success));
} else {
String errorMsg = res.optString("message", handlerMain.getContext().getString(R.string.upload_failed));
handlerMain.start(TAG, finalCode, handlerMain.getContext().getString(R.string.upload_failed) + ": " + errorMsg);
}
} catch (Exception e) {
Log.e(TAG, "B009 Parse Error: " + e.getMessage());
handlerMain.start(TAG, 500, "解析失敗: " + finalReceive);
}
} else {
handlerMain.start(TAG, finalCode, "同步失敗 (" + finalCode + ") " + finalReceive);
}
}
});
}
}).start();
}
/**
@ -293,6 +471,7 @@ public class StarCloudAPI {
// 5. 重要將所有設定同步回資料庫防止 App 重啟後消失
persistCurrentSettings();
MyApp.getInstance().initMqtt();
}
/**

View File

@ -0,0 +1,246 @@
package com.unibuy.smartdevice.external.mqtt;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.util.Log;
import com.google.gson.Gson;
import com.hivemq.client.mqtt.MqttClient;
import com.hivemq.client.mqtt.MqttClientState;
import com.hivemq.client.mqtt.datatypes.MqttQos;
import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient;
import com.unibuy.smartdevice.MyApp;
import com.unibuy.smartdevice.controller.DevXinYuanController;
import com.unibuy.smartdevice.structure.mqtt.CommandAckPayload;
import com.unibuy.smartdevice.structure.mqtt.CommandPayload;
import com.unibuy.smartdevice.structure.mqtt.HeartbeatPayload;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class MqttManager {
private static final String TAG = "MqttManager";
private static MqttManager instance;
private Mqtt3AsyncClient client;
private final Gson gson = new Gson();
private String serialNo;
private MqttCommandListener commandListener;
private ScheduledExecutorService heartbeatExecutor;
private MqttManager() {}
public static synchronized MqttManager getInstance() {
if (instance == null) {
instance = new MqttManager();
}
return instance;
}
public void setCommandListener(MqttCommandListener listener) {
this.commandListener = listener;
}
public void connect(String machineId, String apiToken) {
if (machineId == null || machineId.isEmpty() || apiToken == null || apiToken.isEmpty()) {
Log.e(TAG, "Cannot connect to MQTT: machineId or apiToken is empty");
return;
}
// 如果已經連線或正在連線中 ID 沒變就不重複執行
if (client != null && serialNo != null && serialNo.equals(machineId)) {
MqttClientState state = client.getState();
if (state != MqttClientState.DISCONNECTED) {
Log.i(TAG, "MQTT is already " + state + " for: " + machineId);
return;
}
}
this.serialNo = machineId;
String clientId = "SC_" + machineId;
String willTopic = "machine/" + machineId + "/status";
String willPayload = "{\"status\":\"offline\"}";
// 如果舊的 client 存在但狀態不對先關閉
if (client != null) {
try {
client.disconnect();
} catch (Exception e) {
// ignore
}
}
client = MqttClient.builder()
.useMqttVersion3()
.identifier(clientId)
.serverHost("mqtt.setdomains.work")
.serverPort(443)
.useSslWithDefaultConfig()
.webSocketConfig()
.serverPath("/mqtt")
.applyWebSocketConfig()
.simpleAuth()
.username(machineId)
.password(apiToken.getBytes(StandardCharsets.UTF_8))
.applySimpleAuth()
.automaticReconnectWithDefaultConfig()
.addConnectedListener(context -> {
Log.i(TAG, "MQTT Connection established/recovered");
publishStatus("online");
subscribeToCommandTopic();
startHeartbeatScheduler();
})
.buildAsync();
client.connectWith()
.cleanSession(true)
.keepAlive(30) // Broker 判斷斷線 = 30 × 1.5 = 45 加快 LWT 觸發
.willPublish()
.topic(willTopic)
.payload(willPayload.getBytes(StandardCharsets.UTF_8))
.qos(MqttQos.AT_LEAST_ONCE)
.retain(true)
.applyWillPublish()
.send()
.whenComplete((connAck, throwable) -> {
if (throwable != null) {
Log.e(TAG, "MQTT Initial connection failed: " + throwable.getMessage(), throwable);
} else {
Log.i(TAG, "MQTT Initial connection successful");
}
});
}
private void startHeartbeatScheduler() {
if (heartbeatExecutor != null && !heartbeatExecutor.isShutdown()) {
heartbeatExecutor.shutdownNow();
}
heartbeatExecutor = Executors.newSingleThreadScheduledExecutor();
heartbeatExecutor.scheduleAtFixedRate(() -> {
try {
String ver = "0";
try {
PackageInfo packageInfo = MyApp.getInstance().getPackageManager().getPackageInfo(MyApp.getInstance().getPackageName(), 0);
ver = packageInfo.versionName;
} catch (PackageManager.NameNotFoundException e) {
// ignore
}
int temperature = DevXinYuanController.getTemperature();
publishHeartbeat(ver, temperature);
} catch (Exception e) {
Log.e(TAG, "Error in heartbeat scheduler", e);
}
}, 0, 60, TimeUnit.SECONDS);
}
public void disconnect() {
if (heartbeatExecutor != null && !heartbeatExecutor.isShutdown()) {
heartbeatExecutor.shutdownNow();
}
if (client != null && client.getState().isConnected()) {
client.disconnect();
}
}
private void subscribeToCommandTopic() {
if (client == null || serialNo == null) return;
String commandTopic = "machine/" + serialNo + "/command";
client.subscribeWith()
.topicFilter(commandTopic)
.qos(MqttQos.AT_LEAST_ONCE)
.callback(publish -> {
String payloadString = new String(publish.getPayloadAsBytes(), StandardCharsets.UTF_8);
Log.i(TAG, "Received command payload: " + payloadString);
try {
CommandPayload command = gson.fromJson(payloadString, CommandPayload.class);
if (commandListener != null && command != null) {
commandListener.onCommandReceived(command);
}
} catch (Exception e) {
Log.e(TAG, "Error parsing command payload", e);
}
})
.send()
.whenComplete((subAck, throwable) -> {
if (throwable != null) {
Log.e(TAG, "Failed to subscribe to command topic", throwable);
} else {
Log.i(TAG, "Subscribed to " + commandTopic);
}
});
}
public void publishStatus(String status) {
if (client == null || !client.getState().isConnected()) return;
String payload = "{\"status\":\"" + status + "\"}";
String topic = "machine/" + serialNo + "/status";
client.publishWith()
.topic(topic)
.payload(payload.getBytes(StandardCharsets.UTF_8))
.qos(MqttQos.AT_LEAST_ONCE)
.retain(true)
.send()
.whenComplete((publishResult, throwable) -> {
if (throwable != null) {
Log.e(TAG, "Failed to publish status", throwable);
} else {
Log.d(TAG, "Status published: " + payload);
}
});
}
public void publishHeartbeat(String firmwareVersion, int temperature) {
if (client == null || !client.getState().isConnected()) return;
HeartbeatPayload payloadObj = new HeartbeatPayload(firmwareVersion, temperature);
String payload = gson.toJson(payloadObj);
String topic = "machine/" + serialNo + "/heartbeat";
client.publishWith()
.topic(topic)
.payload(payload.getBytes(StandardCharsets.UTF_8))
.qos(MqttQos.AT_MOST_ONCE)
.send()
.whenComplete((publishResult, throwable) -> {
if (throwable != null) {
Log.e(TAG, "Failed to publish heartbeat", throwable);
} else {
Log.d(TAG, "Heartbeat published: " + payload);
}
});
}
public void publishCommandAck(String commandId, String result, String message) {
publishCommandAck(new CommandAckPayload(commandId, result, message));
}
public void publishCommandAck(CommandAckPayload ack) {
if (client == null || !client.getState().isConnected()) return;
String payload = gson.toJson(ack);
String topic = "machine/" + serialNo + "/command/ack";
client.publishWith()
.topic(topic)
.payload(payload.getBytes(StandardCharsets.UTF_8))
.qos(MqttQos.AT_LEAST_ONCE)
.send()
.whenComplete((publishResult, throwable) -> {
if (throwable != null) {
Log.e(TAG, "Failed to publish command ack", throwable);
} else {
Log.d(TAG, "Command ACK published: " + payload);
}
});
}
public interface MqttCommandListener {
void onCommandReceived(CommandPayload command);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,10 +15,13 @@ import com.unibuy.smartdevice.AppCompatActivityAbstract;
import com.unibuy.smartdevice.MyApp;
import com.unibuy.smartdevice.databinding.ActivityVmcSlotListBinding;
import com.unibuy.smartdevice.devices.SlotField;
import com.unibuy.smartdevice.exception.ErrorCode;
import com.unibuy.smartdevice.exception.LogsParseException;
import com.unibuy.smartdevice.exception.LogsSettingEmptyException;
import com.unibuy.smartdevice.external.HttpAPI;
import com.unibuy.smartdevice.external.StarCloudAPI;
import com.unibuy.smartdevice.structure.ProductStructure;
import com.unibuy.smartdevice.structure.ReportSlotListStructure;
import com.unibuy.smartdevice.structure.SlotStructure;
import com.unibuy.smartdevice.database.SlotsDao;
import com.unibuy.smartdevice.tools.HandlerMain;
@ -30,6 +33,7 @@ public class VmcSlotListActivity extends AppCompatActivityAbstract {
private ActivityResultLauncher<Intent> resultLauncher;
private RecyclerVmcSlotListAdpter recyclerVmcSlotListAdpter;
private HttpAPI httpAPI;
private StarCloudAPI starCloudAPI;
@Override
protected Context setCtx() {
@ -43,7 +47,18 @@ public class VmcSlotListActivity extends AppCompatActivityAbstract {
@Override
protected HandlerMain setHandlerMain() {
return new ToastHandlerMain(getCtx(), getLogs());
return new ToastHandlerMain(getCtx(), getLogs()) {
@Override
protected void execute(Context context, int commandCode, String message) {
// 無論成功或失敗都隱藏進度條並恢復按鈕
if (binding != null && binding.progressLayout != null) {
binding.progressLayout.setVisibility(View.GONE);
binding.buttonBack.setEnabled(true);
binding.buttonSave.setEnabled(true);
}
super.execute(context, commandCode, message);
}
};
}
@Override
@ -53,6 +68,7 @@ public class VmcSlotListActivity extends AppCompatActivityAbstract {
binding = ActivityVmcSlotListBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
httpAPI = new HttpAPI(getHandlerMain());
starCloudAPI = new StarCloudAPI(getHandlerMain(), getHandlerMain());
resultLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
new ActivityResultCallback<ActivityResult>() {
@Override
@ -85,12 +101,29 @@ public class VmcSlotListActivity extends AppCompatActivityAbstract {
binding.buttonBack.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
saveData();
updateSlotListNum();
// 如果有進度條在跑不允許直接返回
if (binding.progressLayout.getVisibility() == View.VISIBLE) {
return;
}
finish();
}
});
binding.buttonSave.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// 1. 本地儲存
saveData();
// 2. 顯示進度條並開始雲端同步
binding.progressLayout.setVisibility(View.VISIBLE);
binding.buttonBack.setEnabled(false);
binding.buttonSave.setEnabled(false);
updateSlotListNum();
}
});
binding.buttonReload.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
@ -189,14 +222,17 @@ public class VmcSlotListActivity extends AppCompatActivityAbstract {
}
public void updateSlotListNum(){
ReportSlotListStructure data = MyApp.getInstance().getReportSlotListStructure();
data.setAccount(MyApp.getInstance().getSuAccess());
// 如果 Intent 沒傳 vmcType預設給 "U4"
String vmcType = getIntent().getStringExtra("vmctype");
if (vmcType == null || vmcType.isEmpty()) vmcType = "U4";
data.setVmctype(vmcType);
try {
MyApp.getInstance().getReportSlotListStructure().setAccount(MyApp.getInstance().getSuAccess());
MyApp.getInstance().getReportSlotListStructure().setVmctype("U4");
httpAPI.reportSlotList(MyApp.getInstance().getReportSlotListStructure());
} catch (LogsParseException e) {
throw new RuntimeException(e);
} catch (LogsSettingEmptyException e) {
throw new RuntimeException(e);
this.starCloudAPI.reportSlotList(data);
} catch (Exception e) {
getLogs().error(ErrorCode.NETWORK_UNKNOWN_ERROR, "同步啟動失敗: " + e.getMessage());
}
}
}

View File

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

View File

@ -69,7 +69,7 @@
android:layout_weight="1"
android:backgroundTint="@color/black"
android:textSize="28dp"
android:text="返回" />
android:text="@string/back" />
<Button
android:id="@+id/buttonReload"
@ -78,6 +78,6 @@
android:layout_weight="1"
android:backgroundTint="@color/black"
android:textSize="28dp"
android:text="重載圖片" />
android:text="@string/sync_product" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -20,6 +20,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:visibility="gone"
android:backgroundTint="@color/black"
android:text="智販機"
android:textSize="24dp" />
@ -60,20 +61,67 @@
<Button
android:id="@+id/buttonBack"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:backgroundTint="@color/black"
android:text="儲存並返回"
android:text="返回"
android:textSize="28dp"/>
<Button
android:id="@+id/buttonSave"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:backgroundTint="@color/black"
android:text="@string/upload_to_backend"
android:textSize="28dp" />
<Button
android:id="@+id/buttonReload"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:visibility="gone"
android:backgroundTint="@color/black"
android:text="清空" />
</LinearLayout>
<!-- 中央浮動進度區塊 (預設隱藏) -->
<FrameLayout
android:id="@+id/progressLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#80000000"
android:visibility="gone"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<LinearLayout
android:layout_width="400dp"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp"
android:gravity="center"
android:background="@drawable/progress_bg"
android:layout_gravity="center">
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/progressText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingTop="12dp"
android:textSize="20sp"
android:textColor="@color/black"
android:text="@string/uploading_data" />
</LinearLayout>
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -389,4 +389,7 @@
<string name="there_is_something_at_the_pickup_port">There is something at the pickup port, please contact the service staff to handle it!</string>
<string name="i_knew">knew</string>
<string name="input_vehicle">Input Vehicle</string>
<string name="reload_product">Reload Products</string>
<string name="reload_product_warning">This will download all products from the cloud and overwrite any unsaved local inventory changes. Are you sure you want to continue?</string>
<string name="downloading_product_data">Downloading product data...</string>
</resources>

View File

@ -156,6 +156,7 @@
<string name="get_sehicle">車両を入手する</string>
<string name="unflashed_vehicles">フラッシュされていない車両</string>
<string name="complete_purchase_within_40_seconds">40秒以内に購入を完了してください</string>
<string name="back">戻る</string>
<string name="cancel">キャンセル</string>
<!-- ui/dialog/CheckoutCashDialog.java -->
<string name="waiting_opening">開始を待っています</string>
@ -388,4 +389,9 @@
<string name="there_is_something_at_the_pickup_port">ピックアップポートに何かある場合は、サービススタッフに連絡して対応してください!</string>
<string name="i_knew">知っていた</string>
<string name="input_vehicle">スマホバーコード への入力</string>
<string name="sync_product">商品データを同期</string>
<string name="sync_product_warning">クラウドから商品データを同期しますか?これにより、すべての商品の名称、価格、画像が更新されます。</string>
<string name="downloading_product_data">商品データを同期中...</string>
<string name="syncing">同期中... </string>
<string name="sync_completed">商品データの同期が完了しました</string>
</resources>

View File

@ -85,6 +85,7 @@
<string name="get_sehicle">取得載具</string>
<string name="unflashed_vehicles">未刷載具</string>
<string name="complete_purchase_within_40_seconds">請在40秒內完成加購動作</string>
<string name="back">返回</string>
<string name="cancel">取消</string>
<!-- ui/dialog/CheckoutCashDialog.java -->
<string name="waiting_opening">等待開啟</string>
@ -300,4 +301,13 @@
<string name="input_vehicle">輸入載具</string>
<string name="member_card_pickup_title">領取確認</string>
<string name="please_tap_member_card">請將員山卡靠近讀卡機</string>
<string name="sync_product">下載商品資料</string>
<string name="sync_product_warning">確定要從雲端下載商品資料嗎?這將會更新所有商品的品名、價格與圖片,並覆蓋目前的資訊。</string>
<string name="downloading_product_data">正在下載商品資料...</string>
<string name="syncing">下載中... </string>
<string name="sync_completed">商品資料下載完成</string>
<string name="upload_to_backend">上傳後台</string>
<string name="uploading_data">資料上傳中...</string>
<string name="upload_success">資料上傳成功</string>
<string name="upload_failed">資料上傳失敗</string>
</resources>