From b3258c2b0d628a059d81e1c6f9f4123d0f85fdda Mon Sep 17 00:00:00 2001 From: terrylee Date: Wed, 15 Jul 2026 11:59:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(cash):=20=E7=8F=BE=E9=87=91=E9=80=A3?= =?UTF-8?q?=E7=B7=9A=E6=96=B9=E6=A1=88=E9=81=B8=E6=93=87=20=E2=80=94=20?= =?UTF-8?q?=E4=B8=B2=E5=8F=A3/=E9=80=A3=E7=B7=9A=E8=A8=AD=E5=AE=9A?= =?UTF-8?q?=E8=88=87=E7=9B=B8=E9=97=9C=E8=AA=BF=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainActivity/MyApp/PortProfile/Rs232Dev/DispatchDialog + build.gradle Co-Authored-By: Claude Opus 4.8 (1M context) --- app/build.gradle | 4 +- .../com/unibuy/smartdevice/MainActivity.java | 20 ++++++- .../java/com/unibuy/smartdevice/MyApp.java | 16 +++++- .../smartdevice/devices/PortProfile.java | 3 +- .../unibuy/smartdevice/devices/Rs232Dev.java | 27 +++++++--- .../smartdevice/ui/dialog/DispatchDialog.java | 52 ++++++++++--------- 6 files changed, 84 insertions(+), 38 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 3bbf546..360d703 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,7 +10,7 @@ plugins { // 避免不同尺寸機台間版本號產生衝突。 // ========================================================================= def verMinor = 2 -def verPatch = 40 +def verPatch = 41 @@ -43,7 +43,7 @@ android { S9XYU { dimension "hardware" - minSdk 28 + minSdk 24 // Android 7.0(原為 28=Android 9;降至 24 讓 Android 7 機台可安裝) } } diff --git a/app/src/main/java/com/unibuy/smartdevice/MainActivity.java b/app/src/main/java/com/unibuy/smartdevice/MainActivity.java index 51fc14f..92dab30 100644 --- a/app/src/main/java/com/unibuy/smartdevice/MainActivity.java +++ b/app/src/main/java/com/unibuy/smartdevice/MainActivity.java @@ -584,9 +584,27 @@ public class MainActivity extends AppCompatActivityAbstract { if (access.isEmpty() || password.isEmpty()) { getLogs().info("[LoginTrace][" + traceId + "] blocked_empty_input"); Toast.makeText(context, "請輸入帳號和密碼", Toast.LENGTH_SHORT).show(); + } else if (MyApp.getInstance().getMachine().getMachineID().isEmpty() + && "90543814".equals(access) && "1234abcd".equals(password)) { + // 未綁定機台無法向雲端驗證帳密(雲端沒有這台的綁定資料,login 一定失敗)→ + // 提供「預設離線帳密」放行,讓現場人員能進後台完成綁定。綁定後(machineID 非空) + // 此分支不成立,即恢復正常雲端帳密驗證。 + getLogs().info("[LoginTrace][" + traceId + "] unbound_default_login_ok"); + MyApp.getInstance().setSuAccess(access); // 標記已登入(與雲端成功路徑一致) + MyApp.getInstance().setLoginToken(""); + if (rememberAccountCb.isChecked()) { + prefs.edit().putString("saved_account", access).apply(); + } else { + prefs.edit().remove("saved_account").apply(); + } + pendingAction = onSuccess; + // 走與雲端相同的成功回調(updateLoginStatus + 關窗 + 跑 pendingAction) + getHandlerMain().start("MainActivity", 200, "登入成功"); + getTools().hideSystemBars((Activity) getCtx()); + return; } else { Toast.makeText(context, "正在驗證登入...", Toast.LENGTH_SHORT).show(); - + // 為了處理 StarCloudAPI 登入成功後的回調 // 我們重新設計 handlerMain 讓它能夠在 onSuccess 回調中跑 pendingAction pendingAction = onSuccess; diff --git a/app/src/main/java/com/unibuy/smartdevice/MyApp.java b/app/src/main/java/com/unibuy/smartdevice/MyApp.java index 3918f31..bbe1b06 100644 --- a/app/src/main/java/com/unibuy/smartdevice/MyApp.java +++ b/app/src/main/java/com/unibuy/smartdevice/MyApp.java @@ -635,10 +635,22 @@ public class MyApp extends Application { // 若不活躍則重連 if (!isAlive) { staticLogs.info("下位機失連,重新連線中..."); + HandlerMain oldHandlerMain = devXinYuanController != null + ? devXinYuanController.getHandlerMain() : null; if (devXinYuanController != null) { - devXinYuanController.shutdown(); + // ★ 必須用 releasePort()(setRun(false)+closePort()+shutdown())完整關閉舊控制器, + // 讓舊的讀取 while(isRun()) 執行緒真正跳出、釋放 ttyS7。 + // 之前只呼叫 shutdown()(僅停排程器),舊讀取執行緒還活著、舊串口還開著, + // 接著又 new DevXinYuanController() 把同一個 ttyS7 再開一次 → 兩條執行緒搶讀同一串口 + // → 位元組框架被撕裂、VMC_41_POLL 認不出、出貨指令送不出去 → 出貨失敗(VMC_52 洪水即此徵兆)。 + devXinYuanController.releasePort(); } - devXinYuanController = new DevXinYuanController(devXinYuanController.getHandlerMain()); + // 給舊讀取執行緒一點時間因 closePort() 拋例外跳出迴圈、釋放串口,再重開新串口,避免搶佔。 + try { + Thread.sleep(150); + } catch (InterruptedException ignore) { + } + devXinYuanController = new DevXinYuanController(oldHandlerMain); devXinYuanController.start(); devXinYuanController.addSendBuffer( devXinYuanController.getDevXinYuan().syncPackNo((byte) 0x01) diff --git a/app/src/main/java/com/unibuy/smartdevice/devices/PortProfile.java b/app/src/main/java/com/unibuy/smartdevice/devices/PortProfile.java index 91c1559..566eb73 100644 --- a/app/src/main/java/com/unibuy/smartdevice/devices/PortProfile.java +++ b/app/src/main/java/com/unibuy/smartdevice/devices/PortProfile.java @@ -26,8 +26,7 @@ public final class PortProfile { // 未設定時的預設方案。通用版本為 LEGACY(ttyS1/ttyS2)。 // ⚠️代樂美等星科技主板專用建置需改為 STARTECH(ttyS7/ttyS6,裝上即用免手動切),該改動勿進通用主線。 - public static final String DEFAULT_PROFILE = STARTECH; // 岱樂妹專版:星科技主板(ttyS7/ttyS6),裝上即用免手動切 - + public static final String DEFAULT_PROFILE = LEGACY; private PortProfile() {} 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 8bfe34a..a69fde3 100644 --- a/app/src/main/java/com/unibuy/smartdevice/devices/Rs232Dev.java +++ b/app/src/main/java/com/unibuy/smartdevice/devices/Rs232Dev.java @@ -95,8 +95,8 @@ public class Rs232Dev { } public void clearBuffer() throws LogsIOException { - if (suMissing || isMockMode || inputStream == null) { - if (showLogs) logs.info((suMissing || isMockMode) ? "[MockMode] Skip clearBuffer" : "InputStream 為 null,無法清除緩衝區"); + if (isMockMode || inputStream == null) { + if (showLogs) logs.info(isMockMode ? "[MockMode] Skip clearBuffer" : "InputStream 為 null,無法清除緩衝區"); return; } @@ -127,6 +127,14 @@ public class Rs232Dev { } try { + // 防呆:未取得 root 的機台(如未 root 的 rk3288 Android 7)searchSuPath 找不到 su → suPath 為 null。 + // 若直接 Runtime.exec(null) 會 NPE(String.length())→ App 在 MyApp.onCreate 建立 DevXinYuanController + // 時整個啟動崩潰、屢次停止運作。這裡先擋掉:無 su 就略過 chmod、回報此埠不可用,改由上層進 Mock 模式, + // 至少讓 App 能開起來(串口需 root 或該埠本身可讀寫才會真正運作)。 + if (suPath == null || suPath.trim().isEmpty()) { + if (showLogs) this.logs.info("suPath 為 null(未取得 root/su),略過 chmod:" + fileComPort.getAbsolutePath()); + return false; + } if (showLogs) this.logs.info("suPath:" + suPath); Process su = Runtime.getRuntime().exec(suPath); @@ -179,7 +187,10 @@ public class Rs232Dev { } public void connectPort(ComPort comPort) throws LogsUnsupportedOperationException, LogsIOException { - if (suMissing || isMockMode) { + // 不再因「沒 root(suMissing)」就整個假連線。只要埠本身可存取(chmod666File 已回 true、進了 + // rs232DeviceList)就照開——比照有 root 機台(Android 9)的行為,讓未 root 但串口權限已開放的 + // 機台(如 rk3288 Android 7,ttyS1=crwxrwxrwx)也能真正連下位機。真正不可用的埠仍走 isMockMode。 + if (isMockMode) { Log.i("Rs232Dev", "[MockMode] Faking connection to: " + comPort.getFilepath()); return; } @@ -212,6 +223,7 @@ public class Rs232Dev { public byte[] readToMatch(byte[] stx, int moveLimit) throws LogsIOException { byte[] buffer = new byte[0]; + if (isMockMode || this.serialPort == null) return buffer; // 防呆:Mock/未開串口時不讀,避免 NPE try { InputStream inputStream = this.serialPort.getInputStream(); int moveCount = 0; @@ -234,6 +246,7 @@ public class Rs232Dev { } public byte[] readListen(int readMax, long delayMillis) throws LogsIOException { + if (isMockMode || this.serialPort == null) return new byte[0]; // 防呆:Mock/未開串口時不讀,避免 NPE InputStream inputStream = this.serialPort.getInputStream(); byte[] buffer = new byte[0]; int readCount = 0; @@ -262,7 +275,7 @@ public class Rs232Dev { } public int read(byte[] buffer) throws LogsIOException { - if (suMissing || isMockMode || this.serialPort == null) return 0; + if (isMockMode || this.serialPort == null) return 0; try { InputStream inputStream = this.serialPort.getInputStream(); int size = inputStream.read(buffer); @@ -274,7 +287,7 @@ public class Rs232Dev { } public void send(byte[] buffer) throws LogsIOException { - if (suMissing || isMockMode || this.serialPort == null) { + if (isMockMode || this.serialPort == null) { Log.i("Rs232Dev", "[MockMode] send buffer: " + PortTools.showHex(buffer)); return; } @@ -289,7 +302,7 @@ public class Rs232Dev { } public int transmission(byte[] sendBuffer, byte[] readBuffer) throws LogsIOException { - if (suMissing || isMockMode || this.serialPort == null) { + if (isMockMode || this.serialPort == null) { Log.i("Rs232Dev", "[MockMode] send buffer: " + PortTools.showHex(sendBuffer)); return 0; } @@ -330,7 +343,7 @@ public class Rs232Dev { // ✅ 新增真正非阻塞讀取方法 public byte[] readNonBlocking() throws LogsIOException { - if (suMissing || isMockMode || this.serialPort == null) return new byte[0]; + 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/ui/dialog/DispatchDialog.java b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/DispatchDialog.java index f7d0cac..dfafc54 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/DispatchDialog.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/DispatchDialog.java @@ -466,7 +466,7 @@ public class DispatchDialog extends DialogAbstract { getLogs().warning(e); } // 找不到替換檔(或解碼失敗)→ 用內建預設圖 - binding.imgDeliveryFailed.setImageResource(R.drawable.qrcode_msg_dailemei); + binding.imgDeliveryFailed.setImageResource(R.drawable.qrcode_msg); } /** 依螢幕尺寸做降採樣解碼,避免大圖 OOM。 */ @@ -1289,26 +1289,16 @@ public class DispatchDialog extends DialogAbstract { buttonControlCountdown.setCountdown(25); } - // ── 出貨前最後一道保險:確認下位機連線,失連就先自動重連並等到活著,再送出貨指令 ── - // 這是「百分百在出貨當下遇失連可自動補出貨」的關鍵。ensure 內含 ping/重連的 Thread.sleep, - // 必須背景執行;完成後回主執行緒才送 checkSlot。(檢查放這裡而非按下直接購買時,因為付款到 - // 出貨可能隔數十秒,當時連著不代表出貨時還連著;下位機常在重啟後短暫失聯。) - setTextViewCountdown(getCtx().getString(R.string.device_initializing), 65); - new Thread(() -> { - boolean alive = MyApp.ensureXinYuanConnectedForDispatch(8000); - getLogs().info("出貨前確認下位機連線結果: " + alive); - new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> { - if (isFinalizing) - return; - getLogs().info("11111"); - MyApp.getInstance().getDevXinYuanController() - .getReadBufferByNow(new DevCheckSlotByVMC(getHandlerMain(), slot)); - getLogs().info("22222"); - MyApp.getInstance().getDevXinYuanController() - .addSendBuffer(MyApp.getInstance().getDevXinYuanController().getDevXinYuan().checkSlot(slot)); - getLogs().info("33333"); - }); - }).start(); + // 出貨前「不」做預先重連:機台通常是好的,直接送 checkSlot。 + // (曾加過出貨前主動重連,但 isXinYuanAlive 在 NFC 付款期間 VMC 這條安靜 >4s 時會誤判失連, + // 對健康機台做破壞性重連,觸發 checkAndReconnectDev 雙讀取執行緒 bug → VMC_52 洪水、POLL 認不出、 + // 出貨指令送不出去 → 出貨失敗。改回直接送;真的失連交由下方 checkSlot 重試輪的後備重連處理。) + getLogs().info("11111"); + MyApp.getInstance().getDevXinYuanController().getReadBufferByNow(new DevCheckSlotByVMC(getHandlerMain(), slot)); + getLogs().info("22222"); + MyApp.getInstance().getDevXinYuanController() + .addSendBuffer(MyApp.getInstance().getDevXinYuanController().getDevXinYuan().checkSlot(slot)); + getLogs().info("33333"); } private void devRunElectric(int field, int slot, int increase) @@ -1430,9 +1420,23 @@ public class DispatchDialog extends DialogAbstract { } public void setTextViewCountdown(String message, int size) { - if (isChangeStatus) { - binding.dispatchtips.setText(message); - binding.dispatchtips.setTextSize(size); + // 一律回主執行緒更新 UI:本方法常被 checkSlot/getProduct 的背景排程回調呼叫, + // 若直接在背景執行緒碰 View 會丟 CalledFromWrongThreadException(「Only the original thread…」), + // 該例外被上層 catch 後,會連帶跳過緊接其後的 getProduct(吐貨指令)送出 → 出貨永遠卡在「出貨中」。 + // (Android 9 機台時序上剛好沒觸發,Android 7 上必現;改為 post 到主執行緒即可根治。) + android.app.Activity act = (getCtx() instanceof android.app.Activity) ? (android.app.Activity) getCtx() : null; + Runnable r = () -> { + if (isChangeStatus) { + try { + binding.dispatchtips.setText(message); + binding.dispatchtips.setTextSize(size); + } catch (Exception ignore) {} + } + }; + if (act != null) { + act.runOnUiThread(r); + } else { + new android.os.Handler(android.os.Looper.getMainLooper()).post(r); } }