diff --git a/CHANGELOG.md b/CHANGELOG.md index e297d8a..2dd52a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,30 @@ +## 0.2.0 + +**Cash drawer reliability & service binding fixes** + +* **Breaking:** `openDrawer()` now completes only once the printer firmware confirms the kick. + It previously resolved as soon as the AIDL call was dispatched, so a drawer that never opened + still reported success. It now throws a `PlatformException` with code `OPERATION_FAILED`, + `PRINTER_EXCEPTION`, `UNAVAILABLE` or `TIMEOUT`. +* **Breaking:** `drawerStatus()` and `drawerTimesOpen()` no longer return `false` / `0` when the + printer service is unreachable — they throw a `PlatformException` with code `UNAVAILABLE`. + A dead binding used to be indistinguishable from a drawer that is simply not attached. +* **Fixed:** Added the `` declaration for `woyou.aidlservice.jiuiv5` to the plugin + manifest. On Android 11+ with `targetSdk >= 30`, package visibility filtering made the Sunmi + service invisible, so `bindService()` returned `false` and the printer was reported as + "not found" even when installed. Host apps no longer need to declare this themselves. +* **Fixed:** `BIND_SERVICE` could never resolve. The pending `Result` was only completed from + `onServiceConnected`, so a refused or failed bind left the Dart future awaiting forever — which + froze app startup for any host that awaited it before `runApp()`. Every exit path now settles + the result, including a `false` return from `bindService()`, `onNullBinding` and `onBindingDied`. +* **Fixed:** The connection is now re-established after `onBindingDied`, and commands that find the + service missing schedule a reconnect. Previously a service restart left the plugin permanently + broken until the host app was restarted. +* **Fixed:** `unbindService()` now releases a connection that was requested but never completed, + and the plugin cleans up when the Flutter engine detaches. +* In-flight AIDL callbacks are held with a strong reference so they cannot be garbage collected + before the printer replies, and are bounded by a 5s timeout. + ## 0.1.0 **Major Architecture Refactor & Modernization** diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 5d6f96d..0761322 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,3 +1,14 @@ - + + + + + + + + + diff --git a/android/src/main/java/dev/francescodema/sunmi_task_printer/SunmiTaskPrinterMethod.java b/android/src/main/java/dev/francescodema/sunmi_task_printer/SunmiTaskPrinterMethod.java index ff19859..a87ba02 100644 --- a/android/src/main/java/dev/francescodema/sunmi_task_printer/SunmiTaskPrinterMethod.java +++ b/android/src/main/java/dev/francescodema/sunmi_task_printer/SunmiTaskPrinterMethod.java @@ -10,6 +10,11 @@ import android.os.Looper; import android.os.RemoteException; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + import io.flutter.plugin.common.MethodChannel.Result; import woyou.aidlservice.jiuiv5.ICallback; import woyou.aidlservice.jiuiv5.ILcdCallback; @@ -24,48 +29,190 @@ public class SunmiTaskPrinterMethod { private final Context _context; private IWoyouService _printerService; private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + /** + * Pending BIND_SERVICE result, resolved exactly once by whichever callback + * fires first. Touched only from the main thread. + */ private Result bindResultPending; + /** + * True once bindService() has been accepted by the system, regardless of + * whether onServiceConnected has fired yet. Needed so unbindService() can + * release a connection that never completed. + */ + private boolean isBound; + + /** + * Milliseconds to wait for a printer callback before giving up. The AIDL + * service is normally prompt; this only guards against a callback that the + * firmware never delivers, so Dart is never left awaiting forever. + */ + private static final long CALLBACK_TIMEOUT_MS = 5000; + + /** + * Strong references to callbacks currently in flight. A Binder stub passed + * to a remote process is otherwise eligible for garbage collection before + * the reply arrives, which would silently drop the result. + */ + private final Set pendingCallbacks = Collections.synchronizedSet(new HashSet<>()); + private final ServiceConnection connService = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder service) { _printerService = IWoyouService.Stub.asInterface(service); - if (bindResultPending != null) { - bindResultPending.success(true); - bindResultPending = null; - } + resolveBindResult(true, null, null); } @Override public void onServiceDisconnected(ComponentName name) { + // The process died but the binding survives: Android calls + // onServiceConnected again once the service is back. _printerService = null; } + + @Override + public void onNullBinding(ComponentName name) { + // The service exists but refused to return a binder: never leave Dart awaiting. + resolveBindResult(false, "NULL_BINDING", "Printer service returned a null binding"); + } + + @Override + public void onBindingDied(ComponentName name) { + // Terminal for this connection: without an explicit rebind every + // later call would fail with "Service disconnected" until the host + // app is restarted. + _printerService = null; + resolveBindResult(false, "BINDING_DIED", "Printer service binding died"); + rebind(); + } }; public SunmiTaskPrinterMethod(Context context) { this._context = context; } + private Intent printerServiceIntent() { + Intent intent = new Intent(); + intent.setPackage("woyou.aidlservice.jiuiv5"); + intent.setAction("woyou.aidlservice.jiuiv5.IWoyouService"); + return intent; + } + + /** + * Drops the current connection and asks for a fresh one. Used after the + * binding dies, and lazily when a command finds the service missing. + */ + private void rebind() { + try { + _context.unbindService(connService); + } catch (IllegalArgumentException ignored) { + // Nothing was registered; nothing to release. + } + isBound = false; + try { + isBound = _context.bindService(printerServiceIntent(), connService, Context.BIND_AUTO_CREATE); + } catch (SecurityException ignored) { + isBound = false; + } + } + + /** + * Reports the service as unavailable and schedules a reconnect attempt, so + * the next command has a working binding even though this one fails. + */ + private void reportUnavailable(Result result) { + sendError(result, "UNAVAILABLE", "Printer service is not connected"); + if (!isBound) { + mainHandler.post(this::rebind); + } + } + + /** + * Completes the outstanding BIND_SERVICE call, if any. Every path out of + * bindService() must funnel through here so the Dart future always settles. + */ + private void resolveBindResult(boolean success, String errorCode, String errorMessage) { + mainHandler.post(() -> { + Result pending = bindResultPending; + if (pending == null) { + return; + } + bindResultPending = null; + if (success) { + pending.success(true); + } else { + pending.error(errorCode, errorMessage, null); + } + }); + } + public void bindService(Result result) { if (_printerService != null) { result.success(true); return; } + if (bindResultPending != null) { + result.error("BIND_IN_PROGRESS", "A bind request is already pending", null); + return; + } + bindResultPending = result; - Intent intent = new Intent(); - intent.setPackage("woyou.aidlservice.jiuiv5"); - intent.setAction("woyou.aidlservice.jiuiv5.IWoyouService"); - _context.bindService(intent, connService, Context.BIND_AUTO_CREATE); + + boolean accepted; + try { + accepted = _context.bindService(printerServiceIntent(), connService, Context.BIND_AUTO_CREATE); + } catch (SecurityException e) { + accepted = false; + } + + if (accepted) { + isBound = true; + } else { + // Service missing, or not visible to this app because the host manifest + // lacks a entry for woyou.aidlservice.jiuiv5. + try { + _context.unbindService(connService); + } catch (IllegalArgumentException ignored) { + // Nothing was registered; nothing to release. + } + resolveBindResult(false, "SERVICE_UNAVAILABLE", + "Sunmi printer service is not available on this device"); + } } public void unbindService(Result result) { - if (_printerService != null) { - _context.unbindService(connService); - _printerService = null; + if (isBound) { + try { + _context.unbindService(connService); + } catch (IllegalArgumentException ignored) { + // Already unbound. + } + isBound = false; } + _printerService = null; + resolveBindResult(false, "UNBOUND", "Service was unbound before binding completed"); result.success(true); } + /** + * Releases the service connection when the Flutter engine goes away, so a + * detached engine never leaks a bound connection. + */ + public void dispose() { + if (isBound) { + try { + _context.unbindService(connService); + } catch (IllegalArgumentException ignored) { + // Already unbound. + } + isBound = false; + } + _printerService = null; + pendingCallbacks.clear(); + resolveBindResult(false, "UNBOUND", "Flutter engine detached before binding completed"); + } + private void runOnBackground(Runnable task) { TaskProvider.executor.execute(task); } @@ -82,6 +229,66 @@ private void sendError(Result result, String code, String message) { * Dummy callback to satisfy Sunmi AIDL requirements without blocking Flutter. * In buffer mode, physical callbacks do not fire until commit. */ + /** + * Bridges a Sunmi AIDL callback to a Flutter Result, settling it exactly + * once with the outcome the firmware actually reports. Use this for + * commands whose success cannot be assumed from a successful dispatch, + * such as kicking the cash drawer. + */ + private ICallback createResultCallback(Result result, String operation) { + AtomicBoolean settled = new AtomicBoolean(false); + ICallback[] holder = new ICallback[1]; + + Runnable onTimeout = () -> { + if (settled.compareAndSet(false, true)) { + pendingCallbacks.remove(holder[0]); + result.error("TIMEOUT", operation + " reported no result within " + + CALLBACK_TIMEOUT_MS + "ms", null); + } + }; + + ICallback callback = new ICallback.Stub() { + @Override + public void onRunResult(boolean isSuccess) { + settle(isSuccess, isSuccess ? null : "OPERATION_FAILED", + operation + " was rejected by the printer"); + } + + @Override + public void onReturnString(String resultStr) { + } + + @Override + public void onRaiseException(int code, String msg) { + settle(false, "PRINTER_EXCEPTION", operation + " failed (code " + code + "): " + msg); + } + + @Override + public void onPrintResult(int code, String msg) { + } + + private void settle(boolean success, String errorCode, String errorMessage) { + if (!settled.compareAndSet(false, true)) { + return; + } + pendingCallbacks.remove(holder[0]); + mainHandler.removeCallbacks(onTimeout); + mainHandler.post(() -> { + if (success) { + result.success(true); + } else { + result.error(errorCode, errorMessage, null); + } + }); + } + }; + + holder[0] = callback; + pendingCallbacks.add(callback); + mainHandler.postDelayed(onTimeout, CALLBACK_TIMEOUT_MS); + return callback; + } + private ICallback createDummyCallback() { return new ICallback.Stub() { @Override @@ -114,7 +321,7 @@ public void initPrinter(Result result) { runOnBackground(() -> { try { if (_printerService == null) { - sendError(result, "UNAVAILABLE", "Service disconnected"); + reportUnavailable(result); return; } _printerService.printerInit(createDummyCallback()); @@ -159,7 +366,7 @@ public void printText(String text, Result result) { runOnBackground(() -> { try { if (_printerService == null) { - sendError(result, "UNAVAILABLE", "Service disconnected"); + reportUnavailable(result); return; } _printerService.printText(text, createDummyCallback()); @@ -174,7 +381,7 @@ public void setAlignment(int alignment, Result result) { runOnBackground(() -> { try { if (_printerService == null) { - sendError(result, "UNAVAILABLE", "Service disconnected"); + reportUnavailable(result); return; } _printerService.setAlignment(alignment, createDummyCallback()); @@ -189,7 +396,7 @@ public void setFontSize(int fontSize, Result result) { runOnBackground(() -> { try { if (_printerService == null) { - sendError(result, "UNAVAILABLE", "Service disconnected"); + reportUnavailable(result); return; } _printerService.setFontSize(fontSize, createDummyCallback()); @@ -204,7 +411,7 @@ public void printColumn(String[] stringColumns, int[] columnWidth, int[] columnA runOnBackground(() -> { try { if (_printerService == null) { - sendError(result, "UNAVAILABLE", "Service disconnected"); + reportUnavailable(result); return; } _printerService.printColumnsText(stringColumns, columnWidth, columnAlignment, createDummyCallback()); @@ -219,7 +426,7 @@ public void printImage(Bitmap bitmap, Result result) { runOnBackground(() -> { try { if (_printerService == null) { - sendError(result, "UNAVAILABLE", "Service disconnected"); + reportUnavailable(result); return; } _printerService.printBitmap(bitmap, createDummyCallback()); @@ -234,7 +441,7 @@ public void cutPaper(Result result) { runOnBackground(() -> { try { if (_printerService == null) { - sendError(result, "UNAVAILABLE", "Service disconnected"); + reportUnavailable(result); return; } _printerService.cutPaper(createDummyCallback()); @@ -314,11 +521,11 @@ public void openDrawer(Result result) { runOnBackground(() -> { try { if (_printerService == null) { - sendError(result, "UNAVAILABLE", "Service disconnected"); + reportUnavailable(result); return; } - _printerService.openDrawer(createDummyCallback()); - sendSuccess(result, true); + // Resolved by the callback: a dispatched kick is not an opened drawer. + _printerService.openDrawer(createResultCallback(result, "Opening the cash drawer")); } catch (RemoteException e) { sendError(result, "REMOTE_EXCEPTION", e.getMessage()); } @@ -329,12 +536,12 @@ public void drawerIsConnected(Result result) { runOnBackground(() -> { try { if (_printerService == null) { - sendSuccess(result, false); + reportUnavailable(result); return; } sendSuccess(result, _printerService.getDrawerStatus()); } catch (RemoteException e) { - sendSuccess(result, false); + sendError(result, "REMOTE_EXCEPTION", e.getMessage()); } }); } @@ -343,12 +550,12 @@ public void timesOpened(Result result) { runOnBackground(() -> { try { if (_printerService == null) { - sendSuccess(result, 0); + reportUnavailable(result); return; } sendSuccess(result, _printerService.getOpenDrawerTimes()); } catch (RemoteException e) { - sendSuccess(result, 0); + sendError(result, "REMOTE_EXCEPTION", e.getMessage()); } }); } @@ -357,7 +564,7 @@ public void lineWrap(int lines, Result result) { runOnBackground(() -> { try { if (_printerService == null) { - sendError(result, "UNAVAILABLE", "Service disconnected"); + reportUnavailable(result); return; } _printerService.lineWrap(lines, createDummyCallback()); @@ -372,7 +579,7 @@ public void sendRaw(byte[] bytes, Result result) { runOnBackground(() -> { try { if (_printerService == null) { - sendError(result, "UNAVAILABLE", "Service disconnected"); + reportUnavailable(result); return; } _printerService.sendRAWData(bytes, createDummyCallback()); @@ -432,7 +639,7 @@ public void printQRCode(String data, int moduleSize, int errorLevel, Result resu runOnBackground(() -> { try { if (_printerService == null) { - sendError(result, "UNAVAILABLE", "Service disconnected"); + reportUnavailable(result); return; } _printerService.printQRCode(data, moduleSize, errorLevel, createDummyCallback()); @@ -447,7 +654,7 @@ public void printBarCode(String data, int barcodeType, int textPosition, int wid runOnBackground(() -> { try { if (_printerService == null) { - sendError(result, "UNAVAILABLE", "Service disconnected"); + reportUnavailable(result); return; } _printerService.printBarCode(data, barcodeType, height, width, textPosition, createDummyCallback()); @@ -477,7 +684,7 @@ public void sendLCDString(String string, Result result) { runOnBackground(() -> { try { if (_printerService == null) { - sendError(result, "UNAVAILABLE", "Service disconnected"); + reportUnavailable(result); return; } _printerService.sendLCDString(string, createDummyLcdCallback()); @@ -492,7 +699,7 @@ public void sendLCDBitmap(Bitmap bitmap, Result result) { runOnBackground(() -> { try { if (_printerService == null) { - sendError(result, "UNAVAILABLE", "Service disconnected"); + reportUnavailable(result); return; } _printerService.sendLCDBitmap(bitmap, createDummyLcdCallback()); @@ -507,7 +714,7 @@ public void sendLCDDoubleString(String topText, String bottomText, Result result runOnBackground(() -> { try { if (_printerService == null) { - sendError(result, "UNAVAILABLE", "Service disconnected"); + reportUnavailable(result); return; } _printerService.sendLCDDoubleString(topText, bottomText, createDummyLcdCallback()); @@ -522,7 +729,7 @@ public void sendLCDFillString(String string, int size, boolean fill, Result resu runOnBackground(() -> { try { if (_printerService == null) { - sendError(result, "UNAVAILABLE", "Service disconnected"); + reportUnavailable(result); return; } _printerService.sendLCDFillString(string, size, fill, createDummyLcdCallback()); @@ -537,7 +744,7 @@ public void sendLCDMultiString(String[] text, int[] align, Result result) { runOnBackground(() -> { try { if (_printerService == null) { - sendError(result, "UNAVAILABLE", "Service disconnected"); + reportUnavailable(result); return; } _printerService.sendLCDMultiString(text, align, createDummyLcdCallback()); diff --git a/android/src/main/java/dev/francescodema/sunmi_task_printer/SunmiTaskPrinterPlugin.java b/android/src/main/java/dev/francescodema/sunmi_task_printer/SunmiTaskPrinterPlugin.java index bba5d85..c87d530 100644 --- a/android/src/main/java/dev/francescodema/sunmi_task_printer/SunmiTaskPrinterPlugin.java +++ b/android/src/main/java/dev/francescodema/sunmi_task_printer/SunmiTaskPrinterPlugin.java @@ -32,6 +32,10 @@ public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBindin @Override public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { + if (sunmiTaskPrinterMethod == null) { + result.error("NOT_ATTACHED", "Plugin is detached from the Flutter engine", null); + return; + } switch (call.method) { case "getPlatformVersion" -> result.success("Android " + android.os.Build.VERSION.RELEASE); @@ -167,5 +171,10 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { @Override public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { channel.setMethodCallHandler(null); + channel = null; + if (sunmiTaskPrinterMethod != null) { + sunmiTaskPrinterMethod.dispose(); + sunmiTaskPrinterMethod = null; + } } } \ No newline at end of file diff --git a/example/lib/main.dart b/example/lib/main.dart index c4e7234..0788c94 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -13,14 +13,20 @@ void main() async { debugPrint( "[SunmiDebug] Initializing WidgetsFlutterBinding and binding service..."); try { - final bool? bound = await SunmiTaskPrinter.bindingService(); + // Bounded wait: the UI must come up even if the printer service is missing + // or slow to connect, otherwise runApp() is never reached. + final bool? bound = await SunmiTaskPrinter.bindingService() + .timeout(const Duration(seconds: 5)); debugPrint("[SunmiDebug] Service binding returned: $bound"); + } on TimeoutException { + debugPrint( + "[SunmiDebug] Service binding timed out; continuing without it."); } catch (e) { - debugPrint("[SunmiDebug] CRITICAL: Service binding failed: $e"); + debugPrint("[SunmiDebug] Service binding failed: $e"); } await SystemChrome.setPreferredOrientations( - [DeviceOrientation.landscapeRight, DeviceOrientation.landscapeRight]); + [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]); runApp(const MyApp()); } diff --git a/lib/sunmi_task_printer.dart b/lib/sunmi_task_printer.dart index bcaf797..9f1c398 100644 --- a/lib/sunmi_task_printer.dart +++ b/lib/sunmi_task_printer.dart @@ -298,16 +298,29 @@ class SunmiTaskPrinter { } /// Sends electrical pulse currents to trip standard outer connection cash drawer relays. + /// + /// Completes only once the printer firmware confirms the kick. Throws a + /// [PlatformException] if the drawer did not open, with code `OPERATION_FAILED` + /// (rejected), `PRINTER_EXCEPTION` (firmware error), `UNAVAILABLE` (printer + /// service not connected) or `TIMEOUT` (no answer from the firmware). static Future openDrawer() async { await _channel.invokeMethod("OPEN_DRAWER"); } /// Verifies if a valid link loop context is established with the terminal drawer mechanism. + /// + /// Returns `false` only when the printer genuinely reports no drawer attached. + /// Throws a [PlatformException] with code `UNAVAILABLE` when the printer + /// service itself is unreachable, so a dead binding is never mistaken for + /// missing hardware. static Future drawerStatus() async { return await _channel.invokeMethod("DRAWER_STATUS") ?? false; } /// Returns total tracked lifetime open counts recorded directly from local peripheral memory blocks. + /// + /// Throws a [PlatformException] with code `UNAVAILABLE` when the printer + /// service is unreachable, rather than reporting a count of zero. static Future drawerTimesOpen() async { return await _channel.invokeMethod("DRAWER_OPENED") ?? 0; } diff --git a/pubspec.yaml b/pubspec.yaml index bc4486a..3caccc9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: sunmi_task_printer description: "A comprehensive software package to effectively manage Sunmi printers and their drivers, ensuring seamless printing operations." -version: 0.1.0 +version: 0.2.0 repository: https://github.com/FrenkyDema/sunmi_task_printer environment: diff --git a/test/sunmi_task_printer_test.dart b/test/sunmi_task_printer_test.dart index 6707a72..e977ae4 100644 --- a/test/sunmi_task_printer_test.dart +++ b/test/sunmi_task_printer_test.dart @@ -108,6 +108,53 @@ void main() { expect(log[1].method, 'OPEN_DRAWER'); expect(log[2].method, 'DRAWER_STATUS'); }); + + test('Drawer failures surface instead of reporting success', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + log.add(methodCall); + throw PlatformException( + code: 'OPERATION_FAILED', + message: 'Opening the cash drawer was rejected by the printer', + ); + }); + + await expectLater( + SunmiTaskPrinter.openDrawer(), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'OPERATION_FAILED', + ), + ), + ); + }); + + test('Drawer diagnostics do not mask an unreachable service', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + log.add(methodCall); + throw PlatformException( + code: 'UNAVAILABLE', + message: 'Printer service is not connected', + ); + }); + + // A dead binding must not read as "no drawer attached" or "opened 0 times". + await expectLater( + SunmiTaskPrinter.drawerStatus(), + throwsA( + isA().having((e) => e.code, 'code', 'UNAVAILABLE'), + ), + ); + await expectLater( + SunmiTaskPrinter.drawerTimesOpen(), + throwsA( + isA().having((e) => e.code, 'code', 'UNAVAILABLE'), + ), + ); + }); }); group('Utility & Models', () {