Architecture Decision Record
ADR-071: Device Capability Pattern for Barcode Scanning and QR Display
Status
Accepted (2026-08-12). Amended 2026-08-13: the composition-time string trade-off below was resolved in v1.147.0 by a deferred-resolution overload; see the updated trade-off entry. Amended 2026-08-14: source citations re-anchored, and the registration path corrected (the string overload delegates to the delegate overload, which is where the ZXing and singleton registrations happen).
Context
ADC's badge check-in feature (ADR-072) needs two things that look like one thing: an attendee's device has to show a QR code, and an organizer's device has to read one. They are not the same kind of capability. Rendering a QR is pure managed computation that every head can do, including WebAssembly. Reading one needs a camera, a permission grant, a preview surface and a decode loop, none of which exist on a browser head that this workspace targets.
ADR-042 already decided the shape for the second half: one
small interface per capability in MMCA.Common.UI, a safe fallback TryAdd-registered by
AddDeviceCapabilityDefaults, and a native override registered by the MAUI head after AddUIShared.
It did not decide anything about the first half, and it left two questions open that this feature is
the first to hit. Scanning is the first capability whose null fallback is not decoration: a head
without a camera cannot simply hide the affordance, because the underlying task (check an attendee in)
still has to be completable. And a camera is the first capability whose mere availability costs
something outside the code, since Android CAMERA and iOS NSCameraUsageDescription are declarations
an app store reviewer reads.
Two constraints bound the implementation. MMCA.Common.UI is a single-target net10.0 Razor class
library that must stay WebAssembly-compatible, so a QR generator that reaches for System.Drawing or a
native imaging asset is not available to it. And the pair is not conference-specific: a loyalty or
ticket-redemption flow in MMCA.Store would want the identical two halves, so building them inside ADC
would be the app-local duplication ADR-042 exists to prevent.
Decision
Split the feature by what it actually depends on: QR display ships as a shared component, barcode scanning ships as an ADR-042 capability whose native half is opt-in per head.
- Display is a component, not a capability.
QrCodeImage(MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/QrCodeImage.razor) renders a payload as an inline base64 PNG data URI through QRCoder's managedPngByteQRCodepath (:71-74), so it works identically on SSR, Server, WASM and MAUI with no per-head registration at all.Payload(:15-17) andAltText(:23-25) are both[EditorRequired], which is what keeps a generated image from shipping without an accessible name;PixelsPerModule(default 10, clamped to at least 1 at:74),ErrorCorrectionandClasscover sizing and styling. A blank payload renders nothing rather than a broken image (:66-69), and the bitmap is memoized against the three inputs that affect it (:42-56), so a parent re-render does not re-encode. - The error-correction level is a framework enum, not the generator's.
QrErrorCorrectionLevel(Components/QrErrorCorrectionLevel.cs:9-21:Low,Medium(default),Quartile,High) is mapped onto QRCoder'sECCLevelinside the component (QrCodeImage.razor:77-83), so the package's public surface does not pin consumers to the generator that happens to back it today. - Scanning is one contract on the ADR-042 pattern.
IBarcodeScannerService(Services/Capabilities/IBarcodeScannerService.cs:11-20) is two members:bool IsSupportedandTask<string?> ScanAsync(CancellationToken cancellationToken = default). The contract is that an implementation never throws: an unsupported head, a denied permission, a user-cancelled scan and a cancelled token all returnnull(:3-10), and the decoded payload is documented as untrusted input.NullBarcodeScannerService(Fallbacks/NullBarcodeScannerService.cs:9-16) reportsIsSupported == falseand returnsnull, TryAdd-registered byAddDeviceCapabilityDefaults(Services/Capabilities/DependencyInjection.cs:57). - The native implementation is opt-in and deliberately NOT folded into
UseMauiDeviceCapabilities. A head asks for the camera by name:UseCommonBarcodeScanner(string cancelText = "Cancel", string cameraDescription = "Scan a code")onMauiAppBuilder(MMCA.Common.UI.Maui/HostingDependencyInjection.cs:67-70), which delegates to theFunc<string>overload (:92-103); that overload is where ZXing'sUseBarcodeReader()(:99) runs and whereMauiBarcodeScannerServiceis plain-AddSingletoned over the TryAdd default (:100-101).UseMauiDeviceCapabilities(:29-42) does not call it, and says why (:47-49): a head that never scans should ship neither the camera handler nor a camera permission declaration. - The scan is a modal page with exactly one resolution.
MauiBarcodeScannerService(Capabilities/MauiBarcodeScannerService.cs:24) marshals to the main thread, then insideScanOnMainThreadAsync(:94-119) pushes aBarcodeScanPagemodally (:109) and pops it in afinally(:115-118). The page holds aTaskCompletionSource<string?>(Capabilities/BarcodeScanPage.cs:23-24) that four paths can complete and only the first wins: a decode, the cancel button, the hardware back gesture, andOnDisappearing; the caller'sCancellationTokenis bridged to the sameCancel()(MauiBarcodeScannerService.cs:112). Only 2D formats are read (BarcodeScanPage.cs:36), because 1D symbologies multiply false positives on a badge screen. - Permission stays with the head, not the framework. No framework code calls
Permissions.RequestAsync<Permissions.Camera>. An undeclared or denied camera produces a preview that never decodes and is cancelled out of, which the contract already renders asnull(MauiBarcodeScannerService.cs:7-11), so the degradation path and the cancel path are the same path. - Supported means Android or iOS.
IsSupportedgates onDeviceInfo.Current.Platform(:66-68); Windows and Mac Catalyst heads keep the null behavior even after opting in.
Packaging follows ADR-042 exactly: QRCoder 1.8.0 (MIT) is a MMCA.Common.UI dependency and
ZXing.Net.Maui.Controls 0.10.3 (MIT) a MMCA.Common.UI.Maui one, both pinned in
MMCA.Common/Directory.Packages.props (:134, :157), and the MAUI package keeps its
windows-job build and pack.
Rationale
- Rendering a QR is not a device concern, so making it one would have been ceremony. As a capability it would have needed an interface, a null fallback and a native override for something four heads all do the same way, and the null fallback would have meant a head that renders no badge at all. As a component it is one tag with no registration, which is why the web fallback in ADC's check-in flow costs nothing.
- Managed-only is a hard requirement, not a preference. QRCoder's
PngByteQRCodepath needs no native imaging asset, which is what lets the component ride into the WebAssembly head thatMMCA.Common.UImust keep supporting. nullbeats an exception when cancellation is the normal case. A scan is a user-cancellable operation: the attendee walks away, the organizer taps back, the code never decodes. Modelling those as exceptions would make the ordinary path expensive and force every caller into atry. The contract of ADR-042's fallbacks (never throw, report capability throughIsSupported) already said this, and scanning is where it pays.- Opting in by name keeps a permission declaration honest. Folding the camera into
UseMauiDeviceCapabilitieswould have handed every MAUI head an app-store-visible camera permission for a feature it may not have. The one extra line in a head'sMauiProgramis the whole price of that. - Common, not ADC, because nothing here is about conferences. ADR-042 established that reusable infrastructure belongs to the framework and app-specific behavior stays in the app. A QR display component and a barcode reader are the former by construction: ADC's check-in feature supplies only the payload format and the verification, both of which stay in ADC (ADR-072).
- ZXing.Net.MAUI is MIT and is the maintained option. Licensing is a decided axis in this workspace (ADR-016 pins MassTransit to v8 precisely because v9 is commercial), and a permissive license on a package that ships inside a published framework package is not negotiable. The alternative, hand-writing a camera preview and decoder per platform, is a large amount of code to own for a feature measured in one screen.
Trade-offs
- The scan page's strings were resolved at composition, not per call (resolved in v1.147.0).
As shipped in v1.145.0,
cancelTextandcameraDescriptionwere captured into the singleton at registration, so a head that switches culture at runtime under ADR-027 kept the cancel button, page title and semantic description in the language that was current at startup. v1.147.0 added aUseCommonBarcodeScanner(Func<string> cancelText, Func<string> cameraDescription)overload whose delegates are invoked once per scan, when the modal page is built, so the scan surface follows the in-app language. The original string overload remains and keeps its startup-fixed semantics (its XML doc says so); heads with a language switcher should pass the delegate overload. QrCodeImagehas no error surface. There is notry/catcharound the encode, so a payload too large for the chosen version and error-correction combination propagates out ofOnParametersSetrather than degrading to a message. The intended payloads are short opaque tokens, so the failure mode is a programming error rather than a data condition, but the component does not say so at runtime.- A null fallback still masks a missing registration. This is ADR-042's recorded trade-off, and here
it is functional rather than decorative: a MAUI head that forgets
UseCommonBarcodeScannergets the same behavior as a browser head. The mitigation is on the consumer, which is why ADC's check-in page branches onIsSupportedand renders a manual entry path instead (ADR-072) rather than hiding a button. - Opt-in means the platform matrix is not uniform. A Windows or Mac Catalyst MAUI head that calls
UseCommonBarcodeScannerstill reportsIsSupported == false, so "registered" and "usable" are two different questions a caller has to ask separately. - A generator dependency now ships to the browser.
QRCoderis aMMCA.Common.UIdependency, so it rides down to every WebAssembly client whether or not that client ever renders a code. It is a small managed assembly and trimming applies, but the cost is not zero and it is paid by heads that do not use the feature. - The decoded payload is input, and the contract can only say so. Nothing in the framework validates what a camera decodes. The security of a scan therefore lives entirely in the consumer's verification step, which is exactly why ADR-072's credential is server-verified rather than self-describing.
Related
ADR-042 (the capability pattern this extends: contract in
MMCA.Common.UI, native implementation in MMCA.Common.UI.Maui, override after AddUIShared),
ADR-072 (the ADC feature that motivated both halves and owns the
payload format and its verification), ADR-027 (the culture model the
builder-time strings do not participate in), ADR-016
(lockstep release and the licensing posture behind the ZXing choice),
ADR-045 (the previous capability addition, and the same
treatment of untrusted device input).