Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/CodexaCP/DG_APK/llms.txt

Use this file to discover all available pages before exploring further.

Dragon Guard Handheld’s service layer is the backbone that connects the UI to the Dragon Guard backend. Every service is registered in the .NET MAUI dependency-injection container at app startup. Singleton services hold state for the lifetime of the process; transient services are resolved fresh for each DI consumer. Understanding the lifetime and contract of each service is essential when building new view-models or extending the HTTP pipeline.

SessionService

DI lifetime: Singleton SessionService is the single source of truth for the authenticated user’s identity within the running process. It persists tokens and company data using SecureStorage with a transparent fallback to Preferences on devices where the Android Keystore is unavailable.
public class SessionService
{
    // ── Events ────────────────────────────────────────────────────────────────
    public event Action? SessionChanged;

    // ── State flags ───────────────────────────────────────────────────────────
    public bool IsAuthenticated             { get; }   // Token is non-empty
    public bool HasAmbiguousCompanyContext  { get; }   // Companies.Count > 1
    public bool HasValidOperationalContext  { get; }   // IsAuthenticated && ActiveCompanyId.HasValue && !HasAmbiguousCompanyContext

    // ── Identity properties ───────────────────────────────────────────────────
    public string                           Token           { get; }
    public string                           Email           { get; }
    public string                           FullName        { get; }
    public Guid?                            ActiveCompanyId { get; }
    public IReadOnlyList<AuthCompanyAccessDto> Companies   { get; }
    public string                           ActiveRoleCode  { get; }  // derived from Companies

    // ── Lifecycle ─────────────────────────────────────────────────────────────
    public async Task InitializeAsync();
    public async Task SignInAsync(AuthSessionResponseDto session);
    public async Task SignOutAsync();
    public async Task SetActiveCompanyAsync(Guid companyId);
}

Key behaviors

  • InitializeAsync() is called once at app startup. It reads all persisted keys from secure storage, deserializes the company list, and fires SessionChanged. If the token is missing, the company list is empty, or more than one company is present (ambiguous context), all stored session data is cleared before raising the event.
  • SignInAsync(AuthSessionResponseDto) enforces the single-company rule: it throws InvalidOperationException with a Spanish user-facing message if the session carries zero or more than one valid company.
  • SignOutAsync() clears all in-memory state and removes every persisted key from both SecureStorage and Preferences.
  • SetActiveCompanyAsync(Guid) is reserved for future multi-company flows. It throws if HasAmbiguousCompanyContext is true (the handheld currently requires exactly one company per user).
  • ActiveRoleCode is a computed property — it resolves the role from Companies by matching ActiveCompanyId, falling back to the first company entry, then to string.Empty.
SessionChanged is fired after every lifecycle operation (InitializeAsync, SignInAsync, SignOutAsync, SetActiveCompanyAsync). Shell navigation and the AuthenticatedHttpMessageHandler both subscribe to this event to react to session state transitions.

Storage keys

KeyStorageDescription
auth_tokenSecureStorage → PreferencesJWT bearer token
auth_emailSecureStorage → PreferencesUser email address
auth_full_nameSecureStorage → PreferencesDisplay name
auth_companiesSecureStorage → PreferencesJSON-serialized List<AuthCompanyAccessDto>
auth_active_company_idSecureStorage → PreferencesActive company GUID string

DI lifetime: Singleton NavigationService wraps MAUI’s NavigationPage stack with a SemaphoreSlim(1,1) to prevent double-push races that occur when users tap quickly on a handheld device. All navigation is marshalled to the main thread.
public sealed class NavigationService
{
    // Push a page — aborts and redirects to login if session is not authenticated
    public async Task PushAsync(Page page);

    // Push a page without authentication check (used for login/setup flows)
    public async Task PushUnprotectedAsync(Page page);

    // Pop the top page; no-op if NavigationStack.Count ≤ 1
    public async Task PopAsync();
}

Key behaviors

  • PushAsync checks SessionService.IsAuthenticated before acquiring the semaphore. If the session is invalid it calls app.ShowLoginAsync() and returns without pushing.
  • All three methods use WaitAsync(0) — a non-blocking semaphore attempt. If another navigation is already in progress the call is silently dropped, preventing stack corruption.
  • Navigation is always performed with animated: false for snappy handheld response.
PushAsync will redirect the user to the login screen if the token has been cleared (for example, after a 401 response from the API). Never call PushAsync inside a catch block that handles InvalidOperationException from AuthenticatedHttpMessageHandler — the redirect is already in flight.

ApiSettingsService

DI lifetime: Singleton ApiSettingsService owns the base URL strategy for every HTTP client in the app. In DEBUG builds, users can override the URL from the connection settings screen; in RELEASE builds the URL is always the compiled-in default.
public sealed class ApiSettingsService
{
    public event Action? Changed;

    public string CurrentBaseUrl { get; }

