Architecture Decision Record
ADR-042: Device Capability Abstraction (MAUI Blazor Hybrid)
Status
Accepted (2026-07-10, amended 2026-07-17 and 2026-07-23).
Context
The consumer apps ship the same Blazor component set through three heads: MAUI Blazor Hybrid
(Android/iOS/MacCatalyst/Windows), Blazor Server SSR, and WebAssembly. Native device capabilities
(share sheet, clipboard, haptics, geolocation, local notifications, connectivity, biometrics,
text-to-speech) only exist on the MAUI head, and some have partial browser equivalents
(navigator.share, navigator.clipboard, navigator.onLine, aria-live regions). Shared Razor
components cannot reference MAUI APIs: MMCA.Common.UI is a single-target net10.0 Razor class
library that must stay WASM-compatible (its layer rule allows Shared only), and any MAUI-typed code
in it would break the web heads at compile time.
Two constraints shape the packaging. First, per-head service selection already has a working
precedent: ITokenStorageService is implemented by each host and registered after AddUIShared
(MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:98). Second, MMCA.Common's CI and release
pipelines run on ubuntu-latest, which cannot build MAUI target frameworks at all, while the
framework's packages release in lockstep (ADR-016).
Decision
Add a per-capability contract layer to MMCA.Common.UI and a fifteenth package,
MMCA.Common.UI.Maui, carrying the native implementations.
One small interface per capability, no aggregate device service. Contracts live in
Source/Presentation/MMCA.Common.UI/Services/Capabilities/(18 at introduction:IShareService,IClipboardService,IHapticFeedbackService,IMapNavigationService,IGeolocationService,IExternalLinkService,ITextToSpeechService,IAccessibilityAnnouncer,ILocalNotificationService,IScreenshotService,IDevicePreferences,IBatteryStatusService,IBiometricAuthenticator,ISpeechToTextService,IExternalAuthBroker,IDeepLinkDispatcher,IConnectivityStatusService,ILocalCacheStore). Four more have joined since, for 22 today, all TryAdd-registered byAddDeviceCapabilityDefaults(Source/Presentation/MMCA.Common.UI/Services/Capabilities/DependencyInjection.cs:27-60) and overridden natively byAddMauiDeviceCapabilities(Source/Presentation/MMCA.Common.UI.Maui/DependencyInjection.cs:28-58):IGeocodingService(Wave 3),IMediaPickerService(ADR-045), and the push pairIPushRegistrationServiceandIPushDeviceTokenProvider(ADR-044). Each capability has an independent fallback story, and per-capability contracts let heads adopt incrementally.Safe defaults for every contract, TryAdd-registered by
AddUIShared.AddDeviceCapabilityDefaults(Source/Presentation/MMCA.Common.UI/Services/Capabilities/DependencyInjection.cs) registers a null or neutral implementation per contract (Fallbacks/), so shared components resolve every capability on every head with zero host changes. Three fallback flavors: browser-equivalent (JS interop), null-object exposingIsSupported == false(components hide the affordance), and constant stubs (Blazor Server connectivity is always online: a dead circuit already means the whole UI is down).Heads override AFTER
AddUISharedwith plain Add registrations. Last registration wins for single-service resolution, soAddBrowserDeviceCapabilities()(browser implementations overwwwroot/capabilities-interop.js, prerender-safe per theMauiBackNavigationBridgedegradation contract) andAddMauiDeviceCapabilities()/UseMauiDeviceCapabilities()(the MAUI package) replace the defaults without touching them.MMCA.Common.UI.Mauiis the one MAUI-TFM package. It multi-targets net10.0-android/ios/maccatalyst/windows, referencesMMCA.Common.UIplusMicrosoft.Maui.ControlsandPlugin.LocalNotification(majors track MAUI majors, pinned together inDirectory.Packages.props), and stays OUT ofMMCA.Common.slnx, the same mechanism that keeps the UI gallery and E2E projects out of the ubuntu unit run. Dedicated windows jobs build (ci.ymlbuild-maui, a required gate) and pack (release.ymlpublish-maui, same tag, own SBOM gate) it, so the lockstep release stays whole. Layer rule: UI + Shared only (Source/Build/MMCA.Common.LayerEnforcement.targets,EnforceUIMauiLayerBoundary); it is deliberately absent from the ubuntu NetArchTest runtime map because its assemblies cannot load there, and the compile-time target is the enforcement.Deep links funnel through one boundary. Native navigation sources (notification taps, app actions, app links, QR scans) publish app-relative routes into the singleton
IDeepLinkDispatcher(buffered, capacity one, for cold starts); theDeepLinkListenercomponent in the shared layout drains the buffer after first render and navigates live requests. The payoff of Blazor Hybrid: web URLs and app routes are identical, so no translation table exists anywhere.Shared components adapt, never branch on platform.
ExternalLinkrenders a real new-tab anchor on web heads and intercepts the click into the system browser whereIExternalLinkService.InterceptsLinksis true, becausetarget="_blank"silently dead-ends inside a BlazorWebView.OfflineBannerrenders only whenIConnectivityStatusServicereports offline. Permission flows live INSIDE each MAUI implementation (check, rationale, request, degrade, never throw); components never see permission state.
Rationale
- A god
IDeviceCapabilitiesinterface would force every head to implement everything and turn each new capability into a breaking change; per-capability contracts are open/closed and mock cleanly in bUnit. - Null defaults inside
AddUISharedmean a head that knows nothing about capabilities keeps working, which is exactly the inert-by-default posture of ADR-024's notification layer. - Putting implementations in a framework package (not each app's MAUI head) gives the Store and ADC heads the same capabilities for one registration line, and keeps the ADR-015 discipline: reusable infrastructure belongs to Common.
- The windows-job packaging exception is the smallest change that preserves both truths: ubuntu CI stays fast and MAUI TFMs get built at all.
Trade-offs
- A fifteenth package raises release surface: two runners must both succeed for a whole release.
Accepted; the
publish-mauijob is gated by the same tag and SBOM discipline. - The TryAdd-default plus later-plain-Add pattern relies on registration order (
AddUISharedfirst). Documented on both extension methods; the order is already load-bearing in every host for other reasons. - Null-object fallbacks can mask a missing registration (a MAUI head that forgets
UseMauiDeviceCapabilities()silently loses haptics rather than failing fast). Accepted for decoration-grade capabilities; feature waves that depend on a capability assertIsSupportedin their UI and surface the gap visibly. - Biometrics, speech-to-text, and the external-auth broker now ship native MAUI implementations,
all three registered by
AddMauiDeviceCapabilities()(Source/Presentation/MMCA.Common.UI.Maui/DependencyInjection.cs:43,:44,:58). The residual trade-off is configuration, not code:MauiExternalAuthBrokerregisters unconditionally but reportsIsAvailable == false(Source/Presentation/MMCA.Common.UI.Maui/Capabilities/MauiExternalAuthBroker.cs:39) until the head suppliesOAuth:MobileRedirectScheme, so a misconfigured head quietly keeps the web anchor flow rather than failing fast.