Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 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
44cbac6
feat(#11281): hand the hosted session back to the page
paulpascal Oct 16, 2026
a9bd400
refactor(#11281): drop two getters nothing calls
paulpascal Oct 16, 2026
75f83ec
test(#11281): check a new start clears the previous session
paulpascal Oct 16, 2026
7a418ea
refactor(#11281): keep only what holds for both readers of the hostin…
paulpascal Oct 16, 2026
2ae1b87
fix(#11281): declare the portrait scanner activity
paulpascal Oct 16, 2026
008cb0c
refactor(#11281): open the portrait scanner without a test hook
paulpascal Oct 17, 2026
f688a59
refactor(#11281): tidy the scanner comments and manifest
paulpascal Oct 17, 2026
878df74
fix(#11281): stop refusing a pairing code by its age
paulpascal Oct 18, 2026
d59732f
fix(#11281): keep the offline sync session when the screen is recreated
paulpascal Oct 18, 2026
0b53823
refactor(#11281): validate a scanned code only where it is joined
paulpascal Oct 18, 2026
a16b975
refactor(#11281): build the pairing callback in one place
paulpascal Oct 18, 2026
7bd0cf2
refactor(#11281): drop the unused scanner constant and the old key alias
paulpascal Oct 18, 2026
7c926e5
refactor(#11281): correct the permission comments and drop an issue n…
paulpascal Oct 18, 2026
c1c52e1
fix(#11281): report a hotspot refused after the permission check as a…
paulpascal Oct 19, 2026
b7e283f
refactor(#11281): make the offline sync singletons' factories private
paulpascal Oct 19, 2026
aaf67e1
fix(#11281): send offline sync results to the webapp without URL deco…
paulpascal Oct 19, 2026
1995996
fix(#11281): keep the bridge thread usable after a failed certificate…
paulpascal Oct 19, 2026
c6f3a22
fix(#11281): ignore losing the previous wifi while joining before And…
paulpascal Oct 19, 2026
0a153d6
refactor(#11281): drop the unused QR bitmap methods
paulpascal Oct 19, 2026
e4e3a60
refactor(#11281): describe the key pin and session entries as they are
paulpascal Oct 19, 2026
d878224
fix(#11281): say location is off when joining on Android 8.1 and 9
paulpascal Oct 19, 2026
c8b88e4
fix(#11281): bound the host's response by characters, not lines
paulpascal Oct 19, 2026
ccaec0f
refactor(#11281): describe the pin as a key in the remaining comments
paulpascal Oct 19, 2026
0bcda22
refactor(#11281): drop the always-true join support check
paulpascal Oct 19, 2026
8885008
fix(#11281): open the app on a device without wifi
paulpascal Oct 20, 2026
e95a891
refactor(#11281): correct three offline sync comments
paulpascal Oct 20, 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: 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package org.medicmobile.webapp.mobile;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertSame;

import android.Manifest;
import android.os.SystemClock;
import android.webkit.WebView;

import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.rule.GrantPermissionRule;

import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.medicmobile.webapp.mobile.offlinesync.OfflineSyncManager;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

import org.json.JSONTokener;

@RunWith(AndroidJUnit4.class)
public class EmbeddedBrowserActivityTest {

@Rule
public GrantPermissionRule permissionRule = GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS);

@Test
public void recreating_keepsTheSameManager() {
AtomicReference<OfflineSyncManager> manager = new AtomicReference<>();
try (ActivityScenario<EmbeddedBrowserActivity> scenario = ActivityScenario.launch(EmbeddedBrowserActivity.class)) {
scenario.onActivity(activity -> manager.set(activity.getOfflineSyncManager()));

scenario.recreate();

assertNotNull(manager.get());
scenario.onActivity(activity -> assertSame(manager.get(), activity.getOfflineSyncManager()));
}
}

@Test
public void aHostLabel_reachesTheWebappAsText() throws Exception {
String label = "x%22);}}catch(e){}window.injected=true;try{if(1){(%22";
try (ActivityScenario<EmbeddedBrowserActivity> scenario = ActivityScenario.launch(EmbeddedBrowserActivity.class)) {
scenario.onActivity(activity -> activity.evaluateJavascript(
"window.CHTCore = { AndroidApi: { v1: { resolveOfflineSyncPairing: (ok, detail) => { window.received = detail; } } } };",
false));

scenario.onActivity(activity -> activity.resolveOfflineSyncPairing(true, label));

assertEquals(label, new JSONTokener(waitFor(scenario, "window.received")).nextValue());
assertEquals("\"undefined\"", read(scenario, "typeof window.injected"));
}
}

private static String waitFor(ActivityScenario<EmbeddedBrowserActivity> scenario, String expression) throws Exception {
long deadline = SystemClock.uptimeMillis() + 5000;
String value = read(scenario, expression);
while ("null".equals(value) && SystemClock.uptimeMillis() < deadline) {
SystemClock.sleep(100);
value = read(scenario, expression);
}
return value;
}

private static String read(ActivityScenario<EmbeddedBrowserActivity> scenario, String expression) throws Exception {
AtomicReference<String> value = new AtomicReference<>();
CountDownLatch done = new CountDownLatch(1);
scenario.onActivity(activity -> ((WebView) activity.findViewById(R.id.wbvMain))
.evaluateJavascript(expression, result -> {
value.set(result);
done.countDown();
}));
done.await(5, TimeUnit.SECONDS);
return value.get();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package org.medicmobile.webapp.mobile.offlinesync;

import static org.junit.Assert.assertNotNull;

import android.Manifest;
import android.app.Activity;
import android.app.Instrumentation;

import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.rule.GrantPermissionRule;

import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

@RunWith(AndroidJUnit4.class)
public class QrScannerActivityTest {

@Rule
public GrantPermissionRule cameraPermission = GrantPermissionRule.grant(Manifest.permission.CAMERA);

@Test
public void opensTheScannerScreen() {
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
Instrumentation.ActivityMonitor scanner = instrumentation.addMonitor(
PortraitCaptureActivity.class.getName(), null, false);

Activity opened;
try (ActivityScenario<QrScannerActivity> ignored = ActivityScenario.launch(QrScannerActivity.class)) {
opened = instrumentation.waitForMonitorWithTimeout(scanner, 10_000);
assertNotNull("the scanner screen never opened", opened);
} finally {
instrumentation.removeMonitor(scanner);
}
instrumentation.runOnMainSync(opened::finish);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package org.medicmobile.webapp.mobile.offlinesync;

import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;

import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties;

import androidx.test.ext.junit.runners.AndroidJUnit4;

import java.security.GeneralSecurityException;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.Certificate;
import java.util.Collections;

import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;

@RunWith(AndroidJUnit4.class)
public class SessionCertificateDeviceTest {

private static final String OTHER = "session-certificate-device-test";

private SessionCertificate certificate;

@After
public void tearDown() throws Exception {
Thread.interrupted();
if (certificate != null) {
certificate.destroy();
}
keyStore().deleteEntry(OTHER);
}

@Test
public void aFailedHandshake_leavesTheCallingThreadUsable() throws Exception {
certificate = SessionCertificate.forDevice("test");
certificate.renew();
pairOurKeyWithAnotherCertificate();

assertThrows(GeneralSecurityException.class, certificate::assertUsableOnThisDevice);
assertFalse(Thread.currentThread().isInterrupted());
}

/** The served key no longer matches the certificate the self-test pins, so the handshake fails. */
private static void pairOurKeyWithAnotherCertificate() throws Exception {
KeyPairGenerator generator =
KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore");
generator.initialize(new KeyGenParameterSpec.Builder(OTHER, KeyProperties.PURPOSE_SIGN)
.setDigests(KeyProperties.DIGEST_SHA256)
.build());
generator.generateKeyPair();

KeyStore keyStore = keyStore();
Certificate other = keyStore.getCertificate(OTHER);
for (String alias : Collections.list(keyStore.aliases())) {
if (!alias.equals(OTHER)) {
PrivateKey ours = (PrivateKey) keyStore.getKey(alias, null);
keyStore.setKeyEntry(alias, ours, null, new Certificate[] { other });
}
}
}

private static KeyStore keyStore() throws Exception {
KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore");
keyStore.load(null);
return keyStore;
}
}
31 changes: 31 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,27 @@
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="true"
tools:targetApi="23" />

<!-- Offline sync over WiFi hotspot. 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"/>
<activity android:name=".offlinesync.QrScannerActivity"
android:screenOrientation="portrait"
android:exported="false"
tools:ignore="DiscouragedApi"/>
<activity android:name=".offlinesync.PortraitCaptureActivity"
android:screenOrientation="portrait"
android:exported="false"
android:clearTaskOnLaunch="true"
android:stateNotNeeded="true"
android:theme="@style/zxing_CaptureTheme"
android:windowSoftInputMode="stateAlwaysHidden"
tools:ignore="DiscouragedApi"/>

</application>

<queries>
Expand Down
Loading
Loading