From e01d4542ae39c82b9ef9af92e044fae21fd3134b Mon Sep 17 00:00:00 2001 From: FrenkyDema Date: Wed, 9 Sep 2026 17:37:50 +0200 Subject: [PATCH] fix: make cash drawer failures visible and service binding recoverable The example app could not start on Android 11+ devices: BIND_SERVICE never resolved, so `await bindingService()` before `runApp()` hung forever. Two causes, verified on a Sunmi D3 (Android 14, targetSdk 36): 1. Package visibility. Without a entry the Sunmi service is invisible to the app, so bindService() returns false: AppsFilter: ...example -> woyou.aidlservice.jiuiv5 BLOCKED Unable to start service Intent { act=...IWoyouService } U=0: not found The declaration now lives in the plugin manifest, so host apps inherit it. 2. The pending Result was only completed from onServiceConnected, which never fires when the bind is refused. Every exit path now settles it. The same dead-binding state is what a stuck cash drawer looks like, and the plugin hid it: openDrawer() reported success as soon as the AIDL call was dispatched while discarding the firmware's callback, and drawerStatus() / drawerTimesOpen() returned false / 0 when the service was unreachable, indistinguishable from a drawer that is simply not attached. BREAKING CHANGE: openDrawer(), drawerStatus() and drawerTimesOpen() now throw a PlatformException instead of silently reporting success or absent hardware. Also reconnects after onBindingDied, releases a bind that never completed, cleans up on engine detach, and holds in-flight AIDL callbacks with a strong reference so they are not collected before the printer replies. --- CHANGELOG.md | 27 ++ android/src/main/AndroidManifest.xml | 15 +- .../SunmiTaskPrinterMethod.java | 275 +++++++++++++++--- .../SunmiTaskPrinterPlugin.java | 9 + example/lib/main.dart | 12 +- lib/sunmi_task_printer.dart | 13 + pubspec.yaml | 2 +- test/sunmi_task_printer_test.dart | 47 +++ 8 files changed, 360 insertions(+), 40 deletions(-) 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', () {