diff --git a/build.gradle b/build.gradle index ec8e0c0d4..3b81ac9de 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/src/androidTestUnbrandedDebug/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivityTest.java b/src/androidTestUnbrandedDebug/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivityTest.java new file mode 100644 index 000000000..92daa0c2b --- /dev/null +++ b/src/androidTestUnbrandedDebug/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivityTest.java @@ -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 manager = new AtomicReference<>(); + try (ActivityScenario 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 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 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 scenario, String expression) throws Exception { + AtomicReference 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(); + } +} diff --git a/src/androidTestUnbrandedDebug/java/org/medicmobile/webapp/mobile/offlinesync/QrScannerActivityTest.java b/src/androidTestUnbrandedDebug/java/org/medicmobile/webapp/mobile/offlinesync/QrScannerActivityTest.java new file mode 100644 index 000000000..b89ca3fb1 --- /dev/null +++ b/src/androidTestUnbrandedDebug/java/org/medicmobile/webapp/mobile/offlinesync/QrScannerActivityTest.java @@ -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 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); + } +} diff --git a/src/androidTestUnbrandedDebug/java/org/medicmobile/webapp/mobile/offlinesync/SessionCertificateDeviceTest.java b/src/androidTestUnbrandedDebug/java/org/medicmobile/webapp/mobile/offlinesync/SessionCertificateDeviceTest.java new file mode 100644 index 000000000..ae73eaa0a --- /dev/null +++ b/src/androidTestUnbrandedDebug/java/org/medicmobile/webapp/mobile/offlinesync/SessionCertificateDeviceTest.java @@ -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; + } +} diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 336facce8..19434c933 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -26,6 +26,16 @@ --> + + + + + + + + + + + + + + + diff --git a/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java b/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java index 7c18e935e..835f1f1d0 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java +++ b/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java @@ -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 { @@ -50,6 +54,17 @@ public class EmbeddedBrowserActivity extends Activity { private MrdtSupport mrdt; private FilePickerHandler filePickerHandler; private SmsSender smsSender; + private OfflineSyncManager offlineSyncManager; + private OfflineSyncPeer offlineSyncPeer; + private final OfflineSyncPeer.PairCallback pairingCallback = new OfflineSyncPeer.PairCallback() { + @Override public void onPaired(String hostLabel) { + resolveOfflineSyncPairing(true, hostLabel); + } + + @Override public void onFailed(String reason) { + resolveOfflineSyncPairing(false, reason); + } + }; private ChtExternalAppHandler chtExternalAppHandler; private boolean isMigrationRunning = false; @@ -82,6 +97,22 @@ public void onReceiveValue(String result) { error(ex, "Failed to create SmsSender."); } + // Hosting needs a newer Android than joining, so the manager is created only where it can + // work and the webapp asks before offering it. + if(OfflineSyncManager.isHostSupported()) { + try { + this.offlineSyncManager = OfflineSyncManager.getInstance(this, Build.MODEL); + } catch(Exception ex) { + error(ex, "Failed to create OfflineSyncManager."); + } + } + try { + this.offlineSyncPeer = OfflineSyncPeer.getInstance(this); + this.offlineSyncPeer.redirect(pairingCallback); + } catch(Exception ex) { + error(ex, "Failed to create OfflineSyncPeer."); + } + this.settings = SettingsStore.in(this); this.appUrl = settings.getAppUrl(); @@ -228,6 +259,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()); } @@ -268,6 +305,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; } @@ -302,6 +355,26 @@ 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 offers the action again once the + * user has granted it. + */ + 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; @@ -320,6 +393,48 @@ public boolean getLocationPermissions() { } //> PRIVATE HELPERS + /** Why the scan ended without a code: the user backed out, or the scanner reported a failure. */ + 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, pairingCallback); + } + + /** + * The detail can be the host's own label, so this does not go through loadUrl: it + * percent-decodes the script, and an encoded quote would end the string it sits in. + */ + 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)), false); + } + + private void offlineSyncPermissionsResolved(boolean granted) { + evaluateJavascript(String.format( + "window.CHTCore.AndroidApi.v1.offlineSyncPermissionsResolved(%s);", granted)); + } + private void locationRequestResolved() { evaluateJavascript("window.CHTCore.AndroidApi.v1.locationPermissionRequestResolved();"); } @@ -451,7 +566,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; diff --git a/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java b/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java index 88f8453bd..e45b95f18 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java +++ b/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java @@ -1,6 +1,7 @@ package org.medicmobile.webapp.mobile; import static org.medicmobile.webapp.mobile.MedicLog.log; +import static org.medicmobile.webapp.mobile.MedicLog.warn; import static java.util.Calendar.DAY_OF_MONTH; import static java.util.Calendar.MONTH; import static java.util.Calendar.YEAR; @@ -25,6 +26,8 @@ import org.json.JSONException; import org.json.JSONObject; +import org.medicmobile.webapp.mobile.offlinesync.OfflineSyncManager; +import org.medicmobile.webapp.mobile.offlinesync.OfflineSyncPeer; import org.medicmobile.webapp.mobile.util.AppDataStore; import java.io.BufferedReader; @@ -52,6 +55,21 @@ public class MedicAndroidJavascript { private final MrdtSupport mrdt; private final SmsSender smsSender; private final ChtExternalAppHandler chtExternalAppHandler; + private final OfflineSyncManager offlineSyncManager; + private final OfflineSyncPeer offlineSyncPeer; + private final OfflineSyncManager.HostingCallback hostingCallback = new OfflineSyncManager.HostingCallback() { + @Override public void onReady(String qrPayload, String ssid, String password) { + respondToOfflineSync(true, session(qrPayload, ssid, password), ""); + } + + @Override public void onFailed(String reason, String diagnostic) { + respondToOfflineSync(false, reason, diagnostic); + } + + @Override public void onLost(String reason) { + respondToOfflineSync(false, reason, ""); + } + }; private ActivityManager activityManager; private ConnectivityManager connectivityManager; @@ -62,6 +80,11 @@ public MedicAndroidJavascript(EmbeddedBrowserActivity parent) { this.mrdt = parent.getMrdtSupport(); this.smsSender = parent.getSmsSender(); this.chtExternalAppHandler = parent.getChtExternalAppHandler(); + this.offlineSyncManager = parent.getOfflineSyncManager(); + this.offlineSyncPeer = parent.getOfflineSyncPeer(); + if(offlineSyncManager != null) { + offlineSyncManager.redirect(hostingCallback); + } } public void setAlert(Alert soundAlert) { @@ -179,6 +202,127 @@ public boolean sms_available() { return smsSender != null; } + /** + * Whether this device can host an offline sync session. False below Android 8.0, where the + * local-only hotspot API does not exist. Joining a session has no such limit. + */ + @android.webkit.JavascriptInterface + @SuppressWarnings("java:S100") // the webapp calls this name literally over the bridge + public boolean offline_sync_host_available() { + return offlineSyncManager != null && OfflineSyncManager.isHostSupported(); + } + + /** + * Brings up the hotspot and the local server, then reports the payload a peer scans. + * + * Asynchronous: the result arrives on the webapp's offline sync callback rather than as a return value, + * because the hotspot takes seconds to come up. + */ + @android.webkit.JavascriptInterface + @SuppressWarnings("java:S100") // the webapp calls this name literally over the bridge + public void offline_sync_start_hosting() { + if(!offline_sync_host_available()) { + respondToOfflineSync(false, "hotspot_unsupported", ""); + return; + } + // Asks if it is missing, and reports back through offlineSyncPermissionsResolved so the webapp + // can offer the action again. Without this the hotspot call fails with a SecurityException + // the user cannot act on. + if(!parent.getOfflineSyncPermissions()) { + respondToOfflineSync(false, "permissions_required", ""); + return; + } + offlineSyncManager.startHosting(hostingCallback); + } + + @android.webkit.JavascriptInterface + @SuppressWarnings("java:S100") // the webapp calls this name literally over the bridge + public void offline_sync_stop_hosting() { + if(offlineSyncManager != null) { + offlineSyncManager.stopHosting(); + } + } + + /** + * Whether this device can join a session. + * + * Joining works on every version the app supports, by one route or the other. Hosting does not, + * so the webapp asks about each separately: a device can join without being able to host. + */ + @android.webkit.JavascriptInterface + @SuppressWarnings("java:S100") // the webapp calls this name literally over the bridge + public boolean offline_sync_join_available() { + return offlineSyncPeer != null; + } + + /** + * Opens the scanner. The result arrives on the webapp's resolveOfflineSyncPairing callback once the + * device has joined and confirmed the host's key. + */ + @android.webkit.JavascriptInterface + @SuppressWarnings("java:S100") // the webapp calls this name literally over the bridge + public void offline_sync_scan_and_join() { + if(!offline_sync_join_available()) { + parent.resolveOfflineSyncPairing(false, "join_unsupported"); + return; + } + if(!parent.getOfflineSyncPermissions()) { + parent.resolveOfflineSyncPairing(false, "permissions_required"); + return; + } + parent.scanOfflineSyncQrCode(); + } + + @android.webkit.JavascriptInterface + @SuppressWarnings("java:S100") // the webapp calls this name literally over the bridge + public void offline_sync_leave_session() { + if(offlineSyncPeer != null) { + offlineSyncPeer.unpair(); + } + } + + @android.webkit.JavascriptInterface + @SuppressWarnings("java:S100") // the webapp calls this name literally over the bridge + public String offline_sync_hosting_session() { + OfflineSyncManager.Session current = offlineSyncManager == null ? null : offlineSyncManager.currentSession(); + return current == null ? "" : session(current.qrImage, current.ssid, current.password); + } + + /** + * What the webapp needs to put a hosting session on screen. + * + * The QR code carries the network details already, but only as an image: a peer whose camera + * will not focus, or who has no camera permission, still has to be able to join the network + * by hand, and that means the webapp has to be able to show the name and password as text. + */ + private String session(String qrPayload, String ssid, String password) { + try { + return new JSONObject() + .put("qr", qrPayload) + .put("ssid", ssid) + .put("password", password) + .toString(); + } catch (org.json.JSONException e) { + // Nothing here can fail on a JSONObject with three string values. + warn(e, "Could not describe the hosting session"); + return ""; + } + } + + /** Not through loadUrl, which percent-decodes the script and would garble the diagnostic text. */ + private void respondToOfflineSync(boolean ok, String detail, String diagnostic) { + parent.evaluateJavascript(String.format( + "try {" + + "const api = window.CHTCore.AndroidApi;" + + "if (api && api.v1 && api.v1.resolveOfflineSyncHostingResult) {" + + " api.v1.resolveOfflineSyncHostingResult(%s, %s, %s);" + + "}" + + "} catch (error) {" + + " console.error('MedicAndroidJavascript :: Offline sync result not delivered', error);" + + "}", + ok, JSONObject.quote(detail), JSONObject.quote(diagnostic)), false); + } + /** * @param id id associated with this message, e.g. a pouchdb docId * @param destination the recipient phone number for this message diff --git a/src/main/java/org/medicmobile/webapp/mobile/RequestOfflineSyncPermissionsActivity.java b/src/main/java/org/medicmobile/webapp/mobile/RequestOfflineSyncPermissionsActivity.java new file mode 100644 index 000000000..c3cff1563 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/RequestOfflineSyncPermissionsActivity.java @@ -0,0 +1,132 @@ +package org.medicmobile.webapp.mobile; + +import static android.Manifest.permission.ACCESS_FINE_LOCATION; +import static android.Manifest.permission.NEARBY_WIFI_DEVICES; +import static android.content.pm.PackageManager.PERMISSION_GRANTED; +import static org.medicmobile.webapp.mobile.MedicLog.trace; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.provider.Settings; +import android.view.View; +import android.view.Window; +import android.widget.TextView; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.FragmentActivity; + +/** + * Asks for the permissions an offline sync session needs, with a disclosure first. + * + * Which permission that is depends on the Android version. Hosting a local-only hotspot needs + * NEARBY_WIFI_DEVICES from Android 13, and location before that: the platform treated nearby wifi + * as a way of inferring where the user is, so it gated it behind location. Neither is optional, + * and without them startLocalOnlyHotspot throws. + * + * Follows RequestLocationPermissionActivity: explain first, then ask, and send the user to the app + * settings if they have already refused twice. + */ +public class RequestOfflineSyncPermissionsActivity extends FragmentActivity { + + private final ActivityResultLauncher requestPermissionLauncher = + registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), grantedMap -> { + if (hasOfflineSyncPermissions(this)) { + trace(this, "RequestOfflineSyncPermissionsActivity :: User granted the offline sync permissions."); + setResult(RESULT_OK); + finish(); + return; + } + + if (shouldSendToAppSettings()) { + trace( + this, + "RequestOfflineSyncPermissionsActivity :: User refused twice or selected \"never ask again\"." + + " Sending user to the app's settings to grant it manually." + ); + this.appSettingsLauncher.launch(appSettingsIntent()); + return; + } + + trace(this, "RequestOfflineSyncPermissionsActivity :: User refused the offline sync permissions."); + setResult(RESULT_CANCELED); + finish(); + }); + + private final ActivityResultLauncher appSettingsLauncher = + registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { + boolean granted = hasOfflineSyncPermissions(this); + trace(this, "RequestOfflineSyncPermissionsActivity :: Returned from app settings, granted:%s", granted); + setResult(granted ? RESULT_OK : RESULT_CANCELED); + finish(); + }); + + /** + * The permissions a hotspot needs on this device. + * + * From Android 13 the platform has a dedicated nearby-wifi permission; before that the same + * capability sat behind location. + */ + static String[] requiredPermissions() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return new String[] { NEARBY_WIFI_DEVICES }; + } + return new String[] { ACCESS_FINE_LOCATION }; + } + + /** Whether this device already has what a session needs. */ + public static boolean hasOfflineSyncPermissions(Context context) { + for (String permission : requiredPermissions()) { + if (ContextCompat.checkSelfPermission(context, permission) != PERMISSION_GRANTED) { + return false; + } + } + return true; + } + + private boolean shouldSendToAppSettings() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return false; + } + for (String permission : requiredPermissions()) { + if (!shouldShowRequestPermissionRationale(permission)) { + return true; + } + } + return false; + } + + private Intent appSettingsIntent() { + Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + intent.setData(Uri.fromParts("package", getPackageName(), null)); + return intent; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + this.requestWindowFeature(Window.FEATURE_NO_TITLE); + setContentView(R.layout.request_offline_sync_permission); + + String appName = getResources().getString(R.string.app_name); + String message = getResources().getString(R.string.offlineSyncRequestMessage); + TextView field = findViewById(R.id.offlineSyncMessageText); + field.setText(String.format(message, appName)); + } + + public void onClickOk(View view) { + trace(this, "RequestOfflineSyncPermissionsActivity :: User agreed with the disclosure message."); + requestPermissionLauncher.launch(requiredPermissions()); + } + + public void onClickNegative(View view) { + trace(this, "RequestOfflineSyncPermissionsActivity :: User disagreed with the disclosure message."); + setResult(RESULT_CANCELED); + finish(); + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/offlinesync/HotspotJoiner.java b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/HotspotJoiner.java new file mode 100644 index 000000000..4e49ab3e3 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/HotspotJoiner.java @@ -0,0 +1,274 @@ +package org.medicmobile.webapp.mobile.offlinesync; + +import static org.medicmobile.webapp.mobile.MedicLog.log; +import static org.medicmobile.webapp.mobile.MedicLog.warn; + +import android.content.Context; +import android.location.LocationManager; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkRequest; +import android.net.wifi.WifiManager; +import android.net.wifi.WifiNetworkSpecifier; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; + +/** + * Joins the host's hotspot on the peer device. + * + * Android changed how an app may do this, so there are two paths and both are needed. + * + * From Android 10 an app asks the system for a network matching a specifier, the user confirms + * it, and the app receives a {@link Network} it must send traffic over explicitly: joining does + * not become the device's default connection. Android 10 also broke the older way, where an app + * saved the network and switched to it, so neither path works on both sides of that line. + * + * Callers get a {@link Network} either way and must bind their sockets to it. On the older path + * the hotspot does become the default connection, but binding is still correct and keeps the + * calling code identical across versions. + */ +public class HotspotJoiner { + + private static final String JOIN_FAILED = "join_failed"; + + /** Long enough for the user to accept the system prompt, short enough to fail visibly. */ + private static final int JOIN_TIMEOUT_MS = 60_000; + + private final ConnectivityManager connectivityManager; + private final WifiManager wifiManager; + private final LocationManager locationManager; + private ConnectivityManager.NetworkCallback activeCallback; + /** Only the API 29+ path gets a timeout from the platform; below that we run our own. */ + private final Handler timeoutHandler = new Handler(Looper.getMainLooper()); + private Runnable joinTimeout; + /** Set only on the pre-Android-10 path, where the network is saved to the device and must be + * taken back off it afterwards. */ + private int savedNetworkId = -1; + + public HotspotJoiner(ConnectivityManager connectivityManager, WifiManager wifiManager, + LocationManager locationManager) { + if (connectivityManager == null || wifiManager == null || locationManager == null) { + throw new IllegalArgumentException("connectivityManager, wifiManager and locationManager must not be null"); + } + this.connectivityManager = connectivityManager; + this.wifiManager = wifiManager; + this.locationManager = locationManager; + } + + public static HotspotJoiner create(Context context) { + Context appContext = context.getApplicationContext(); + return new HotspotJoiner( + (ConnectivityManager) appContext.getSystemService(Context.CONNECTIVITY_SERVICE), + (WifiManager) appContext.getSystemService(Context.WIFI_SERVICE), + (LocationManager) appContext.getSystemService(Context.LOCATION_SERVICE)); + } + + + /** + * Asks the system to join the named network. The user sees a confirmation prompt. + * + * @param callback receives the network to send traffic over, or a failure reason + */ + public void join(String ssid, String password, JoinCallback callback) { + if (callback == null) { + throw new IllegalArgumentException("callback must not be null"); + } + if (ssid == null || ssid.trim().isEmpty() || password == null || password.isEmpty()) { + callback.onFailed("invalid_credentials"); + return; + } + + leave(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + requestNetwork(ssid, password, callback); + } else { + joinSavedNetwork(ssid, password, callback); + } + } + + /** + * The route for Android 9 and below: save the network, switch to it, and watch for the + * system reporting it connected. + * + * Android 10 made addNetwork fail for apps, which is why this is not used above it. + */ + @SuppressWarnings("deprecation") + private void joinSavedNetwork(String ssid, String password, JoinCallback callback) { + // Android 8.1 and 9 hide the connected network's name while location is off, and this path + // only counts the join as done once it can read that name. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 && !WifiHotspotProvider.isLocationEnabled(locationManager)) { + callback.onFailed("location_services_off"); + return; + } + + android.net.wifi.WifiConfiguration config = new android.net.wifi.WifiConfiguration(); + // the platform expects these quoted + config.SSID = "\"" + ssid + "\""; + config.preSharedKey = "\"" + password + "\""; + + int networkId = wifiManager.addNetwork(config); + if (networkId == -1) { + warn(HotspotJoiner.class, "The device would not save the host's network"); + callback.onFailed(JOIN_FAILED); + return; + } + savedNetworkId = networkId; + + // watch first, so a fast connection is not missed between enabling and registering + watchForWifi(ssid, callback); + if (!wifiManager.enableNetwork(networkId, true)) { + leave(); + warn(HotspotJoiner.class, "The device would not switch to the host's network"); + callback.onFailed(JOIN_FAILED); + } + } + + /** + * Reports the wifi network once the system says it is up. + * + * This watches wifi in general, because below Android 10 there is no way to ask for one + * specific network, so it checks the SSID before calling the join done. Nothing here is a + * security boundary: the key pin is what proves the host is the right device. + */ + @SuppressWarnings("deprecation") + private void watchForWifi(String ssid, JoinCallback callback) { + NetworkRequest request = new NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .build(); + + activeCallback = new ConnectivityManager.NetworkCallback() { + /** The host's network once joined. Losing any other wifi, such as the one being left, is not a failure. */ + private Network hostNetwork; + + @Override public void onAvailable(Network network) { + if (!isConnectedTo(ssid)) { + // some other wifi came up; keep waiting for the host's + return; + } + cancelJoinTimeout(); + hostNetwork = network; + log(HotspotJoiner.class, "Joined the host's network"); + callback.onJoined(network); + } + + @Override public void onLost(Network network) { + if (!network.equals(hostNetwork)) { + return; + } + warn(HotspotJoiner.class, "Lost the host's network"); + callback.onLost(); + } + }; + connectivityManager.registerNetworkCallback(request, activeCallback); + startJoinTimeout(callback); + } + + /** Whether the device is currently on the named network. */ + @SuppressWarnings("deprecation") + private boolean isConnectedTo(String ssid) { + android.net.wifi.WifiInfo info = wifiManager.getConnectionInfo(); + if (info == null || info.getSSID() == null) { + return false; + } + // the platform reports it quoted + return info.getSSID().replace("\"", "").equals(ssid); + } + + /** + * Fails the join if the network never arrives. Without this the pre-Android-10 path waits + * forever and the user is left on "connecting" with nothing to act on. + */ + private void startJoinTimeout(JoinCallback callback) { + cancelJoinTimeout(); + joinTimeout = () -> { + joinTimeout = null; + warn(HotspotJoiner.class, "Gave up waiting for the host's network"); + leave(); + callback.onFailed(JOIN_FAILED); + }; + timeoutHandler.postDelayed(joinTimeout, JOIN_TIMEOUT_MS); + } + + private void cancelJoinTimeout() { + if (joinTimeout != null) { + timeoutHandler.removeCallbacks(joinTimeout); + joinTimeout = null; + } + } + + @android.annotation.TargetApi(29) + private void requestNetwork(String ssid, String password, JoinCallback callback) { + NetworkRequest request = new NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + // the hotspot has no upstream, so without this the system discards it for lacking one + .removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .setNetworkSpecifier(new WifiNetworkSpecifier.Builder() + .setSsid(ssid) + .setWpa2Passphrase(password) + .build()) + .build(); + + activeCallback = new ConnectivityManager.NetworkCallback() { + @Override public void onAvailable(Network network) { + log(HotspotJoiner.class, "Joined the host's network"); + callback.onJoined(network); + } + + @Override public void onUnavailable() { + warn(HotspotJoiner.class, "Could not join the host's network"); + callback.onFailed(JOIN_FAILED); + } + + @Override public void onLost(Network network) { + warn(HotspotJoiner.class, "Lost the host's network"); + callback.onLost(); + } + }; + connectivityManager.requestNetwork(request, activeCallback, JOIN_TIMEOUT_MS); + } + + /** Releases the network, letting the device return to its usual connection. */ + public void leave() { + cancelJoinTimeout(); + if (activeCallback != null) { + try { + connectivityManager.unregisterNetworkCallback(activeCallback); + } catch (IllegalArgumentException e) { + // already unregistered; nothing to undo + warn(e, "Network callback was already unregistered"); + } + activeCallback = null; + } + forgetSavedNetwork(); + log(HotspotJoiner.class, "Left the host's network"); + } + + /** + * Takes the host's network back off the device. + * + * Only the pre-Android-10 path saves it. Leaving it behind would strand the user on a + * hotspot with no internet, and the device would keep rejoining it whenever it is in range. + */ + @SuppressWarnings("deprecation") + private void forgetSavedNetwork() { + if (savedNetworkId == -1) { + return; + } + wifiManager.removeNetwork(savedNetworkId); + // back to whichever network the device would normally use + wifiManager.reconnect(); + savedNetworkId = -1; + } + + public interface JoinCallback { + /** @param network every socket to the host must be bound to this, or it will not arrive */ + void onJoined(Network network); + + void onFailed(String reason); + + /** The host went away mid-session. */ + void onLost(); + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/offlinesync/HotspotProvider.java b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/HotspotProvider.java new file mode 100644 index 000000000..d2bf651b5 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/HotspotProvider.java @@ -0,0 +1,63 @@ +package org.medicmobile.webapp.mobile.offlinesync; + +/** + * Abstraction for WiFi hotspot management, so the session logic can be tested without a radio. + * + * The only implementation is {@link WifiHotspotProvider}, which brings up a real + * LocalOnlyHotspot and therefore needs API 26. There is deliberately no stub implementation + * that reports a hotspot it did not create: a device either has one or is told it has not. + */ +public interface HotspotProvider { + + /** + * Start the hotspot asynchronously. + * On success, callback receives SSID, password, and IP address. + * On failure, callback receives a reason string. + * + * @param callback result callback (never null) + */ + void start(HotspotCallback callback); + + /** + * Stop the hotspot and release all resources. + * Safe to call even if not running (no-op in that case). + */ + void stop(); + + /** + * Check if the hotspot is currently running. + * + * @return true if hotspot is active and accepting connections + */ + boolean isRunning(); + + /** + * Callback for hotspot start result. + */ + interface HotspotCallback { + /** + * Called when the hotspot has started successfully. + * + * @param ssid the network name clients should connect to + * @param password the WPA2 password for the network + * @param ipAddress the IP address of this device on the hotspot network + */ + void onStarted(String ssid, String password, String ipAddress); + + /** + * Called when the hotspot failed to start. + * + * @param reason a stable code the webapp can map to a message + */ + void onFailed(String reason); + + /** + * Called when a running hotspot went away on its own. + * + * The system can take it down at any point, for instance when the user turns WiFi off + * or another app needs the radio. Without this, a session would appear to be running + * with nothing behind it for as long as nobody happened to try to use it. + */ + void onStopped(); + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/offlinesync/LocalHttpServer.java b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/LocalHttpServer.java new file mode 100644 index 000000000..510b0d0c9 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/LocalHttpServer.java @@ -0,0 +1,107 @@ +package org.medicmobile.webapp.mobile.offlinesync; + +import static org.medicmobile.webapp.mobile.MedicLog.log; +import static org.medicmobile.webapp.mobile.MedicLog.warn; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.security.GeneralSecurityException; + +import fi.iki.elonen.NanoHTTPD; + +/** + * The local HTTP server a host device runs for the duration of an offline sync session. + * + * It answers a status probe so a peer can confirm it reached the right device. + * + * Served over TLS using a {@link SessionCertificate}. The certificate is self-signed and + * deliberately untrusted by any CA: the peer pins its key against the fingerprint printed in the QR + * code. Anyone else on the hotspot can reach this port, so the handshake is what identifies the + * host, not the network. + */ +public class LocalHttpServer extends NanoHTTPD { + + /** + * Bind an OS-assigned port rather than competing for a fixed one. + * + * The peer is told the port by the QR payload, so a hardcoded number buys nothing and costs + * a whole class of failure: another app holding it, or our own previous session not yet + * released. NanoHTTPD sets SO_REUSEADDR, which covers a socket lingering in TIME_WAIT, but + * not a live listener. Asking for 0 sidesteps both. + */ + public static final int EPHEMERAL_PORT = 0; + + private static final String MIME_JSON = "application/json"; + private static final String STATUS_PATH = "/_offline-sync/status"; + /** Bumped when the wire contract changes, so a peer can refuse a host it cannot talk to. */ + private static final int PROTOCOL_VERSION = 1; + + private final String deviceLabel; + private final SessionCertificate certificate; + + public LocalHttpServer(String deviceLabel, SessionCertificate certificate) { + this(EPHEMERAL_PORT, deviceLabel, certificate); + } + + public LocalHttpServer(int port, String deviceLabel, SessionCertificate certificate) { + super(port); + if (deviceLabel == null || deviceLabel.trim().isEmpty()) { + throw new IllegalArgumentException("deviceLabel must not be empty"); + } + if (certificate == null) { + throw new IllegalArgumentException("certificate must not be null"); + } + this.deviceLabel = deviceLabel; + this.certificate = certificate; + } + + /** Starts listening. Safe to call when already running. */ + public void startServer() throws IOException, GeneralSecurityException { + if (isAlive()) { + log(this, "Local server already running on port " + getListeningPort()); + return; + } + makeSecure(certificate.sslServerSocketFactory(), null); + start(NanoHTTPD.SOCKET_READ_TIMEOUT, false); + // the real port is only known once bound, and it is what goes into the QR payload + log(this, "Local server listening on port " + getListeningPort()); + } + + /** Stops listening. Safe to call when already stopped. */ + public void stopServer() { + if (!isAlive()) { + return; + } + stop(); + log(this, "Local server stopped"); + } + + @Override public Response serve(IHTTPSession session) { + String uri = session.getUri(); + if (Method.GET.equals(session.getMethod()) && STATUS_PATH.equals(uri)) { + return handleStatus(); + } + warn(this, "Rejected request for " + session.getMethod() + " " + uri); + return newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_JSON, "{\"error\":\"not found\"}"); + } + + /** + * Answers a peer's probe: confirms this is a CHT host and says who it is, so the peer can + * show the user which device it paired with. + */ + private Response handleStatus() { + try { + JSONObject body = new JSONObject(); + body.put("service", "cht-offline-sync"); + body.put("protocol_version", PROTOCOL_VERSION); + body.put("device_label", deviceLabel); + return newFixedLengthResponse(Response.Status.OK, MIME_JSON, body.toString()); + } catch (JSONException e) { + warn(e, "Could not build the status response"); + return newFixedLengthResponse( + Response.Status.INTERNAL_ERROR, MIME_JSON, "{\"error\":\"status unavailable\"}"); + } + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/offlinesync/OfflineSyncManager.java b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/OfflineSyncManager.java new file mode 100644 index 000000000..1acdce581 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/OfflineSyncManager.java @@ -0,0 +1,278 @@ +package org.medicmobile.webapp.mobile.offlinesync; + +import static org.medicmobile.webapp.mobile.MedicLog.log; +import static org.medicmobile.webapp.mobile.MedicLog.warn; + +import android.content.Context; +import android.location.LocationManager; +import android.net.wifi.WifiManager; + +import org.json.JSONException; + +import java.security.GeneralSecurityException; + +/** + * Owns an offline sync pairing session on the host device. + * + * Brings up the hotspot, starts the local server behind it, and produces the payload the peer + * scans. Nothing here knows what will later travel over the link. + * + * Hosting needs Android 8.0 for LocalOnlyHotspot while the app supports 5.0, so callers must + * check {@link #isHostSupported()} first; the webapp uses it to decide whether to offer the + * option at all. + */ +public class OfflineSyncManager { + + private static OfflineSyncManager instance; + + private final WifiHotspotManager hotspotManager; + private final LocalHttpServer server; + private final SessionCertificate certificate; + /** The code the last onReady handed out, kept so the screen can show it again. */ + private volatile String currentQrImage; + /** Who hears about the session. Replaced when the screen that started it is recreated. */ + private volatile HostingCallback callback; + + public OfflineSyncManager(WifiHotspotManager hotspotManager, LocalHttpServer server, + SessionCertificate certificate) { + if (hotspotManager == null || server == null || certificate == null) { + throw new IllegalArgumentException("collaborators must not be null"); + } + this.hotspotManager = hotspotManager; + this.server = server; + this.certificate = certificate; + } + + /** + * The manager for this process. The platform holds the hotspot reservation per process, and + * the screen is recreated on a theme or language change, so the session belongs to the + * process: a manager per screen would lose track of a hotspot that is still running. + */ + public static synchronized OfflineSyncManager getInstance(Context context, String deviceLabel) + throws GeneralSecurityException { + if (instance == null) { + instance = create(context, deviceLabel); + } + return instance; + } + + /** + * Builds a manager wired to the real WiFi radio, with a fresh TLS identity for the session. + */ + private static OfflineSyncManager create(Context context, String deviceLabel) throws GeneralSecurityException { + Context appContext = context.getApplicationContext(); + WifiManager wifiManager = (WifiManager) appContext.getSystemService(Context.WIFI_SERVICE); + LocationManager locationManager = + (LocationManager) appContext.getSystemService(Context.LOCATION_SERVICE); + SessionCertificate certificate = SessionCertificate.forDevice(deviceLabel); + return new OfflineSyncManager( + new WifiHotspotManager( + new WifiHotspotProvider(wifiManager, locationManager)), + new LocalHttpServer(deviceLabel, certificate), + certificate); + } + + /** Whether this device can host at all. False on Android below 8.0. */ + public static boolean isHostSupported() { + return WifiHotspotProvider.isSupported(); + } + + /** + * Starts hosting: hotspot first, then the local server, then hands back the code a peer scans. + * + * Reports failure rather than partial success. If the server cannot bind, the hotspot is + * taken back down so the device is not left advertising a network with nothing behind it. + */ + public void startHosting(HostingCallback callback) { + if (callback == null) { + throw new IllegalArgumentException("callback must not be null"); + } + if (!isHostSupported()) { + callback.onFailed("hotspot_unsupported", "LocalOnlyHotspot needs Android 8.0"); + return; + } + this.callback = callback; + + // The platform allows one local-only hotspot reservation per app, and a session that failed + // part way through still holds it. Without this, every attempt after a failure collides with + // the wreckage of the last one until the platform times the reservation out on its own. + // Idempotent, so starting from nothing costs nothing. + currentQrImage = null; + server.stopServer(); + hotspotManager.stopHotspot(); + + try { + // Each session gets a fresh identity: stopHosting destroys the key, so without this a + // second session would find nothing in the keystore to serve TLS with. + certificate.renew(); + } catch (GeneralSecurityException e) { + warn(e, "Could not prepare a certificate for this session"); + callback.onFailed("certificate_failed", describe(e)); + return; + } + + hotspotManager.startHotspot(new HotspotProvider.HotspotCallback() { + @Override public void onStarted(String ssid, String password, String ipAddress) { + HostingCallback current = OfflineSyncManager.this.callback; + if (startLocalServer(current)) { + publishPairingCode(ssid, password, ipAddress, current); + } + } + + @Override public void onFailed(String reason) { + OfflineSyncManager.this.callback.onFailed(reason, ""); + } + + @Override public void onStopped() { + // Nothing is listening on a network that no longer exists, and a peer part way + // through handing something over has already lost it. + currentQrImage = null; + server.stopServer(); + certificate.destroy(); + OfflineSyncManager.this.callback.onLost("hotspot_stopped"); + } + }); + } + + /** + * Brings the local server up. False means the caller has already been told why, and the + * hotspot is back down: a network advertising nothing is worse than no network. + */ + private boolean startLocalServer(HostingCallback callback) { + try { + server.startServer(); + return true; + } catch (GeneralSecurityException e) { + // The key exists by now, so this is the device refusing to serve TLS with it rather + // than anything about the server. Reported apart from a bind failure, because the two + // have nothing in common and only one of them is worth retrying. + warn(e, "This device could not serve TLS, taking the hotspot back down"); + hotspotManager.stopHotspot(); + callback.onFailed("certificate_failed", describe(e)); + return false; + } catch (Exception e) { + warn(e, "Local server failed to start, taking the hotspot back down"); + hotspotManager.stopHotspot(); + callback.onFailed("server_start_failed", describe(e)); + return false; + } + } + + /** Builds the code a peer scans, tearing the session down if it cannot be produced. */ + private void publishPairingCode(String ssid, String password, String ipAddress, HostingCallback callback) { + try { + String payload = QrCodeHelper.buildPayload(new QrCodeHelper.HotspotCredentials( + ssid, password, ipAddress, server.getListeningPort(), certificate.fingerprint())); + String qrImage = QrCodeHelper.generateQrDataUrl(payload); + if (qrImage == null) { + stopHosting(); + callback.onFailed("payload_failed", "the QR image could not be rendered"); + return; + } + log(OfflineSyncManager.class, "Hosting session ready on " + ipAddress); + currentQrImage = qrImage; + callback.onReady(qrImage, ssid, password); + } catch (JSONException | GeneralSecurityException | IllegalArgumentException e) { + // IllegalArgumentException included deliberately: QrCodeHelper rejects empty credentials + // that way, and letting it escape would leave the hotspot up with nothing reported. + warn(e, "Could not build the pairing payload"); + stopHosting(); + callback.onFailed("payload_failed", describe(e)); + } + } + + /** Sends what happens to the session from now on to a new callback, e.g. a recreated screen. */ + public void redirect(HostingCallback callback) { + if (callback == null) { + throw new IllegalArgumentException("callback must not be null"); + } + this.callback = callback; + } + + /** Tears the session down. Safe to call when nothing is running. */ + public void stopHosting() { + currentQrImage = null; + server.stopServer(); + hotspotManager.stopHotspot(); + certificate.destroy(); + log(OfflineSyncManager.class, "Hosting session stopped"); + } + + /** + * Whether a peer could reach this device right now. + * + * Both halves, because either can go without the other: the system can take the hotspot + * away, and the local server's listener can die while the network is still up. Reporting + * hosting when only one of them is left would leave the screen showing a code that leads + * nowhere. + */ + public boolean isHosting() { + return hotspotManager.isActive() && server.isAlive(); + } + + /** The session a peer can join right now, or null. */ + public Session currentSession() { + String image = currentQrImage; + if (image == null || !isHosting()) { + return null; + } + return new Session(image, hotspotManager.getActiveSsid(), hotspotManager.getActivePassword()); + } + + /** + * An exception as one line, causes included. + * + * Hosting depends on what a particular device's keystore allows, and that varies by OEM, so + * the reason has to travel off the device: a phone in the field is never on a cable, and the + * keystore names the attribute it refused only in the cause chain. + */ + private static String describe(Throwable error) { + StringBuilder description = new StringBuilder(); + String separator = ""; + for (Throwable cause = error; cause != null && description.length() < 400; cause = cause.getCause()) { + description.append(separator).append(cause.getClass().getSimpleName()); + if (cause.getMessage() != null) { + description.append(": ").append(cause.getMessage()); + } + separator = " <- "; + } + return description.toString(); + } + + /** The details a peer needs, as handed to {@link HostingCallback#onReady}. */ + public static final class Session { + public final String qrImage; + public final String ssid; + public final String password; + + Session(String qrImage, String ssid, String password) { + this.qrImage = qrImage; + this.ssid = ssid; + this.password = password; + } + } + + /** What happens to a hosting session, from trying to start it to losing it. */ + public interface HostingCallback { + /** + * @param qrImage a PNG data URL of the code, ready for the webapp to display + * @param ssid the network the peer must join, shown so it can be joined by hand + * @param password that network's password, for the same reason + */ + void onReady(String qrImage, String ssid, String password); + + /** + * @param reason a stable code the webapp can map to a message + * @param diagnostic what actually went wrong, for support. Never shown to the user: it + * cannot be translated. Empty when there is nothing to add beyond the code. + */ + void onFailed(String reason, String diagnostic); + + /** + * A session that was running is gone, without anyone asking for it to stop. + * + * @param reason a stable code the webapp can map to a message + */ + void onLost(String reason); + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/offlinesync/OfflineSyncPeer.java b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/OfflineSyncPeer.java new file mode 100644 index 000000000..be90ddd0e --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/OfflineSyncPeer.java @@ -0,0 +1,126 @@ +package org.medicmobile.webapp.mobile.offlinesync; + +import static org.medicmobile.webapp.mobile.MedicLog.log; +import static org.medicmobile.webapp.mobile.MedicLog.warn; + +import android.content.Context; +import android.net.Network; + +import java.io.IOException; + +/** + * The joining side of a pairing session. + * + * Takes the payload from a scanned QR code, joins the network it names, and confirms the device + * answering there is the one the code came from. Pairing is only complete once that check passes: + * reaching something at the address proves nothing on a network anyone can join. + */ +public class OfflineSyncPeer { + + private static OfflineSyncPeer instance; + + private final HotspotJoiner joiner; + /** Who hears about the pairing. Replaced when the screen that started it is recreated. */ + private volatile PairCallback callback; + + public OfflineSyncPeer(HotspotJoiner joiner) { + if (joiner == null) { + throw new IllegalArgumentException("joiner must not be null"); + } + this.joiner = joiner; + } + + private static OfflineSyncPeer create(Context context) { + return new OfflineSyncPeer(HotspotJoiner.create(context)); + } + + /** + * The peer for this process. The joined network belongs to the process, and the screen is + * recreated on a theme or language change, so a peer per screen could no longer leave it. + */ + public static synchronized OfflineSyncPeer getInstance(Context context) { + if (instance == null) { + instance = create(context.getApplicationContext()); + } + return instance; + } + + /** + * Joins the session described by a scanned payload. + * + * @param payloadJson the QR contents as scanned, validated here before anything is joined + * @param callback reports the host's label once its key has been verified + */ + public void pair(String payloadJson, PairCallback callback) { + if (callback == null) { + throw new IllegalArgumentException("callback must not be null"); + } + this.callback = callback; + + QrValidation validation = QrCodeHelper.validateQrPayload(payloadJson); + if (!validation.isAccepted()) { + warn(OfflineSyncPeer.class, "Rejected a scanned code: " + validation.getDetail()); + callback.onFailed(validation.getCode()); + return; + } + + QrCodeHelper.QrPayload payload = QrCodeHelper.parsePayload(payloadJson); + if (payload == null) { + callback.onFailed("unreadable_payload"); + return; + } + + joiner.join(payload.getSsid(), payload.getPassword(), new HotspotJoiner.JoinCallback() { + @Override public void onJoined(Network network) { + verifyHost(network, payload, OfflineSyncPeer.this.callback); + } + + @Override public void onFailed(String reason) { + OfflineSyncPeer.this.callback.onFailed(reason); + } + + @Override public void onLost() { + OfflineSyncPeer.this.callback.onFailed("network_lost"); + } + }); + } + + /** + * Confirms the host is the device the QR code came from, then reports success. + * + * A failure here is not a networking hiccup to retry quietly: it means something answered + * that could not prove it was the host, so the session is abandoned. + */ + private void verifyHost(Network network, QrCodeHelper.QrPayload payload, PairCallback callback) { + try { + String label = new PeerClient(network, payload.getCertFingerprint()) + .fetchStatus(payload.getIpAddress(), payload.getPort()); + log(OfflineSyncPeer.class, "Paired with " + label); + callback.onPaired(label); + } catch (IOException e) { + warn(e, "Could not verify the host, abandoning the session"); + unpair(); + callback.onFailed("host_not_verified"); + } + } + + /** Sends what happens to the pairing from now on to a new callback, e.g. a recreated screen. */ + public void redirect(PairCallback callback) { + if (callback == null) { + throw new IllegalArgumentException("callback must not be null"); + } + this.callback = callback; + } + + /** Leaves the session's network. */ + public void unpair() { + joiner.leave(); + } + + public interface PairCallback { + /** @param hostLabel what the host calls itself, so the user can confirm the right device */ + void onPaired(String hostLabel); + + void onFailed(String reason); + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/offlinesync/PeerClient.java b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/PeerClient.java new file mode 100644 index 000000000..015159baa --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/PeerClient.java @@ -0,0 +1,145 @@ +package org.medicmobile.webapp.mobile.offlinesync; + +import static org.medicmobile.webapp.mobile.MedicLog.log; + +import android.net.Network; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLSession; + +/** + * Talks to the host over the joined network, accepting only the pinned key. + * + * Two things have to be right for this to reach the intended device and nothing else. The socket + * is bound to the {@link Network} the join returned, because the hotspot is not the device's + * default route and unbound traffic would leave over mobile data. And the TLS handshake trusts + * only the key whose fingerprint came from the QR code, because everyone in range can + * join the same hotspot. + */ +public class PeerClient { + + private static final int CONNECT_TIMEOUT_MS = 10_000; + private static final int READ_TIMEOUT_MS = 10_000; + // The status response is a few dozen bytes. The read timeout only bounds inactivity, so a + // host that streams steadily would never trip it: this bounds the total instead. + private static final int MAX_BODY_CHARS = 64 * 1024; + + /** + * Accepts any hostname, on purpose, because the pin decides identity here. + * + * The host's certificate is issued to a device label and we connect to an IP address that + * changes every session, so name-based verification could never pass and would only stop + * pairing from working. What replaces it is stricter, not weaker: the trust manager accepts + * exactly one key, the one whose fingerprint was read from the QR code, and rejects every + * other including any a certificate authority would vouch for. + * + * Written as a named field rather than a lambda so it is visible to review and to lint, + * which only inspects classes for this and would otherwise never see it. + */ + @android.annotation.SuppressLint("BadHostnameVerifier") + private static final HostnameVerifier PIN_IS_THE_IDENTITY_CHECK = new HostnameVerifier() { + @Override public boolean verify(String hostname, SSLSession session) { + return true; + } + }; + + private final Network network; + private final String certFingerprint; + + public PeerClient(Network network, String certFingerprint) { + if (network == null) { + throw new IllegalArgumentException("network must not be null"); + } + if (certFingerprint == null || certFingerprint.trim().isEmpty()) { + throw new IllegalArgumentException("certFingerprint must not be empty"); + } + this.network = network; + this.certFingerprint = certFingerprint; + } + + /** + * Confirms the device at this address is the host from the QR code. + * + * @return the host's reported label + * @throws IOException if the host cannot be reached, or presents a different key + */ + public String fetchStatus(String ipAddress, int port) throws IOException { + JSONObject body = get(ipAddress, port, "/_offline-sync/status"); + try { + String label = body.getString("device_label"); + log(PeerClient.class, "Reached host: " + label); + return label; + } catch (JSONException e) { + throw new IOException("The host's status response was not what we expect", e); + } + } + + private JSONObject get(String ipAddress, int port, String path) throws IOException { + HttpsURLConnection connection = open(ipAddress, port, path); + try { + int status = connection.getResponseCode(); + if (status != HttpURLConnection.HTTP_OK) { + throw new IOException("The host answered " + status + " for " + path); + } + return parse(readBody(connection)); + } finally { + connection.disconnect(); + } + } + + private HttpsURLConnection open(String ipAddress, int port, String path) throws IOException { + URL url = new URL("https://" + ipAddress + ":" + port + path); + // through the joined network, not whatever the device would otherwise use + HttpsURLConnection connection = (HttpsURLConnection) network.openConnection(url); + try { + connection.setSSLSocketFactory(PinnedCertificateTrust.socketFactoryFor(certFingerprint)); + } catch (GeneralSecurityException e) { + throw new IOException("Could not pin the host's key", e); + } + connection.setHostnameVerifier(PIN_IS_THE_IDENTITY_CHECK); + connection.setConnectTimeout(CONNECT_TIMEOUT_MS); + connection.setReadTimeout(READ_TIMEOUT_MS); + connection.setRequestMethod("GET"); + return connection; + } + + private String readBody(HttpsURLConnection connection) throws IOException { + return readBounded(connection.getInputStream()); + } + + /** Package-private so the bound can be tested without standing up a server. */ + static String readBounded(InputStream stream) throws IOException { + StringBuilder body = new StringBuilder(); + char[] buffer = new char[4096]; + try (InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) { + int read; + while ((read = reader.read(buffer)) != -1) { + if (body.length() + read > MAX_BODY_CHARS) { + throw new IOException("The host's response is larger than we will read"); + } + body.append(buffer, 0, read); + } + } + return body.toString(); + } + + private JSONObject parse(String body) throws IOException { + try { + return new JSONObject(body); + } catch (JSONException e) { + throw new IOException("The host's response was not valid json", e); + } + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/offlinesync/PinnedCertificateTrust.java b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/PinnedCertificateTrust.java new file mode 100644 index 000000000..7e7f413db --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/PinnedCertificateTrust.java @@ -0,0 +1,140 @@ +package org.medicmobile.webapp.mobile.offlinesync; + +import java.security.GeneralSecurityException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +/** + * Trusts exactly one key: the one whose fingerprint the peer read from the QR code. + * + * The host's certificate is self-signed and generated seconds earlier, so no certificate + * authority can vouch for it and normal validation is useless here. Pinning replaces that trust: + * the QR code travelled over a channel an attacker cannot forge (a human looking at a screen), + * so the fingerprint on it is the anchor. + * + * Everyone within range can join the hotspot, so without this a second device could answer on + * the host's address and the peer would never know. + */ +public final class PinnedCertificateTrust { + + private PinnedCertificateTrust() {} + + /** + * A socket factory that completes a handshake only with the pinned key. + * + * @param expectedFingerprint SHA-256 of the host's public key, as read from the QR code + */ + public static SSLSocketFactory socketFactoryFor(String expectedFingerprint) + throws GeneralSecurityException { + if (expectedFingerprint == null || expectedFingerprint.trim().isEmpty()) { + throw new GeneralSecurityException("Refusing to connect without a pinned fingerprint"); + } + + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, new TrustManager[] { new PinningTrustManager(expectedFingerprint) }, null); + return context.getSocketFactory(); + } + + /** Compares two fingerprints, ignoring case and separators. */ + static boolean matches(String expected, String actual) { + return normalise(expected).equals(normalise(actual)); + } + + private static String normalise(String fingerprint) { + return fingerprint == null ? "" : fingerprint.replaceAll("[^A-Fa-f0-9]", "").toUpperCase(java.util.Locale.US); + } + + static String fingerprintOf(X509Certificate certificate) throws GeneralSecurityException { + return SessionCertificate.fingerprintOfKey(certificate); + } + + /** + * The check itself, kept separate from the TLS plumbing so it can be tested directly. + * + * @throws CertificateException if the host is not the device whose QR code was scanned + */ + static void verifyPinned(X509Certificate[] chain, String expectedFingerprint) + throws CertificateException { + if (chain == null || chain.length == 0) { + throw new CertificateException("The host presented no certificate"); + } + + String actual; + try { + actual = fingerprintOf(chain[0]); + } catch (GeneralSecurityException e) { + // CertificateException extends GeneralSecurityException, so the mismatch below is + // thrown outside this block: catching it here would hide why the host was refused. + throw new CertificateException("Could not read the host's certificate", e); + } + + if (!matches(expectedFingerprint, actual)) { + // Both values, so the log shows which key was expected and which one arrived. Neither + // is a secret: the expected one is printed in the QR code and the other was just sent + // over the wire. + throw new CertificateException( + "The host's key does not match the one in the QR code (expected " + + shorten(expectedFingerprint) + ", got " + shorten(actual) + + " subject " + subjectOf(chain[0]) + ")"); + } + } + + /** + * The certificate's subject, for the message above. + * + * Null-safe because this runs while rejecting a certificate we have already decided not to + * trust: anything about it may be missing or malformed, so building the diagnostic does not + * throw. + */ + private static String subjectOf(X509Certificate certificate) { + try { + return certificate.getSubjectX500Principal() == null + ? "unknown" : certificate.getSubjectX500Principal().getName(); + } catch (RuntimeException e) { + return "unreadable"; + } + } + + /** Enough of a fingerprint to compare by eye, in a message that has to stay readable. */ + private static String shorten(String fingerprint) { + String normalised = normalise(fingerprint); + return normalised.length() <= 16 ? normalised : normalised.substring(0, 16); + } + + /** + * Lint objects to custom trust managers because they are usually written to accept + * everything. This one is the opposite: it accepts a single key and rejects all + * others, including any a certificate authority would vouch for. Delegating to the platform + * default is not an option, since the host's certificate is self-signed and seconds old, so + * the default would reject it outright and pairing could never work. + */ + @android.annotation.SuppressLint("CustomX509TrustManager") + private static final class PinningTrustManager implements X509TrustManager { + + private final String expectedFingerprint; + + PinningTrustManager(String expectedFingerprint) { + this.expectedFingerprint = expectedFingerprint; + } + + @Override public void checkServerTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + verifyPinned(chain, expectedFingerprint); + } + + /** This side never presents a certificate, so there is nothing for the host to check. */ + @Override public void checkClientTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + throw new CertificateException("This trust manager is for connecting, not serving"); + } + + @Override public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/offlinesync/PortraitCaptureActivity.java b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/PortraitCaptureActivity.java new file mode 100644 index 000000000..f949ceb02 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/PortraitCaptureActivity.java @@ -0,0 +1,27 @@ +package org.medicmobile.webapp.mobile.offlinesync; + +import com.journeyapps.barcodescanner.CaptureActivity; +import com.journeyapps.barcodescanner.DecoratedBarcodeView; + +import org.medicmobile.webapp.mobile.R; + +/** + * The scanner screen, with a way out that ZXing's own screen lacks. + * + * ZXing declares its own CaptureActivity as sensorLandscape, so this one's manifest entry locks + * it to portrait, like the rest of the flow. + */ +public class PortraitCaptureActivity extends CaptureActivity { + + /** + * Our layout rather than ZXing's, which is the preview alone. + * + * Leaving without a result is already handled as a cancelled scan, so the button only has to + * finish the activity. + */ + @Override protected DecoratedBarcodeView initializeContent() { + setContentView(R.layout.offline_sync_capture); + findViewById(R.id.offlineSyncScannerCancel).setOnClickListener(view -> finish()); + return findViewById(R.id.zxing_barcode_scanner); + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/offlinesync/QrCodeHelper.java b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/QrCodeHelper.java new file mode 100644 index 000000000..479061d6f --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/QrCodeHelper.java @@ -0,0 +1,336 @@ +package org.medicmobile.webapp.mobile.offlinesync; + +import static org.medicmobile.webapp.mobile.MedicLog.error; + +import android.graphics.Bitmap; +import android.util.Base64; +import android.graphics.Color; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.EncodeHintType; +import com.google.zxing.WriterException; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel; + +import java.io.ByteArrayOutputStream; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.EnumMap; +import java.util.Map; + +/** + * Generates and validates QR codes for offline sync WiFi hotspot credential exchange. + * + * QR Payload: + * { + * "type": "cht-offline-sync", + * "v": 1, + * "ssid": "AndroidShare_1234", // whatever the OS named the hotspot, we do not choose it + * "pwd": "randomPassword123", // likewise + * "ip": "192.168.43.1", + * "fp": "3A:7F:...:C2", // SHA-256 of the host's public key, colon-separated hex + * "port": 8443 + * } + * + * Type field must be "cht-offline-sync" + * TLS fingerprint must match the host key presented on connection + * QR regenerated each session, old codes are invalid + */ +public final class QrCodeHelper { + + + private static final int QR_SIZE = 512; // pixels + private static final String PAYLOAD_TYPE = "cht-offline-sync"; + private static final int PAYLOAD_VERSION = 1; + + // QR payload JSON keys + /** SHA-256 of the host's session public key; the peer refuses any other key. */ + private static final String KEY_FINGERPRINT = "fp"; + private static final String KEY_SSID = "ssid"; + private static final String KEY_PWD = "pwd"; + private static final String KEY_IP = "ip"; + private static final String KEY_PORT = "port"; + private static final int MAX_PORT = 65535; + // Every validation failure means the same thing to the user: the code cannot be used, ask + // for another. The prose behind it goes to the log, never across the bridge. + private static final String UNREADABLE = "unreadable_payload"; + + private QrCodeHelper() { + // Static utility class + } + + /** + * Hotspot credentials grouped for QR code generation. + */ + @SuppressWarnings("java:S107") // Data class: all fields are logically related + public static final class HotspotCredentials { + final String ssid; + final String password; + final String ipAddress; + final int port; + final String certFingerprint; + + public HotspotCredentials(String ssid, String password, String ipAddress, int port, + String certFingerprint) { + this.ssid = ssid; + this.password = password; + this.ipAddress = ipAddress; + this.port = port; + this.certFingerprint = certFingerprint; + } + } + + /** + * Renders the payload as a PNG data URL, for the webapp to show. + * + * Encoding here rather than in the webapp keeps the QR library on one side: ZXing is already + * a dependency for scanning, so the browser needs none. + * + * @return a data: URL, or null if the payload could not be encoded + */ + public static String generateQrDataUrl(String payloadJson) { + try { + Bitmap bitmap = encodeQrBitmap(payloadJson, QR_SIZE); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out); + bitmap.recycle(); + return "data:image/png;base64," + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP); + } catch (WriterException e) { + error(e, "Could not render the pairing code"); + return null; + } + } + + /** + * Build the QR payload JSON string from hotspot credentials. + * + * @param creds hotspot credentials + * @return JSON string matching + * @throws JSONException if JSON construction fails + */ + public static String buildPayload(HotspotCredentials creds) throws JSONException { + validatePayloadParams(creds.ssid, creds.password, creds.ipAddress, creds.port); + if (creds.certFingerprint == null || creds.certFingerprint.trim().isEmpty()) { + // a payload without one would pair over a connection nothing can verify + throw new JSONException("certFingerprint is required"); + } + + JSONObject payload = new JSONObject(); + payload.put("type", PAYLOAD_TYPE); + payload.put("v", PAYLOAD_VERSION); + payload.put(KEY_SSID, creds.ssid); + payload.put(KEY_PWD, creds.password); + payload.put(KEY_IP, creds.ipAddress); + payload.put(KEY_FINGERPRINT, creds.certFingerprint); + payload.put(KEY_PORT, creds.port); + return payload.toString(); + } + + private static void validatePayloadParams(String ssid, String password, + String ipAddress, int port) { + requireNonEmpty(ssid, "ssid"); + requireNonEmpty(password, "password"); + requireNonEmpty(ipAddress, "ipAddress"); + if (port <= 0 || port > MAX_PORT) { + throw new IllegalArgumentException("port must be between 1 and " + MAX_PORT + ", got: " + port); + } + } + + private static void requireNonEmpty(String value, String name) { + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException(name + " must not be null or empty"); + } + } + + /** + * Validate a scanned QR payload. + * + * Checks: + * Type must be "cht-offline-sync" + * Required fields: ssid, pwd, ip, port + * + * @param payloadJson the scanned QR code content + * @return QrValidation.accept() if valid, + * QrValidation.reject(reason) if invalid + */ + public static QrValidation validateQrPayload(String payloadJson) { + if (payloadJson == null || payloadJson.isEmpty()) { + return QrValidation.reject(UNREADABLE, "empty payload"); + } + + JSONObject payload; + try { + payload = new JSONObject(payloadJson); + } catch (JSONException e) { + return QrValidation.reject(UNREADABLE, "invalid json: " + e.getMessage()); + } + + QrValidation headerCheck = checkQrTypeAndVersion(payload); + if (headerCheck != null) { + return headerCheck; + } + + return validateRequiredFields(payload); + } + + private static QrValidation checkQrTypeAndVersion(JSONObject payload) { + String type = payload.optString("type", ""); + if (!PAYLOAD_TYPE.equals(type)) { + return QrValidation.reject(UNREADABLE, "invalid type '" + type + + "', expected '" + PAYLOAD_TYPE + "'"); + } + int version = payload.optInt("v", -1); + if (version != PAYLOAD_VERSION) { + return QrValidation.reject(UNREADABLE, "unsupported version: " + version + + ", expected " + PAYLOAD_VERSION); + } + return null; + } + + /** + * Parse a validated QR payload into its constituent fields. + * Call validateQrPayload() first to ensure the payload is valid. + * + * @param payloadJson valid QR payload JSON + * @return parsed QrPayload object, or null if parsing fails + */ + public static QrPayload parsePayload(String payloadJson) { + try { + JSONObject json = new JSONObject(payloadJson); + return new QrPayload( + json.getString(KEY_SSID), + json.getString(KEY_PWD), + json.getString(KEY_IP), + json.getInt(KEY_PORT), + json.getString(KEY_FINGERPRINT) + ); + } catch (JSONException e) { + error(e, "Failed to parse QR payload"); + return null; + } + } + + // --- Private helpers --- + + /** + * Validate required fields in the QR payload. + * + * The fingerprint counts as required: a payload without one would let a peer pair over a + * connection it cannot verify, which is the attack the pinning exists to stop. + */ + private static final String[] REQUIRED_FIELDS = { KEY_SSID, KEY_PWD, KEY_IP, KEY_FINGERPRINT }; + + private static QrValidation validateRequiredFields(JSONObject payload) { + for (String field : REQUIRED_FIELDS) { + if (isEmptyField(payload, field)) { + return QrValidation.reject(UNREADABLE, "missing required field: " + field); + } + } + int port = payload.optInt(KEY_PORT, 0); + if (port <= 0 || port > MAX_PORT) { + return QrValidation.reject(UNREADABLE, "invalid port: " + port); + } + return QrValidation.accept(); + } + + private static boolean isEmptyField(JSONObject obj, String key) { + return obj.optString(key, "").isEmpty(); + } + + /** + * Encode a string into a QR code Bitmap using ZXing. + * + * @param content the string content to encode + * @param size bitmap width and height in pixels + * @return the QR code as a Bitmap + * @throws WriterException if ZXing encoding fails + */ + private static Bitmap encodeQrBitmap(String content, int size) throws WriterException { + Map hints = new EnumMap<>(EncodeHintType.class); + hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M); + hints.put(EncodeHintType.CHARACTER_SET, "UTF-8"); + hints.put(EncodeHintType.MARGIN, 2); + + QRCodeWriter writer = new QRCodeWriter(); + BitMatrix bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, size, size, hints); + + return renderBitmapPixels(bitMatrix); + } + + private static Bitmap renderBitmapPixels(BitMatrix bitMatrix) { + int width = bitMatrix.getWidth(); + int height = bitMatrix.getHeight(); + int[] pixels = fillPixelArray(bitMatrix, width, height); + + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565); + bitmap.setPixels(pixels, 0, width, 0, 0, width, height); + return bitmap; + } + + private static int[] fillPixelArray(BitMatrix bitMatrix, int width, int height) { + int[] pixels = new int[width * height]; + for (int y = 0; y < height; y++) { + fillRow(bitMatrix, pixels, y, width); + } + return pixels; + } + + private static void fillRow(BitMatrix bitMatrix, int[] pixels, int y, int width) { + int offset = y * width; + for (int x = 0; x < width; x++) { + pixels[offset + x] = bitMatrix.get(x, y) ? Color.BLACK : Color.WHITE; + } + } + + /** + * Parsed QR payload data class. + * Holds the extracted fields from a validated QR code. + */ + @SuppressWarnings("java:S107") // Data class: all fields are logically related + public static final class QrPayload { + private final String ssid; + private final String password; + private final String ipAddress; + private final int port; + private final String certFingerprint; + + QrPayload(String ssid, String password, String ipAddress, + int port, String certFingerprint) { + this.ssid = ssid; + this.password = password; + this.ipAddress = ipAddress; + this.port = port; + this.certFingerprint = certFingerprint; + } + + /** The fingerprint of the key the host must present. Any other key means it is not the host. */ + public String getCertFingerprint() { + return certFingerprint; + } + + public String getSsid() { + return ssid; + } + + public String getPassword() { + return password; + } + + public String getIpAddress() { + return ipAddress; + } + + public int getPort() { + return port; + } + + @Override + public String toString() { + return "QrPayload{ssid='" + ssid + "', ip='" + ipAddress + + "', port=" + port + '}'; + } + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/offlinesync/QrScannerActivity.java b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/QrScannerActivity.java new file mode 100644 index 000000000..2acc63fe8 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/QrScannerActivity.java @@ -0,0 +1,112 @@ +package org.medicmobile.webapp.mobile.offlinesync; + +import static org.medicmobile.webapp.mobile.MedicLog.log; +import static org.medicmobile.webapp.mobile.MedicLog.warn; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; + +import com.google.zxing.integration.android.IntentIntegrator; +import org.medicmobile.webapp.mobile.R; +import com.google.zxing.integration.android.IntentResult; + +/** + * Activity that launches ZXing QR scanner for offline sync credential exchange. + * + * The sending device scans the receiving device's QR code to get the WiFi hotspot credentials. + * Result returned via onActivityResult with the scanned QR payload. + * + * Result extras: + * EXTRA_QR_RESULT: the scanned QR payload JSON string (on RESULT_OK) + * EXTRA_QR_ERROR: a stable error code (on RESULT_CANCELED with error) + */ +public class QrScannerActivity extends Activity { + + + public static final String EXTRA_QR_RESULT = "qr_result"; + public static final String EXTRA_QR_ERROR = "qr_error"; + + private boolean scannerLaunched = false; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (savedInstanceState != null) { + scannerLaunched = savedInstanceState.getBoolean("scannerLaunched", false); + } + + if (!scannerLaunched) { + launchScanner(); + } + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + outState.putBoolean("scannerLaunched", scannerLaunched); + super.onSaveInstanceState(outState); + } + + /** + * Launch the ZXing barcode scanner configured for QR codes only. + */ + @SuppressWarnings("deprecation") // IntentIntegrator deprecated but no replacement in zxing-android-embedded + private void launchScanner() { + scannerLaunched = true; + + IntentIntegrator integrator = new IntentIntegrator(this); + integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE); + integrator.setPrompt(getString(R.string.offlineSyncScanPrompt)); + integrator.setBeepEnabled(true); + integrator.setOrientationLocked(true); + integrator.setCaptureActivity(PortraitCaptureActivity.class); + integrator.initiateScan(); + } + + @SuppressWarnings("deprecation") // IntentIntegrator.parseActivityResult deprecated; no replacement + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + IntentResult scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, data); + + if (scanResult != null) { + String scannedContent = scanResult.getContents(); + + if (scannedContent == null) { + // User cancelled the scan + log(this, "QR scan cancelled by user"); + setResult(RESULT_CANCELED); + finish(); + return; + } + + log(this, "QR code scanned"); + handleScannedPayload(scannedContent); + } else { + // Unexpected result, not from ZXing + super.onActivityResult(requestCode, resultCode, data); + // "unknown" rather than a code of its own: the user message would be the same, and a + // code with no offline_sync.error key reaches them as raw text. The log carries the specifics. + warn(this, "Unexpected onActivityResult, not a ZXing result"); + returnError("unknown"); + } + } + + /** Hands the scanned payload back. The peer validates it before joining anything. */ + private void handleScannedPayload(String scannedContent) { + Intent resultIntent = new Intent(); + resultIntent.putExtra(EXTRA_QR_RESULT, scannedContent); + setResult(RESULT_OK, resultIntent); + finish(); + } + + /** + * Return an error result to the caller. + */ + private void returnError(String error) { + Intent resultIntent = new Intent(); + resultIntent.putExtra(EXTRA_QR_ERROR, error); + setResult(RESULT_CANCELED, resultIntent); + finish(); + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/offlinesync/QrValidation.java b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/QrValidation.java new file mode 100644 index 000000000..21f9070cc --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/QrValidation.java @@ -0,0 +1,49 @@ +package org.medicmobile.webapp.mobile.offlinesync; + +/** + * Outcome of validating a scanned QR payload: accepted, or rejected with a code and a detail. + * + * The code is what crosses the bridge, so it is a stable token the webapp turns into a + * translated message. The detail is the prose behind it and is for the log only: a sentence + * built here cannot be translated, and would reach the user as raw text. + */ +public final class QrValidation { + + private static final QrValidation ACCEPTED_RESULT = new QrValidation(true, null, null); + + private final boolean accepted; + private final String code; + private final String detail; + + private QrValidation(boolean accepted, String code, String detail) { + this.accepted = accepted; + this.code = code; + this.detail = detail; + } + + public static QrValidation accept() { + return ACCEPTED_RESULT; + } + + public static QrValidation reject(String code, String detail) { + return new QrValidation(false, code, detail); + } + + public boolean isAccepted() { + return accepted; + } + + /** The stable failure code for the webapp, or {@code null} when accepted. */ + public String getCode() { + return code; + } + + /** Why it failed, for the log. Not shown to a user, because it cannot be translated. */ + public String getDetail() { + return detail; + } + + @Override public String toString() { + return accepted ? "QrValidation{accepted}" : "QrValidation{rejected: " + code + ": " + detail + "}"; + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/offlinesync/SessionCertificate.java b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/SessionCertificate.java new file mode 100644 index 000000000..d3df11545 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/SessionCertificate.java @@ -0,0 +1,404 @@ +package org.medicmobile.webapp.mobile.offlinesync; + +import static org.medicmobile.webapp.mobile.MedicLog.log; +import static org.medicmobile.webapp.mobile.MedicLog.warn; + +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; + +import java.io.IOException; +import java.math.BigInteger; +import java.net.InetAddress; +import java.security.GeneralSecurityException; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.cert.Certificate; +import java.util.Date; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import java.net.Socket; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import javax.net.ssl.KeyManager; +import javax.net.ssl.X509KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLServerSocketFactory; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import javax.security.auth.x500.X500Principal; + +import fi.iki.elonen.NanoHTTPD; + +/** + * A throwaway TLS identity for one pairing session. + * + * The peer has no way to know this certificate in advance, so it is not trusted by a CA and is + * not meant to be. It is pinned instead: the host shows its public key's SHA-256 fingerprint in + * the QR code, and the peer refuses any certificate whose key does not match it. That is what + * stops a second device on the same hotspot impersonating the host. + * + * A fresh key is generated per session and dropped afterwards, so a captured fingerprint is + * worthless once the session ends. + * + * Keys live in the Android keystore and never leave it. Generating them there also produces the + * self-signed certificate for us, which is why no certificate library is needed. + */ +@android.annotation.TargetApi(26) +public class SessionCertificate { + + private static final String KEYSTORE_TYPE = "AndroidKeyStore"; + private static final String DEFAULT_LABEL = "CHT device"; + private static final String LOOPBACK = "127.0.0.1"; + private static final int HANDSHAKE_TIMEOUT_MS = 5000; + /** + * Every session gets its OWN alias, never a shared one. + * + * Deleting and regenerating under one alias is not enough: key material is cached per alias + * inside the process, so the TLS layer can go on serving the retired key while + * getCertificate() already returns the new one. The peer is then offered a key the QR code + * does not name and pairing can never succeed. How long anything is cached is not ours to + * know, and a fresh alias cannot collide with a stale entry either way. + */ + private static final String ALIAS_PREFIX = "cht-offline-sync-session"; + + private KeyStore keyStore; + private final String deviceLabel; + /** The alias this session generated, or null before renew() and after destroy(). */ + private String alias; + + private SessionCertificate(KeyStore keyStore, String deviceLabel) { + this.keyStore = keyStore; + this.deviceLabel = deviceLabel; + } + + /** + * Prepares a certificate holder without minting anything yet. + * + * Nothing is generated here. Minting the key runs a real TLS handshake against ourselves to + * prove the device can serve with it, which belongs to the start of a session: at app startup + * a device that cannot serve TLS would lose the sharing option from the menu without a word. + * {@link #renew()} is called when hosting starts, and reports the failure where someone can see it. + * + * @param deviceLabel shown as the certificate subject, so a curious peer sees something + * meaningful rather than a placeholder + */ + public static SessionCertificate forDevice(String deviceLabel) throws GeneralSecurityException { + return new SessionCertificate(loadKeyStore(), deviceLabel); + } + + /** + * Replaces the key and certificate with a fresh pair. + * + * Called at the start of every hosting session: {@link #destroy()} removes the entry when a + * session ends, so a second session would otherwise find nothing to serve TLS with. + */ + public void renew() throws GeneralSecurityException { + // A reload, not the instance held since construction: that one is a snapshot, and reading a + // freshly generated entry through it can return the previous key. + keyStore = loadKeyStore(); + discardOldAliases(); + alias = ALIAS_PREFIX + "-" + UUID.randomUUID(); + + KeyPairGenerator generator = + KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, KEYSTORE_TYPE); + // Both purposes, because which one the handshake needs depends on the cipher suite the two + // devices agree on: a modern ECDHE suite has the server sign, an RSA key-transport suite has + // it decrypt. Allowing only signing would work on most devices and fail on some. + generator.initialize(new KeyGenParameterSpec.Builder( + alias, KeyProperties.PURPOSE_SIGN | KeyProperties.PURPOSE_DECRYPT) + .setCertificateSubject(new X500Principal("CN=" + sanitise(deviceLabel))) + .setCertificateSerialNumber(BigInteger.ONE) + .setCertificateNotBefore(notBefore()) + .setCertificateNotAfter(notAfter()) + // NONE alongside the real digests for the same reason as the padding below: the TLS + // stack hashes the handshake itself and asks the key for a raw operation over the + // result, which keystore reports as digest NONE. + .setDigests( + KeyProperties.DIGEST_NONE, + KeyProperties.DIGEST_SHA256, + KeyProperties.DIGEST_SHA1) + .setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) + // NONE alongside PKCS1 because the TLS stack does its own padding: to sign the + // handshake it hands the key an already-padded block through a raw + // RSA/ECB/NoPadding operation, and a key that only authorizes PKCS1 is refused by + // the keystore, which fails the handshake and so the whole session. + .setEncryptionPaddings( + KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1, + KeyProperties.ENCRYPTION_PADDING_NONE) + .build()); + generator.generateKeyPair(); + + assertUsableOnThisDevice(); + log(SessionCertificate.class, "Generated a session certificate"); + } + + /** + * Removes any session key left behind, ours or a previous run's. + * + * Each session has its own alias, so a crash between renew() and destroy() would otherwise + * leave one in the keystore for good. + */ + private void discardOldAliases() throws GeneralSecurityException { + java.util.Enumeration aliases = keyStore.aliases(); + while (aliases.hasMoreElements()) { + String existing = aliases.nextElement(); + if (existing.startsWith(ALIAS_PREFIX)) { + keyStore.deleteEntry(existing); + } + } + } + + /** Backdated a little, so a peer whose clock runs slightly behind still accepts it. */ + private static Date notBefore() { + return new Date(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)); + } + + /** A session lasts minutes; a day is generous and keeps a leaked certificate short-lived. */ + private static Date notAfter() { + return new Date(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1)); + } + + private static KeyStore loadKeyStore() throws GeneralSecurityException { + try { + KeyStore keyStore = KeyStore.getInstance(KEYSTORE_TYPE); + keyStore.load(null); + return keyStore; + } catch (Exception e) { + throw new GeneralSecurityException("Could not open the Android keystore", e); + } + } + + /** A subject is not a security boundary, but a label still cannot break the DN. */ + static String sanitise(String deviceLabel) { + if (deviceLabel == null) { + return DEFAULT_LABEL; + } + // Build.MODEL is whatever the vendor set, so it can be entirely outside this set and strip + // to nothing. Falling through with an empty subject would leave the certificate with a bare + // "CN=". + String cleaned = deviceLabel.replaceAll("[^A-Za-z0-9 ._-]", "").trim(); + return cleaned.isEmpty() ? DEFAULT_LABEL : cleaned; + } + + /** The socket factory NanoHTTPD serves through. */ + public SSLServerSocketFactory sslServerSocketFactory() throws GeneralSecurityException { + if (alias == null) { + throw new GeneralSecurityException("No session certificate to serve"); + } + try { + KeyManagerFactory keyManagers = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + // null, not an empty array: keystore-backed keys are not password protected, and some + // implementations reject an empty password rather than treating it as none. + keyManagers.init(keyStore, null); + return NanoHTTPD.makeSSLSocketFactory(keyStore, serveOnly(keyManagers.getKeyManagers())); + } catch (IOException e) { + throw new GeneralSecurityException("Could not build the TLS socket factory", e); + } + } + + /** + * Binds the key managers to this session's entry. + * + * A key manager handed the whole keystore picks whichever entry it likes, and nothing stops + * the keystore holding entries other than this session's. Choosing one of those serves a key + * the QR code does not name, and pairing then fails in a way nothing on the device can + * explain. The peer trusts exactly one key, so the server has to offer exactly that one. + */ + private KeyManager[] serveOnly(KeyManager[] managers) { + KeyManager[] bound = new KeyManager[managers.length]; + for (int i = 0; i < managers.length; i++) { + bound[i] = managers[i] instanceof X509KeyManager x509 + ? new SessionKeyManager(x509, alias) : managers[i]; + } + return bound; + } + + /** A key manager that answers with this session's entry whatever it is asked for. */ + static final class SessionKeyManager implements X509KeyManager { + + private final X509KeyManager delegate; + private final String alias; + + SessionKeyManager(X509KeyManager delegate, String alias) { + this.delegate = delegate; + this.alias = alias; + } + + @Override public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { + return alias; + } + + @Override public String[] getServerAliases(String keyType, Principal[] issuers) { + return new String[] { alias }; + } + + @Override public X509Certificate[] getCertificateChain(String ignored) { + return delegate.getCertificateChain(alias); + } + + @Override public PrivateKey getPrivateKey(String ignored) { + return delegate.getPrivateKey(alias); + } + + // This side never acts as a TLS client: the peer verifies the host, not the other way round. + @Override public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { + return null; + } + + @Override public String[] getClientAliases(String keyType, Principal[] issuers) { + return new String[0]; + } + } + + /** + * SHA-256 of the session's PUBLIC KEY, as uppercase hex separated by colons. + * + * The public key, deliberately, and not the certificate around it. The key is what proves + * identity: only the device holding the matching private key can complete the handshake. + * Everything else in the certificate (serial, validity, subject, the self-signature) is + * self-asserted, adds no security to a self-signed certificate, and some keystores re-derive + * it when the entry is read back. Hashing all of it would make pairing depend on a keystore + * encoding its certificate identically twice, which nothing guarantees. + * + * This is also what certificate pinning normally means in practice: HPKP and the OWASP + * guidance both pin the SubjectPublicKeyInfo rather than the certificate. + */ + public String fingerprint() throws GeneralSecurityException { + if (alias == null) { + throw new GeneralSecurityException("No session certificate to fingerprint"); + } + Certificate certificate = keyStore.getCertificate(alias); + if (certificate == null) { + throw new GeneralSecurityException("No session certificate to fingerprint"); + } + return fingerprintOfKey(certificate); + } + + /** + * The pinned value for a certificate, used by both ends so they cannot drift apart. + * + * @throws GeneralSecurityException if the certificate carries no readable public key + */ + static String fingerprintOfKey(Certificate certificate) throws GeneralSecurityException { + if (certificate.getPublicKey() == null || certificate.getPublicKey().getEncoded() == null) { + throw new GeneralSecurityException("Certificate has no readable public key"); + } + return formatFingerprint( + MessageDigest.getInstance("SHA-256").digest(certificate.getPublicKey().getEncoded())); + } + + static String formatFingerprint(byte[] digest) { + // Indexed rather than for-each so the separator can key off the position. Checking the + // builder's own emptiness instead would need CharSequence.isEmpty, which is API 35. + StringBuilder hex = new StringBuilder(digest.length * 3); + for (int i = 0; i < digest.length; i++) { + if (i > 0) { + hex.append(':'); + } + hex.append(String.format(Locale.US, "%02X", digest[i])); + } + return hex.toString(); + } + + /** + * Completes a TLS handshake against ourselves, to prove this device can actually serve with + * this key before a hotspot is advertised. + * + * Building the socket factory only proves the key loads. Keystore keys are held by hardware + * on many devices, and whether one can sign a handshake is something only that device can + * answer. Doing it here costs a moment at session start and turns a mysterious failure later + * into a clear one now. + */ + void assertUsableOnThisDevice() throws GeneralSecurityException { + SSLServerSocketFactory serverFactory = sslServerSocketFactory(); + String fingerprint = fingerprint(); + + try (SSLServerSocket serverSocket = (SSLServerSocket) serverFactory.createServerSocket( + 0, 1, InetAddress.getByName(LOOPBACK))) { + serverSocket.setSoTimeout(HANDSHAKE_TIMEOUT_MS); + Thread server = acceptOneConnection(serverSocket); + + // the peer's own pinning code, so this checks both halves of the handshake + SSLSocketFactory clientFactory = PinnedCertificateTrust.socketFactoryFor(fingerprint); + try (SSLSocket client = + (SSLSocket) clientFactory.createSocket(LOOPBACK, serverSocket.getLocalPort())) { + client.setSoTimeout(HANDSHAKE_TIMEOUT_MS); + client.startHandshake(); + } + server.join(HANDSHAKE_TIMEOUT_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new GeneralSecurityException("Interrupted while checking the session certificate", e); + } catch (IOException e) { + throw new GeneralSecurityException( + "This device cannot serve TLS with a keystore-backed key " + whatIsBeingServed(), e); + } + } + + /** + * Which entry the key manager is actually serving, for the failure above. + * + * The handshake reports a key that does not match the one just generated, and only the + * keystore's own view can say whose key it is. Best effort: this runs while reporting a + * failure, so it does not throw one of its own. + */ + private String whatIsBeingServed() { + StringBuilder report = new StringBuilder("[mine=").append(alias); + try { + report.append(" held="); + java.util.Enumeration held = keyStore.aliases(); + while (held.hasMoreElements()) { + report.append(held.nextElement()).append(','); + } + KeyManagerFactory factory = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + factory.init(keyStore, null); + for (javax.net.ssl.KeyManager manager : factory.getKeyManagers()) { + if (manager instanceof javax.net.ssl.X509KeyManager x509) { + report.append(" serving=").append(java.util.Arrays.toString( + x509.getServerAliases("RSA", null))); + } + } + report.append(" algo=").append(KeyManagerFactory.getDefaultAlgorithm()); + } catch (Exception e) { + report.append(" unreadable:").append(e.getClass().getSimpleName()); + } + return report.append(']').toString(); + } + + /** Accepts a single handshake in the background so the client above has something to talk to. */ + private static Thread acceptOneConnection(SSLServerSocket serverSocket) { + Thread thread = new Thread(() -> { + try (SSLSocket accepted = (SSLSocket) serverSocket.accept()) { + accepted.startHandshake(); + } catch (IOException e) { + // the client reports the failure; this side only has to not hang + warn(e, "Loopback handshake failed on the serving side"); + } + }, "offline-sync-cert-selftest"); + thread.setDaemon(true); + thread.start(); + return thread; + } + + /** Drops the key, so the session's identity cannot be reused. */ + public void destroy() { + try { + if (alias != null && keyStore.containsAlias(alias)) { + keyStore.deleteEntry(alias); + log(SessionCertificate.class, "Session certificate destroyed"); + } + alias = null; + } catch (Exception e) { + // The next session discards every session entry before making its own, so this is not + // fatal, but a key we meant to destroy and did not is worth knowing about. + warn(e, "Could not destroy the session certificate"); + } + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/offlinesync/WifiHotspotManager.java b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/WifiHotspotManager.java new file mode 100644 index 000000000..01bdc803e --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/WifiHotspotManager.java @@ -0,0 +1,111 @@ +package org.medicmobile.webapp.mobile.offlinesync; + +import static org.medicmobile.webapp.mobile.MedicLog.log; +import static org.medicmobile.webapp.mobile.MedicLog.warn; + +/** + * Manages the WiFi hotspot lifecycle for offline sync. + * + * Wraps a {@link HotspotProvider} with start/stop timing, state tracking so a second start + * returns the running hotspot rather than competing for the radio, and the credentials of the + * session while it lasts. + * + * There is deliberately no idle timeout: nothing would poll it, and a session is ended by the + * user, by the peer disconnecting, or by the system taking the hotspot away. + */ +public class WifiHotspotManager { + + private final HotspotProvider provider; + + private long startedAt; + private String activeSsid; + private String activePassword; + private String activeIpAddress; + + public WifiHotspotManager(HotspotProvider provider) { + if (provider == null) { + throw new IllegalArgumentException("provider must not be null"); + } + this.provider = provider; + } + + /** + * Start the hotspot. Wraps the provider callback with lifecycle tracking. + * + * @param callback receives hotspot credentials on success, or failure reason + */ + public void startHotspot(HotspotProvider.HotspotCallback callback) { + if (callback == null) { + throw new IllegalArgumentException("callback must not be null"); + } + + if (provider.isRunning()) { + warn(this, "Hotspot already active, returning existing credentials"); + callback.onStarted(activeSsid, activePassword, activeIpAddress); + return; + } + + provider.start(new HotspotProvider.HotspotCallback() { + @Override + public void onStarted(String ssid, String password, String ipAddress) { + startedAt = System.currentTimeMillis(); + activeSsid = ssid; + activePassword = password; + activeIpAddress = ipAddress; + + log(this, "Hotspot started: SSID=" + ssid + ", IP=" + ipAddress); + callback.onStarted(ssid, password, ipAddress); + } + + @Override + public void onFailed(String reason) { + warn(this, "Hotspot failed to start: " + reason); + callback.onFailed(reason); + } + + @Override + public void onStopped() { + warn(this, "Hotspot went away while a session was running"); + clearCredentials(); + callback.onStopped(); + } + }); + } + + /** + * Stop the hotspot and clear all state. + */ + public void stopHotspot() { + if (provider.isRunning()) { + provider.stop(); + long duration = System.currentTimeMillis() - startedAt; + log(this, "Hotspot stopped after " + (duration / 1000) + "s"); + } + clearCredentials(); + } + + private void clearCredentials() { + activeSsid = null; + activePassword = null; + activeIpAddress = null; + startedAt = 0; + } + + /** + * Check if the hotspot is currently active. + */ + public boolean isActive() { + return provider.isRunning(); + } + + // --- Getters for active credentials --- + + public String getActiveSsid() { + return activeSsid; + } + + public String getActivePassword() { + return activePassword; + } + +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/offlinesync/WifiHotspotProvider.java b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/WifiHotspotProvider.java new file mode 100644 index 000000000..938ac0324 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/offlinesync/WifiHotspotProvider.java @@ -0,0 +1,434 @@ +package org.medicmobile.webapp.mobile.offlinesync; + +import static org.medicmobile.webapp.mobile.MedicLog.error; +import static org.medicmobile.webapp.mobile.MedicLog.log; +import static org.medicmobile.webapp.mobile.MedicLog.trace; +import static org.medicmobile.webapp.mobile.MedicLog.warn; + +import android.net.wifi.SoftApConfiguration; +import android.location.LocationManager; +import android.net.wifi.WifiManager; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; + +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.util.Enumeration; + +/** + * Production HotspotProvider using WifiManager.startLocalOnlyHotspot() (API 26+). + * + * Creates an isolated local network with no internet routing: sending devices + * connect directly to the receiving one over WiFi. + * + * Notes: + * - Requires android.permission.CHANGE_WIFI_STATE + * - The OS assigns a random SSID and password (we cannot control them) + * - The hotspot IP varies by OEM (detected at runtime from network interfaces) + * - Only one LocalOnlyHotspot reservation can be active at a time + */ +public class WifiHotspotProvider implements HotspotProvider { + + + /** Known hotspot interface names by OEM */ + private static final String[] KNOWN_HOTSPOT_INTERFACES = { + "ap0", "swlan0", "wlan1", "softap0", "wifi_bridge0", "ap_br0" + }; + + private static final int IP_DETECT_MAX_RETRIES = 15; + private static final long IP_DETECT_RETRY_DELAY_MS = 500; // 15 × 500ms = 7.5s max + + private final WifiManager wifiManager; + private final LocationManager locationManager; + private WifiManager.LocalOnlyHotspotReservation reservation; + private volatile boolean running = false; + /** + * The platform delivers its callbacks on whatever looper it is handed, and detecting the + * hotspot's address polls for up to 7.5 seconds. On the main looper that is past Android's + * ANR threshold, so the app would freeze and can be killed while the hotspot comes up. + */ + private HandlerThread callbackThread; + + public WifiHotspotProvider(WifiManager wifiManager, LocationManager locationManager) { + if (wifiManager == null) { + throw new IllegalArgumentException("wifiManager must not be null"); + } + this.wifiManager = wifiManager; + this.locationManager = locationManager; + } + + /** + * Whether this device can host a local-only hotspot. + * + * LocalOnlyHotspot arrived in API 26 and the app supports 21, so hosting is genuinely + * unavailable on older devices. Peers are unaffected: joining a network needs no such API. + */ + public static boolean isSupported() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O; + } + + /** The code for why this device cannot start a hotspot right now, or null when it can. */ + private String reasonHostingIsUnavailable() { + if (!isSupported()) { + warn(WifiHotspotProvider.class, + "Local-only hotspot needs Android 8.0, this device is on API " + Build.VERSION.SDK_INT); + return "hotspot_unsupported"; + } + + // Granting the permission is not enough: the platform also refuses to start a hotspot while + // location is switched off, and says so in a way that looks like any other failure. + if (!isLocationEnabled(locationManager)) { + warn(WifiHotspotProvider.class, "Location services are off, cannot start a hotspot"); + return "location_services_off"; + } + + if (running) { + warn(this, "Hotspot already running"); + return "hotspot_already_running"; + } + + return null; + } + + @Override + public void start(HotspotCallback callback) { + if (callback == null) { + throw new IllegalArgumentException("callback must not be null"); + } + + String unavailable = reasonHostingIsUnavailable(); + if (unavailable != null) { + callback.onFailed(unavailable); + return; + } + + try { + startLocalOnlyHotspot(callback); + } catch (Exception e) { + error(e, "Failed to start hotspot"); + stopCallbackThread(); + callback.onFailed("hotspot_error"); + } + } + + /** + * The only place the API-26 call is made. Reached solely through {@link #start}, which + * returns early via {@link #isSupported()} on older devices, so the annotation records a + * precondition the caller has already enforced rather than assuming it for the whole class. + */ + @android.annotation.TargetApi(26) + private void startLocalOnlyHotspot(HotspotCallback callback) { + callbackThread = new HandlerThread("offline-sync-hotspot"); + callbackThread.start(); + wifiManager.startLocalOnlyHotspot(createHotspotCallback(callback), + new Handler(callbackThread.getLooper())); + } + + private void stopCallbackThread() { + if (callbackThread != null) { + callbackThread.quitSafely(); + callbackThread = null; + } + } + + @android.annotation.TargetApi(26) + private WifiManager.LocalOnlyHotspotCallback createHotspotCallback(HotspotCallback callback) { + return new WifiManager.LocalOnlyHotspotCallback() { + @Override + public void onStarted(WifiManager.LocalOnlyHotspotReservation hotspotReservation) { + reservation = hotspotReservation; + running = true; + + String ssid = extractSsid(hotspotReservation); + String password = extractPassword(hotspotReservation); + if (ssid == null || password == null) { + warn(this, "Hotspot started but the system did not say how to join it"); + stop(); + callback.onFailed("hotspot_no_credentials"); + return; + } + + String ip = detectHotspotIpWithRetry(); + if (ip == null) { + handleIpDetectionFailure(hotspotReservation, callback); + return; + } + + log(this, "LocalOnlyHotspot started: SSID=" + ssid + ", IP=" + ip); + callback.onStarted(ssid, password, ip); + } + + @Override + public void onStopped() { + log(this, "LocalOnlyHotspot stopped by system"); + running = false; + reservation = null; + callback.onStopped(); + } + + @Override + public void onFailed(int reason) { + running = false; + String reasonStr = mapFailureReason(reason); + warn(this, "LocalOnlyHotspot failed: " + reasonStr); + callback.onFailed(reasonStr); + } + }; + } + + private void handleIpDetectionFailure(WifiManager.LocalOnlyHotspotReservation hotspotReservation, + HotspotCallback callback) { + warn(this, "Hotspot started but could not detect IP, aborting"); + running = false; + closeReservationQuietly(hotspotReservation); + reservation = null; + stopCallbackThread(); + callback.onFailed("hotspot_no_address"); + } + + /** + * Whether the device's location setting is on, which the platform requires for a hotspot and, + * on some versions, for reading the joined network's name. + * + * Separate from the permission: a user can grant location access and still have the setting + * switched off, and the platform then fails in a way that looks like any other error. + */ + static boolean isLocationEnabled(LocationManager locationManager) { + if (locationManager == null) { + return false; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + return locationManager.isLocationEnabled(); + } + return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) + || locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER); + } + + @android.annotation.TargetApi(26) + private static void closeReservationQuietly(WifiManager.LocalOnlyHotspotReservation res) { + try { + res.close(); + } catch (Exception e) { + warn(e, "Error closing hotspot reservation during cleanup"); + } + } + + @Override + public void stop() { + if (!isSupported()) { + return; + } + if (reservation != null) { + try { + reservation.close(); + log(this, "LocalOnlyHotspot reservation closed"); + } catch (Exception e) { + error(e, "Error closing hotspot reservation"); + } + reservation = null; + } + stopCallbackThread(); + running = false; + } + + @Override + public boolean isRunning() { + return running; + } + + // --- Private helpers --- + + @SuppressWarnings("deprecation") + @android.annotation.TargetApi(26) + private String extractSsid(WifiManager.LocalOnlyHotspotReservation hotspotReservation) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + SoftApConfiguration config = hotspotReservation.getSoftApConfiguration(); + return config.getSsid(); + } + // API 26-29: use deprecated WifiConfiguration + android.net.wifi.WifiConfiguration wifiConfig = hotspotReservation.getWifiConfiguration(); + // Null rather than a made-up name. A peer would put whatever goes in here into a QR code + // and try to join it, so inventing one advertises a network that does not exist. + return wifiConfig == null ? null : wifiConfig.SSID; + } + + @SuppressWarnings("deprecation") + @android.annotation.TargetApi(26) + private String extractPassword(WifiManager.LocalOnlyHotspotReservation hotspotReservation) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + SoftApConfiguration config = hotspotReservation.getSoftApConfiguration(); + return config.getPassphrase(); + } + // API 26-29: use deprecated WifiConfiguration + android.net.wifi.WifiConfiguration wifiConfig = hotspotReservation.getWifiConfiguration(); + // Null rather than an empty password, for the same reason as the name above. + return wifiConfig == null ? null : wifiConfig.preSharedKey; + } + + /** + * Retry wrapper for detectHotspotIp(). On many devices the hotspot network + * interface isn't fully configured when onStarted() fires: the OS needs a + * few hundred milliseconds to assign the IP. We retry up to + * IP_DETECT_MAX_RETRIES times with IP_DETECT_RETRY_DELAY_MS between attempts. + */ + private static String detectHotspotIpWithRetry() { + for (int attempt = 1; attempt <= IP_DETECT_MAX_RETRIES; attempt++) { + String ip = detectHotspotIp(); + if (ip != null) { + return ip; + } + if (attempt < IP_DETECT_MAX_RETRIES) { + trace(WifiHotspotProvider.class, "Hotspot IP not ready, retry " + attempt + "/" + IP_DETECT_MAX_RETRIES); + sleepForRetry(); + } + } + return null; + } + + private static void sleepForRetry() { + try { + Thread.sleep(IP_DETECT_RETRY_DELAY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + warn(WifiHotspotProvider.class, "IP detection retry interrupted"); + } + } + + /** + * Detect the actual hotspot IP by scanning network interfaces. + * Phase 1: Check known hotspot interface names. + * Phase 2: Enumerate all interfaces, skip loopback and wlan0 (regular WiFi). + * Returns null if no hotspot IP is found, and the caller then fails the session. + */ + private static String detectHotspotIp() { + try { + String ip = detectFromKnownInterfaces(); + if (ip != null) { + return ip; + } + return detectFromAllInterfaces(); + } catch (Exception e) { + warn(e, "Error detecting hotspot IP"); + } + warn(WifiHotspotProvider.class, "Could not detect hotspot IP from any network interface"); + return null; + } + + /** Phase 1: Try known hotspot interface names (fast path). */ + private static String detectFromKnownInterfaces() throws java.net.SocketException { + for (String ifName : KNOWN_HOTSPOT_INTERFACES) { + String ip = tryInterface(ifName); + if (ip != null) { + return ip; + } + } + return null; + } + + private static String tryInterface(String ifName) throws java.net.SocketException { + NetworkInterface nif = NetworkInterface.getByName(ifName); + if (nif == null || !nif.isUp()) { + return null; + } + String ip = getIpv4Address(nif); + if (ip != null) { + log(WifiHotspotProvider.class, "Detected hotspot IP: " + ip + " on " + ifName); + } + return ip; + } + + /** Phase 2: Enumerate all interfaces, prefer non-wlan0. Falls back to wlan0. */ + private static String detectFromAllInterfaces() throws java.net.SocketException { + Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); + if (interfaces == null) { + return null; + } + + String wlan0Ip = null; + while (interfaces.hasMoreElements()) { + NetworkInterface nif = interfaces.nextElement(); + String result = processInterface(nif); + if (result != null) { + return result; + } + wlan0Ip = getWlan0Fallback(nif, wlan0Ip); + } + + if (wlan0Ip != null) { + log(WifiHotspotProvider.class, "Using wlan0 fallback IP: " + wlan0Ip); + } + return wlan0Ip; + } + + private static String processInterface(NetworkInterface nif) throws java.net.SocketException { + if (!nif.isUp() || nif.isLoopback()) { + return null; + } + return getIpFromInterface(nif); + } + + /** Get IP from a non-wlan0 interface, or null if wlan0 or no IP. */ + private static String getIpFromInterface(NetworkInterface nif) { + String name = nif.getName(); + if ("wlan0".equals(name)) { + return null; + } + String ip = getIpv4Address(nif); + if (ip != null) { + log(WifiHotspotProvider.class, "Detected hotspot IP: " + ip + " on " + name); + } + return ip; + } + + /** Save wlan0 IP as fallback if this is the wlan0 interface. */ + private static String getWlan0Fallback(NetworkInterface nif, String currentFallback) { + if (!"wlan0".equals(nif.getName())) { + return currentFallback; + } + String ip = getIpv4Address(nif); + if (ip != null) { + trace(WifiHotspotProvider.class, "Found wlan0 IP: " + ip + " (saving as fallback)"); + return ip; + } + return currentFallback; + } + + private static String getIpv4Address(NetworkInterface nif) { + Enumeration addrs = nif.getInetAddresses(); + while (addrs.hasMoreElements()) { + InetAddress addr = addrs.nextElement(); + if (!addr.isLoopbackAddress() && addr instanceof Inet4Address) { + return addr.getHostAddress(); + } + } + return null; + } + + /** + * Turns a platform failure into a stable code for the webapp, and logs the detail. + * + * The code is what crosses to the webapp, which maps it to a translated message; the prose + * and the raw value stay in the log, where they are what lets support tell an OEM that + * forbids hotspots from a device that is merely busy. + */ + private String mapFailureReason(int reason) { + switch (reason) { + case WifiManager.LocalOnlyHotspotCallback.ERROR_NO_CHANNEL: + warn(WifiHotspotProvider.class, "No wifi channel available (code " + reason + ")"); + return "hotspot_no_channel"; + case WifiManager.LocalOnlyHotspotCallback.ERROR_INCOMPATIBLE_MODE: + warn(WifiHotspotProvider.class, + "Incompatible wifi mode, tethering may be active (code " + reason + ")"); + return "hotspot_incompatible_mode"; + case WifiManager.LocalOnlyHotspotCallback.ERROR_TETHERING_DISALLOWED: + warn(WifiHotspotProvider.class, + "Tethering disallowed by this device (code " + reason + ")"); + return "hotspot_tethering_disallowed"; + default: + warn(WifiHotspotProvider.class, "Hotspot failed (code " + reason + ")"); + return "hotspot_error"; + } + } +} diff --git a/src/main/res/layout/offline_sync_capture.xml b/src/main/res/layout/offline_sync_capture.xml new file mode 100644 index 000000000..dd2c6fe25 --- /dev/null +++ b/src/main/res/layout/offline_sync_capture.xml @@ -0,0 +1,29 @@ + + + + + + +