    // Returns the platform default URL (Android vs desktop)
    public string GetDefaultBaseUrl();

    // Normalizes a URL string, prepending http:// if scheme is absent
    public bool TryNormalize(string? value, out string normalized);

    // Returns normalized URL or the platform default if value is invalid
    public string NormalizeOrDefault(string? value);

    // Persists a new base URL; enforces AllowConnectionOverride guard
    public bool Save(string? value, out string normalized, out string errorMessage);

    // Resolves a relative or absolute path against CurrentBaseUrl
    public Uri BuildUri(string relativeOrAbsolute);

    // Fires a live HTTP probe against registration-options (8 s timeout)
    public async Task<(bool Success, string Message)> TestConnectionAsync(
        string? candidate = null,
        CancellationToken cancellationToken = default);
}

Key behaviors

  • TryNormalize auto-prepends http:// if the input has no scheme, then validates via Uri.TryCreate. The output is always normalized to scheme://host:port/ (trailing slash included).
  • Save returns false with a Spanish error message if AppExperienceService.AllowConnectionOverride is false (i.e., in release builds).
  • BuildUri resolves a path against CurrentBaseUrl. If the input is already an absolute http/https URI it is returned as-is; all other inputs (relative paths or URIs with any other scheme/host) are resolved against CurrentBaseUrl.
  • TestConnectionAsync probes api/auth/registration-options. A 200 response is considered a valid connection; any non-success status code returns the HTTP status number in the message.

AppExperienceService

DI lifetime: Singleton AppExperienceService gates features that should only be available in test builds. It is a pure compile-time switch — no logic is evaluated at runtime.
public sealed class AppExperienceService
{
    public bool ShowConnectionSettings { get; }   // true in DEBUG, false in RELEASE
    public bool AllowConnectionOverride { get; }  // true in DEBUG, false in RELEASE
    public string ExperienceMode { get; }         // "TEST" | "OFFICIAL"
}
AppExperienceService is injected into ApiSettingsService rather than using #if DEBUG directly in the settings service — this keeps the gate logic in a single, testable location and allows the connection settings UI to bind to ShowConnectionSettings without preprocessor directives in XAML.

DeviceIdentityService

DI lifetime: Singleton DeviceIdentityService provides a stable, human-readable device identifier and name that are sent to the backend during authentication.
public sealed class DeviceIdentityService
{
    public string DeviceId   { get; }   // WiFi MAC → ANDROID_ID → stored GUID
    public string DeviceName { get; }   // DeviceInfo.Name → Manufacturer + Model
}

Device ID resolution chain

  1. WiFi MAC address — reads wlan0 / any interface whose name contains wifi via NetworkInterface. Rejects all-zero and privacy-placeholder addresses (02:00:00:00:00:00).
  2. ANDROID_ID — reads Settings.Secure.ANDROID_ID via the Android ContentResolver. Uppercased.
  3. Stored GUID — generates a new Guid.NewGuid().ToString("N").ToUpperInvariant(), persists it to Preferences under handheld_device_id, and returns it for all future calls.
On non-Android platforms only step 3 applies.

AuthenticatedHttpMessageHandler

DI lifetime: Transient (delegating handler) AuthenticatedHttpMessageHandler is a DelegatingHandler that sits in the HTTP pipeline for all authenticated API clients. It adds the two headers required by the Dragon Guard backend on every outgoing request.
public class AuthenticatedHttpMessageHandler : DelegatingHandler
{
    // Injected automatically by DI
    public AuthenticatedHttpMessageHandler(SessionService sessionService);

    // Overrides DelegatingHandler.SendAsync
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken);
}

Header injection logic

ConditionAction
IsAuthenticated is falseSigns out, redirects to login, throws InvalidOperationException
Always (when authenticated)Sets Authorization: Bearer <Token>
ActiveCompanyId has value and header not already presentSets X-Company-Id: <guid>
Response is 401 Unauthorized or 403 ForbiddenSigns out and redirects to login
Because this handler calls SessionService.SignOutAsync() and app.ShowLoginAsync() on 401/403, consumers do not need to handle auth failures themselves — the pipeline takes care of it. Simply let the exception propagate and the user will be on the login screen.

DynamicBaseUrlMessageHandler

DI lifetime: Transient (delegating handler) DynamicBaseUrlMessageHandler rewrites the RequestUri of every outgoing request to use the current base URL from ApiSettingsService. This allows the base URL to be changed at runtime (in debug builds) without re-creating HTTP clients.
public sealed class DynamicBaseUrlMessageHandler : DelegatingHandler
{
    public DynamicBaseUrlMessageHandler(ApiSettingsService apiSettingsService);

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken);
}

Rewrite logic

A request URI is rewritten when it is either a relative URI or has the host placeholder.invalid. The path-and-query portion is preserved; only the authority part is replaced by ApiSettingsService.BuildUri(pathAndQuery). This means HTTP clients can be registered with BaseAddress = new Uri("http://placeholder.invalid/") as a no-op placeholder — the handler silently swaps in the real host at send time.

ApiErrorMapper

DI lifetime: Static utility (no DI registration) ApiErrorMapper translates raw HTTP error payloads (plain strings or JSON bodies) into Spanish user-facing messages. It is used by all domain services before surfacing errors to view-models.
public static class ApiErrorMapper
{
    // Maps a raw error string to a localized user message
    public static string ToUserMessage(string rawError, string fallback);
}

Message extraction strategy

ToUserMessage first calls a private ExtractMessage helper that:
  1. Tries to parse rawError as JSON and returns the value of the first matching property: message, error, title, or detail.
  2. If the JSON root is a plain string value, returns it directly.
  3. Falls back to rawError.Trim().Trim('"') for plain-text error responses.
Known error substrings and their mapped Spanish messages:
Raw error containsSpanish message
Nothing to postNo hay cantidades para postear…
BinId is required / has no Bin / ubicación interna es obligatoriaLa linea no tiene ubicacion/bin asignado…
shipmentmustbeopen / Only OPEN shipments / Only open operational shipmentsEste envio no esta bloqueado por posteo…
negativeAvailableNotAllowed / Negative available inventoryStock insuficiente. El item no permite available negativo.
Insufficient stockStock insuficiente para postear el despacho…
Over-receivingLa cantidad recibida supera la cantidad esperada…
Sequence not configuredFalta configurar la secuencia de posteo en el sistema.
Already postedEste documento ya fue posteado.

FlexibleGuidJsonConverter

Type: JsonConverter<Guid> (System.Text.Json) FlexibleGuidJsonConverter is a custom JSON converter that prevents deserialization failures when the backend returns a GUID as a malformed string, an empty string, or null. It is registered on all JsonSerializerOptions instances used within the app.
public sealed class FlexibleGuidJsonConverter : JsonConverter<Guid>
{
    // Returns Guid.Empty for null tokens or strings that fail Guid.TryParse
    public override Guid Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options);

    // Writes the Guid as a standard string value
    public override void Write(
        Utf8JsonWriter writer,
        Guid value,
        JsonSerializerOptions options);
}
Read handles three token types:
  • JsonTokenType.NullGuid.Empty
  • JsonTokenType.StringGuid.TryParse; returns Guid.Empty on failure
  • Any other token type → throws JsonException

Domain Services

The following services wrap specific backend API endpoints. They follow a consistent pattern: each receives an HttpClient via constructor injection and uses ApiErrorMapper.ToUserMessage to surface errors.

BinService

DI lifetime: Transient (uses AddHttpClient<BinService>)
public class BinService
{
    public BinService(HttpClient http);

    // GET api/bins?activeOnly=true&pageNumber=1&pageSize={pageSize}
    public async Task<List<BinDto>> GetActiveBinsAsync(int pageSize = 100);
}

CompanyService

DI lifetime: Transient
public class CompanyService
{
    public CompanyService(HttpClient httpClient);

    // POST api/companies
    public async Task<CompanyResponse> CreateCompanyAsync(CreateCompanyRequest request);
}

TransferService

DI lifetime: Transient
public class TransferService
{
    public TransferService(HttpClient http);

    // GET api/transferheaders?pageNumber={n}&pageSize={n}
    public async Task<List<TransferHeaderDto>> GetTransferHeadersAsync(
        int pageNumber = 1, int pageSize = 50);

    // POST api/transferheaders/post
    public async Task PostTransferResultAsync(PostTransferDto dto);
}
TransferService uses ApiErrorMapper.ToUserMessage for both operations, producing Spanish error messages for stock, sequence, and already-posted conditions.

BaseViewModel

Location: Handheld.ViewModels.Base BaseViewModel is the abstract base class for all view-models. It implements INotifyPropertyChanged with a CallerMemberName-based SetProperty<T> helper to eliminate boilerplate.
public abstract class BaseViewModel : INotifyPropertyChanged
{
    public bool   IsLoading    { get; set; }   // raises IsEmpty + IsNotLoading
    public bool   IsNotLoading { get; }        // computed: !IsLoading
    public bool   HasError     { get; set; }   // raises IsEmpty
    public string ErrorMessage { get; set; }

    public virtual bool IsEmpty { get; }       // override in subclasses

    public event PropertyChangedEventHandler? PropertyChanged;

    protected void OnPropertyChanged([CallerMemberName] string? propertyName = null);

    protected bool SetProperty<T>(
        ref T backingStore,
        T value,
        [CallerMemberName] string? propertyName = null);
}
Setting IsLoading automatically raises PropertyChanged for both IsEmpty and IsNotLoading, so XAML bindings on all three properties stay in sync without any extra code in subclasses.

Build docs developers (and LLMs) love