Skip to content

PRD: Offline Mode — MAUI Blazor Hybrid Android App #110

@fboucher

Description

@fboucher

Problem Statement

As a NoteBookmark user, I rely on the app to save bookmarks and take reading notes throughout my day. However, because the current app is a Blazor Server application — where all rendering depends on a live SignalR connection to the server — I am completely unable to use it when I am offline (e.g., on an airplane, in a low-connectivity area, or simply away from Wi-Fi on my Android tablet). I cannot browse my saved posts, I cannot write notes, and I cannot review summaries. Any work I want to do with my reading list must wait until I am back online.


Solution

Build NoteBookmark.MauiApp, a .NET MAUI Blazor Hybrid Android application, as a second client that runs alongside the existing web app. Both clients share the same REST API (NoteBookmark.Api). The MAUI app adds a local SQLite database for offline storage and a sync engine that reconciles changes when connectivity is restored, using a last-write-wins strategy based on DateModified timestamps.

This approach reuses the existing Blazor component library (extracted into a shared Razor Class Library) and avoids any breaking changes to the existing web application.


User Stories

  1. As a user, I want to install the NoteBookmark app on my Android tablet so that I can access my reading list natively without opening a browser.
  2. As a user, I want to browse all my saved posts while offline so that I can decide what to read even without internet access.
  3. As a user, I want to read the metadata (title, author, URL, date) of saved posts while offline so that I can choose which article to open.
  4. As a user, I want to write notes on a saved post while offline so that I can capture my thoughts immediately as I read.
  5. As a user, I want to edit an existing note while offline so that I can refine my thoughts without needing a connection.
  6. As a user, I want to mark a post as read while offline so that my reading list stays up to date even when disconnected.
  7. As a user, I want my offline changes to sync automatically when I come back online so that I don't have to manually trigger a sync.
  8. As a user, I want the sync to use the most recent version of a record (last-write-wins) so that I don't lose work done on either device.
  9. As a user, I want to see a clear visual indicator when the app is in offline mode so that I understand why some actions may be limited.
  10. As a user, I want to log in to the app once and stay logged in, even across offline periods, so that I don't have to re-authenticate every time I open the app.
  11. As a user, I want the app to silently refresh my authentication token when I come back online so that my session is never interrupted.
  12. As a user, I want to search my saved posts while offline so that I can find specific articles in my local cache.
  13. As a user, I want to filter my posts by read/unread status while offline so that I can focus on what I haven't read yet.
  14. As a user, I want to view my notes for a post while offline so that I can review what I previously wrote.
  15. As a user, I want to view the list of summaries while offline so that I can review previously generated summaries.
  16. As a user, I want the app's settings (theme, AI configuration) to be available offline so that the UI is consistent regardless of connectivity.
  17. As a user, I want the sync to run in the foreground when I resume the app after being offline so that my latest changes are pushed and pulled promptly.
  18. As a user, I want the app to queue any writes made offline and replay them in order when the connection is restored so that no data is lost.
  19. As a user, I want delta sync — only transferring records changed since my last sync — so that the sync is fast and uses minimal data.
  20. As a user, I want the MAUI app to feel visually consistent with the web app (same Fluent UI design system) so that the experience is familiar.

Implementation Decisions

New Projects

  • NoteBookmark.SharedUI — Razor Class Library. Reusable Blazor components extracted from NoteBookmark.BlazorApp. Both the existing web app and the new MAUI app reference this library. Components include: post list, post detail, note dialog, summary list, search, and settings form.
  • NoteBookmark.MauiApp — .NET MAUI Blazor Hybrid project targeting Android. References NoteBookmark.SharedUI and NoteBookmark.Domain. Contains the MAUI shell (app lifecycle, navigation, DI bootstrap) and all offline/sync infrastructure.

Domain / API Changes

  • Add DateModified (UTC timestamp) to the Post and Note domain models and their Azure Table Storage representations. Updated automatically on every create or update.
  • Extend the existing API endpoints to support a modifiedAfter query parameter (ISO 8601 timestamp), enabling delta fetch: GET /posts?modifiedAfter={ts} and GET /notes?modifiedAfter={ts}.
  • The DateModified field is also used as the tiebreaker in last-write-wins conflict resolution during sync.

