Skip to content

feat(#11281): add an offline sync pairing screen - #11450

Open
paulpascal wants to merge 46 commits into
medic:11284-offline-syncfrom
paulpascal:11281-p2p-pairing-ui
Open

paulpascal wants to merge 46 commits into
medic:11284-offline-syncfrom
paulpascal:11281-p2p-pairing-ui

Conversation

@paulpascal

@paulpascal paulpascal commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Description

Closes #11281 (webapp half). The android half is medic/cht-android#443.

Adds an offline-sync page where one device hosts a session and another joins it by scanning a QR code. The page talks to cht-android only through OfflineSyncService, over the medicmobile_android bridge.

The entry shows in both navigations (header and sidebar) when the user holds the permission for that side AND the device can do it:

  • host: can_relay_offline_data_bundle and offline_sync_host_available()
  • join: can_send_offline_data_bundle and offline_sync_join_available(), never for an online-only user, since the server refuses their bundles

Hosting needs Android 8 and joining works on everything we support, so the webapp asks the app instead of guessing from a version. An app older than this webapp has the bridge but not these methods, so it counts as no bridge: the entry stays hidden and the page says "Mobile app required" instead of throwing.

Android answers through three AndroidApi.v1 callbacks: resolveOfflineSyncHostingResult, resolveOfflineSyncPairing and offlineSyncPermissionsResolved. What it sends is always a code, so all the wording lives in the translation files, en es fr ne sw. The Nepali and Swahili strings could use a native speaker's eye.

The two permissions are documented in medic/cht-docs#2267 (draft until release).

Code review checklist

  • UI/UX backwards compatible: Test it works for the new design (enabled by default). And test it works in the old design, enable can_view_old_navigation permission to see the old design. Test it has appropriate design for RTL languages.
  • Readable: Concise, well named, follows the style guide
  • Documented: Configuration and user documentation on cht-docs
  • Tested: Unit and/or e2e where appropriate
  • Internationalised: All user facing text
  • Backwards compatible: Works with existing data and configuration or includes a migration. Any breaking changes documented in the release notes.
  • AI disclosure: Please disclose use of AI per the guidelines.

License

The software is provided under AGPL-3.0. Contributions to this project are accepted under the same license.

@paulpascal
paulpascal force-pushed the 11281-p2p-pairing-ui branch 5 times, most recently from 22deadc to a75d524 Compare September 28, 2026 19:11
@paulpascal
paulpascal force-pushed the 11281-p2p-pairing-ui branch 3 times, most recently from 9e3fc1d to 77f6572 Compare October 8, 2026 05:52
@paulpascal
paulpascal force-pushed the 11281-p2p-pairing-ui branch from 77f6572 to 0d3cfe2 Compare October 8, 2026 21:54
@jkuester jkuester added the ai-review Triggers the ai-review workflow to run on the PR. label Oct 15, 2026
@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Note

🤖 This is an AI-generated review, requested by @jkuester. Its content and suggestions can be wrong. Please respond with a comment if you have doubts regarding any of the review. A maintainer will also review this PR.

🔍 OpenCodeReview found 24 issue(s) in this PR.

  • ✅ Successfully posted inline: 24 comment(s)

Requirements

  • Delivered — Adds an offline-sync page — webapp/src/ts/modules/offline-sync/offline-sync.routes.ts:8, registered at webapp/src/ts/app-routing.module.ts:32
  • Delivered — Host starts a session from a webapp button that calls the bridge hook — webapp/src/ts/modules/offline-sync/offline-sync.component.html:44 → webapp/src/ts/services/offline-sync.service.ts:122
  • Delivered — Host screen shows the QR code the peer scans, plus the SSID and password as text — webapp/src/ts/modules/offline-sync/offline-sync.component.html:79, webapp/src/ts/modules/offline-sync/offline-sync.component.html:84, tested at webapp/tests/karma/ts/modules/offline-sync/offline-sync.component.spec.ts:118
  • Delivered — Peer joins from a "Scan QR code" button that calls the bridge hook — webapp/src/ts/modules/offline-sync/offline-sync.component.html:56 → webapp/src/ts/services/offline-sync.service.ts:134, tested at webapp/tests/karma/ts/modules/offline-sync/offline-sync.component.spec.ts:185
  • Delivered — The page reaches cht-android only through OfflineSyncService, over window.medicmobile_android — webapp/src/ts/services/offline-sync.service.ts:61
  • Delivered — The entry shows in the header navigation — webapp/src/ts/components/header/header.component.html:79, driven by webapp/src/ts/components/base-menu/base-menu.component.ts:47
  • Delivered — The entry shows in the sidebar navigation — webapp/src/ts/components/sidebar-menu/sidebar-menu.component.html:64, tested at webapp/tests/karma/ts/components/sidebar-menu/sidebar-menu.component.spec.ts:167
  • Delivered — Hosting requires can_relay_offline_data_bundle and offline_sync_host_available() — webapp/src/ts/services/offline-sync.service.ts:92, tested at webapp/tests/karma/ts/services/offline-sync.service.spec.ts:60
  • Delivered — Joining requires can_send_offline_data_bundle and offline_sync_join_available() — webapp/src/ts/services/offline-sync.service.ts:100, tested at webapp/tests/karma/ts/services/offline-sync.service.spec.ts:67
  • Delivered — Joining is never offered to an online-only user — webapp/src/ts/services/offline-sync.service.ts:102, tested at webapp/tests/karma/ts/services/offline-sync.service.spec.ts:88
  • Delivered — Capability is asked of the app rather than inferred from an Android version — webapp/src/ts/services/offline-sync.service.ts:93, webapp/src/ts/services/offline-sync.service.ts:102
  • Delivered — An app with the bridge but without these methods counts as no bridge, so the entry stays hidden and nothing throws — webapp/src/ts/services/offline-sync.service.ts:63, tested at webapp/tests/karma/ts/services/offline-sync.service.spec.ts:53
  • Delivered — The page shows "Mobile app required" when unsupported — webapp/src/ts/modules/offline-sync/offline-sync.component.html:14, api/resources/translations/messages-en.properties:1406, tested at webapp/tests/karma/ts/modules/offline-sync/offline-sync.component.spec.ts:80
  • Delivered — Android answers through the three AndroidApi.v1 callbacks resolveOfflineSyncHostingResult, resolveOfflineSyncPairing and offlineSyncPermissionsResolved — webapp/src/ts/services/android-api.service.ts:239, tested at webapp/tests/karma/ts/services/android-api.service.spec.ts:201
  • Delivered — The native side sends codes only, and every user-facing string comes from the translation files in en, es, fr, ne and sw — webapp/src/ts/modules/offline-sync/offline-sync.component.ts:127, api/resources/translations/messages-en.properties:1403, api/resources/translations/messages-es.properties:1403, api/resources/translations/messages-fr.properties:1403, api/resources/translations/messages-ne.properties:1403, api/resources/translations/messages-sw.properties:1403
  • Pending verification — The styling works in both navigation designs and in RTL (the unchecked "UI/UX backwards compatible" item) — webapp/src/css/offline-sync.less:1 adds no RTL rules and no test or screenshot exercises the page; rendering it under can_view_old_navigation and an RTL locale would settle it.

Preconditions to confirm

  • cht-android must expose offline_sync_host_available, offline_sync_join_available, offline_sync_start_hosting, offline_sync_stop_hosting, offline_sync_is_hosting, offline_sync_scan_and_join and offline_sync_leave_session, and invoke the three v1 callbacks — webapp/src/ts/services/offline-sync.service.ts:122 calls them but feat(#11281): bring up the wifi hotspot and pairing cht-android#443 supplies them.
  • Every failure code cht-android can report needs a key in all five files, or the raw offline_sync.error.<code> reaches the user — webapp/src/ts/modules/offline-sync/offline-sync.component.ts:127.
  • Both permissions ship with no roles assigned, so the entry is hidden until a deployment grants them — config/default/app_settings.json:288.
  • The hosting payload must be JSON carrying qr, ssid and password; anything else is reported as payload_failed — webapp/src/ts/services/offline-sync.service.ts:150.

Undisclosed Changes

  • Every failure submits a feedback document to the server carrying the failure code, the device make, model and Android version, and the native diagnostic string — webapp/src/ts/modules/offline-sync/offline-sync.component.ts:132, with the device details read from the bridge at webapp/src/ts/services/offline-sync.service.ts:78.
  • isHosting() is added to the service and exercised by a test but is never called from the page or either menu — webapp/src/ts/services/offline-sync.service.ts:130.
  • .offline-sync-scope is added to the stylesheet but matches no element in the template — webapp/src/css/offline-sync.less:53.

Alternative Approaches

  • The bridge feature-detection follows the established pattern in webapp/src/ts/services/android-app-launcher.service.ts:17 and webapp/src/ts/services/mrdt.service.ts:13, which the CHT documentation names as the way to treat an older app as lacking the feature; no better approach found.
  • deviceDescription() is a third copy of the getDeviceInfo() parse already in webapp/src/ts/services/telemetry.service.ts:108 and webapp/src/ts/modules/about/about.component.ts:141; one of those could be promoted to a shared helper instead.

Comment on lines +1403 to +1404
offline_sync.menu.title = Offline sync
offline_sync.title = Offline sync

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
This file is otherwise maintained in alphabetical key order (e.g. ...number\ records → online.action.message → overwrite.existing.records.help → partner.*... around line 989-992). The new offline_sync.* block (48 keys) is appended at the very end of the file instead of being inserted alphabetically between number\ records and online.action.message, where offline_sync sorts. This breaks the established convention and makes future diffs/merges harder to review, and increases the risk of accidentally introducing duplicate keys that go unnoticed.

Comment on lines +1403 to +1410
offline_sync.menu.title = Offline sync
offline_sync.title = Offline sync
offline_sync.loading = Checking this device...
offline_sync.unsupported.title = Mobile app required
offline_sync.unsupported.description = Offline sync requires the CHT Android app. Open this page on your Android device to use it.
offline_sync.no_role.title = Offline sync not available
offline_sync.no_role.description = Your user role is not configured for offline sync. Contact your administrator if you need access.
offline_sync.host.title = Receive data

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
Beyond being appended at the end of the file, the new offline_sync.* keys are also not sorted alphabetically among themselves (menu.title, title, loading, unsupported., no_role., host., wifi., peer., failed., error.* — grouped by UI flow instead). Other multi-key groups in this file (e.g. android_app., contact.profile.) keep their sub-keys in strict alphabetical order. Sorting this block internally (e.g. error.* < failed.* < host.* < loading < menu.title < no_role.* < peer.* < title < unsupported.* < wifi.*) would match the established convention and make future diffs/lookups easier.

Suggestion:

Suggested change
offline_sync.menu.title = Offline sync
offline_sync.title = Offline sync
offline_sync.loading = Checking this device...
offline_sync.unsupported.title = Mobile app required
offline_sync.unsupported.description = Offline sync requires the CHT Android app. Open this page on your Android device to use it.
offline_sync.no_role.title = Offline sync not available
offline_sync.no_role.description = Your user role is not configured for offline sync. Contact your administrator if you need access.
offline_sync.host.title = Receive data
offline_sync.error.certificate_failed = This device cannot secure the connection. Offline sync will not work on this phone, use another device to receive.
... (keys sorted alphabetically) ...
offline_sync.wifi.ssid = WiFi network

Comment on lines +25 to +28
.mat-mdc-button-base {
margin-right: 10px;
margin-top: 5px;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style · low
margin-right is a physical property and isn't swapped by direction: rtl. Other modules in this codebase that use margin-right pair it with a matching override in rtl.less (see .select-mode-available, .item-summary, etc.), but no such override was added for .offline-sync here, so the host/scan/stop/retry buttons will keep right-side spacing even in RTL locales.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related: we should be sure to smoke-test the new layout with an RTL language (Arabic) and make sure it renders as expected.

Comment thread webapp/src/css/offline-sync.less Outdated
Comment on lines +53 to +57
.offline-sync-scope {
display: flex;
flex-direction: column;
margin-bottom: 10px;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
.offline-sync-scope isn't referenced anywhere in offline-sync.component.html (or anywhere else in the webapp). This looks like leftover/dead CSS from an earlier version of the template — either wire it up where it's needed or remove it.

*/
private async checkOfflineSyncVisibility() {
try {
this.offlineSyncVisible = await this.offlineSyncService.canHost() || await this.offlineSyncService.canJoin();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance · medium
canHost() and canJoin() are independent async checks (each calls AuthService.has(), which hits CHTDatasourceService), but they are awaited sequentially here, so most users (who can't host) pay for two round-trips instead of one. OfflineSyncComponent.ngOnInit() already runs the same two calls via Promise.all([...]) — using the same pattern here would be both faster and consistent with that file.

Suggestion:

Suggested change
this.offlineSyncVisible = await this.offlineSyncService.canHost() || await this.offlineSyncService.canJoin();
const [canHost, canJoin] = await Promise.all([
this.offlineSyncService.canHost(),
this.offlineSyncService.canJoin(),
]);
this.offlineSyncVisible = canHost || canJoin;

Comment on lines +61 to +62
private get bridge() {
const bridge = (globalThis as any)?.medicmobile_android ?? null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
medicmobile_android is already declared as any on the global Window interface in polyfills.ts, and other services (app.component.ts, task-notifications.service.ts) access it via globalThis?.medicmobile_android without any cast. The (globalThis as any) cast here is an unnecessary, uncommented any usage that's inconsistent with the rest of the codebase.

Suggestion:

Suggested change
private get bridge() {
const bridge = (globalThis as any)?.medicmobile_android ?? null;
private get bridge() {
const bridge = globalThis?.medicmobile_android ?? null;

Comment on lines +130 to +132
isHosting(): boolean {
return !!this.bridge?.offline_sync_is_hosting();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
isHosting() is never called from OfflineSyncComponent or anywhere else in the app (only exercised by its own unit test). As it stands, OfflineSyncComponent.ngOnInit never checks whether a hosting session is already active, so re-entering this screen after navigating away (or recreating the component) while the native side is still hosting will show the 'idle' start-hosting card instead of reflecting the in-progress session. Either use this method to restore state on init, or remove it if it's genuinely unused.

Comment on lines +161 to +163
permissionsResolvedBy(granted: boolean) {
this.permissionsSubject.next(granted);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style · low
Naming is inconsistent with the sibling resolver methods on this service: hostingResolved(...) and pairingResolved(...) follow a <noun>Resolved pattern, but this one is permissionsResolvedBy. Consider renaming to permissionsResolved(granted) (and renaming the observable getter above, which already has that name, to something else) for consistency.

Comment on lines +52 to +54
fixture = TestBed.createComponent(OfflineSyncComponent);
component = fixture.componentInstance;
await component.ngOnInit();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
create() manually invokes and awaits ngOnInit() (needed because it's async), but two tests ('shows the network beside the code...' and 'offers a way back after a failure') later call fixture.detectChanges(). Since detectChanges() has never run on this fixture yet, Angular treats it as the view's first check and invokes ngOnInit() a second time, adding a second subscription to hostingResult()/pairingResult()/permissionsResolved(). In the current tests this is harmless only because no .next() call happens after that second detectChanges(); if a future edit emits on these subjects afterward, handlers (and side effects like feedbackService.submit) would fire twice, silently producing wrong counts. Consider guarding against a second detectChanges()-triggered ngOnInit() (e.g. by not calling detectChanges() in those two tests, or by not manually invoking ngOnInit() and instead awaiting the service promises after a single detectChanges()).

Comment on lines +87 to +89
expect(component.supported).to.be.false;
expect(component.canHost).to.be.false;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test · low
The test configures canJoin to resolve false but never asserts component.canJoin, so this branch is exercised without verification. If the component ever fails to wire the join-availability flag through (e.g. only canHost is read), this test would still pass even though the title claims the screen 'offers nothing'.

Suggestion:

Suggested change
expect(component.supported).to.be.false;
expect(component.canHost).to.be.false;
});
expect(component.supported).to.be.false;
expect(component.canHost).to.be.false;
expect(component.canJoin).to.be.false;
});

@github-actions github-actions Bot removed the ai-review Triggers the ai-review workflow to run on the PR. label Oct 15, 2026
@medic medic deleted a comment from github-actions Bot Oct 15, 2026
@medic medic deleted a comment from github-actions Bot Oct 15, 2026
@medic medic deleted a comment from github-actions Bot Oct 15, 2026
@medic medic deleted a comment from github-actions Bot Oct 15, 2026
@medic medic deleted a comment from github-actions Bot Oct 15, 2026
@medic medic deleted a comment from github-actions Bot Oct 15, 2026
@medic medic deleted a comment from github-actions Bot Oct 15, 2026
@medic medic deleted a comment from github-actions Bot Oct 15, 2026
@paulpascal

Copy link
Copy Markdown
Contributor Author

Cc: @inromualdo

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants