[FIX] 修復 Star-Cloud 管理員登入流程與 ApiKey 持久化機制

1. 修正 B000 登入 API 路徑並補上機台 Authorization Token 驗證,解決 401 授權失敗問題。
2. 遷移 login 實作至 HttpURLConnection 直連模式,確保網路連線錯誤能正確反饋至 UI。
3. 實作 persistCurrentSettings 統一持久化方法,確保 Token 變更後立即同步至 SQLite。
4. 優化 MyApp 初始化邏輯,修正 App 重啟後 ApiKey 無法從資料庫正確還原的問題。
5. 禁用 HomeActivity 與 FontendActivity 中的舊版 B010/B022 背景任務,避免新舊系統衝突。
6. 於 .agents/rules/ 新增 Star-Cloud API 開發規範文件,防止未來代碼回退。
7. 移除偵錯用的冗餘 Toast 訊息,優化使用者體驗。
This commit is contained in:
sky121113 2026-05-06 17:53:37 +08:00
parent 4b2dc746f6
commit a73c618360
18 changed files with 762 additions and 135 deletions

View File

@ -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`)始終只有一條最新紀錄。

View File

@ -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可在無串口設備的手機上正常運作不崩潰。

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String> 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)
// 停止位默认11: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();

View File

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

View File

@ -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<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;
}
/**
* 將下載回來的設定儲存到 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<NetworkInterface> interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());
for (NetworkInterface intf : interfaces) {
List<InetAddress> 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";
}
}

View File

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

View File

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

View File

@ -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<String, String> mapByRequestProperty) {
this.urlString = urlString;
this.data = postData;
this.cookie = cookie;
this.referer = referer;
this.contextType = contextType;
this.mapByRequestProperty = mapByRequestProperty;
super.start();
}

View File

@ -61,6 +61,62 @@ public class HttpConnect {
this.logs = new Logs(this.getClass());
}
public int formGet(String urlString, String cookie, String referer, Map<String, String> 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<String, String> mapByRequestProperty) throws LogsMalformedURLException, LogsIOException {
logs.debug("[form post]" + "url:" + urlString + " data:" + data);

View File

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

View File

@ -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<String> statusList = new ArrayList<>();
private RecyclerSettingListAdpter recyclerSettingListAdpter;
private HttpAPI httpAPI;
private StarCloudAPI starCloudAPI;
private static ProgressBar progressBar;
private static TextView progressText;
private static WeakReference<SettingActivity> 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;

View File

@ -254,16 +254,16 @@
<Button
android:id="@+id/btnTopRight"
android:layout_width="400dp"
android:layout_height="400dp"
android:text="右上角按鈕"
android:layout_width="200dp"
android:layout_height="100dp"
android:text="進入後台"
android:textSize="24sp"
android:backgroundTint="@color/gray"
android:backgroundTint="@color/red"
android:textColor="@android:color/white"
android:alpha="0"
android:alpha="1"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:visibility="gone"
android:layout_marginTop="8dp"
android:visibility="visible"
android:layout_marginTop="60dp"
android:layout_marginEnd="8dp"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,3 +1,2 @@
#This file is generated by updateDaemonJvm
toolchainVendor=jetbrains
toolchainVersion=21
#toolchainVendor=jetbrains
#toolchainVersion=21

0
gradlew vendored Normal file → Executable file
View File