Local Storage Layer (MauiApp)

  • ILocalDataService — interface mirroring the data operations needed by the UI (get posts, get notes, save note, update post, etc.).
  • LocalDataService — sqlite-net-pcl implementation. Local schema mirrors Post and Note domain models with three additional fields: DateModified (UTC), IsPendingSync (bool), IsDeleted (soft delete flag).
  • Database is initialized and migrated on app startup.
  • Stores LastSyncTimestamp in MAUI Preferences.

Offline-Aware Data Layer (MauiApp)

  • IOfflineDataService — single interface used by all Blazor components inside MauiApp. Routes read/write operations based on connectivity state.
  • OfflineDataService — implementation: when offline, delegates to ILocalDataService; when online, calls the remote API (PostNoteClient) and mirrors results to local SQLite. Writes made offline are flagged IsPendingSync = true.

Sync Engine (MauiApp)

  • SyncService — triggered on App.OnResume and on the Connectivity.ConnectivityChanged event (transition to online).
  • Push phase: query all local records where IsPendingSync = true; PATCH/POST each to the API; on success, clear the flag.
  • Pull phase: call GET /posts?modifiedAfter={LastSyncTimestamp} and GET /notes?modifiedAfter={LastSyncTimestamp}; for each returned record, apply last-write-wins against the local copy using DateModified; update LastSyncTimestamp to now.
  • Soft-deleted local records are pushed as deletes during the push phase.

Authentication (MauiApp)

  • OIDC via WebAuthenticator — opens Keycloak login in the system browser; handles the redirect callback.
  • Token cache — access token and refresh token stored in MAUI SecureStorage.
  • On app startup: load cached tokens; if the access token is expired and the device is online, attempt a silent refresh; if offline and token is not yet expired, allow access.
  • If the refresh fails or the token is expired while offline, the user sees a "Session expired — please go online to re-authenticate" message.

UI / UX

  • Offline banner — a persistent banner is shown at the top of the app whenever Connectivity.NetworkAccess != NetworkAccess.Internet, clearly labelling the offline state.
  • All write actions (add note, mark as read) are permitted while offline; they are queued and synced later.
  • Navigation and layout reuse the shared Fluent UI components from NoteBookmark.SharedUI.

Authorization

  • All screens in NoteBookmark.MauiApp require a valid (possibly cached) authenticated session.
  • API calls from the MAUI app use the cached Bearer token in the Authorization header, identical to the web app.

Testing Decisions

Good tests verify observable behavior through public contracts — not internal implementation details. Tests should not assert on private fields, internal method calls, or specific SQL queries.

LocalDataService — Integration Tests

  • Use an in-memory or temp-file SQLite database.
  • Test: save a post/note, retrieve it, update it, soft-delete it, query pending sync records.
  • Prior art: the existing ApiTestFixture / AzureStorageTestFixture patterns in the test projects.

SyncService — Unit Tests

  • Mock ILocalDataService and PostNoteClient (or IOfflineDataService).
  • Test: push phase sends all IsPendingSync=true records and clears the flag on success; pull phase applies last-write-wins correctly (remote newer wins, local newer wins); LastSyncTimestamp is updated after a successful sync.
  • Use xUnit + FluentAssertions (already in the codebase).

API Endpoints — Integration Tests

  • Use WebApplicationFactory (already used in NoteBookmark.Api.Tests).
  • Test: GET /posts?modifiedAfter={ts} returns only records modified after the given timestamp; GET /notes?modifiedAfter={ts} same; PATCH /posts/{id} and PATCH /notes/{id} correctly update DateModified.
  • Tests should cover both empty-result and multi-result cases for delta queries.

Out of Scope

  • iOS support — Android is the primary target; iOS can be added in a future issue.
  • Background sync — sync runs in the foreground only (app resume / connectivity change); no background service or background fetch.
  • File and blob sync — markdown export files and blob-stored content are not synced offline; only structured Post and Note records are.
  • Multi-device conflict resolution — beyond last-write-wins. Complex merge strategies are deferred.
  • Play Store publishing — APK build configuration is in scope; store submission is a separate concern.
  • Push notifications for sync status.

Further Notes

  • The existing NoteBookmark.BlazorApp is not modified in terms of behavior — only the Blazor component library extraction (NoteBookmark.SharedUI) affects it structurally.
  • The v-next branch should be used as the base for all feature branches related to this PRD, and PRs should merge back into v-next.
  • All GitHub issues spawned from this PRD should carry the app label.
  • The DateModified field addition to the API is a non-breaking change — existing clients that don't send modifiedAfter simply receive all records as before.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    Status

    Backlog

    Status

    No status

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions