Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c338af2
feat(#11281): bring up the hotspot and local server for pairing
paulpascal Sep 17, 2026
6134947
feat(#11281): serve pairing over TLS with a pinned session certificate
paulpascal Sep 17, 2026
76aafed
feat(#11281): ask for the permissions a session needs
paulpascal Sep 17, 2026
e1b7b64
feat(#11281): join the host's network and verify it by its pin
paulpascal Sep 17, 2026
273007e
feat(#11281): wire scanning, joining and host verification to the webapp
paulpascal Sep 17, 2026
5b6a888
fix(#11281): join on every supported version, and prove the key works
paulpascal Sep 17, 2026
15455e5
fix(#11281): return a scannable code and say why a session failed
paulpascal Sep 17, 2026
066c08d
fix(#11281): reset the notification store with a blocking write
paulpascal Sep 22, 2026
f8eb76b
fix(#11281): address the sonar findings on the p2p code
paulpascal Sep 22, 2026
f2f0cd2
fix(#11281): send a stable code when a scanned payload is rejected
paulpascal Sep 23, 2026
7e50ba3
fix(#11281): report an unexpected scan result as a code with a key
paulpascal Sep 23, 2026
0d2e7dc
fix(#11281): bound the response read from a paired host
paulpascal Sep 23, 2026
d1e1358
fix(#11281): bound and target the join on android 9 and below
paulpascal Sep 23, 2026
8279567
fix(#11281): detect the hotspot address off the main thread
paulpascal Sep 23, 2026
58afc85
fix(#11281): keep a usable certificate subject for any device model
paulpascal Sep 23, 2026
e3831a8
fix(#11281): drop the idle-timeout tracking nothing polls
paulpascal Sep 23, 2026
31a1a6d
docs(#11281): correct the manifest note about cleartext and tls
paulpascal Sep 23, 2026
0c00920
fix(#11281): mint a fresh certificate for each hosting session
paulpascal Sep 23, 2026
58e4981
fix(#11281): use static imports in the p2p manager spec
paulpascal Sep 23, 2026
ec7ef63
fix(#11281): authorize the padding the tls handshake needs
paulpascal Sep 24, 2026
21f4a8b
fix(#11281): never advertise a network the system did not describe
paulpascal Sep 24, 2026
6b1e9b1
fix(#11281): mint the session certificate when hosting starts
paulpascal Sep 24, 2026
b0ed624
refactor(#11281): call it offline sync everywhere, not p2p
paulpascal Sep 29, 2026
0c7b6b6
fix(#11281): make the session identity work on any device, and give t…
paulpascal Sep 29, 2026
7b93340
fix(#11281): pass the failure diagnostic on to the webapp
paulpascal Sep 30, 2026
3ab34e2
fix(#11281): seed the notification day marker instead of racing the w…
paulpascal Sep 30, 2026
d112274
chore(#11281): clear the sonar findings on the offline sync sources
paulpascal Sep 30, 2026
30180cb
fix(#11281): say data, not reports
paulpascal Oct 9, 2026
2b23263
refactor(#11281): clean up comments
paulpascal Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,11 @@ android {
}

dependencies {
// Offline sync over WiFi hotspot (#11281): NanoHTTPD is a single-file HTTP server, ZXing does QR both ways
implementation 'org.nanohttpd:nanohttpd:2.3.1'
implementation 'com.google.zxing:core:3.5.3'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'

implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.24')
implementation 'androidx.browser:browser:1.8.0'
Expand Down
23 changes: 23 additions & 0 deletions src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@
<uses-permission android:name="android.permission.SEND_SMS"/>
-->

<!-- Offline sync over WiFi hotspot. NEARBY_WIFI_DEVICES only exists from 33, and below it the
platform requires location permission for wifi scanning, which is already declared above. -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" android:minSdkVersion="33"/>
<uses-feature android:name="android.hardware.wifi" android:required="false"/>
<uses-feature android:name="android.hardware.camera" android:required="false"/>

<application android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
Expand Down Expand Up @@ -99,6 +109,19 @@
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="true"
tools:targetApi="23" />

<!-- Offline sync over WiFi hotspot (#11281). No cleartext config is declared and none is needed: the
only outbound call is the peer's probe of the host, and that is https against a pinned
per-session certificate. -->
<activity android:name="RequestOfflineSyncPermissionsActivity"
android:screenOrientation="portrait"
android:exported="false"
tools:ignore="DiscouragedApi,LockedOrientationActivity"/>
<activity android:name=".offlinesync.QrScannerActivity"
android:screenOrientation="portrait"
android:exported="false"
tools:ignore="DiscouragedApi"/>

</application>

<queries>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@
import java.util.Arrays;
import java.util.Optional;

import org.medicmobile.webapp.mobile.offlinesync.OfflineSyncManager;
import org.medicmobile.webapp.mobile.offlinesync.OfflineSyncPeer;
import org.medicmobile.webapp.mobile.offlinesync.QrScannerActivity;

@SuppressWarnings({ "PMD.GodClass", "PMD.TooManyMethods" })
public class EmbeddedBrowserActivity extends Activity {

Expand All @@ -50,6 +54,8 @@ public class EmbeddedBrowserActivity extends Activity {
private MrdtSupport mrdt;
private FilePickerHandler filePickerHandler;
private SmsSender smsSender;
private OfflineSyncManager offlineSyncManager;
private OfflineSyncPeer offlineSyncPeer;
private ChtExternalAppHandler chtExternalAppHandler;
private boolean isMigrationRunning = false;

Expand Down Expand Up @@ -82,6 +88,19 @@ public void onReceiveValue(String result) {
error(ex, "Failed to create SmsSender.");
}

// Hosting and joining have different Android version floors, so each is created only where
// it can actually work and the webapp asks before offering either.
if(OfflineSyncManager.isHostSupported()) {
try {
this.offlineSyncManager = OfflineSyncManager.create(this, Build.MODEL);
} catch(Exception ex) {
error(ex, "Failed to create OfflineSyncManager.");
}
}
if(OfflineSyncPeer.isJoinSupported()) {
this.offlineSyncPeer = OfflineSyncPeer.create(this);
}

this.settings = SettingsStore.in(this);
this.appUrl = settings.getAppUrl();

Expand Down Expand Up @@ -228,6 +247,12 @@ protected void onActivityResult(int requestCd, int resultCode, Intent intent) {
case ACCESS_SEND_SMS_PERMISSION:
this.smsSender.resumeProcess(resultCode);
return;
case ACCESS_OFFLINE_SYNC_PERMISSIONS:
offlineSyncPermissionsResolved(resultCode == RESULT_OK);
return;
case SCAN_OFFLINE_SYNC_QR_CODE:
offlineSyncQrCodeScanned(resultCode, intent);
return;
default:
trace(this, "onActivityResult() :: no handling for requestCode=%s", requestCode.name());
}
Expand Down Expand Up @@ -268,6 +293,22 @@ SmsSender getSmsSender() {
return this.smsSender;
}

OfflineSyncManager getOfflineSyncManager() {
return this.offlineSyncManager;
}

OfflineSyncPeer getOfflineSyncPeer() {
return this.offlineSyncPeer;
}

/** Opens the scanner so the user can read a host's QR code. */
void scanOfflineSyncQrCode() {
startActivityForResult(
new Intent(this, QrScannerActivity.class),
RequestCode.SCAN_OFFLINE_SYNC_QR_CODE.getCode()
);
}

ChtExternalAppHandler getChtExternalAppHandler() {
return this.chtExternalAppHandler;
}
Expand Down Expand Up @@ -302,6 +343,25 @@ public void setMigrationRunning(boolean migrationRunning) {
isMigrationRunning = migrationRunning;
}

/**
* Whether an offline sync session may start, asking for the permissions if it may not.
*
* Returns false while the request is in flight; the webapp retries once the user has answered.
*/
public boolean getOfflineSyncPermissions() {
if (RequestOfflineSyncPermissionsActivity.hasOfflineSyncPermissions(this)) {
trace(this, "getOfflineSyncPermissions() :: Offline sync permissions already granted");
return true;
}

trace(this, "getOfflineSyncPermissions() :: Offline sync permissions not granted before, requesting access...");
startActivityForResult(
new Intent(this, RequestOfflineSyncPermissionsActivity.class),
RequestCode.ACCESS_OFFLINE_SYNC_PERMISSIONS.getCode()
);
return false;
}

public boolean getLocationPermissions() {
boolean hasFineLocation = ContextCompat.checkSelfPermission(this, ACCESS_FINE_LOCATION) == PERMISSION_GRANTED;
boolean hasCoarseLocation = ContextCompat.checkSelfPermission(this, ACCESS_COARSE_LOCATION) == PERMISSION_GRANTED;
Expand All @@ -320,6 +380,55 @@ public boolean getLocationPermissions() {
}

//> PRIVATE HELPERS
/**
* Why the scan ended without a code. The scanner reports this; without it every bad code
* would look to the user like they had cancelled the scan themselves.
*/
private static String scanFailureCode(Intent intent) {
if (intent == null) {
return "scan_cancelled";
}
String reason = intent.getStringExtra(QrScannerActivity.EXTRA_QR_ERROR);
return reason == null ? "scan_cancelled" : reason;
}

/** Hands a scanned code to the peer, which joins and then checks the host is who it claims. */
private void offlineSyncQrCodeScanned(int resultCode, Intent intent) {
if(resultCode != RESULT_OK || intent == null) {
resolveOfflineSyncPairing(false, scanFailureCode(intent));
return;
}

String payload = intent.getStringExtra(QrScannerActivity.EXTRA_QR_RESULT);
this.offlineSyncPeer.pair(payload, new OfflineSyncPeer.PairCallback() {
@Override public void onPaired(String hostLabel) {
resolveOfflineSyncPairing(true, hostLabel);
}

@Override public void onFailed(String reason) {
resolveOfflineSyncPairing(false, reason);
}
});
}

private void resolveOfflineSyncPairing(boolean ok, String detail) {
evaluateJavascript(String.format(
"try {" +
"const api = window.CHTCore.AndroidApi;" +
"if (api && api.v1 && api.v1.resolveOfflineSyncPairing) {" +
" api.v1.resolveOfflineSyncPairing(%s, %s);" +
"}" +
"} catch (error) {" +
" console.error('EmbeddedBrowserActivity :: Offline sync pairing result not delivered', error);" +
"}",
ok, org.json.JSONObject.quote(detail)));
}

private void offlineSyncPermissionsResolved(boolean granted) {
evaluateJavascript(String.format(
"window.CHTCore.AndroidApi.v1.offlineSyncPermissionsResolved(%s);", granted));
}

private void locationRequestResolved() {
evaluateJavascript("window.CHTCore.AndroidApi.v1.locationPermissionRequestResolved();");
}
Expand Down Expand Up @@ -451,7 +560,9 @@ public enum RequestCode {
ACCESS_SEND_SMS_PERMISSION(102),
CHT_EXTERNAL_APP_ACTIVITY(103),
GRAB_MRDT_PHOTO_ACTIVITY(104),
FILE_PICKER_ACTIVITY(105);
FILE_PICKER_ACTIVITY(105),
ACCESS_OFFLINE_SYNC_PERMISSIONS(106),
SCAN_OFFLINE_SYNC_QR_CODE(107);

private final int requestCode;

Expand Down
Loading
Loading