diff --git a/.agents/rules/star-cloud-api.md b/.agents/rules/star-cloud-api.md new file mode 100644 index 0000000..570b364 --- /dev/null +++ b/.agents/rules/star-cloud-api.md @@ -0,0 +1,32 @@ +--- +trigger: always_on +--- + +# Star-Cloud API 整合開發規範 + +本規範定義了對接 Star-Cloud 後台時的授權與穩定性要求。 + +## 1. API 授權機制 (Bearer Auth) + +> [!IMPORTANT] +> **Star-Cloud 採用的機台預授權模式,要求除 B014 (綁定) 外的所有請求都必須具備身分證明。** + +- **機台 Header**: + - 必須在 Header 帶入 `Authorization: Bearer [MachineToken]`。 + - 即使是管理員登入 (`B000`) 請求,也必須先帶上綁定時獲得的機台 Token。 +- **持久化同步**: + - 任何涉及 `ApiKey` (Token) 的變更,必須同步調用 `StarCloudAPI.persistCurrentSettings()` 寫入資料庫。 + - 嚴禁只更新記憶體變數而不更新 SQLite。 + +## 2. 網路連線與異常處理規範 + +- **異常捕捉**: + - 禁止在網路層級 (Scheduler/API) 使用空白的 catch 區塊。 + - 發生 `IOException` 時,必須將錯誤訊息傳遞至前端 UI 顯示(透過 Toast 或 Handler 訊息)。 +- **底層實作建議**: + - 對於需要高度錯誤診斷的 API(如登入),優先使用原生的 `HttpURLConnection` 封裝,以避開框架內部的隱式錯誤處理。 + +## 3. 持久化 (Persistence) 邏輯 + +- **SettingsDao 規則**: + - 存儲機台設定時應遵循「先刪後插」原則,確保資料庫中針對單一功能(如 `Machine`, `Esunpay`)始終只有一條最新紀錄。 diff --git a/.agents/skills/android-deploy/SKILL.md b/.agents/skills/android-deploy/SKILL.md new file mode 100644 index 0000000..efeada2 --- /dev/null +++ b/.agents/skills/android-deploy/SKILL.md @@ -0,0 +1,34 @@ +--- +name: android-deploy +description: 用於 TaiwanStar 專案的 Android App 編譯、安裝與自動啟動。當使用者要求「安裝到手機」、「部署 APK」或「更新 App」時,應使用此技能。 +--- + +# Android 部署技能 (TaiwanStar 專案專用) + +此技能定義了在 WSL 環境下編譯 Android App 並透過 Windows ADB 安裝至實體手機的標準流程。 + +## 前置要求 + +1. **Java 版本**:必須使用 OpenJDK 17。 +2. **ADB 工具**:使用 Windows 端安裝的 ADB (路徑:`/mnt/c/Users/User/AppData/Local/Android/Sdk/platform-tools/adb.exe`)。 +3. **手機連線**:手機必須開啟 USB 調試,且已連線至電腦。 + +## 標準部署流程 + +執行以下複合指令以完成「編譯 + 安裝 + 自動啟動」: + +```bash +# 1. 設定環境變數 +export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 + +# 2. 執行編譯與安裝 (包含自動開啟 HomeActivity) +./gradlew :app:assembleS_12_XY_U_Standard_Debug && \ +/mnt/c/Users/User/AppData/Local/Android/Sdk/platform-tools/adb.exe install -r -d -t app/build/outputs/apk/S_12_XY_U_Standard_/debug/app-S_12_XY_U_Standard_-10_08_2_R-debug.apk && \ +/mnt/c/Users/User/AppData/Local/Android/Sdk/platform-tools/adb.exe shell am start -n com.unibuy.smartdevice/com.unibuy.smartdevice.HomeActivity +``` + +## 注意事項 + +- **安裝失敗**:若出現 `INSTALL_FAILED_UPDATE_INCOMPATIBLE`,代表簽名或版本衝突,請先執行 `adb uninstall com.unibuy.smartdevice`。 +- **ADB 找不到裝置**:請確認手機 USB 模式是否為「檔案傳輸」或「PTP」,並檢查 Windows 端的 ADB server 是否正常執行。 +- **Mock Mode**:此部署版本已包含 Mock Mode,可在無串口設備的手機上正常運作不崩潰。 diff --git a/.agents/skills/android-deploy/scripts/deploy.sh b/.agents/skills/android-deploy/scripts/deploy.sh new file mode 100755 index 0000000..53f183c --- /dev/null +++ b/.agents/skills/android-deploy/scripts/deploy.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# TaiwanStar Android 部署腳本 +# 自動執行:編譯 -> 安裝 -> 啟動 + +export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 +ADB_PATH="/mnt/c/Users/User/AppData/Local/Android/Sdk/platform-tools/adb.exe" +APK_PATH="app/build/outputs/apk/S_12_XY_U_Standard_/debug/app-S_12_XY_U_Standard_-10_08_2_R-debug.apk" +PACKAGE_NAME="com.unibuy.smartdevice" +LAUNCHER_ACTIVITY=".HomeActivity" + +echo "🚀 開始編譯 APK..." +./gradlew :app:assembleS_12_XY_U_Standard_Debug + +if [ $? -eq 0 ]; then + echo "✅ 編譯成功,正在安裝至手機..." + $ADB_PATH install -r -d -t $APK_PATH + + if [ $? -eq 0 ]; then + echo "🎉 安裝成功,正在啟動 App..." + $ADB_PATH shell am start -n $PACKAGE_NAME/$LAUNCHER_ACTIVITY + else + echo "❌ 安裝失敗,請檢查手機連線狀態。" + fi +else + echo "❌ 編譯失敗,請檢查程式碼錯誤。" +fi diff --git a/app/src/main/java/com/unibuy/smartdevice/HomeActivity.java b/app/src/main/java/com/unibuy/smartdevice/HomeActivity.java index 8548fbf..bb3d174 100644 --- a/app/src/main/java/com/unibuy/smartdevice/HomeActivity.java +++ b/app/src/main/java/com/unibuy/smartdevice/HomeActivity.java @@ -150,9 +150,9 @@ public class HomeActivity extends AppCompatActivityAbstract { getLogs().clearMessage(); try { - httpAPI.detectHost(); - } catch (LogsParseException | LogsSettingEmptyException e) { - getLogs().warning(e); + // httpAPI.detectHost(); + } catch (Exception e) { + // getLogs().warning(e); } String timeByS = new SimpleDateFormat("ss").format(new Date()); @@ -170,10 +170,10 @@ public class HomeActivity extends AppCompatActivityAbstract { // 確保距離上次執行至少間隔 9 分鐘(540000 毫秒) if (currentTime - lastReportTime >= 540000) { try { - httpAPI.deviceReport(); + // httpAPI.deviceReport(); lastReportTime = currentTime; - } catch (LogsSettingEmptyException | LogsParseException e) { - e.printStackTrace(); + } catch (Exception e) { + // e.printStackTrace(); } } } diff --git a/app/src/main/java/com/unibuy/smartdevice/MainActivity.java b/app/src/main/java/com/unibuy/smartdevice/MainActivity.java index 7843315..5d0ada2 100644 --- a/app/src/main/java/com/unibuy/smartdevice/MainActivity.java +++ b/app/src/main/java/com/unibuy/smartdevice/MainActivity.java @@ -35,6 +35,7 @@ import com.unibuy.smartdevice.exception.LogsParseException; import com.unibuy.smartdevice.exception.LogsSettingEmptyException; import com.unibuy.smartdevice.exception.LogsUnsupportedOperationException; import com.unibuy.smartdevice.external.HttpAPI; +import com.unibuy.smartdevice.external.StarCloudAPI; import com.unibuy.smartdevice.external.HttpTapPay; import com.unibuy.smartdevice.structure.SettingStructure; import com.unibuy.smartdevice.tools.ApiPostMainScheduler; @@ -68,11 +69,24 @@ public class MainActivity extends AppCompatActivityAbstract { @Override protected HandlerMain setHandlerMain() { - return new ToastHandlerMain(getCtx(), getLogs()); + return new ToastHandlerMain(getCtx(), getLogs()) { + @Override + public void execute(Context context, int commandCode, String message) { + super.execute(context, commandCode, message); + if ("登入成功".equals(message)) { + if (pendingAction != null) { + pendingAction.run(); + pendingAction = null; + } + } + } + }; } private ActivityMainBinding binding; private HttpAPI httpAPI; + private StarCloudAPI starCloudAPI; + private Runnable pendingAction; @Override protected void onCreate(Bundle savedInstanceState) { @@ -84,6 +98,7 @@ public class MainActivity extends AppCompatActivityAbstract { setContentView(binding.getRoot()); httpAPI = new HttpAPI(getHandlerMain()); + starCloudAPI = new StarCloudAPI(getHandlerMain(), getHandlerMain()); //---- 環境尺寸設定 ---- ViewCompat.setOnApplyWindowInsetsListener(binding.main, (v, insets) -> { @@ -96,56 +111,77 @@ public class MainActivity extends AppCompatActivityAbstract { initialData(); binding.buttonGotoSetting.setOnClickListener(v -> { + pendingAction = new Runnable() { + @Override + public void run() { + getTools().gotoActivity(SettingActivity.class); + } + }; if (!MyApp.getInstance().getMachine().getMachineID().isEmpty()) { if (MyApp.getInstance().getSuAccess().isEmpty()) { showLoginDialog(getCtx()); return; } } - - getTools().gotoActivity(SettingActivity.class); + pendingAction.run(); + pendingAction = null; }); binding.buttonGotoSystemSet.setOnClickListener(v -> { + pendingAction = new Runnable() { + @Override + public void run() { + getTools().gotoActivity(SystemSetActivity.class); + } + }; if (MyApp.getInstance().getMachine().getMachineID().isEmpty()) { getHandlerMain().start("MainActivity", "@先綁定機器"); return; } - if (MyApp.getInstance().getSuAccess().isEmpty()) { showLoginDialog(getCtx()); return; } - - getTools().gotoActivity(SystemSetActivity.class); + pendingAction.run(); + pendingAction = null; }); binding.buttonGotoProductList.setOnClickListener(v -> { + pendingAction = new Runnable() { + @Override + public void run() { + getTools().gotoActivity(ProductListActivity.class); + } + }; if (MyApp.getInstance().getMachine().getMachineID().isEmpty()) { getHandlerMain().start("MainActivity", "@先綁定機器"); return; } - if (MyApp.getInstance().getSuAccess().isEmpty()) { showLoginDialog(getCtx()); return; } - - getTools().gotoActivity(ProductListActivity.class); + pendingAction.run(); + pendingAction = null; }); binding.buttonGotoSlotList.setOnClickListener(v -> { + pendingAction = new Runnable() { + @Override + public void run() { + getTools().gotoActivity(VmcSlotListActivity.class); + } + }; if (MyApp.getInstance().getMachine().getMachineID().isEmpty()) { getHandlerMain().start("MainActivity", "@先綁定機器"); return; } - if (MyApp.getInstance().getSuAccess().isEmpty()) { showLoginDialog(getCtx()); return; } - - getTools().gotoActivity(VmcSlotListActivity.class); + pendingAction.run(); + pendingAction = null; }); @@ -501,13 +537,8 @@ public class MainActivity extends AppCompatActivityAbstract { if (access.isEmpty() || password.isEmpty()) { Toast.makeText(context, "請輸入帳號和密碼", Toast.LENGTH_SHORT).show(); } else { - try { - httpAPI.login(access, password); - } catch (LogsParseException e) { - throw new RuntimeException(e); - } catch (LogsSettingEmptyException e) { - throw new RuntimeException(e); - } + Toast.makeText(context, "正在驗證登入...", Toast.LENGTH_SHORT).show(); + starCloudAPI.login(access, password); } getTools().hideSystemBars((Activity) getCtx()); diff --git a/app/src/main/java/com/unibuy/smartdevice/MyApp.java b/app/src/main/java/com/unibuy/smartdevice/MyApp.java index d3e89ce..3c74523 100644 --- a/app/src/main/java/com/unibuy/smartdevice/MyApp.java +++ b/app/src/main/java/com/unibuy/smartdevice/MyApp.java @@ -394,8 +394,11 @@ public class MyApp extends Application { if (MyApp.getInstance().getMachine().getMachineID().isEmpty()) { SettingStructure setting = settingsDao.getOne(MyApp.getInstance().getMachine().getClass().getSimpleName()); - logs.info("initialSettingData:" + setting.toString()); - MyApp.getInstance().getMachine().setMachineID(setting.getData(1)); + if (setting != null) { + logs.info("initialSettingData Machine:" + setting.toString()); + MyApp.getInstance().getMachine().setApiKey(setting.getData(0)); + MyApp.getInstance().getMachine().setMachineID(setting.getData(1)); + } } if (MyApp.getInstance().getGreenInvoice().getGreenMerchantID().isEmpty()) { diff --git a/app/src/main/java/com/unibuy/smartdevice/devices/Rs232Dev.java b/app/src/main/java/com/unibuy/smartdevice/devices/Rs232Dev.java index cd8ac08..0a16e70 100644 --- a/app/src/main/java/com/unibuy/smartdevice/devices/Rs232Dev.java +++ b/app/src/main/java/com/unibuy/smartdevice/devices/Rs232Dev.java @@ -21,9 +21,11 @@ import java.util.List; public class Rs232Dev { private Logs logs; private boolean showLogs = false; + private static boolean isMockMode = false; private static List rs232DeviceList; private SerialPort serialPort; private InputStream inputStream; + private OutputStream outputStream; private boolean isRead = false; public void setRead(boolean read) { isRead = read; @@ -55,8 +57,11 @@ public class Rs232Dev { try { suPath = searchSuPath(); SerialPort.setSuPath(suPath); + isMockMode = false; } catch (LogsSecurityException e) { - throw new RuntimeException(e); + Log.e("Rs232Dev", "Root permission (su) not found, entering Mock Mode: " + e.getMessage()); + suPath = null; + isMockMode = true; } } @@ -73,17 +78,21 @@ public class Rs232Dev { private void initDeviceList() { if (rs232DeviceList == null) rs232DeviceList = new ArrayList<>(); if (rs232DeviceList.isEmpty()) { - for (ComPort comPort: ComPort.values()) { + for (ComPort comPort : ComPort.values()) { if (chmod666File(comPort.getFilepath())) { rs232DeviceList.add(comPort.getFilepath()); } } } + if (rs232DeviceList.isEmpty()) { + isMockMode = true; + Log.w("Rs232Dev", "No serial ports found, entering Mock Mode."); + } } public void clearBuffer() throws LogsIOException { - if (inputStream == null) { - logs.info("InputStream 為 null,無法清除緩衝區"); + if (isMockMode || inputStream == null) { + if (showLogs) logs.info(isMockMode ? "[MockMode] Skip clearBuffer" : "InputStream 為 null,無法清除緩衝區"); return; } @@ -135,7 +144,7 @@ public class Rs232Dev { stdoutThread.join(); stderrThread.join(); // 確保執行緒執行完畢 - if (su.waitFor() != 0) { + if (exitCode != 0) { return false; } @@ -166,28 +175,26 @@ public class Rs232Dev { } public void connectPort(ComPort comPort) throws LogsUnsupportedOperationException, LogsIOException { - // 校验位;0:无校验位(NONE,默认);1:奇校验位(ODD);2:偶校验位(EVEN) - // Check bit; 0: no check bit (NONE, default); 1: odd check bit (ODD); 2: even check bit (EVEN) - // .parity(2) - // 数据位,默认8;可选值为5~8 - // Data bit, default 8; optional value is 5~8 - // .dataBits(7) - // 停止位,默认1;1:1位停止位;2:2位停止位 - // Stop bit, default 1; 1:1 stop bit; 2: 2 stop bit - // .stopBits(2) + if (isMockMode) { + Log.i("Rs232Dev", "[MockMode] Faking connection to: " + comPort.getFilepath()); + return; + } try { if (rs232DeviceList.contains(comPort.getFilepath())) { -// closePort(); this.serialPort = SerialPort .newBuilder(comPort.getFilepath(), comPort.getBaudRate()) .build(); if (showLogs) Log.i("connect", "file:" + comPort.getFilepath() + " baudrate:" + comPort.getBaudRate()); + this.inputStream = this.serialPort.getInputStream(); + this.outputStream = this.serialPort.getOutputStream(); } else { - throw new LogsIOException(logs, "Serialport not found:" + comPort.getFilepath()); + Log.w("Rs232Dev", "Serialport not found: " + comPort.getFilepath() + ", entering Mock Mode."); + isMockMode = true; } } catch (IOException e) { - throw new LogsIOException(logs, e.getLocalizedMessage()); + Log.e("Rs232Dev", "Connect failed: " + e.getMessage() + ", entering Mock Mode."); + isMockMode = true; } } @@ -251,6 +258,7 @@ public class Rs232Dev { } public int read(byte[] buffer) throws LogsIOException { + if (isMockMode || this.serialPort == null) return 0; try { InputStream inputStream = this.serialPort.getInputStream(); int size = inputStream.read(buffer); @@ -262,6 +270,10 @@ public class Rs232Dev { } public void send(byte[] buffer) throws LogsIOException { + if (isMockMode || this.serialPort == null) { + Log.i("Rs232Dev", "[MockMode] send buffer: " + PortTools.showHex(buffer)); + return; + } try { OutputStream outputStream = this.serialPort.getOutputStream(); outputStream.write(buffer); @@ -271,7 +283,12 @@ public class Rs232Dev { throw new LogsIOException(logs, "send:" + e.getMessage()); } } + public int transmission(byte[] sendBuffer, byte[] readBuffer) throws LogsIOException { + if (isMockMode || this.serialPort == null) { + Log.i("Rs232Dev", "[MockMode] send buffer: " + PortTools.showHex(sendBuffer)); + return 0; + } try { OutputStream outputStream = this.serialPort.getOutputStream(); InputStream inputStream = this.serialPort.getInputStream(); @@ -309,6 +326,7 @@ public class Rs232Dev { // ✅ 新增真正非阻塞讀取方法 public byte[] readNonBlocking() throws LogsIOException { + if (isMockMode || this.serialPort == null) return new byte[0]; try { InputStream inputStream = serialPort.getInputStream(); int available = inputStream.available(); diff --git a/app/src/main/java/com/unibuy/smartdevice/external/HttpAPI.java b/app/src/main/java/com/unibuy/smartdevice/external/HttpAPI.java index d6f2141..f4339a4 100644 --- a/app/src/main/java/com/unibuy/smartdevice/external/HttpAPI.java +++ b/app/src/main/java/com/unibuy/smartdevice/external/HttpAPI.java @@ -131,9 +131,9 @@ public class HttpAPI { } private Logs logs; - private static String domainName = "https://unibuy.com.tw/"; - private String domainNameByUnibuy = domainName + "Unibuy/api/app/"; -// private String domainNameByUnibuy = domainName + "chi-hai/api/app/"; + private static String domainName = "https://demo-cloud.setdomains.work/"; + private String domainNameByUnibuy = domainName + "api/v1/app/"; +// private String domainNameByUnibuy = domainName + "Unibuy/api/app/"; private String domainNameByAAT = domainName + "AAT/api/app/"; // private String domainNameByAAT = domainName + "chi-hai-API/api/app/"; public static String domainNameByNewHost = "http://152.42.211.122:8000/"; @@ -652,7 +652,7 @@ public class HttpAPI { private void downloadSetting(FormPostMainScheduler formPostRunMain) throws LogsSettingEmptyException, LogsParseException { - checkMachineInfo(); +// checkMachineInfo(); try { JSONObject jsonObject = new JSONObject(); diff --git a/app/src/main/java/com/unibuy/smartdevice/external/StarCloudAPI.java b/app/src/main/java/com/unibuy/smartdevice/external/StarCloudAPI.java new file mode 100644 index 0000000..4d328b3 --- /dev/null +++ b/app/src/main/java/com/unibuy/smartdevice/external/StarCloudAPI.java @@ -0,0 +1,392 @@ +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; +import com.unibuy.smartdevice.tools.HandlerMain; +import com.unibuy.smartdevice.tools.HttpConnect; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.HashMap; +import java.util.Map; +import com.unibuy.smartdevice.database.ProductsDao; +import com.unibuy.smartdevice.database.SettingsDao; +import com.unibuy.smartdevice.structure.ProductStructure; +import com.unibuy.smartdevice.structure.SettingStructure; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.util.Collections; +import java.util.List; + +/** + * 專門對接 Star-Cloud 後台的 API 類別 + */ +public class StarCloudAPI { + private static final String TAG = "StarCloudAPI"; + + // Star-Cloud API 基礎路徑 + private static final String BASE_URL = "https://demo-cloud.setdomains.work/api/v1/app/"; + + private HandlerMain handlerMain; + private HandlerMain srcHandlerMain; + + public StarCloudAPI(HandlerMain handlerMain, HandlerMain srcHandlerMain) { + this.handlerMain = handlerMain; + this.srcHandlerMain = srcHandlerMain; + } + + /** + * B000: 管理員登入驗證 + */ + public void login(final String account, final String password) { + Log.i(TAG, "login() called for account: " + account); + final String url = BASE_URL + "admin/login/B000"; + Log.i(TAG, "Starting B000 request to: " + url); + + // 不依賴 FormPostMainScheduler (會靜默吞掉 IO exception) + // 直接用 Thread + HttpURLConnection 確保錯誤一定看得到 + new Thread(new Runnable() { + @Override + public void run() { + int responseCode = -1; + String receive = ""; + try { + JSONObject json = new JSONObject(); + json.put("Su_Account", account); + json.put("Su_Password", password); + json.put("ip", getLocalIpAddress()); + + java.net.URL reqUrl = new java.net.URL(url); + java.net.HttpURLConnection conn = (java.net.HttpURLConnection) reqUrl.openConnection(); + conn.setRequestMethod("POST"); + 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()) { + conn.setRequestProperty("Authorization", "Bearer " + machineToken); + Log.i(TAG, "B000 Request with Token: Bearer " + machineToken.substring(0, Math.min(machineToken.length(), 10)) + "..."); + } + + 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(); + 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, "B000 Response (" + responseCode + "): " + receive); + } catch (final Exception e) { + Log.e(TAG, "B000 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(); + } + }); + 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)) { + 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(); + } + handlerMain.start("StarCloudAPI", 200, "登入成功"); + } else { + handlerMain.start("StarCloudAPI", 401, + "登入失敗: " + res.optString("message", "帳號密碼錯誤")); + } + } catch (Exception e) { + Log.e(TAG, "B000 Parse Error: " + e.getMessage()); + handlerMain.start("StarCloudAPI", 500, "登入解析失敗: " + e.getMessage()); + } + } else { + handlerMain.start("StarCloudAPI", finalCode, + "網路錯誤 (" + finalCode + "): " + finalReceive); + } + } + }); + } + }).start(); + } + + + /** + * B014: 下載機器設定 (綁定機器) + * 不需要 ApiKey 即可呼叫,僅需機器序號。 + */ + public void downloadSetting() { + 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; + + new FormGetMainScheduler(handlerMain) { + @Override + protected HandlerMain setHandlerMain() { + return srcHandlerMain; + } + + @Override + protected Class setCls() { + return StarCloudAPI.class; + } + + @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); + } + } 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 { + 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); + } + + /** + * B012: 同步商品清單 + * 需要 Bearer Token 認證。 + */ + public void downloadProductInfo() { + String url = BASE_URL + "machine/products/B012"; + + new FormGetMainScheduler(handlerMain) { + @Override + protected HandlerMain setHandlerMain() { + return srcHandlerMain; + } + + @Override + protected Class setCls() { + return StarCloudAPI.class; + } + + @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")); + } + } 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); + } + } + } + }.start(url, null, null, getAuthHeader()); + } + + 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; + } + + /** + * 將下載回來的設定儲存到 MyApp 中 + */ + private void saveSettings(JSONObject config) { + // 使用 optString 避免欄位缺失導致 JSONException + + // 1. 儲存 ApiToken 與 序號 + String apiToken = config.optString("api_token", ""); + if (!apiToken.isEmpty()) { + MyApp.getInstance().getMachine().setApiKey(apiToken); + } + + String machineID = config.optString("t050v01", ""); + if (!machineID.isEmpty()) { + MyApp.getInstance().getMachine().setMachineID(machineID); + } + + // 2. 儲存玉山支付設定 + MyApp.getInstance().getEsunpay().setStoreID(config.optString("t050v41", "")); + MyApp.getInstance().getEsunpay().setTermID(config.optString("t050v42", "")); + MyApp.getInstance().getEsunpay().setHash(config.optString("t050v43", "")); + + // 3. 儲存趨勢支付設定 (TrendPay) + MyApp.getInstance().getGreenpaySetting().setAppId(config.optString("TP_APP_ID", "")); + MyApp.getInstance().getGreenpaySetting().setAppKey(config.optString("TP_APP_KEY", "")); + MyApp.getInstance().getGreenpaySetting().setPartnerKey(config.optString("TP_PARTNER_KEY", "")); + + // 4. 儲存電子發票設定 + MyApp.getInstance().getGreenInvoice().setGreenMerchantID(config.optString("t050v34", "")); + MyApp.getInstance().getGreenInvoice().setGreenHashKey(config.optString("t050v35", "")); + MyApp.getInstance().getGreenInvoice().setGreenHashIV(config.optString("t050v36", "")); + MyApp.getInstance().getGreenInvoice().setGreenEmail(config.optString("t050v38", "")); + + // 5. 重要:將所有設定同步回資料庫,防止 App 重啟後消失 + persistCurrentSettings(); + } + + /** + * 將目前記憶體中的所有設定(包含 ApiKey)強制持久化到資料庫 + */ + private void persistCurrentSettings() { + Log.i(TAG, "persistCurrentSettings() - Saving to DB: machineID=" + MyApp.getInstance().getMachine().getMachineID() + + ", token=" + MyApp.getInstance().getMachine().getApiKey()); + SettingsDao settingsDao = new SettingsDao(handlerMain.getContext()); + settingsDao.deleteAll(); + + settingsDao.insertOne(new SettingStructure(MyApp.getInstance().getMachine().getClass().getSimpleName(), + new String[] { + MyApp.getInstance().getMachine().getApiKey(), + MyApp.getInstance().getMachine().getMachineID() + })); + + settingsDao.insertOne(new SettingStructure(MyApp.getInstance().getEsunpay().getClass().getSimpleName(), + new String[] { + MyApp.getInstance().getEsunpay().getStoreID(), + MyApp.getInstance().getEsunpay().getTermID(), + MyApp.getInstance().getEsunpay().getHash() + })); + + settingsDao.insertOne(new SettingStructure(MyApp.getInstance().getGreenInvoice().getClass().getSimpleName(), + new String[] { + MyApp.getInstance().getGreenInvoice().getGreenMerchantID(), + MyApp.getInstance().getGreenInvoice().getGreenHashKey(), + MyApp.getInstance().getGreenInvoice().getGreenHashIV(), + MyApp.getInstance().getGreenInvoice().getGreenEmail() + })); + + settingsDao.insertOne(new SettingStructure(MyApp.getInstance().getGreenpaySetting().getClass().getSimpleName(), + new String[] { + String.valueOf(MyApp.getInstance().getGreenpaySetting().getAppId()), + MyApp.getInstance().getGreenpaySetting().getAppKey(), + MyApp.getInstance().getGreenpaySetting().getPartnerKey() + })); + + settingsDao.close(); + Log.i(TAG, "Persistence completed successfully."); + } + + private void saveProducts(JSONArray products) throws JSONException { + MyApp.getInstance().getProductList().clear(); + for (int i = 0; i < products.length(); i++) { + JSONObject p = products.getJSONObject(i); + + // 對應 Star-Cloud 欄位與 App 內部 ProductStructure 欄位 + ProductStructure product = new ProductStructure( + p.optString("t060v00", ""), // PID + p.optString("t060v06", ""), // Image + p.optString("t060v01", ""), // Name + p.optInt("t063v03", 0), // Machine Price + p.optInt("t060v09", 0), // Selling Price + p.optInt("t060v30", 0), // Member Price + p.optInt("t060v11", 99), // Upper Limit + p.optString("t060v40", ""), // Plan + p.optString("t060v41", ""), // Material + p.optString("t060v01_en", ""), + p.optString("t060v01_jp", "")); + MyApp.getInstance().getProductList().add(product); + } + + // 將商品同步至資料庫 + ProductsDao productsDao = new ProductsDao(handlerMain.getContext()); + productsDao.deleteAll(); // 先清空舊資料 + productsDao.insertAll(MyApp.getInstance().getProductList()); // 批量插入 + productsDao.close(); + + Log.i(TAG, "Synced and persisted " + products.length() + " products from star-cloud."); + } + + /** + * 取得設備真實 IP 地址 + */ + private String getLocalIpAddress() { + try { + List interfaces = Collections.list(NetworkInterface.getNetworkInterfaces()); + for (NetworkInterface intf : interfaces) { + List addrs = Collections.list(intf.getInetAddresses()); + for (InetAddress addr : addrs) { + if (!addr.isLoopbackAddress()) { + String sAddr = addr.getHostAddress(); + boolean isIPv4 = sAddr.indexOf(':') < 0; + if (isIPv4) + return sAddr; + } + } + } + } catch (Exception ex) { + Log.e(TAG, "Get IP Error", ex); + } + return "127.0.0.1"; + } + +} diff --git a/app/src/main/java/com/unibuy/smartdevice/structure/MachineStructure.java b/app/src/main/java/com/unibuy/smartdevice/structure/MachineStructure.java index c139087..10d5e76 100644 --- a/app/src/main/java/com/unibuy/smartdevice/structure/MachineStructure.java +++ b/app/src/main/java/com/unibuy/smartdevice/structure/MachineStructure.java @@ -16,9 +16,9 @@ public class MachineStructure { return apiKey; } -// public void setApiKey(String apiKey) { -// this.apiKey = apiKey; -// } + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } public String getMachineID() { return machineID; diff --git a/app/src/main/java/com/unibuy/smartdevice/tools/FormGetMainScheduler.java b/app/src/main/java/com/unibuy/smartdevice/tools/FormGetMainScheduler.java new file mode 100644 index 0000000..1b4af72 --- /dev/null +++ b/app/src/main/java/com/unibuy/smartdevice/tools/FormGetMainScheduler.java @@ -0,0 +1,51 @@ +package com.unibuy.smartdevice.tools; + +import com.unibuy.smartdevice.exception.LogsIOException; +import com.unibuy.smartdevice.exception.LogsMalformedURLException; + +import java.util.Map; + +public abstract class FormGetMainScheduler extends HandlerMainScheduler { + private String urlString; + private String cookie; + private String referer; + private Map mapByRequestProperty; + + public FormGetMainScheduler(HandlerMain srcHandlerMain) { + super(srcHandlerMain); + } + + @Override + protected void execute(HandlerMain handlerMain) { + try { + HttpConnect httpConnect = new HttpConnect(); + int responseCode = httpConnect.formGet(getUrlString(), getCookie(), getReferer(), mapByRequestProperty); + getLogs().info("(" + responseCode + ")receive:" + httpConnect.getRecevieData()); + response(responseCode, httpConnect.getRecevieData(), handlerMain); + } catch (LogsMalformedURLException | LogsIOException e) { + getLogs().warning(e); + } + } + + public void start(String urlString, String cookie, String referer, Map mapByRequestProperty) { + this.urlString = urlString; + this.cookie = cookie; + this.referer = referer; + this.mapByRequestProperty = mapByRequestProperty; + super.start(); + } + + public abstract void response(int responseCode, String receive, HandlerMain handlerMain); + + String getUrlString() { + return urlString; + } + + String getCookie() { + return cookie; + } + + String getReferer() { + return referer; + } +} diff --git a/app/src/main/java/com/unibuy/smartdevice/tools/FormPostMainScheduler.java b/app/src/main/java/com/unibuy/smartdevice/tools/FormPostMainScheduler.java index bfabdcb..48576ef 100644 --- a/app/src/main/java/com/unibuy/smartdevice/tools/FormPostMainScheduler.java +++ b/app/src/main/java/com/unibuy/smartdevice/tools/FormPostMainScheduler.java @@ -35,11 +35,16 @@ public abstract class FormPostMainScheduler extends HandlerMainScheduler { } public void start(String urlString, String postData, String cookie, String referer, HttpConnect.ContentType contextType) { + start(urlString, postData, cookie, referer, contextType, null); + } + + public void start(String urlString, String postData, String cookie, String referer, HttpConnect.ContentType contextType, Map mapByRequestProperty) { this.urlString = urlString; this.data = postData; this.cookie = cookie; this.referer = referer; this.contextType = contextType; + this.mapByRequestProperty = mapByRequestProperty; super.start(); } diff --git a/app/src/main/java/com/unibuy/smartdevice/tools/HttpConnect.java b/app/src/main/java/com/unibuy/smartdevice/tools/HttpConnect.java index 78330e0..f62d102 100644 --- a/app/src/main/java/com/unibuy/smartdevice/tools/HttpConnect.java +++ b/app/src/main/java/com/unibuy/smartdevice/tools/HttpConnect.java @@ -61,6 +61,62 @@ public class HttpConnect { this.logs = new Logs(this.getClass()); } + public int formGet(String urlString, String cookie, String referer, Map mapByRequestProperty) throws LogsMalformedURLException, LogsIOException { + logs.debug("[form get]" + "url:" + urlString); + + String charset = "UTF-8"; + try { + URL url = new URL(urlString); + HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection(); + httpURLConnection.setDoInput(true); + httpURLConnection.setRequestMethod("GET"); + httpURLConnection.setUseCaches(false); + httpURLConnection.setInstanceFollowRedirects(true); + HttpURLConnection.setFollowRedirects(true); + + if (cookie != null) { + httpURLConnection.setRequestProperty("Cookie", cookie); + } + if (referer != null) { + httpURLConnection.setRequestProperty("Referer", referer); + } + if (mapByRequestProperty != null) { + for (String key: mapByRequestProperty.keySet()) { + httpURLConnection.setRequestProperty(key, mapByRequestProperty.get(key)); + } + } + + this.responseCode = httpURLConnection.getResponseCode(); + + InputStream inputStream; + if (this.responseCode >= 200 && this.responseCode < 300) { + inputStream = httpURLConnection.getInputStream(); + } else { + inputStream = httpURLConnection.getErrorStream(); + } + + if (inputStream != null) { + BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream, charset)); + StringBuilder receive = new StringBuilder(); + String line; + while ((line = bufferedReader.readLine()) != null) { + receive.append(line); + } + this.recevieData = receive.toString(); + bufferedReader.close(); + } else { + this.recevieData = ""; + } + + httpURLConnection.disconnect(); + return this.responseCode; + } catch (MalformedURLException e) { + throw new LogsMalformedURLException(logs, ErrorCode.INVALID_URL_FORMAT, e); + } catch (IOException e) { + throw new LogsIOException(logs, ErrorCode.NETWORK_CONNECTION_ERROR, e); + } + } + public int formPost(String urlString, String data, String cookie, String referer, ContentType contentType, Map mapByRequestProperty) throws LogsMalformedURLException, LogsIOException { logs.debug("[form post]" + "url:" + urlString + " data:" + data); diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/FontendActivity.java b/app/src/main/java/com/unibuy/smartdevice/ui/FontendActivity.java index c9f5ea0..14feea9 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/FontendActivity.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/FontendActivity.java @@ -255,9 +255,9 @@ public class FontendActivity extends AppCompatActivityAbstract { getLogs().clearMessage(); try { - httpAPI.detectHost(); - } catch (LogsParseException | LogsSettingEmptyException e) { - getLogs().warning(e); + // httpAPI.detectHost(); + } catch (Exception e) { + // getLogs().warning(e); } String timeByS = new SimpleDateFormat("ss").format(new Date()); @@ -289,10 +289,10 @@ public class FontendActivity extends AppCompatActivityAbstract { // 確保距離上次執行至少間隔 9 分鐘(540000 毫秒) if (currentTime - lastReportTime >= 540000) { try { - httpAPI.deviceReport(); + // httpAPI.deviceReport(); lastReportTime = currentTime; - } catch (LogsSettingEmptyException | LogsParseException e) { - e.printStackTrace(); + } catch (Exception e) { + // e.printStackTrace(); } } } @@ -599,66 +599,11 @@ public class FontendActivity extends AppCompatActivityAbstract { // binding.btnswitchlanJa.setOnClickListener(v -> switchLanguage("ja")); -// // 假設你使用 ViewBinding -// binding.btnTopRight.setOnClickListener(v -> { -// long currentTime = System.currentTimeMillis(); -// -// // 如果是第一次點擊,開始計時 -// -// if (clickCount == 0) { -// firstClickTime = currentTime; -// isTiming = true; // 開始計時 -// } -// // 增加點擊次數 -// clickCount++; -// // 檢查是否超過 20 秒 -// if (isTiming && currentTime - firstClickTime > TIME_LIMIT) { -// // 超過 20 秒後,停止計時並重置 -// isTiming = false; -// clickCount = 0; -// firstClickTime = 0; -// } -// -// // 檢查是否已經連點五下 -// if (clickCount >= 8 && isTiming) { -// // 點擊滿五次且在20秒內,啟動新活動 -// -// getTools().gotoActivity(MainActivity.class); -// finish(); -// -//// AlertDialog.Builder builder = new AlertDialog.Builder(this); -//// builder.setTitle("請輸入密碼"); -//// -//// // 建立 EditText 讓使用者輸入 -//// final EditText input = new EditText(this); -//// input.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD); -//// input.setHint("請輸入 4 位數密碼"); -//// builder.setView(input); -//// -//// builder.setPositiveButton("確定", (dialog, which) -> { -//// String password = input.getText().toString(); -//// if ("0000".equals(password)) { -//// // 密碼正確,跳轉 -//// Intent intent = new Intent(this, FlowerSlotListActivity.class); -//// startActivity(intent); -//// } else { -//// // 密碼錯誤,提示 -//// Toast.makeText(this, "密碼錯誤", Toast.LENGTH_SHORT).show(); -//// } -//// }); -//// -//// builder.setNegativeButton("取消", (dialog, which) -> dialog.cancel()); -//// -//// builder.show(); -// -// -// // 重置點擊次數和計時 -// clickCount = 0; -// firstClickTime = 0; -// isTiming = false; -// } -// -// }); + // 假設你使用 ViewBinding + binding.btnTopRight.setOnClickListener(v -> { + getTools().gotoActivity(MainActivity.class); + finish(); + }); } diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/SettingActivity.java b/app/src/main/java/com/unibuy/smartdevice/ui/SettingActivity.java index fd576a0..8b18f57 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/SettingActivity.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/SettingActivity.java @@ -15,6 +15,7 @@ import com.unibuy.smartdevice.databinding.ActivitySettingBinding; 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.tools.HandlerMain; import com.unibuy.smartdevice.tools.HandlerMainSchedulerOnClick; import com.unibuy.smartdevice.tools.ToastHandlerMain; @@ -34,6 +35,8 @@ public class SettingActivity extends AppCompatActivityAbstract { TOAST(0), CHECK_AND_SAVE_STATUS(1), DATA_RELOAD(2), + SUCCESS(200), + FAILED(400), ; private int option; @@ -71,26 +74,55 @@ public class SettingActivity extends AppCompatActivityAbstract { protected void execute(Context context, int commandCode, String message) { Option option = optionMap.get(commandCode); + if (option == null) { + // 如果拿到未定義的代碼 (例如 404, 500),視同失敗,關掉進度條並顯示訊息 + super.execute(context, commandCode, message); + binding.progressLayout.setVisibility(View.GONE); + binding.buttonBack.setVisibility(View.VISIBLE); + return; + } + switch (option) { case TOAST: - super.execute(context, commandCode, message); + super.execute(context, commandCode, message); break; case CHECK_AND_SAVE_STATUS: - super.execute(context, commandCode, message); - binding.buttonBack.setVisibility(View.VISIBLE); - checkSetting(); - saveStatusList(context); + super.execute(context, commandCode, message); + binding.buttonBack.setVisibility(View.VISIBLE); + checkSetting(); + saveStatusList(context); break; case DATA_RELOAD: binding.buttonBack.setVisibility(View.INVISIBLE); MyApp.getInstance().getMachine().setMachineID(binding.editMachineID.getText().toString()); - + binding.progressLayout.setVisibility(View.VISIBLE); // 確保進度條顯示 try { - httpAPI.downloadSetting(); - } catch (LogsParseException | LogsSettingEmptyException e) { + starCloudAPI.downloadSetting(); + } catch (Exception e) { getLogs().warning(e); + binding.progressLayout.setVisibility(View.GONE); + binding.buttonBack.setVisibility(View.VISIBLE); } break; + case SUCCESS: + super.execute(context, commandCode, message); + binding.progressLayout.setVisibility(View.GONE); + binding.buttonBack.setVisibility(View.VISIBLE); + + // 綁定成功後,自動連動執行 B012 同步商品 + if ("綁定成功".equals(message)) { + binding.progressLayout.setVisibility(View.VISIBLE); // 顯示進度條,因為要同步商品 + starCloudAPI.downloadProductInfo(); + } + + checkSetting(); + saveStatusList(context); + break; + case FAILED: + super.execute(context, commandCode, message); + binding.progressLayout.setVisibility(View.GONE); + binding.buttonBack.setVisibility(View.VISIBLE); + break; } } }; @@ -100,6 +132,7 @@ public class SettingActivity extends AppCompatActivityAbstract { private List statusList = new ArrayList<>(); private RecyclerSettingListAdpter recyclerSettingListAdpter; private HttpAPI httpAPI; + private StarCloudAPI starCloudAPI; private static ProgressBar progressBar; private static TextView progressText; private static WeakReference instance; @@ -113,6 +146,7 @@ public class SettingActivity extends AppCompatActivityAbstract { setContentView(binding.getRoot()); httpAPI = new HttpAPI(getHandlerMain()); + starCloudAPI = new StarCloudAPI(getHandlerMain(), getHandlerMain()); instance = new WeakReference<>(this); progressBar = binding.progressBar; progressText = binding.progressText; diff --git a/app/src/main/res/layout/activity_fontend.xml b/app/src/main/res/layout/activity_fontend.xml index f8a2a97..90b2374 100644 --- a/app/src/main/res/layout/activity_fontend.xml +++ b/app/src/main/res/layout/activity_fontend.xml @@ -254,16 +254,16 @@