printly 0.3.0
printly: ^0.3.0 copied to clipboard
Thermal printer SDK for Flutter. Bluetooth Classic + BLE, ESC/POS receipts (text, barcode, QR), and Turkish that prints even on printers that ignore code pages.
0.3.0 — 2026-08-07 #
Breaking #
startScan()now excludes nameless BLE advertisements by default. Measured in the field, 135 of 141 records in one office scan were nameless privacy-rotated phones, wearables and beacons that no consumer can present as a printer choice; they are now filtered natively, before ever crossing the platform channel. PassincludeUnnamed: true(available onstartScan()andPrintlyScanSession.start()) to see everything, e.g. in diagnostic UIs. Nameless Classic sightings are never filtered (Android inquiry can deliver the name in a later follow-up broadcast), and neither are nameless re-sightings of devices already on the list (RSSI refreshes from frames that omit the local name).PrintlyPlatform.startScan()gains abool includeUnnamedparameter — custom platform implementations must add it to their override.
Added #
- Connecting by a known address without any scan is now documented in the
README — the public
PrintlyDeviceconstructor has always allowed it (Android; on iOS the address is a per-phone CoreBluetooth UUID that must come from a previous scan).
Deprecated #
- The last-device persistence cluster —
loadLastConnectedDevice(),lastConnectedDevice,forgetLastConnectedDevice(),reconnectLastDevice(),enableAutoReconnect(),isAutoReconnectEnabled— is deprecated and will be removed in v1.0.0 together with theshared_preferencesdependency. Persisting the printer is an app concern: store the address and transport with your own storage and reconstruct the device via the publicPrintlyDeviceconstructor — see the README section "Connecting without scanning (known address)".
Changed #
devicesStreamno longer re-emits the full list when only RSSI values moved. In a 140-device environment every re-advertisement re-emitted 4×/second and consumers ended up diffing the list themselves just to silence state churn. Identity, name, bonding,seenInScanand transport changes still emit;ScanController.currentDevicesnow reads the live dedup map, so the freshest RSSI is always available synchronously.
Fixed #
devicesStreamno longer keeps emitting afterstopScan()has completed. Android'sBluetoothLeScanner.stopScan()is asynchronous, so results buffered on the event channel kept landing afterisScanninghad already reportedfalse— silently growing the "final" list a consumer had just rendered (measured in the field: 115 → 141 entries after "scan finished"). Results arriving while the stop is still in flight, and during theclassicFirstround transition, are still accepted; only results after the scan has genuinely ended are dropped.
0.2.0 — 2026-08-06 #
Merges Classic/BLE sightings of one printer into a single PrintlyDevice
with transport selection moved to connect(), adds QR/barcode Unicode
sanitization and the TurkishCodePage.toLatin1() helper, splits adapter
state from permission state, and fixes an Android BLE GATT discovery race.
Breaking #
- Android:
adapterStateno longer reportsunauthorizedwhen runtime permissions are missing — it reports the actual radio state (orunknownwhere the OS refuses to reveal it). The old behaviour froze the stream atunauthorizeduntil process restart because Android never re-broadcasts on permission changes. Migration: gate UI onPrintly.instance.checkPermissions()for permission state;BluetoothAdapterState.unauthorizedstill occurs on iOS. PrintlyDevicenow models one physical radio per record: thetypefield is replaced byavailableTransports(a dual-mode printer that advertises both Classic and BLE appears once),dedupKeyis the bare address, and equality is address-only. Transport selection moved toconnect(). Persisted last-device entries from 0.1.x are migrated automatically. Migration: replacedevice.typereads withdevice.availableTransports.availableTransportsis validated with a runtime assert (non-empty), whichconstevaluation cannot satisfy — any 0.1.xconst PrintlyDevice(...)call site no longer compiles and must drop theconst.kDefaultScanTimeoutdrops from 30 s to 10 s, andstartScan()'s default transport set is now platform-aware instead of always{classic, ble}— iOS defaults to{ble}(it has no public Classic API). Pass an explicittimeout/types(or setPrintly.instance.defaultScanTimeout) to keep the previous behaviour.connect()now selects the transport (explicittransport:parameter to override) instead of the interimdevice.availableTransports.first. The default preference is platform-specific: Android prefers Classic for a dual-mode radio (the field-proven RFCOMM path), iOS always uses BLE (Classic requires MFi certification, out of scope — a Classic-only device now rejects withPrintlyUnsupportedException(classicRequiresMfi)instead of attempting a doomed connect). Switching an already-connected device to a different transport on the same address requires passing an explicit, differenttransport:—connect()with no transport, or the same one, while already connected remains a no-op.PrintlyPlatform.connect()/disconnect()/write()(the interface custom platform implementations extend) gain arequired ConnectionType transportparameter; the native side keys sessions bytype:address, so the three calls for one session must always pass the same value —ConnectionController.transportOf()is the retained per-device source of truth.
Added #
Printly.instance.isLocationServiceEnabled()— whether the OS location service currently gates Bluetooth scanning on this device. Alwaystrueon iOS and on Android API 31+ (printly declaresBLUETOOTH_SCANwithneverForLocation, which removes the dependency).Printly.instance.openLocationSettings()— opens the system location settings page (Android only; a no-op returningfalseon iOS).TurkishCodePage.toLatin1()— public sanitization helper that makes any string Latin-1 safe, transliterating Turkish letters and typographic punctuation to readable ASCII.PrintJob.qr()now acceptsunmappable: PrintlyUnmappable(defaultthrowError, unchanged) andreplacement, so field input containing₺, smart quotes or em dashes can print a sanitized QR instead of throwing.PrintJob.barcode()gains the sameunmappablepolicy for CODE128 and CODE39 (symbology-aware: numeric symbologies still validate strictly, CODE39 folds lowercase and defaults its replacement to-).Printly.checkPermissions()— reads the current permission status without ever prompting, evaluating exactly the same permission set asrequestPermissions()so consumers no longer duplicate the API-level mapping.PrintlyDevice.seenInScan— distinguishes a bonded seed from an actual scan sighting (falsemay not currently be in range).PrintlyDevice.hasName— whethernameis present and non-blank.Printly.startScan()/ScanController.startScan()gainincludeBonded(defaulttrue) — setfalseto exclude Classic bonded-cache seeds fromdevicesStreamuntil they are actually confirmed by an inquiry result.Printly.defaultScanTimeout— mutable app-wide default applied tostartScan()calls that pass no explicittimeout, instead of every call site repeating a customDuration.startScan()now picks a platform-appropriate default transport set whentypesis omitted ({classic, ble}on Android,{ble}on iOS) instead of always requesting Classic — seeScanController.defaultScanTypesForPlatform.Printly.transportOf()/ConnectionController.transportOf()— theConnectionTypeconnect()chose (or was explicitly told to use) for a device: the active link's transport, or the last one used once disconnected, ornullif the device has never been attempted this session.Printly.newScanSession()/PrintlyScanSession— a screen-scoped scan handle whosedevices/isScanningstreams are seeded empty/falseand never replay a previous screen's state, unlike the process-lifetimedevicesStream/isScanningStream. Encodes three field bugs traced to that replay landing in a fresh screen: a staleisScanning: falseclobbering optimistic "scanning…" UI, a replay misread as "scan finished, nothing found" triggering a phantom BLE fallback, and a two-minute-old 42-device list rendering before the new scan started (a tap on it ended in a connect timeout).stop()is ref-counted across concurrently active sessions — the native scan only stops once the last one lets go.ScanStrategy/Printly.startScan()'s newstrategyparameter (defaultScanStrategy.parallel, current behaviour unchanged) — passScanStrategy.classicFirstto scan Bluetooth Classic first on Android and fall back to a single BLE round only when nothing named answered, instead of requesting both transports at once. Encodes a field-observed pattern: a Classic inquiry saturates the radio, so scanning it alone first (then BLE only if needed) surfaces printers a parallel scan can miss under contention. No-op two-round loop — there is never a third round. On iOS it degrades silently to a single BLE round.Printly.requestEnableBluetooth()— asks the user to turn Bluetooth on without leaving the app. On Android this shows the systemACTION_REQUEST_ENABLEdialog over the current activity (rejects withPrintlyPermissionExceptionon API 31+ whenBLUETOOTH_CONNECThas not been granted). On iOS there is no programmatic toggle, so it creates a short-livedCBCentralManagerwithCBCentralManagerOptionShowPowerAlertKey— Apple's only sanctioned "turn it on" prompt, whose own "Settings" button legitimately deep-links to the system Bluetooth pane (somethingopenBluetoothSettings()cannot do). Returns whether the request was shown, not whether the radio ended up on — watchadapterStatefor the actual outcome.PrintlyDevice.mergeWithandPrintlyDevice.fromWireMap— public merge/decode helpers backing the unified device model.
Fixed #
-
Android below API 31: starting a scan while the location service is off — even with the location permission granted — used to return no results and no error, so the scan looked like an empty room.
startScan()now rejects immediately withPrintlyScanException(PrintlyErrorCode.locationServicesDisabled). -
A disconnect that lands mid-connect now explains itself — the bare wire reason (
disconnected) was accurate but actionless for a genuinely ambiguous failure: the radio links up and the peer closes it cleanly.PrintlyErrorCode.disconnectednow names the plausible causes (printer already connected elsewhere, out of range, or refusing that transport) and points atconnect(transport:). -
Blank advertised names no longer clobber a known name during merge — a whitespace-only BLE advertisement (real hardware behavior) must not flip
hasNametofalseand trigger UI fallback to "(unnamed)" or a spurious classicFirst BLE fallback round. -
Android BLE:
requestMtu()anddiscoverServices()were issued back-to-back, racing on the single-operation GATT queue; on some stacks (observed on Android 11) discovery was silently dropped and every BLE connect died on the 10 s timeout. Discovery is now chained after the MTU exchange settles, with a 1.5 s fallback so a missingonMtuChangedcan never wedge the connect. Callers passing very short custom connect timeouts should note the MTU exchange may now occupy up to the first 1.5 s of the budget. -
PrintJob.barcode()now throwsArgumentErrorfor CODE128 payloads containing non-encodable characters, as its documentation always promised — previously such payloads silently printed a corrupt symbol.
0.1.0 — 2026-07-31 #
First release. Bluetooth thermal printing for Flutter, written from scratch in Kotlin, Swift and Dart, with Turkish text that works even on printers that cannot be told about code pages.
Bluetooth #
- Classic (SPP) and BLE in one plugin — Classic on Android, BLE on both platforms. Network transport is not implemented and is not advertised.
BluetoothAdapterStateas a six-value enum stream, not a bool.- Scanning with de-duplication by transport + address, 250 ms emission coalescing, timeouts, and a separate error stream for mid-scan failures.
- Per-device
ConnectionStatestreams plusactiveDeviceStream.connectedmeans ready to print — services discovered and a writable characteristic resolved — not merely linked. - Runtime permissions (Android 12+ and legacy, iOS) behind
PrintlyPermissionStatus, with no third-party permission type in the API. - Last-device persistence and opt-in auto-reconnect.
Printing #
- Fluent
PrintJobbuilder: text, feed, cut, divider, raw bytes, seven barcode symbologies, and QR with automatic module sizing from payload length, error level and paper width. - Turkish through code pages: CP857 primary, Windows-1254 and ISO-8859-9 fallbacks, byte tables derived from the Unicode Consortium mappings and cross-checked against CPython and libiconv.
- 58 mm and 80 mm paper, with characters-per-line and dot width derived from the setting rather than hardcoded at call sites.
Raster — Turkish on any printer #
PrintlyRaster.text()draws glyphs with the platform's own text engine and sends dots, so the printer's character set stops mattering. Some printers ignoreESC toutright; on those this is the only way to printğorİ.PrintlyRaster.image()decodes PNG/JPEG/WebP and fits it to the paper.PrintlyRaster.widgetKey()captures a mountedRepaintBoundary.PrintlyBitmapis immutable, so a logo rendered once is free to reuse; it also keepsPrintJob.bitmap()synchronous and chainable.- Floyd-Steinberg or plain threshold dithering, emitted as
GS v 0bands.
Errors #
- Sealed
PrintlyExceptionhierarchy over aPrintlyErrorCodevocabulary shared byte-for-byte between Dart, Kotlin and Swift. A rawPlatformExceptionnever reaches your code and there is nothing to string-match.
Platform #
- Android: minSdk 24, 16 KB page-size compatible (pure Kotlin, no
.so). - iOS: 13.0+, shipped for both CocoaPods and Swift Package Manager.
- Flutter
>=3.35.3— the first release bundling the Dart 3.9.2 this package requires. 3.35.0 through 3.35.2 ship Dart 3.9.0 and were previously claimed in error, which would have met anyone on them with a resolution failure rather than a clear "unsupported". CI now builds against both this floor and current stable, which is how the mismatch surfaced.
Known limits #
- Verified on one printer (Cashino PTP-II). Broader support is the goal, not a claim.
- Ink coverage is a hardware limit: a broad solid-black area can draw more current than a cheap 5 V head sustains and the printer may cut out with no catchable error. printly reports size and coverage but does not block.
- Bluetooth Classic gives a whole job a single 10-second write budget on Android; a very long raster receipt can exceed it.
Development log #
The sections below record how the package was built, sprint by sprint. They are kept for provenance — every entry above is already covered by one of them.
Sprint 6 — Raster pipeline (2026-07-31) #
Turkish text no longer depends on the printer's character set.
Added
PrintlyRaster— renders to printable dots:text()shapes a string with the platform text engine (wrapping, alignment, weight) — the path that makes Turkish work on printers that ignoreESC t.image()decodes PNG/JPEG/WebP and fits it to the paper.widthis a ceiling, not a stretch: a narrow image keeps its size and the printer centres it rather than being upscaled into a blur.repaintBoundary()/widgetKey()capture a mounted, paintedRepaintBoundary. A widget is not accepted directly because Flutter offers no supported way to render a detached tree — an API that took one would be promising what it cannot do.Offstageand zero-opacity subtrees are rejected with an explanation instead of an engine assert.
PrintlyBitmap— an immutable one-bit image, and the seam between the asynchronous rendering half and the synchronous command half. Rendering a logo once and stamping it onto every receipt costs nothing after the first.toRgba()expands it back to pixels so a preview can show exactly what the head will burn.PrintlyDithering— Floyd-Steinberg (a two-row error buffer rather than a full plane: 1.5 KB instead of 1.5 MB for a receipt) or a plain threshold. Two values, not three: anonemode would be byte-identical tothreshold.PrintJob.bitmap()appends a rendered bitmap synchronously, so it still chains. Alignment goes through the generator's style cache — emitted as raw bytes it would leave the cache stale and the nexttext()would print misaligned.textRaster(),image()andwidget()are awaitable sugar over it, typed so a mistaken cascade is a compile error rather than a scrambled receipt.PrintConfig.rasterBandHeight(default 64 rows). Tall images are split intoGS v 0bands, each about 3 KB at 58 mm, staying under 256 rows so the command's high height byte is always zero — firmware that ignores that byte is a known hazard.
Notes
- The wrapped library's own raster path is not used. For any width that is not
already a multiple of 8 it replaces the pixel data with a zero-filled
fixed-length list and then calls
insertAllon it, so it throws before it can print — and derives its header from the unaligned width regardless. printly emitsGS v 0itself and rounds widths down to a multiple of 8, because rounding up would overflow the head and make the printer wrap. - Transparent pixels composite to white, not black.
dart:uireturns premultiplied alpha, so reading the colour channels of a transparent canvas naively yields black — and a receipt-sized black bitmap would burn a roll. - Text uses a lighter cutoff (176) than images (128). A font rasteriser antialiases, and at the neutral cutoff only the one-dot core of each stroke burns; thermal heads render isolated dots weakly, so ordinary weights came out washed out while bold looked fine. Measured on paper.
- Verified end to end on a Cashino PTP-II — the printer that ignores
ESC tand so could not print Turkish at all before this.
Sprint 5 — iOS Bluetooth print & platform readiness (2026-07-02) #
Added
- iOS ESC/POS printing over BLE.
print()/write()now works on iOS: after the link comes up, printly discovers services, resolves the write characteristic (same preference order as Android:FF02,49535343-8841-…,FFE1, then any writable), and streams the job in single-ATT-packet chunks bounded by a 5 s per-chunk watchdog. Theconnectedstate now means ready-to-print on iOS exactly as it does on Android — it is emitted only after the write characteristic is resolved, and the connect timeout covers service discovery too. Error vocabulary is byte-identical with Android (not_connected,not_ready,write_busy,write_timeout,write_failed). - Swift Package Manager support. iOS sources moved to the SPM layout
(
ios/printly/Sources/printly/) with aPackage.swift; Flutter 3.44+ resolves the plugin via SPM automatically. The.podspecstill points at the same sources, so CocoaPods apps are unaffected. - Android 16 KB page-size compatibility documented: the Android side is pure Kotlin with no bundled native binaries, so the plugin is 16 KB compatible as-is; README explains what consumers need to check.
Changed
- Network (Ethernet/WiFi) printing moved out of the
v0.1.0scope to a post-v1 release; the raster pipeline moved to Sprint 6 (still pre-v1).PrintlyDevice.network(...)andConnectionType.networkremain in the API and keep rejecting withnetwork_not_supported.
Hardening & deep-review fixes (2026-07-02) #
A six-dimension adversarially-verified code review (69 findings) was applied
across all three layers, verified end-to-end (flutter analyze clean,
151 tests, Android APK + iOS build green).
Fixed
- Persistence: the last-connected device is now persisted on every
successful
connect()(previously only after a store-touching API call), and a persisted auto-reconnect flag re-arms its adapter listener on the next launch — both "remember my printer" features actually work now. - Dart races:
stopScan(); startScan();(the natural rescan gesture) is queued instead of silently swallowed; a staledisconnectedevent from a torn-down link no longer rejects a freshconnect()to the same device. - QR: payloads over 252 bytes emitted a corrupt
GS ( kstore command (the wrapped library hardcodespH=0); printly now emits the QR function sequence itself with correct two-byte length math. Payloads beyond the QR byte-mode maximum (2953) throwArgumentError. - Barcodes: literal
{in CODE128 payloads is escaped ({{) and caller-supplied{A/{B/{Cselectors are honoured; module width is clamped to the spec range 2–6 and emitted on every barcode so one barcode's width never leaks into the next. - Failed-future caching: a failed
CapabilityProfile.load()orLastDeviceStore.open()no longer bricksnewJob()/persistence for the rest of the session — failures are evicted and retried. - Android: scanning with Bluetooth off now rejects with
bluetooth_not_powered_on(was a silent, empty 30 s scan); GATT callbacks are main-thread-confined (removes watchdog/retry data races); aconnectGattearly-failure race that could leak a GATT client slot is closed and anullreturn fails fast; user-initiated disconnects no longer terminate inerror;connect()during teardown rejects withdisconnect_in_progressinstead of re-emitting a falseconnected; Classicwrite()is gated on connection state (its watchdog could kill an in-flight connect); Classic connect-timeout reason aligned toconnect_timeout;write_timeout/disconnectednow reach Dart as their own error codes. - iOS: the first
startScan()/connect()after launch no longer fails deterministically (CBCentralManageroperations queue until the firstdidUpdateState); connect-timeout work items are cancelled on terminal states (a stale timeout could abort a later attempt to the same peripheral); duplicateconnect()re-emits the effective state so a hot-restarted Dart side rehydrates (Android parity);disconnect()during a pending connect reaps synchronously and established links get a 4 s fallback reaper;detachFromEngineimplemented (native resources no longer outlive the engine); the privateApp-Prefs:URL scheme removed (App Store 2.5.1 risk — app settings page is opened instead);.unauthorizedsurfaces aspermission_denied(was a misleadingbluetooth_not_powered_on).
Added
- Typed error model: sealed
PrintlyExceptionhierarchy (PrintlyScanException,PrintlyConnectionExceptionwith aTimeoutException-compatible timeout subtype,PrintlyWriteException,PrintlyPermissionException,PrintlyUnsupportedException) carrying a cross-platformPrintlyErrorCode;PlatformExceptions are mapped at the method channel, so consumers switch on codes instead of parsing strings. PrintlyPermissionStatus— printly's own enum;permission_handler'sPermissionStatusno longer leaks into (or is re-exported from) the public API.Printly.scanErrorsStream— mid-scan native failures (Bluetooth toggled off, scan-failed callbacks) are surfaced instead of looking like a normal timeout stop.- CP437 table for
PrintlyCharset.latin: é ü ç ö £ ° and the rest of the CP437 repertoire now print instead of?(only ğ Ğ ı İ ş Ş remain unrepresentable on that page). - Android BLE MTU negotiation (
requestMtu(517), chunking at MTU−3,WRITE_TYPE_NO_RESPONSEpreferred when supported) — the throughput prerequisite for the upcoming raster sprint. - Wire-protocol strings single-sourced per platform (
WireProtocolin Dart,WireCodesin Kotlin and Swift) — channel/method/key/error strings can no longer drift silently at individual call sites. - iOS
PERMISSION_BLUETOOTH=1Podfile requirement documented in the README and applied to the example app (without itrequestPermissions()always reportedpermanentlyDeniedon iOS). - Exhaustive code-page table tests (every defined byte of CP437/CP857/
Windows-1254/ISO-8859-9 round-trips), a golden receipt byte-stream test
pinning the wire format against dependency upgrades, GS
!size-operand and style-reset tests, facade persistence/auto-reconnect regression tests, and typed-error mapping tests. 151 tests total.
Changed
Printly.connect()fails fast withPrintlyUnsupportedExceptionforConnectionType.networkdevices until the network transport lands.requestPermissions()returnsPrintlyPermissionStatus.- The speculative
wireCode/fromWireCodeAPI was removed from the nine print-layer enums (nothing crosses the wire there; the connection/adapter enums keep theirs). - Unused
image,mockito, andbuild_runnerdependencies removed; the Flutter lower bound corrected to>=3.35.0to matchsdk: ^3.9.2. - LICENSE set to MIT. CI now compiles both native layers (example APK +
iOS build), runs the example tests, and the publish dry-run gate is no
longer advisory (
continue-on-errorremoved). QrSizingexported alongsideTurkishCodePageas stable utilities.- Docs honesty pass: pubspec/podspec/README no longer advertise
unimplemented Ethernet/WiFi;
ConnectionState.reconnectingdocumented as reserved (never emitted today); barcode GS k function A/B docs corrected;raw()documents its style-cache caveat.
Sprint 4 — ESC/POS core & Turkish charset #
- Print-layer enums (all
Printly-prefixed to avoid clashes withdart:ui/Material):PrintlyPaperWidth(58 mm = 384 dots, 80 mm = 576 dots),PrintlyTextAlign,PrintlyTextStyle,PrintlyTextSize,PrintlyCutMode,PrintlyBarcodeType,PrintlyCharset,PrintlyQrErrorLevel,PrintlyHriPosition. TurkishCodePage.encode()— a dedicated CP857 (primary) / Windows-1254 / ISO-8859-9 encoder. The byte tables were derived from the Unicode Consortium mappings and cross-checked against the CPythoncp857/cp1254codecs and libiconv; they correct the inaccurate example table in the roadmap (e.g.ğ→0xA7,ş→0x9F,Ş→0x9E).ESC tselectors corrected to CP857 = 13 and WPC1254 = 48.PrintConfigand the fluentPrintJobbuilder (text,feed,cut,divider,raw,barcode,qr), layered onesc_pos_utils_plusfor command framing while overriding only the Turkish text encoding viatextEncoded.- 1-D barcodes for 7 symbologies (EAN-13/8, UPC-A, CODE39, CODE128, ITF, CODABAR) with automatic CODE128
{Bcode-set prefixing and HRI controls. - Smart QR sizing (
QrSizing): estimates the symbol version from payload length + error level and picks the largest module dot size that fits the paper, with an optionalmaxModuleSizecap. Printly.instance.newJob(...)andprint(device, job), plus a newwrite({device, bytes})platform method.- Android native write path: RFCOMM
OutputStreamwrites for Classic, and GATT service discovery + ack-gated chunked characteristic writes (API 33+ and legacy paths) for BLE. iOSwriterejects withunsupported_platformpending Sprint 6. - Example app: 58/80 mm paper-width selector and a Turkish test-receipt button (text + QR + barcode).
Sprint 3 — Device discovery & connection #
- Core models:
ConnectionType,ConnectionState,PrintlyDevice, andPrintlyConnectionEventwith stable integer wire codes shared across Dart/Kotlin/Swift. ScanControllerwith re-entrancy guards (concurrentstartScancalls share one native scan), dedup/merge by transport + address, and automatic timeout.ConnectionControllerwith per-device state streams,activeDeviceStream, serialised device switching (disconnect previous → connect new), and failure-reason tracking.- Persistence via
LastDeviceStore(SharedPreferences) for the last-connected device and an opt-in auto-reconnect flag. - Public API on
Printly.instance:startScan,stopScan,devicesStream,connect,disconnect,connectionStateOf,activeDeviceStream,loadLastConnectedDevice,reconnectLastDevice,enableAutoReconnect. - Android native split into layered files (adapter/scan/connection/util) with modern, non-deprecated APIs:
BluetoothLeScanner,BluetoothDevice.TRANSPORT_LE, API 33+getParcelableExtraoverloads. Classic RFCOMM connects run on a dedicated IO thread with socket-close based timeout. - iOS native split around a shared
CentralControllerthat owns a singleCBCentralManager, so the Bluetooth permission prompt only appears once. BLE scan + connect implemented; Classic rejects withclassic_requires_mfi; network deferred. - Example app extended with start/stop scan, discovered devices list with per-device connect/disconnect buttons, and active-device banner.
Sprint 2 — Bluetooth state & permissions #
BluetoothAdapterStateenum with 6 values and stable integer wire codes.- Live adapter state stream (
Printly.instance.adapterState) backed by a broadcast receiver on Android and a lazily instantiatedCBCentralManageron iOS. - Cached
currentAdapterStateand synchronousisBluetoothAvailablegetter viaBluetoothManager. requestPermissions()with platform-specific flows (Android 12+ vs 11-, iOS) and aggregatedPermissionStatusresult.openBluetoothSettings()andopenAppSettings()helpers.- Example app rewritten around a Bluetooth playground page.
Sprint 1 — Foundation & scaffold #
- Package scaffold, CI (analyze + test + publish dry-run), and strict lint baseline.