From 6e5355afeafcd58828be0b0a4c2ba571a665d957 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 23:13:35 +0100 Subject: [PATCH] feat(app-invite): isolate ContactService to mobile targets Splits the single ContactService class (which threw PlatformNotSupportedException on non-Android/iOS targets) into a platform-conditional structure: - IContactService + ContactDto: shared abstraction in src/PostIt/PostIt/Services/IContactService.cs. ViewModels depend on this; concrete providers map their native shapes to ContactDto. - ContactService.Mobile.cs: MAUI Essentials implementation, compiled only when ANDROID or IOS is defined. Wraps Contacts.Default.GetAllAsync() with permission handling and a NotImplementedInReferenceAssemblyException safety net. - ContactService.Desktop.cs: stub returning an empty list, compiled when neither ANDROID nor IOS is defined. Replaces the 'throw PlatformNotSupportedException' path so desktop targets (PostIt.Desktop, PostIt.Browser) build and run cleanly. The Microsoft.Maui.Essentials portable facade is referenced from PostIt.csproj, but it only becomes functional when the host application project (PostIt.Android, future PostIt.iOS) also references the platform-specific implementation. No tests added: per AGENTS.md, a 'stub returns empty list' test on PostIt.Tests (net10.0 desktop target) would be cosmetic and not detect the real failure mode. Android-side tests require a working PostIt.Android project, which doesn't exist yet. Future providers (Google Contacts API, Exchange, CardDAV) plug in as additional IContactService implementations selected by DI configuration. --- .../PostIt/Services/ContactService.Desktop.cs | 27 ++++++++ .../PostIt/Services/ContactService.Mobile.cs | 64 +++++++++++++++++++ src/PostIt/PostIt/Services/ContactService.cs | 39 ----------- src/PostIt/PostIt/Services/IContactService.cs | 29 +++++++++ 4 files changed, 120 insertions(+), 39 deletions(-) create mode 100644 src/PostIt/PostIt/Services/ContactService.Desktop.cs create mode 100644 src/PostIt/PostIt/Services/ContactService.Mobile.cs delete mode 100644 src/PostIt/PostIt/Services/ContactService.cs create mode 100644 src/PostIt/PostIt/Services/IContactService.cs diff --git a/src/PostIt/PostIt/Services/ContactService.Desktop.cs b/src/PostIt/PostIt/Services/ContactService.Desktop.cs new file mode 100644 index 00000000..82746c49 --- /dev/null +++ b/src/PostIt/PostIt/Services/ContactService.Desktop.cs @@ -0,0 +1,27 @@ +#if !ANDROID && !IOS +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace PostIt.Services; + +/// +/// Desktop stub for IContactService. +/// +/// On desktop targets (Linux, macOS, Windows) MAUI Essentials +/// Contacts.Default throws NotImplementedInReferenceAssemblyException, +/// so we short-circuit with an empty list rather than trying to +/// call into the portable facade at runtime. +/// +/// Future provider plug-ins (Google Contacts API, Exchange EWS, +/// CardDAV) can either replace this stub on a per-OS basis or +/// live behind their own IContactService implementation that the +/// DI container selects by configuration. +/// +public sealed class ContactService : IContactService +{ + public Task> GetDeviceContactsAsync(CancellationToken ct = default) + => Task.FromResult>(Array.Empty()); +} +#endif diff --git a/src/PostIt/PostIt/Services/ContactService.Mobile.cs b/src/PostIt/PostIt/Services/ContactService.Mobile.cs new file mode 100644 index 00000000..744fb9a6 --- /dev/null +++ b/src/PostIt/PostIt/Services/ContactService.Mobile.cs @@ -0,0 +1,64 @@ +#if ANDROID || IOS +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Maui.ApplicationModel.Communication; +using Microsoft.Maui.ApplicationModel; +using Microsoft.Maui.Devices; + +namespace PostIt.Services; + +/// +/// Mobile implementation backed by MAUI Essentials Contacts.Default. +/// +/// Compiled only for ANDROID and IOS. On desktop targets, see +/// ContactService.Desktop.cs (the stub that wins at compile time). +/// +/// Note: at runtime, this class throws +/// NotImplementedInReferenceAssemblyException unless the host +/// application project also references the platform-specific +/// Microsoft.Maui.Essentials implementation (typically the +/// PostIt.Android project). On iOS the same is required via +/// PostIt.iOS. On desktop the stub is used and this file is excluded. +/// +public sealed class ContactService : IContactService +{ + public async Task> GetDeviceContactsAsync(CancellationToken ct = default) + { + if (DeviceInfo.Current.Platform == DevicePlatform.Unknown) + return Array.Empty(); + + try + { + var status = await Permissions.RequestAsync(); + if (status != PermissionStatus.Granted) + return Array.Empty(); + + var contacts = await Contacts.Default.GetAllAsync(); + if (contacts is null) return Array.Empty(); + + var result = new List(); + foreach (var c in contacts) + { + var emails = new List(); + if (c.Emails is not null) + { + foreach (var e in c.Emails) + { + if (!string.IsNullOrEmpty(e.EmailAddress)) + emails.Add(e.EmailAddress); + } + } + result.Add(new ContactDto(c.Id, c.DisplayName ?? string.Empty, emails)); + } + return result; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"ContactService: {ex.Message}"); + return Array.Empty(); + } + } +} +#endif diff --git a/src/PostIt/PostIt/Services/ContactService.cs b/src/PostIt/PostIt/Services/ContactService.cs deleted file mode 100644 index bc71d11b..00000000 --- a/src/PostIt/PostIt/Services/ContactService.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.Maui.ApplicationModel.Communication; -using Microsoft.Maui.ApplicationModel; -using Microsoft.Maui.Devices; - -public class ContactService -{ - public async Task> GetDeviceContactsAsync() - { - // 1. Ensure the platform supports MAUI Essentials APIs - if (DeviceInfo.Current.Platform == DevicePlatform.Unknown) - { - throw new PlatformNotSupportedException("MAUI Essentials is not available on this platform."); - } - - try - { - // 2. Request runtime permission (Required for Android & iOS) - var status = await Permissions.RequestAsync(); - if (status != PermissionStatus.Granted) - { - // Permission denied by user - return Array.Empty(); - } - - // 3. Fetch all contacts - var contactsEnumerable = await Contacts.Default.GetAllAsync(); - return contactsEnumerable ?? Array.Empty(); - } - catch (Exception ex) - { - // Handle cross-platform exceptions or logs here - System.Diagnostics.Debug.WriteLine($"Error fetching contacts: {ex.Message}"); - return Array.Empty(); - } - } -} diff --git a/src/PostIt/PostIt/Services/IContactService.cs b/src/PostIt/PostIt/Services/IContactService.cs new file mode 100644 index 00000000..8c6da44a --- /dev/null +++ b/src/PostIt/PostIt/Services/IContactService.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace PostIt.Services; + +/// +/// Abstraction over device contact providers (MAUI Essentials on mobile, +/// future Google/Exchange/IMAP providers). +/// +/// Implementations live next to this file in platform-conditional +/// source files: ContactService.Mobile.cs (ANDROID/IOS) and +/// ContactService.Desktop.cs (everything else). +/// +public interface IContactService +{ + Task> GetDeviceContactsAsync(CancellationToken ct = default); +} + +/// +/// Platform-neutral contact DTO. Source-of-truth shape for the UI layer; +/// concrete providers (MAUI Essentials today, Google Contacts API later) +/// map to this type. +/// +public sealed record ContactDto( + string Id, + string DisplayName, + IReadOnlyList Emails);