From c338af28fe2ae43f4cc4a8889a467c4b4ea00574 Mon Sep 17 00:00:00 2001 From: paulpascal Date: Thu, 17 Sep 2026 11:20:49 +0000 Subject: [PATCH 01/68] feat(#11281): bring up the hotspot and local server for pairing --- build.gradle | 5 + src/main/AndroidManifest.xml | 24 ++ .../mobile/EmbeddedBrowserActivity.java | 16 + .../webapp/mobile/MedicAndroidJavascript.java | 60 +++ .../webapp/mobile/p2p/HotspotProvider.java | 54 +++ .../webapp/mobile/p2p/LocalHttpServer.java | 101 +++++ .../mobile/p2p/P2pForegroundService.java | 131 +++++++ .../webapp/mobile/p2p/P2pManager.java | 115 ++++++ .../mobile/p2p/P2pNotificationChannel.java | 80 ++++ .../webapp/mobile/p2p/QrCodeHelper.java | 364 ++++++++++++++++++ .../webapp/mobile/p2p/QrScannerActivity.java | 149 +++++++ .../webapp/mobile/p2p/QrValidation.java | 41 ++ .../webapp/mobile/p2p/WifiHotspotManager.java | 139 +++++++ .../mobile/p2p/WifiHotspotProvider.java | 361 +++++++++++++++++ .../mobile/p2p/LocalHttpServerTest.java | 112 ++++++ .../webapp/mobile/p2p/P2pManagerTest.java | 134 +++++++ .../webapp/mobile/p2p/QrCodeHelperTest.java | 77 ++++ .../webapp/mobile/p2p/QrValidationTest.java | 33 ++ .../mobile/p2p/WifiHotspotManagerTest.java | 137 +++++++ .../mobile/p2p/WifiHotspotProviderTest.java | 83 ++++ 20 files changed, 2216 insertions(+) create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/HotspotProvider.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/LocalHttpServer.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/P2pForegroundService.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/P2pManager.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/P2pNotificationChannel.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/QrCodeHelper.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/QrScannerActivity.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/QrValidation.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotManager.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotProvider.java create mode 100644 src/test/java/org/medicmobile/webapp/mobile/p2p/LocalHttpServerTest.java create mode 100644 src/test/java/org/medicmobile/webapp/mobile/p2p/P2pManagerTest.java create mode 100644 src/test/java/org/medicmobile/webapp/mobile/p2p/QrCodeHelperTest.java create mode 100644 src/test/java/org/medicmobile/webapp/mobile/p2p/QrValidationTest.java create mode 100644 src/test/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotManagerTest.java create mode 100644 src/test/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotProviderTest.java diff --git a/build.gradle b/build.gradle index ec8e0c0d..5ceb248c 100644 --- a/build.gradle +++ b/build.gradle @@ -488,6 +488,11 @@ android { } dependencies { + // P2P WiFi hotspot sync (#11281): NanoHTTPD is a single-file HTTP server, ZXing does QR both ways + implementation 'org.nanohttpd:nanohttpd:2.3.1' + implementation 'com.google.zxing:core:3.5.3' + implementation 'com.journeyapps:zxing-android-embedded:4.3.0' + implementation fileTree(dir: 'libs', include: ['*.jar']) implementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.24') implementation 'androidx.browser:browser:1.8.0' diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 336facce..1f890a9a 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -26,6 +26,18 @@ --> + + + + + + + + + + + + + + + + diff --git a/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java b/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java index 7c18e935..4d5436a1 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java +++ b/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java @@ -41,6 +41,8 @@ import java.util.Arrays; import java.util.Optional; +import org.medicmobile.webapp.mobile.p2p.P2pManager; + @SuppressWarnings({ "PMD.GodClass", "PMD.TooManyMethods" }) public class EmbeddedBrowserActivity extends Activity { @@ -50,6 +52,7 @@ public class EmbeddedBrowserActivity extends Activity { private MrdtSupport mrdt; private FilePickerHandler filePickerHandler; private SmsSender smsSender; + private P2pManager p2pManager; private ChtExternalAppHandler chtExternalAppHandler; private boolean isMigrationRunning = false; @@ -82,6 +85,15 @@ public void onReceiveValue(String result) { error(ex, "Failed to create SmsSender."); } + // Only devices that can host get a manager at all; the webapp asks before offering the option. + if(P2pManager.isHostSupported()) { + try { + this.p2pManager = P2pManager.create(this, Build.MODEL); + } catch(Exception ex) { + error(ex, "Failed to create P2pManager."); + } + } + this.settings = SettingsStore.in(this); this.appUrl = settings.getAppUrl(); @@ -268,6 +280,10 @@ SmsSender getSmsSender() { return this.smsSender; } + P2pManager getP2pManager() { + return this.p2pManager; + } + ChtExternalAppHandler getChtExternalAppHandler() { return this.chtExternalAppHandler; } diff --git a/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java b/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java index 88f8453b..cd672ecd 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java +++ b/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java @@ -25,6 +25,7 @@ import org.json.JSONException; import org.json.JSONObject; +import org.medicmobile.webapp.mobile.p2p.P2pManager; import org.medicmobile.webapp.mobile.util.AppDataStore; import java.io.BufferedReader; @@ -52,6 +53,7 @@ public class MedicAndroidJavascript { private final MrdtSupport mrdt; private final SmsSender smsSender; private final ChtExternalAppHandler chtExternalAppHandler; + private final P2pManager p2pManager; private ActivityManager activityManager; private ConnectivityManager connectivityManager; @@ -62,6 +64,7 @@ public MedicAndroidJavascript(EmbeddedBrowserActivity parent) { this.mrdt = parent.getMrdtSupport(); this.smsSender = parent.getSmsSender(); this.chtExternalAppHandler = parent.getChtExternalAppHandler(); + this.p2pManager = parent.getP2pManager(); } public void setAlert(Alert soundAlert) { @@ -179,6 +182,63 @@ public boolean sms_available() { return smsSender != null; } + /** + * Whether this device can host a P2P 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 + public boolean p2p_host_available() { + return p2pManager != null && P2pManager.isHostSupported(); + } + + /** + * Brings up the hotspot and the local server, then reports the payload a peer scans. + * + * Asynchronous: the result arrives on the webapp's P2P callback rather than as a return value, + * because the hotspot takes seconds to come up. + */ + @android.webkit.JavascriptInterface + public void p2p_start_hosting() { + if(!p2p_host_available()) { + respondToP2p(false, "hotspot_unsupported"); + return; + } + p2pManager.startHosting(new P2pManager.HostingCallback() { + @Override public void onReady(String qrPayload) { + respondToP2p(true, qrPayload); + } + + @Override public void onFailed(String reason) { + respondToP2p(false, reason); + } + }); + } + + @android.webkit.JavascriptInterface + public void p2p_stop_hosting() { + if(p2pManager != null) { + p2pManager.stopHosting(); + } + } + + @android.webkit.JavascriptInterface + public boolean p2p_is_hosting() { + return p2pManager != null && p2pManager.isHosting(); + } + + private void respondToP2p(boolean ok, String detail) { + parent.evaluateJavascript(String.format( + "try {" + + "const api = window.CHTCore.AndroidApi;" + + "if (api && api.v1 && api.v1.resolveP2pHostingResult) {" + + " api.v1.resolveP2pHostingResult(%s, %s);" + + "}" + + "} catch (error) {" + + " console.error('MedicAndroidJavascript :: P2P result not delivered', error);" + + "}", + ok, JSONObject.quote(detail))); + } + /** * @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/p2p/HotspotProvider.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/HotspotProvider.java new file mode 100644 index 00000000..1db587d7 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/HotspotProvider.java @@ -0,0 +1,54 @@ +package org.medicmobile.webapp.mobile.p2p; + +/** + * 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 human-readable failure reason + */ + void onFailed(String reason); + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/LocalHttpServer.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/LocalHttpServer.java new file mode 100644 index 00000000..f049d2f7 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/LocalHttpServer.java @@ -0,0 +1,101 @@ +package org.medicmobile.webapp.mobile.p2p; + +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 fi.iki.elonen.NanoHTTPD; + +/** + * The local HTTP server a host device runs for the duration of a P2P session. + * + * Pairing only (#11281): it answers a status probe so a peer can confirm it reached the right + * device, and nothing else. The endpoints that carry data arrive with the transfer work. + * + * Everything is served over plain HTTP for now. The link is a local-only hotspot, so traffic + * never leaves the two devices, but that is not a substitute for transport security: a + * per-session certificate and fingerprint pinning are still to come, and until they land this + * server must not carry anything sensitive. + */ +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 = "/_p2p/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; + + public LocalHttpServer(String deviceLabel) { + this(EPHEMERAL_PORT, deviceLabel); + } + + public LocalHttpServer(int port, String deviceLabel) { + super(port); + if (deviceLabel == null || deviceLabel.trim().isEmpty()) { + throw new IllegalArgumentException("deviceLabel must not be empty"); + } + this.deviceLabel = deviceLabel; + } + + /** Starts listening. Safe to call when already running. */ + public void startServer() throws IOException { + if (isAlive()) { + log(this, "Local server already running on port " + getListeningPort()); + return; + } + 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-p2p"); + 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/p2p/P2pForegroundService.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pForegroundService.java new file mode 100644 index 00000000..83da37e7 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pForegroundService.java @@ -0,0 +1,131 @@ +package org.medicmobile.webapp.mobile.p2p; + +import static org.medicmobile.webapp.mobile.MedicLog.log; +import static org.medicmobile.webapp.mobile.MedicLog.warn; + +import android.app.Notification; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.IBinder; + +/** + * Foreground service to keep P2P sync running when the app is in background. + * + * Android aggressively kills background services. A foreground service with + * a persistent notification keeps the process alive during the sync session. + * + * Usage: + * // Start + * Intent i = new Intent(context, P2pForegroundService.class); + * i.setAction(P2pForegroundService.ACTION_START); + * context.startForegroundService(i); // API 26+ + * + * // Stop + * Intent i = new Intent(context, P2pForegroundService.class); + * i.setAction(P2pForegroundService.ACTION_STOP); + * context.startService(i); + */ +public class P2pForegroundService extends Service { + + + public static final String ACTION_START = "org.medicmobile.webapp.mobile.p2p.START"; + public static final String ACTION_STOP = "org.medicmobile.webapp.mobile.p2p.STOP"; + + private P2pNotificationChannel notificationChannel; + + @Override + public void onCreate() { + super.onCreate(); + notificationChannel = new P2pNotificationChannel(this); + notificationChannel.createChannel(); + log(this, "P2P foreground service created"); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null) { + warn(this, "Received null intent, stopping"); + stopSelf(); + return START_NOT_STICKY; + } + + String action = intent.getAction(); + if (ACTION_START.equals(action)) { + handleStart(); + } else if (ACTION_STOP.equals(action)) { + handleStop(); + } else { + warn(this, "Unknown action: " + action); + } + + return START_STICKY; + } + + @Override + public IBinder onBind(Intent intent) { + // Not a bound service + return null; + } + + @Override + public void onDestroy() { + log(this, "P2P foreground service destroyed"); + if (notificationChannel != null) { + notificationChannel.dismiss(); + } + super.onDestroy(); + } + + // --- Static helpers --- + + /** + * Convenience method to start the foreground service. + */ + public static void start(Context context) { + Intent intent = new Intent(context, P2pForegroundService.class); + intent.setAction(ACTION_START); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent); + } else { + context.startService(intent); + } + } + + /** + * Convenience method to stop the foreground service. + */ + public static void stop(Context context) { + Intent intent = new Intent(context, P2pForegroundService.class); + intent.setAction(ACTION_STOP); + context.startService(intent); + } + + // --- Private --- + + private void handleStart() { + log(this, "Starting foreground P2P sync service"); + Notification notification = notificationChannel.buildSyncNotification( + "P2P Sync Active", + "Waiting for peer connections..." + ); + startForeground(P2pNotificationChannel.NOTIFICATION_ID, notification); + } + + /** + * Stops the service and removes its notification. + * + * stopForeground(int) only exists from SDK 24, so the deprecated boolean overload is the + * only option on SDK 21 to 23. The deprecation warning it raises is expected. + */ + private void handleStop() { + log(this, "Stopping foreground P2P sync service"); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE); + } else { + stopForeground(true); + } + stopSelf(); + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pManager.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pManager.java new file mode 100644 index 00000000..e96f7d0e --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pManager.java @@ -0,0 +1,115 @@ +package org.medicmobile.webapp.mobile.p2p; + +import static org.medicmobile.webapp.mobile.MedicLog.log; +import static org.medicmobile.webapp.mobile.MedicLog.warn; + +import android.content.Context; +import android.net.wifi.WifiManager; + +import org.json.JSONException; + +/** + * Owns a P2P 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 P2pManager { + + /** How long a hotspot may sit unused before it is worth shutting down. */ + private static final int IDLE_TIMEOUT_SEC = 300; + + private final WifiHotspotManager hotspotManager; + private final LocalHttpServer server; + + public P2pManager(WifiHotspotManager hotspotManager, LocalHttpServer server) { + if (hotspotManager == null || server == null) { + throw new IllegalArgumentException("hotspotManager and server must not be null"); + } + this.hotspotManager = hotspotManager; + this.server = server; + } + + /** Builds a manager wired to the real WiFi radio. */ + public static P2pManager create(Context context, String deviceLabel) { + WifiManager wifiManager = + (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE); + return new P2pManager( + new WifiHotspotManager(new WifiHotspotProvider(wifiManager), IDLE_TIMEOUT_SEC), + new LocalHttpServer(deviceLabel)); + } + + /** 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 payload a peer + * needs in order to join. + * + * 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"); + return; + } + + hotspotManager.startHotspot(new HotspotProvider.HotspotCallback() { + @Override public void onStarted(String ssid, String password, String ipAddress) { + try { + server.startServer(); + } catch (Exception e) { + warn(e, "Local server failed to start, taking the hotspot back down"); + hotspotManager.stopHotspot(); + callback.onFailed("server_start_failed"); + return; + } + + try { + String payload = QrCodeHelper.buildPayload( + ssid, password, ipAddress, server.getListeningPort()); + log(P2pManager.class, "Hosting session ready on " + ipAddress); + callback.onReady(payload); + } catch (JSONException e) { + warn(e, "Could not build the pairing payload"); + stopHosting(); + callback.onFailed("payload_failed"); + } + } + + @Override public void onFailed(String reason) { + callback.onFailed(reason); + } + }); + } + + /** Tears the session down. Safe to call when nothing is running. */ + public void stopHosting() { + server.stopServer(); + hotspotManager.stopHotspot(); + log(P2pManager.class, "Hosting session stopped"); + } + + public boolean isHosting() { + return hotspotManager.isActive(); + } + + /** Result of trying to start hosting. */ + public interface HostingCallback { + /** @param qrPayload the JSON a peer scans to join */ + void onReady(String qrPayload); + + /** @param reason a stable code the webapp can map to a message */ + void onFailed(String reason); + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pNotificationChannel.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pNotificationChannel.java new file mode 100644 index 00000000..20087d6e --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pNotificationChannel.java @@ -0,0 +1,80 @@ +package org.medicmobile.webapp.mobile.p2p; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; + +/** + * Manages notifications for P2P sync progress. + * + * Creates a dedicated notification channel (required for Android 8.0+) + * and builds notifications for the foreground service and progress updates. + */ +@android.annotation.TargetApi(26) +public class P2pNotificationChannel { + + static final String CHANNEL_ID = "cht_p2p_sync"; + private static final String CHANNEL_NAME = "P2P Sync"; + private static final String CHANNEL_DESCRIPTION = "Notifications for peer-to-peer data sync"; + static final int NOTIFICATION_ID = 42001; + + private final Context context; + private final NotificationManager notificationManager; + + public P2pNotificationChannel(Context context) { + if (context == null) { + throw new IllegalArgumentException("context must not be null"); + } + this.context = context.getApplicationContext(); + this.notificationManager = (NotificationManager) this.context + .getSystemService(Context.NOTIFICATION_SERVICE); + } + + /** + * Create the notification channel. Must be called before showing any notification. + * Safe to call multiple times — Android ignores duplicate channel creation. + */ + public void createChannel() { + // @TargetApi(26) guarantees API 26+ — no version check needed + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW + ); + channel.setDescription(CHANNEL_DESCRIPTION); + channel.setShowBadge(false); + channel.enableVibration(false); + notificationManager.createNotificationChannel(channel); + } + + /** + * Build a basic sync notification (used by the foreground service). + * + * @param title notification title + * @param message notification body text + * @return the built notification + */ + public Notification buildSyncNotification(String title, String message) { + // P2P requires API 26+ — channel constructor always available + Notification.Builder builder = new Notification.Builder(context, CHANNEL_ID); + + builder.setContentTitle(title) + .setContentText(message) + .setSmallIcon(android.R.drawable.stat_notify_sync) + .setOngoing(true) + .setOnlyAlertOnce(true); + + return builder.build(); + } + + + + /** + * Dismiss the notification. + */ + public void dismiss() { + notificationManager.cancel(NOTIFICATION_ID); + } + +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/QrCodeHelper.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/QrCodeHelper.java new file mode 100644 index 00000000..e9881eb6 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/QrCodeHelper.java @@ -0,0 +1,364 @@ +package org.medicmobile.webapp.mobile.p2p; + +import static org.medicmobile.webapp.mobile.MedicLog.error; + +import android.graphics.Bitmap; +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 org.json.JSONException; +import org.json.JSONObject; + +import java.util.EnumMap; +import java.util.Map; + +/** + * Generates and validates QR codes for P2P WiFi hotspot credential exchange. + * + * QR Payload: + * { + * "type": "cht-p2p", + * "v": 1, + * "ssid": "CHT-P2P-a3f7", + * "pwd": "randomPassword123", + * "ip": "192.168.43.1", + * "port": 8443, + * "ts": 1711152000000 + * } + * + * Timestamp must be within 10 minutes of current time + * Type field must be "cht-p2p" + * TLS fingerprint must match server cert 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-p2p"; + private static final int PAYLOAD_VERSION = 1; + private static final long MAX_TIMESTAMP_DRIFT_MS = 10L * 60 * 1000; // 10 minutes + + // QR payload JSON keys + 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 String KEY_TS = "ts"; + + 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; + + public HotspotCredentials(String ssid, String password, String ipAddress, int port) { + this.ssid = ssid; + this.password = password; + this.ipAddress = ipAddress; + this.port = port; + } + } + + /** + * Generate a QR code bitmap from hotspot credentials. + * + * @param creds hotspot credentials + * @return QR code bitmap, or null if generation fails + */ + public static Bitmap generateQrCode(HotspotCredentials creds) { + return generateQrCode(creds, QR_SIZE); + } + + /** + * Generate a QR code bitmap with custom dimensions. + * + * @param creds hotspot credentials + * @param size bitmap width and height in pixels + * @return QR code bitmap, or null if generation fails + */ + public static Bitmap generateQrCode(HotspotCredentials creds, int size) { + try { + String payload = buildPayload(creds); + return encodeQrBitmap(payload, size); + } catch (WriterException | JSONException e) { + error(e, "Failed to generate QR 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 { + return buildPayload(creds.ssid, creds.password, creds.ipAddress, + creds.port); + } + + /** + * Build the QR payload JSON string. + * + * @param ssid the WiFi hotspot SSID + * @param password the WiFi hotspot WPA2 password + * @param ipAddress the supervisor device IP on the hotspot network + * @param port the HTTPS port for the P2P HTTP server + * @return JSON string matching + * @throws JSONException if JSON construction fails + */ + public static String buildPayload(String ssid, String password, + String ipAddress, int port) throws JSONException { + validatePayloadParams(ssid, password, ipAddress, port); + + JSONObject payload = new JSONObject(); + payload.put("type", PAYLOAD_TYPE); + payload.put("v", PAYLOAD_VERSION); + payload.put(KEY_SSID, ssid); + payload.put(KEY_PWD, password); + payload.put(KEY_IP, ipAddress); + payload.put(KEY_PORT, port); + payload.put(KEY_TS, System.currentTimeMillis()); + 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 > 65535) { + throw new IllegalArgumentException("port must be between 1 and 65535, 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-p2p" + * Timestamp within 10 minutes of current time + * 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("empty_payload"); + } + + JSONObject payload; + try { + payload = new JSONObject(payloadJson); + } catch (JSONException e) { + return QrValidation.reject("invalid_json: " + e.getMessage()); + } + + QrValidation headerCheck = checkQrTypeAndVersion(payload); + if (headerCheck != null) { + return headerCheck; + } + + QrValidation tsCheck = checkQrTimestamp(payload); + if (tsCheck != null) { + return tsCheck; + } + + return validateRequiredFields(payload); + } + + private static QrValidation checkQrTypeAndVersion(JSONObject payload) { + String type = payload.optString("type", ""); + if (!PAYLOAD_TYPE.equals(type)) { + return QrValidation.reject("invalid type '" + type + + "', expected '" + PAYLOAD_TYPE + "'"); + } + int version = payload.optInt("v", -1); + if (version != PAYLOAD_VERSION) { + return QrValidation.reject("unsupported version: " + version + + ", expected " + PAYLOAD_VERSION); + } + return null; + } + + private static QrValidation checkQrTimestamp(JSONObject payload) { + long timestamp = payload.optLong(KEY_TS, 0); + if (timestamp == 0) { + return QrValidation.reject("missing timestamp"); + } + long drift = Math.abs(System.currentTimeMillis() - timestamp); + if (drift > MAX_TIMESTAMP_DRIFT_MS) { + return QrValidation.reject("QR code expired. Timestamp drift: " + + (drift / 1000) + "s (max " + (MAX_TIMESTAMP_DRIFT_MS / 1000) + "s)"); + } + 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.getLong(KEY_TS) + ); + } catch (JSONException e) { + error(e, "Failed to parse QR payload"); + return null; + } + } + + // --- Private helpers --- + + /** + * Validate required fields (ssid, pwd, ip, port) in the QR payload. + */ + private static QrValidation validateRequiredFields(JSONObject payload) { + if (isEmptyField(payload, "ssid")) { + return QrValidation.reject("missing required field: ssid"); + } + if (isEmptyField(payload, "pwd")) { + return QrValidation.reject("missing required field: pwd"); + } + if (isEmptyField(payload, "ip")) { + return QrValidation.reject("missing required field: ip"); + } + int port = payload.optInt(KEY_PORT, 0); + if (port <= 0 || port > 65535) { + return QrValidation.reject("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 long timestamp; + + QrPayload(String ssid, String password, String ipAddress, + int port, long timestamp) { + this.ssid = ssid; + this.password = password; + this.ipAddress = ipAddress; + this.port = port; + this.timestamp = timestamp; + } + + public String getSsid() { + return ssid; + } + + public String getPassword() { + return password; + } + + public String getIpAddress() { + return ipAddress; + } + + public int getPort() { + return port; + } + + + public long getTimestamp() { + return timestamp; + } + + @Override + public String toString() { + return "QrPayload{ssid='" + ssid + "', ip='" + ipAddress + + "', port=" + port + ", ts=" + timestamp + '}'; + } + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/QrScannerActivity.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/QrScannerActivity.java new file mode 100644 index 00000000..12b2f189 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/QrScannerActivity.java @@ -0,0 +1,149 @@ +package org.medicmobile.webapp.mobile.p2p; + +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 android.widget.Toast; + +import com.google.zxing.integration.android.IntentIntegrator; +import com.google.zxing.integration.android.IntentResult; + +/** + * Activity that launches ZXing QR scanner for P2P credential exchange. + * + * CHW scans the Supervisor's QR code to get WiFi hotspot credentials. + * Result returned via onActivityResult with the scanned QR payload. + * + * Usage from the calling activity: + * Intent intent = new Intent(context, QrScannerActivity.class); + * startActivityForResult(intent, QrScannerActivity.REQUEST_CODE); + * + * Result extras: + * EXTRA_QR_RESULT — the validated QR payload JSON string (on RESULT_OK) + * EXTRA_QR_ERROR — error message string (on RESULT_CANCELED with error) + * + * Timestamp within 10 minutes + * Type == "cht-p2p" + */ +public class QrScannerActivity extends Activity { + + + public static final String EXTRA_QR_RESULT = "qr_result"; + public static final String EXTRA_QR_ERROR = "qr_error"; + public static final int REQUEST_CODE = 42100; + + 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("Scan the Supervisor's QR code"); + integrator.setBeepEnabled(true); + integrator.setOrientationLocked(true); + integrator.setCaptureActivity(getCaptureActivityClass()); + 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, validating payload"); + handleScannedPayload(scannedContent); + } else { + // Unexpected result — not from ZXing + super.onActivityResult(requestCode, resultCode, data); + warn(this, "Unexpected onActivityResult — not a ZXing result"); + returnError("unexpected_scan_result"); + } + } + + /** + * Validate the scanned QR payload and return the result to the caller. + * Validates QR timestamp and type fields. + */ + private void handleScannedPayload(String scannedContent) { + QrValidation validation = QrCodeHelper.validateQrPayload(scannedContent); + + if (validation.isAccepted()) { + log(this, "QR payload validated successfully"); + Intent resultIntent = new Intent(); + resultIntent.putExtra(EXTRA_QR_RESULT, scannedContent); + setResult(RESULT_OK, resultIntent); + } else { + String reason = validation.getReason(); + warn(this, "QR payload validation failed: " + reason); + Toast.makeText(this, "Invalid QR code: " + reason, Toast.LENGTH_LONG).show(); + returnError(reason); + } + + 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(); + } + + /** + * Get the capture activity class. Uses the default ZXing capture activity. + * Override this method in tests to provide a mock. + * + * @return the Activity class for QR capture + */ + protected Class getCaptureActivityClass() { + // Use the default ZXing embedded capture activity + // This avoids requiring a separate ZXing app install + try { + return Class.forName("com.journeyapps.barcodescanner.CaptureActivity"); + } catch (ClassNotFoundException e) { + warn(this, "ZXing CaptureActivity not found, falling back to default"); + return null; + } + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/QrValidation.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/QrValidation.java new file mode 100644 index 00000000..4f3cc741 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/QrValidation.java @@ -0,0 +1,41 @@ +package org.medicmobile.webapp.mobile.p2p; + +/** + * Outcome of validating a scanned QR payload: accepted, or rejected with a reason. + * + * Deliberately local to the QR pairing code. An earlier iteration reused the document-scope + * classification result for this, which tied pairing to a data model it has nothing to do with. + */ +public final class QrValidation { + + private static final QrValidation ACCEPTED = new QrValidation(true, null); + + private final boolean accepted; + private final String reason; + + private QrValidation(boolean accepted, String reason) { + this.accepted = accepted; + this.reason = reason; + } + + public static QrValidation accept() { + return ACCEPTED; + } + + public static QrValidation reject(String reason) { + return new QrValidation(false, reason); + } + + public boolean isAccepted() { + return accepted; + } + + /** The failure reason, or {@code null} when accepted. */ + public String getReason() { + return reason; + } + + @Override public String toString() { + return accepted ? "QrValidation{accepted}" : "QrValidation{rejected: " + reason + "}"; + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotManager.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotManager.java new file mode 100644 index 00000000..46cb11e3 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotManager.java @@ -0,0 +1,139 @@ +package org.medicmobile.webapp.mobile.p2p; + +import static org.medicmobile.webapp.mobile.MedicLog.log; +import static org.medicmobile.webapp.mobile.MedicLog.warn; + +/** + * Manages the WiFi hotspot lifecycle for P2P sync. + * + * Wraps a {@link HotspotProvider} with: + * - Idle timeout detection, so an unused hotspot shuts itself down + * - Start/stop timing + * - State tracking to prevent double-start + * + * The idle timeout is passed in rather than read from a config object: this class needs one + * number, and the caller owns where it comes from. + */ +public class WifiHotspotManager { + + + private final HotspotProvider provider; + private final long idleTimeoutMs; + + private long startedAt; + private volatile long lastActivityAt; + private String activeSsid; + private String activePassword; + private String activeIpAddress; + + public WifiHotspotManager(HotspotProvider provider, int idleTimeoutSec) { + if (provider == null) { + throw new IllegalArgumentException("provider must not be null"); + } + if (idleTimeoutSec <= 0) { + throw new IllegalArgumentException("idleTimeoutSec must be positive"); + } + this.provider = provider; + this.idleTimeoutMs = (long) idleTimeoutSec * 1000; + } + + /** + * 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) { + long now = System.currentTimeMillis(); + startedAt = now; + lastActivityAt = now; + 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); + } + }); + } + + /** + * 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"); + } + activeSsid = null; + activePassword = null; + activeIpAddress = null; + startedAt = 0; + lastActivityAt = 0; + } + + /** + * Check if the hotspot is currently active. + */ + public boolean isActive() { + return provider.isRunning(); + } + + /** + * Record activity to reset the idle timeout clock. + * Call this whenever a sync operation occurs (doc transfer, auth, etc.). + */ + public void recordActivity() { + lastActivityAt = System.currentTimeMillis(); + } + + /** + * Check if the hotspot has exceeded its idle timeout. + * Used to auto-shutdown the hotspot when no sync activity occurs. + * + * @return true if idle time exceeds the configured idle timeout + */ + public boolean isIdleTimedOut() { + if (!provider.isRunning() || lastActivityAt == 0) { + return false; + } + long idleMs = System.currentTimeMillis() - lastActivityAt; + return idleMs > idleTimeoutMs; + } + + + + // --- Getters for active credentials --- + + public String getActiveSsid() { + return activeSsid; + } + + public String getActivePassword() { + return activePassword; + } + + public String getActiveIpAddress() { + return activeIpAddress; + } + +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotProvider.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotProvider.java new file mode 100644 index 00000000..55367e30 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotProvider.java @@ -0,0 +1,361 @@ +package org.medicmobile.webapp.mobile.p2p; + +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.net.wifi.WifiManager; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; + +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 — CHW devices + * connect directly to the Supervisor's phone 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 WifiManager.LocalOnlyHotspotReservation reservation; + private volatile boolean running = false; + + public WifiHotspotProvider(WifiManager wifiManager) { + if (wifiManager == null) { + throw new IllegalArgumentException("wifiManager must not be null"); + } + this.wifiManager = wifiManager; + } + + /** + * 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; + } + + @Override + public void start(HotspotCallback callback) { + if (callback == null) { + throw new IllegalArgumentException("callback must not be null"); + } + + if (!isSupported()) { + warn(WifiHotspotProvider.class, + "Local-only hotspot needs Android 8.0, this device is on API " + Build.VERSION.SDK_INT); + callback.onFailed("hotspot_unsupported"); + return; + } + + if (running) { + warn(this, "Hotspot already running"); + callback.onFailed("Hotspot already running"); + return; + } + + try { + startLocalOnlyHotspot(callback); + } catch (SecurityException e) { + error(e, "Hotspot SecurityException"); + callback.onFailed("security_exception: " + e.getMessage()); + } catch (Exception e) { + error(e, "Failed to start hotspot"); + callback.onFailed("Unexpected error: " + e.getMessage()); + } + } + + /** + * 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) { + wifiManager.startLocalOnlyHotspot(createHotspotCallback(callback), + new Handler(Looper.getMainLooper())); + } + + @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); + 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; + } + + @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; + callback.onFailed("Could not detect hotspot IP address. Please restart P2P sync."); + } + + @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; + } + 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(); + return wifiConfig != null ? wifiConfig.SSID : "CHT-P2P-unknown"; + } + + @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(); + return wifiConfig != 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 found — caller MUST fail loudly. + */ + 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; + } + + private String mapFailureReason(int reason) { + switch (reason) { + case WifiManager.LocalOnlyHotspotCallback.ERROR_NO_CHANNEL: + return "No WiFi channel available"; + case WifiManager.LocalOnlyHotspotCallback.ERROR_GENERIC: + return "Generic hotspot error"; + case WifiManager.LocalOnlyHotspotCallback.ERROR_INCOMPATIBLE_MODE: + return "Incompatible WiFi mode (tethering may be active)"; + case WifiManager.LocalOnlyHotspotCallback.ERROR_TETHERING_DISALLOWED: + return "Tethering disallowed by device policy"; + default: + return "Unknown hotspot error (code " + reason + ")"; + } + } +} diff --git a/src/test/java/org/medicmobile/webapp/mobile/p2p/LocalHttpServerTest.java b/src/test/java/org/medicmobile/webapp/mobile/p2p/LocalHttpServerTest.java new file mode 100644 index 00000000..e741f3e8 --- /dev/null +++ b/src/test/java/org/medicmobile/webapp/mobile/p2p/LocalHttpServerTest.java @@ -0,0 +1,112 @@ +package org.medicmobile.webapp.mobile.p2p; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.io.ByteArrayOutputStream; + +import fi.iki.elonen.NanoHTTPD; + +@RunWith(RobolectricTestRunner.class) +public class LocalHttpServerTest { + + private static final String LABEL = "Supervisor phone"; + + private NanoHTTPD.IHTTPSession request(NanoHTTPD.Method method, String uri) { + NanoHTTPD.IHTTPSession session = mock(NanoHTTPD.IHTTPSession.class); + when(session.getMethod()).thenReturn(method); + when(session.getUri()).thenReturn(uri); + return session; + } + + /** Response.send is protected, so read the body straight off the response's data stream. */ + private String bodyOf(NanoHTTPD.Response response) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int read; + while ((read = response.getData().read(buffer)) > 0) { + out.write(buffer, 0, read); + } + return out.toString("UTF-8"); + } + + @Test + public void constructor_rejectsAnEmptyLabel() { + assertThrows(IllegalArgumentException.class, () -> new LocalHttpServer("")); + assertThrows(IllegalArgumentException.class, () -> new LocalHttpServer(" ")); + assertThrows(IllegalArgumentException.class, () -> new LocalHttpServer(null)); + } + + @Test + public void status_identifiesTheHostSoAPeerCanConfirmWhatItReached() throws Exception { + LocalHttpServer server = new LocalHttpServer(LABEL); + + NanoHTTPD.Response response = server.serve(request(NanoHTTPD.Method.GET, "/_p2p/status")); + + assertEquals(NanoHTTPD.Response.Status.OK, response.getStatus()); + JSONObject body = new JSONObject(bodyOf(response)); + assertEquals("cht-p2p", body.getString("service")); + assertEquals(LABEL, body.getString("device_label")); + assertEquals(1, body.getInt("protocol_version")); + } + + @Test + public void unknownPath_is404() throws Exception { + LocalHttpServer server = new LocalHttpServer(LABEL); + + NanoHTTPD.Response response = server.serve(request(NanoHTTPD.Method.GET, "/_p2p/anything-else")); + + assertEquals(NanoHTTPD.Response.Status.NOT_FOUND, response.getStatus()); + } + + /** The data endpoints do not exist yet; a peer must not be able to reach one by guessing. */ + @Test + public void dataEndpointsFromTheOldProtocolAreGone() throws Exception { + LocalHttpServer server = new LocalHttpServer(LABEL); + + for (String path : new String[] { "/_p2p/auth", "/_p2p/get-ids", "/_p2p/bulk-get", "/_p2p/accept-docs" }) { + NanoHTTPD.Response response = server.serve(request(NanoHTTPD.Method.POST, path)); + assertEquals(path, NanoHTTPD.Response.Status.NOT_FOUND, response.getStatus()); + } + } + + @Test + public void statusOnlyAnswersGet() throws Exception { + LocalHttpServer server = new LocalHttpServer(LABEL); + + NanoHTTPD.Response response = server.serve(request(NanoHTTPD.Method.POST, "/_p2p/status")); + + assertEquals(NanoHTTPD.Response.Status.NOT_FOUND, response.getStatus()); + } + + @Test + public void stopServer_isSafeWhenNeverStarted() { + new LocalHttpServer(LABEL).stopServer(); + } + + /** Port 0 asks the OS for a free port, which is what removes the "port already in use" failure. */ + @Test + public void defaultsToAnOsAssignedPort() { + assertEquals(0, LocalHttpServer.EPHEMERAL_PORT); + } + + @Test + public void bindsAndReportsTheRealPortItGot() throws Exception { + LocalHttpServer server = new LocalHttpServer(LABEL); + try { + server.startServer(); + assertTrue("expected a real bound port, got " + server.getListeningPort(), + server.getListeningPort() > 0); + } finally { + server.stopServer(); + } + } +} diff --git a/src/test/java/org/medicmobile/webapp/mobile/p2p/P2pManagerTest.java b/src/test/java/org/medicmobile/webapp/mobile/p2p/P2pManagerTest.java new file mode 100644 index 00000000..85ab440b --- /dev/null +++ b/src/test/java/org/medicmobile/webapp/mobile/p2p/P2pManagerTest.java @@ -0,0 +1,134 @@ +package org.medicmobile.webapp.mobile.p2p; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.json.JSONObject; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.io.IOException; + +@RunWith(RobolectricTestRunner.class) +public class P2pManagerTest { + + private static final String SSID = "CHT-P2P-a3f7"; + private static final String PASSWORD = "a-password"; + private static final String IP = "192.168.49.1"; + + private WifiHotspotManager hotspotManager; + private LocalHttpServer server; + private P2pManager manager; + private P2pManager.HostingCallback callback; + + @Before public void setUp() { + hotspotManager = mock(WifiHotspotManager.class); + server = mock(LocalHttpServer.class); + when(server.getListeningPort()).thenReturn(8443); + manager = new P2pManager(hotspotManager, server); + callback = mock(P2pManager.HostingCallback.class); + } + + private void hotspotStarts() { + doAnswer(invocation -> { + HotspotProvider.HotspotCallback cb = invocation.getArgument(0); + cb.onStarted(SSID, PASSWORD, IP); + return null; + }).when(hotspotManager).startHotspot(any()); + } + + @Test public void constructor_rejectsMissingCollaborators() { + assertThrows(IllegalArgumentException.class, () -> new P2pManager(null, server)); + assertThrows(IllegalArgumentException.class, () -> new P2pManager(hotspotManager, null)); + } + + @Test @Config(sdk = 26) + public void startHosting_bringsUpTheHotspotThenTheServerAndReturnsAScannablePayload() throws Exception { + hotspotStarts(); + + manager.startHosting(callback); + + verify(server).startServer(); + ArgumentCaptor payload = ArgumentCaptor.forClass(String.class); + verify(callback).onReady(payload.capture()); + JSONObject json = new JSONObject(payload.getValue()); + org.junit.Assert.assertEquals(SSID, json.getString("ssid")); + org.junit.Assert.assertEquals(IP, json.getString("ip")); + org.junit.Assert.assertEquals(8443, json.getInt("port")); + } + + @Test @Config(sdk = 25) + public void startHosting_refusesOnADeviceThatCannotHost() { + manager.startHosting(callback); + + verify(callback).onFailed("hotspot_unsupported"); + verify(hotspotManager, never()).startHotspot(any()); + } + + @Test @Config(sdk = 26) + public void startHosting_passesAHotspotFailureStraightBack() { + doAnswer(invocation -> { + HotspotProvider.HotspotCallback cb = invocation.getArgument(0); + cb.onFailed("no_wifi"); + return null; + }).when(hotspotManager).startHotspot(any()); + + manager.startHosting(callback); + + verify(callback).onFailed("no_wifi"); + verify(callback, never()).onReady(anyString()); + } + + /** A hotspot with no server behind it is worse than no hotspot: it advertises a dead network. */ + @Test @Config(sdk = 26) + public void startHosting_takesTheHotspotBackDownIfTheServerCannotBind() throws Exception { + hotspotStarts(); + doThrow(new IOException("port in use")).when(server).startServer(); + + manager.startHosting(callback); + + verify(hotspotManager).stopHotspot(); + verify(callback).onFailed("server_start_failed"); + verify(callback, never()).onReady(anyString()); + } + + @Test @Config(sdk = 26) + public void stopHosting_stopsBothHalves() { + manager.stopHosting(); + + verify(server).stopServer(); + verify(hotspotManager).stopHotspot(); + } + + @Test @Config(sdk = 26) + public void isHosting_followsTheHotspot() { + when(hotspotManager.isActive()).thenReturn(true); + assertTrue(manager.isHosting()); + + when(hotspotManager.isActive()).thenReturn(false); + assertFalse(manager.isHosting()); + } + + @Test @Config(sdk = 25) + public void isHostSupported_isFalseBelowApi26() { + assertFalse(P2pManager.isHostSupported()); + } + + @Test @Config(sdk = 26) + public void isHostSupported_isTrueFromApi26() { + assertTrue(P2pManager.isHostSupported()); + } +} diff --git a/src/test/java/org/medicmobile/webapp/mobile/p2p/QrCodeHelperTest.java b/src/test/java/org/medicmobile/webapp/mobile/p2p/QrCodeHelperTest.java new file mode 100644 index 00000000..f29f2356 --- /dev/null +++ b/src/test/java/org/medicmobile/webapp/mobile/p2p/QrCodeHelperTest.java @@ -0,0 +1,77 @@ +package org.medicmobile.webapp.mobile.p2p; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class QrCodeHelperTest { + + private static final String SSID = "CHT-P2P-a3f7"; + private static final String PASSWORD = "a-password"; + private static final String IP = "192.168.49.1"; + private static final int PORT = 8443; + + private QrCodeHelper.HotspotCredentials credentials() { + return new QrCodeHelper.HotspotCredentials(SSID, PASSWORD, IP, PORT); + } + + @Test public void buildPayload_carriesWhatAPeerNeedsToConnect() throws Exception { + JSONObject payload = new JSONObject(QrCodeHelper.buildPayload(credentials())); + + assertEquals(SSID, payload.getString("ssid")); + assertEquals(PASSWORD, payload.getString("pwd")); + assertEquals(IP, payload.getString("ip")); + assertEquals(PORT, payload.getInt("port")); + } + + @Test public void validateQrPayload_acceptsAFreshPayload() throws Exception { + QrValidation result = QrCodeHelper.validateQrPayload(QrCodeHelper.buildPayload(credentials())); + + assertTrue(result.getReason(), result.isAccepted()); + } + + @Test public void validateQrPayload_rejectsEmptyInput() { + assertFalse(QrCodeHelper.validateQrPayload(null).isAccepted()); + assertFalse(QrCodeHelper.validateQrPayload("").isAccepted()); + } + + @Test public void validateQrPayload_rejectsSomethingThatIsNotJson() { + QrValidation result = QrCodeHelper.validateQrPayload("this is not a qr payload"); + + assertFalse(result.isAccepted()); + assertNotNull(result.getReason()); + } + + @Test public void validateQrPayload_rejectsAPayloadMissingTheSsid() throws Exception { + JSONObject payload = new JSONObject(QrCodeHelper.buildPayload(credentials())); + payload.remove("ssid"); + + QrValidation result = QrCodeHelper.validateQrPayload(payload.toString()); + + assertFalse(result.isAccepted()); + assertTrue(result.getReason(), result.getReason().contains("ssid")); + } + + @Test public void validateQrPayload_rejectsAStaleQrCode() throws Exception { + JSONObject payload = new JSONObject(QrCodeHelper.buildPayload(credentials())); + payload.put("ts", System.currentTimeMillis() - (24L * 60 * 60 * 1000)); + + QrValidation result = QrCodeHelper.validateQrPayload(payload.toString()); + + assertFalse("a day-old QR code should not pair", result.isAccepted()); + } + + @Test public void parsePayload_roundTripsWhatBuildPayloadProduced() throws Exception { + QrCodeHelper.QrPayload parsed = QrCodeHelper.parsePayload(QrCodeHelper.buildPayload(credentials())); + + assertNotNull(parsed); + assertEquals(SSID, parsed.getSsid()); + } +} diff --git a/src/test/java/org/medicmobile/webapp/mobile/p2p/QrValidationTest.java b/src/test/java/org/medicmobile/webapp/mobile/p2p/QrValidationTest.java new file mode 100644 index 00000000..3195a81f --- /dev/null +++ b/src/test/java/org/medicmobile/webapp/mobile/p2p/QrValidationTest.java @@ -0,0 +1,33 @@ +package org.medicmobile.webapp.mobile.p2p; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class QrValidationTest { + + @Test + public void accept_isAcceptedAndCarriesNoReason() { + QrValidation validation = QrValidation.accept(); + + assertTrue(validation.isAccepted()); + assertNull(validation.getReason()); + } + + @Test + public void reject_isNotAcceptedAndKeepsTheReason() { + QrValidation validation = QrValidation.reject("missing required field: ssid"); + + assertFalse(validation.isAccepted()); + assertEquals("missing required field: ssid", validation.getReason()); + } + + @Test + public void toString_saysWhichOutcomeItIs() { + assertTrue(QrValidation.accept().toString().contains("accepted")); + assertTrue(QrValidation.reject("expired").toString().contains("expired")); + } +} diff --git a/src/test/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotManagerTest.java b/src/test/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotManagerTest.java new file mode 100644 index 00000000..c89d37a0 --- /dev/null +++ b/src/test/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotManagerTest.java @@ -0,0 +1,137 @@ +package org.medicmobile.webapp.mobile.p2p; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class WifiHotspotManagerTest { + + private static final int IDLE_TIMEOUT_SEC = 60; + private static final String SSID = "CHT-P2P-a3f7"; + private static final String PASSWORD = "a-password"; + private static final String IP = "192.168.49.1"; + + private HotspotProvider provider; + private WifiHotspotManager manager; + + @Before public void setUp() { + provider = mock(HotspotProvider.class); + manager = new WifiHotspotManager(provider, IDLE_TIMEOUT_SEC); + } + + /** Answers a startHotspot call by reporting success back through the provider's callback. */ + private void providerStartsSuccessfully() { + doAnswer(invocation -> { + HotspotProvider.HotspotCallback cb = invocation.getArgument(0); + cb.onStarted(SSID, PASSWORD, IP); + return null; + }).when(provider).start(any()); + } + + @Test public void constructor_rejectsAMissingProvider() { + assertThrows(IllegalArgumentException.class, () -> new WifiHotspotManager(null, IDLE_TIMEOUT_SEC)); + } + + @Test public void constructor_rejectsANonPositiveTimeout() { + assertThrows(IllegalArgumentException.class, () -> new WifiHotspotManager(provider, 0)); + assertThrows(IllegalArgumentException.class, () -> new WifiHotspotManager(provider, -1)); + } + + @Test public void startHotspot_keepsTheCredentialsTheProviderReported() { + providerStartsSuccessfully(); + // down before the call, up after, the way a real provider behaves + when(provider.isRunning()).thenReturn(false, true); + + manager.startHotspot(mock(HotspotProvider.HotspotCallback.class)); + + assertEquals(SSID, manager.getActiveSsid()); + assertEquals(PASSWORD, manager.getActivePassword()); + assertEquals(IP, manager.getActiveIpAddress()); + assertTrue(manager.isActive()); + } + + @Test public void startHotspot_passesSuccessOnToTheCaller() { + providerStartsSuccessfully(); + HotspotProvider.HotspotCallback callback = mock(HotspotProvider.HotspotCallback.class); + + manager.startHotspot(callback); + + verify(callback).onStarted(SSID, PASSWORD, IP); + } + + @Test public void startHotspot_passesFailureOnToTheCaller() { + doAnswer(invocation -> { + HotspotProvider.HotspotCallback cb = invocation.getArgument(0); + cb.onFailed("no wifi hardware"); + return null; + }).when(provider).start(any()); + HotspotProvider.HotspotCallback callback = mock(HotspotProvider.HotspotCallback.class); + + manager.startHotspot(callback); + + verify(callback).onFailed("no wifi hardware"); + assertNull(manager.getActiveSsid()); + } + + /** Once the provider reports itself up, a second start must reuse it rather than start again. */ + @Test public void startHotspot_doesNotStartTwice() { + providerStartsSuccessfully(); + when(provider.isRunning()).thenReturn(false, true); + + manager.startHotspot(mock(HotspotProvider.HotspotCallback.class)); + HotspotProvider.HotspotCallback second = mock(HotspotProvider.HotspotCallback.class); + manager.startHotspot(second); + + verify(provider).start(any()); + // the second caller still gets the live credentials back + verify(second).onStarted(SSID, PASSWORD, IP); + } + + @Test public void stopHotspot_clearsTheCredentials() { + providerStartsSuccessfully(); + when(provider.isRunning()).thenReturn(false, true); + manager.startHotspot(mock(HotspotProvider.HotspotCallback.class)); + + manager.stopHotspot(); + + verify(provider).stop(); + assertNull(manager.getActiveSsid()); + assertNull(manager.getActivePassword()); + } + + @Test public void stopHotspot_doesNothingWhenNeverStarted() { + manager.stopHotspot(); + + verify(provider, never()).stop(); + } + + @Test public void isIdleTimedOut_isFalseWhileTheHotspotIsDown() { + when(provider.isRunning()).thenReturn(false); + + assertFalse(manager.isIdleTimedOut()); + } + + @Test public void isIdleTimedOut_isFalseRightAfterActivity() { + providerStartsSuccessfully(); + when(provider.isRunning()).thenReturn(false, true); + manager.startHotspot(mock(HotspotProvider.HotspotCallback.class)); + + manager.recordActivity(); + + assertFalse(manager.isIdleTimedOut()); + } +} diff --git a/src/test/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotProviderTest.java b/src/test/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotProviderTest.java new file mode 100644 index 00000000..fa1c2a8e --- /dev/null +++ b/src/test/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotProviderTest.java @@ -0,0 +1,83 @@ +package org.medicmobile.webapp.mobile.p2p; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import android.net.wifi.WifiManager; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * LocalOnlyHotspot needs API 26 while the app supports 21, so the gate is exercised on both + * sides of that line. @TargetApi only silences lint, it does not stop the call at runtime. + */ +@RunWith(RobolectricTestRunner.class) +public class WifiHotspotProviderTest { + + // Robolectric 4.16 ships no image below API 25, so the "old device" cases run at the highest + // SDK that still lacks LocalOnlyHotspot. The guard is a single >= 26 comparison, so this + // exercises the same branch a real API 21 device would take. + @Test @Config(sdk = 25) + public void isSupported_isFalseJustBelowApi26() { + assertFalse(WifiHotspotProvider.isSupported()); + } + + @Test @Config(sdk = 26) + public void isSupported_isTrueFromApi26() { + assertTrue(WifiHotspotProvider.isSupported()); + } + + /** + * The whole point of the gate: an old device must be told, not crashed. + * + * The assertion cannot name startLocalOnlyHotspot here, because below API 26 the method does + * not exist on WifiManager at all and even referencing it throws NoSuchMethodError. That is + * exactly the crash this guard prevents, so "the provider touched nothing" is the assertion. + */ + @Test @Config(sdk = 25) + public void start_failsCleanlyBelowApi26AndNeverTouchesTheWifiApi() { + WifiManager wifiManager = mock(WifiManager.class); + HotspotProvider.HotspotCallback callback = mock(HotspotProvider.HotspotCallback.class); + + new WifiHotspotProvider(wifiManager).start(callback); + + verify(callback).onFailed("hotspot_unsupported"); + verifyNoInteractions(wifiManager); + } + + @Test @Config(sdk = 26) + public void start_reachesTheWifiApiFromApi26() { + WifiManager wifiManager = mock(WifiManager.class); + HotspotProvider.HotspotCallback callback = mock(HotspotProvider.HotspotCallback.class); + + new WifiHotspotProvider(wifiManager).start(callback); + + verify(wifiManager).startLocalOnlyHotspot(any(), any()); + verify(callback, never()).onFailed("hotspot_unsupported"); + } + + @Test @Config(sdk = 26) + public void start_rejectsAMissingCallback() { + WifiHotspotProvider provider = new WifiHotspotProvider(mock(WifiManager.class)); + + try { + provider.start(null); + org.junit.Assert.fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) { + // the contract says the callback is required + } + } + + @Test @Config(sdk = 26) + public void isRunning_isFalseBeforeStarting() { + assertFalse(new WifiHotspotProvider(mock(WifiManager.class)).isRunning()); + } +} From 6134947aacac3b65e128dc150d8a9607a9799efb Mon Sep 17 00:00:00 2001 From: paulpascal Date: Thu, 17 Sep 2026 11:42:32 +0000 Subject: [PATCH 02/68] feat(#11281): serve pairing over TLS with a pinned session certificate --- .../webapp/mobile/p2p/LocalHttpServer.java | 25 ++- .../webapp/mobile/p2p/P2pManager.java | 28 ++-- .../mobile/p2p/PinnedCertificateTrust.java | 115 ++++++++++++++ .../webapp/mobile/p2p/QrCodeHelper.java | 39 ++++- .../webapp/mobile/p2p/SessionCertificate.java | 145 ++++++++++++++++++ .../mobile/p2p/LocalHttpServerTest.java | 56 +++++-- .../webapp/mobile/p2p/P2pManagerTest.java | 20 ++- .../p2p/PinnedCertificateTrustTest.java | 91 +++++++++++ .../webapp/mobile/p2p/QrCodeHelperTest.java | 33 +++- 9 files changed, 509 insertions(+), 43 deletions(-) create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/PinnedCertificateTrust.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/SessionCertificate.java create mode 100644 src/test/java/org/medicmobile/webapp/mobile/p2p/PinnedCertificateTrustTest.java diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/LocalHttpServer.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/LocalHttpServer.java index f049d2f7..cb642a8b 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/p2p/LocalHttpServer.java +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/LocalHttpServer.java @@ -7,6 +7,7 @@ import org.json.JSONObject; import java.io.IOException; +import java.security.GeneralSecurityException; import fi.iki.elonen.NanoHTTPD; @@ -16,10 +17,10 @@ * Pairing only (#11281): it answers a status probe so a peer can confirm it reached the right * device, and nothing else. The endpoints that carry data arrive with the transfer work. * - * Everything is served over plain HTTP for now. The link is a local-only hotspot, so traffic - * never leaves the two devices, but that is not a substitute for transport security: a - * per-session certificate and fingerprint pinning are still to come, and until they land this - * server must not carry anything sensitive. + * Served over TLS using a {@link SessionCertificate}. The certificate is self-signed and + * deliberately untrusted by any CA: the peer pins it 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 { @@ -39,17 +40,22 @@ public class LocalHttpServer extends NanoHTTPD { private static final int PROTOCOL_VERSION = 1; private final String deviceLabel; + private final SessionCertificate certificate; - public LocalHttpServer(String deviceLabel) { - this(EPHEMERAL_PORT, deviceLabel); + public LocalHttpServer(String deviceLabel, SessionCertificate certificate) { + this(EPHEMERAL_PORT, deviceLabel, certificate); } - public LocalHttpServer(int port, String deviceLabel) { + 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. */ @@ -58,6 +64,11 @@ public void startServer() throws IOException { log(this, "Local server already running on port " + getListeningPort()); return; } + try { + makeSecure(certificate.sslServerSocketFactory(), null); + } catch (GeneralSecurityException e) { + throw new IOException("Could not enable TLS on the local server", e); + } 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()); diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pManager.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pManager.java index e96f7d0e..5651802d 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pManager.java +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pManager.java @@ -8,6 +8,8 @@ import org.json.JSONException; +import java.security.GeneralSecurityException; + /** * Owns a P2P pairing session on the host device. * @@ -25,22 +27,29 @@ public class P2pManager { private final WifiHotspotManager hotspotManager; private final LocalHttpServer server; + private final SessionCertificate certificate; - public P2pManager(WifiHotspotManager hotspotManager, LocalHttpServer server) { - if (hotspotManager == null || server == null) { - throw new IllegalArgumentException("hotspotManager and server must not be null"); + public P2pManager(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; } - /** Builds a manager wired to the real WiFi radio. */ - public static P2pManager create(Context context, String deviceLabel) { + /** + * Builds a manager wired to the real WiFi radio, with a fresh TLS identity for the session. + */ + public static P2pManager create(Context context, String deviceLabel) throws GeneralSecurityException { WifiManager wifiManager = (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE); + SessionCertificate certificate = SessionCertificate.generate(deviceLabel); return new P2pManager( new WifiHotspotManager(new WifiHotspotProvider(wifiManager), IDLE_TIMEOUT_SEC), - new LocalHttpServer(deviceLabel)); + new LocalHttpServer(deviceLabel, certificate), + certificate); } /** Whether this device can host at all. False on Android below 8.0. */ @@ -76,11 +85,11 @@ public void startHosting(HostingCallback callback) { } try { - String payload = QrCodeHelper.buildPayload( - ssid, password, ipAddress, server.getListeningPort()); + String payload = QrCodeHelper.buildPayload(ssid, password, ipAddress, + server.getListeningPort(), certificate.fingerprint()); log(P2pManager.class, "Hosting session ready on " + ipAddress); callback.onReady(payload); - } catch (JSONException e) { + } catch (JSONException | GeneralSecurityException e) { warn(e, "Could not build the pairing payload"); stopHosting(); callback.onFailed("payload_failed"); @@ -97,6 +106,7 @@ public void startHosting(HostingCallback callback) { public void stopHosting() { server.stopServer(); hotspotManager.stopHotspot(); + certificate.destroy(); log(P2pManager.class, "Hosting session stopped"); } diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/PinnedCertificateTrust.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/PinnedCertificateTrust.java new file mode 100644 index 00000000..05380c29 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/PinnedCertificateTrust.java @@ -0,0 +1,115 @@ +package org.medicmobile.webapp.mobile.p2p; + +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +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 certificate: 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 certificate. + * + * @param expectedFingerprint SHA-256 of the host certificate, 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.formatFingerprint( + MessageDigest.getInstance("SHA-256").digest(certificate.getEncoded())); + } + + /** + * 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)) { + throw new CertificateException( + "The host's certificate does not match the one in the QR code"); + } + } + + /** + * Lint objects to custom trust managers because they are usually written to accept + * everything. This one is the opposite: it accepts a single certificate 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/p2p/QrCodeHelper.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/QrCodeHelper.java index e9881eb6..3c31eb86 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/p2p/QrCodeHelper.java +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/QrCodeHelper.java @@ -28,6 +28,7 @@ * "ssid": "CHT-P2P-a3f7", * "pwd": "randomPassword123", * "ip": "192.168.43.1", + * "fp": "3A:7F:...:C2", // SHA-256 of the host's public key, colon-separated hex * "port": 8443, * "ts": 1711152000000 * } @@ -46,6 +47,8 @@ public final class QrCodeHelper { private static final long MAX_TIMESTAMP_DRIFT_MS = 10L * 60 * 1000; // 10 minutes // QR payload JSON keys + /** SHA-256 of the host's session certificate; the peer refuses anything else. */ + 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"; @@ -65,12 +68,15 @@ public static final class HotspotCredentials { final String password; final String ipAddress; final int port; + final String certFingerprint; - public HotspotCredentials(String ssid, String password, String ipAddress, int port) { + 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; } } @@ -110,8 +116,8 @@ public static Bitmap generateQrCode(HotspotCredentials creds, int size) { * @throws JSONException if JSON construction fails */ public static String buildPayload(HotspotCredentials creds) throws JSONException { - return buildPayload(creds.ssid, creds.password, creds.ipAddress, - creds.port); + return buildPayload(creds.ssid, creds.password, creds.ipAddress, creds.port, + creds.certFingerprint); } /** @@ -124,9 +130,13 @@ public static String buildPayload(HotspotCredentials creds) throws JSONException * @return JSON string matching * @throws JSONException if JSON construction fails */ - public static String buildPayload(String ssid, String password, - String ipAddress, int port) throws JSONException { + public static String buildPayload(String ssid, String password, String ipAddress, int port, + String certFingerprint) throws JSONException { validatePayloadParams(ssid, password, ipAddress, port); + if (certFingerprint == null || 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); @@ -134,6 +144,7 @@ public static String buildPayload(String ssid, String password, payload.put(KEY_SSID, ssid); payload.put(KEY_PWD, password); payload.put(KEY_IP, ipAddress); + payload.put(KEY_FINGERPRINT, certFingerprint); payload.put(KEY_PORT, port); payload.put(KEY_TS, System.currentTimeMillis()); return payload.toString(); @@ -234,6 +245,7 @@ public static QrPayload parsePayload(String payloadJson) { json.getString(KEY_PWD), json.getString(KEY_IP), json.getInt(KEY_PORT), + json.getString(KEY_FINGERPRINT), json.getLong(KEY_TS) ); } catch (JSONException e) { @@ -245,7 +257,10 @@ public static QrPayload parsePayload(String payloadJson) { // --- Private helpers --- /** - * Validate required fields (ssid, pwd, ip, port) in the QR payload. + * 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 QrValidation validateRequiredFields(JSONObject payload) { if (isEmptyField(payload, "ssid")) { @@ -257,6 +272,9 @@ private static QrValidation validateRequiredFields(JSONObject payload) { if (isEmptyField(payload, "ip")) { return QrValidation.reject("missing required field: ip"); } + if (isEmptyField(payload, KEY_FINGERPRINT)) { + return QrValidation.reject("missing required field: fp"); + } int port = payload.optInt(KEY_PORT, 0); if (port <= 0 || port > 65535) { return QrValidation.reject("invalid port: " + port); @@ -323,17 +341,24 @@ public static final class QrPayload { private final String password; private final String ipAddress; private final int port; + private final String certFingerprint; private final long timestamp; QrPayload(String ssid, String password, String ipAddress, - int port, long timestamp) { + int port, String certFingerprint, long timestamp) { this.ssid = ssid; this.password = password; this.ipAddress = ipAddress; this.port = port; + this.certFingerprint = certFingerprint; this.timestamp = timestamp; } + /** The certificate the host must present. Anything else means it is not the host. */ + public String getCertFingerprint() { + return certFingerprint; + } + public String getSsid() { return ssid; } diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/SessionCertificate.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/SessionCertificate.java new file mode 100644 index 00000000..2112b5bc --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/SessionCertificate.java @@ -0,0 +1,145 @@ +package org.medicmobile.webapp.mobile.p2p; + +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.security.GeneralSecurityException; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.cert.Certificate; +import java.util.Locale; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLServerSocketFactory; +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 the certificate's SHA-256 fingerprint in + * the QR code, and the peer refuses any certificate that 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 = "AndroidKeyStore"; + private static final String ALIAS = "cht-p2p-session"; + private static final char[] NO_PASSWORD = new char[0]; + + private final KeyStore keyStore; + + private SessionCertificate(KeyStore keyStore) { + this.keyStore = keyStore; + } + + /** + * Generates a new key and certificate, replacing any left over from a previous session. + * + * @param deviceLabel shown as the certificate subject, so a curious peer sees something + * meaningful rather than a placeholder + */ + public static SessionCertificate generate(String deviceLabel) throws GeneralSecurityException { + KeyStore keyStore = loadKeyStore(); + if (keyStore.containsAlias(ALIAS)) { + keyStore.deleteEntry(ALIAS); + } + + KeyPairGenerator generator = + KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, KEYSTORE); + generator.initialize(new KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_SIGN) + .setCertificateSubject(new X500Principal("CN=" + sanitise(deviceLabel))) + .setCertificateSerialNumber(BigInteger.ONE) + .setDigests(KeyProperties.DIGEST_SHA256) + .setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) + .build()); + generator.generateKeyPair(); + + log(SessionCertificate.class, "Generated a session certificate"); + return new SessionCertificate(keyStore); + } + + private static KeyStore loadKeyStore() throws GeneralSecurityException { + try { + KeyStore keyStore = KeyStore.getInstance(KEYSTORE); + 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 it should not be able to break the DN either. */ + private static String sanitise(String deviceLabel) { + if (deviceLabel == null || deviceLabel.trim().isEmpty()) { + return "CHT device"; + } + return deviceLabel.replaceAll("[^A-Za-z0-9 ._-]", "").trim(); + } + + /** The socket factory NanoHTTPD serves through. */ + public SSLServerSocketFactory sslServerSocketFactory() throws GeneralSecurityException { + try { + KeyManagerFactory keyManagers = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagers.init(keyStore, NO_PASSWORD); + return NanoHTTPD.makeSSLSocketFactory(keyStore, keyManagers); + } catch (IOException e) { + throw new GeneralSecurityException("Could not build the TLS socket factory", e); + } + } + + /** + * SHA-256 of the certificate, as uppercase hex separated by colons. + * + * This is the value the peer pins against, so it is taken over the DER encoding: the bytes + * actually presented during the handshake, not a parsed or re-encoded form of them. + */ + public String fingerprint() throws GeneralSecurityException { + Certificate certificate = keyStore.getCertificate(ALIAS); + if (certificate == null) { + throw new GeneralSecurityException("No session certificate to fingerprint"); + } + return formatFingerprint(MessageDigest.getInstance("SHA-256").digest(certificate.getEncoded())); + } + + static String formatFingerprint(byte[] digest) { + StringBuilder hex = new StringBuilder(digest.length * 3); + for (byte b : digest) { + if (hex.length() > 0) { + hex.append(':'); + } + hex.append(String.format(Locale.US, "%02X", b)); + } + return hex.toString(); + } + + /** Drops the key, so the session's identity cannot be reused. */ + public void destroy() { + try { + if (keyStore.containsAlias(ALIAS)) { + keyStore.deleteEntry(ALIAS); + log(SessionCertificate.class, "Session certificate destroyed"); + } + } catch (Exception e) { + // The next session overwrites the entry anyway, 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/test/java/org/medicmobile/webapp/mobile/p2p/LocalHttpServerTest.java b/src/test/java/org/medicmobile/webapp/mobile/p2p/LocalHttpServerTest.java index e741f3e8..2187d2c6 100644 --- a/src/test/java/org/medicmobile/webapp/mobile/p2p/LocalHttpServerTest.java +++ b/src/test/java/org/medicmobile/webapp/mobile/p2p/LocalHttpServerTest.java @@ -2,8 +2,8 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import org.json.JSONObject; @@ -20,6 +20,16 @@ public class LocalHttpServerTest { private static final String LABEL = "Supervisor phone"; + private SessionCertificate certificate; + + @org.junit.Before public void setUp() { + certificate = mock(SessionCertificate.class); + } + + private LocalHttpServer server() { + return new LocalHttpServer(LABEL, certificate); + } + private NanoHTTPD.IHTTPSession request(NanoHTTPD.Method method, String uri) { NanoHTTPD.IHTTPSession session = mock(NanoHTTPD.IHTTPSession.class); when(session.getMethod()).thenReturn(method); @@ -40,14 +50,15 @@ private String bodyOf(NanoHTTPD.Response response) throws Exception { @Test public void constructor_rejectsAnEmptyLabel() { - assertThrows(IllegalArgumentException.class, () -> new LocalHttpServer("")); - assertThrows(IllegalArgumentException.class, () -> new LocalHttpServer(" ")); - assertThrows(IllegalArgumentException.class, () -> new LocalHttpServer(null)); + assertThrows(IllegalArgumentException.class, () -> new LocalHttpServer("", certificate)); + assertThrows(IllegalArgumentException.class, () -> new LocalHttpServer(" ", certificate)); + assertThrows(IllegalArgumentException.class, () -> new LocalHttpServer(null, certificate)); + assertThrows(IllegalArgumentException.class, () -> new LocalHttpServer(LABEL, null)); } @Test public void status_identifiesTheHostSoAPeerCanConfirmWhatItReached() throws Exception { - LocalHttpServer server = new LocalHttpServer(LABEL); + LocalHttpServer server = server(); NanoHTTPD.Response response = server.serve(request(NanoHTTPD.Method.GET, "/_p2p/status")); @@ -60,7 +71,7 @@ public void status_identifiesTheHostSoAPeerCanConfirmWhatItReached() throws Exce @Test public void unknownPath_is404() throws Exception { - LocalHttpServer server = new LocalHttpServer(LABEL); + LocalHttpServer server = server(); NanoHTTPD.Response response = server.serve(request(NanoHTTPD.Method.GET, "/_p2p/anything-else")); @@ -70,7 +81,7 @@ public void unknownPath_is404() throws Exception { /** The data endpoints do not exist yet; a peer must not be able to reach one by guessing. */ @Test public void dataEndpointsFromTheOldProtocolAreGone() throws Exception { - LocalHttpServer server = new LocalHttpServer(LABEL); + LocalHttpServer server = server(); for (String path : new String[] { "/_p2p/auth", "/_p2p/get-ids", "/_p2p/bulk-get", "/_p2p/accept-docs" }) { NanoHTTPD.Response response = server.serve(request(NanoHTTPD.Method.POST, path)); @@ -80,7 +91,7 @@ public void dataEndpointsFromTheOldProtocolAreGone() throws Exception { @Test public void statusOnlyAnswersGet() throws Exception { - LocalHttpServer server = new LocalHttpServer(LABEL); + LocalHttpServer server = server(); NanoHTTPD.Response response = server.serve(request(NanoHTTPD.Method.POST, "/_p2p/status")); @@ -89,7 +100,7 @@ public void statusOnlyAnswersGet() throws Exception { @Test public void stopServer_isSafeWhenNeverStarted() { - new LocalHttpServer(LABEL).stopServer(); + server().stopServer(); } /** Port 0 asks the OS for a free port, which is what removes the "port already in use" failure. */ @@ -98,15 +109,28 @@ public void defaultsToAnOsAssignedPort() { assertEquals(0, LocalHttpServer.EPHEMERAL_PORT); } + /** + * Proves the server will not listen without TLS. Actually completing a handshake needs a + * keystore-backed certificate, so that is verified on a device rather than faked here. + */ @Test - public void bindsAndReportsTheRealPortItGot() throws Exception { - LocalHttpServer server = new LocalHttpServer(LABEL); + public void startServer_refusesToListenIfTheCertificateIsUnusable() throws Exception { + when(certificate.sslServerSocketFactory()) + .thenThrow(new java.security.GeneralSecurityException("no key")); + + assertThrows(java.io.IOException.class, () -> server().startServer()); + } + + @Test + public void startServer_asksTheSessionCertificateForItsSocketFactory() throws Exception { + when(certificate.sslServerSocketFactory()) + .thenThrow(new java.security.GeneralSecurityException("stop before binding")); + try { - server.startServer(); - assertTrue("expected a real bound port, got " + server.getListeningPort(), - server.getListeningPort() > 0); - } finally { - server.stopServer(); + server().startServer(); + } catch (java.io.IOException expected) { + // we only care that TLS was set up before any socket was opened } + verify(certificate).sslServerSocketFactory(); } } diff --git a/src/test/java/org/medicmobile/webapp/mobile/p2p/P2pManagerTest.java b/src/test/java/org/medicmobile/webapp/mobile/p2p/P2pManagerTest.java index 85ab440b..89cd392e 100644 --- a/src/test/java/org/medicmobile/webapp/mobile/p2p/P2pManagerTest.java +++ b/src/test/java/org/medicmobile/webapp/mobile/p2p/P2pManagerTest.java @@ -29,8 +29,11 @@ public class P2pManagerTest { private static final String PASSWORD = "a-password"; private static final String IP = "192.168.49.1"; + private static final String FINGERPRINT = "AB:CD:EF:01:23:45"; + private WifiHotspotManager hotspotManager; private LocalHttpServer server; + private SessionCertificate certificate; private P2pManager manager; private P2pManager.HostingCallback callback; @@ -38,7 +41,13 @@ public class P2pManagerTest { hotspotManager = mock(WifiHotspotManager.class); server = mock(LocalHttpServer.class); when(server.getListeningPort()).thenReturn(8443); - manager = new P2pManager(hotspotManager, server); + certificate = mock(SessionCertificate.class); + try { + when(certificate.fingerprint()).thenReturn(FINGERPRINT); + } catch (Exception e) { + throw new IllegalStateException(e); + } + manager = new P2pManager(hotspotManager, server, certificate); callback = mock(P2pManager.HostingCallback.class); } @@ -51,8 +60,9 @@ private void hotspotStarts() { } @Test public void constructor_rejectsMissingCollaborators() { - assertThrows(IllegalArgumentException.class, () -> new P2pManager(null, server)); - assertThrows(IllegalArgumentException.class, () -> new P2pManager(hotspotManager, null)); + assertThrows(IllegalArgumentException.class, () -> new P2pManager(null, server, certificate)); + assertThrows(IllegalArgumentException.class, () -> new P2pManager(hotspotManager, null, certificate)); + assertThrows(IllegalArgumentException.class, () -> new P2pManager(hotspotManager, server, null)); } @Test @Config(sdk = 26) @@ -68,6 +78,8 @@ public void startHosting_bringsUpTheHotspotThenTheServerAndReturnsAScannablePayl org.junit.Assert.assertEquals(SSID, json.getString("ssid")); org.junit.Assert.assertEquals(IP, json.getString("ip")); org.junit.Assert.assertEquals(8443, json.getInt("port")); + // the peer needs this to know which certificate to accept + org.junit.Assert.assertEquals(FINGERPRINT, json.getString("fp")); } @Test @Config(sdk = 25) @@ -111,6 +123,8 @@ public void stopHosting_stopsBothHalves() { verify(server).stopServer(); verify(hotspotManager).stopHotspot(); + // the session identity must not outlive the session + verify(certificate).destroy(); } @Test @Config(sdk = 26) diff --git a/src/test/java/org/medicmobile/webapp/mobile/p2p/PinnedCertificateTrustTest.java b/src/test/java/org/medicmobile/webapp/mobile/p2p/PinnedCertificateTrustTest.java new file mode 100644 index 00000000..e666cd2c --- /dev/null +++ b/src/test/java/org/medicmobile/webapp/mobile/p2p/PinnedCertificateTrustTest.java @@ -0,0 +1,91 @@ +package org.medicmobile.webapp.mobile.p2p; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +@RunWith(RobolectricTestRunner.class) +public class PinnedCertificateTrustTest { + + private static final byte[] HOST_CERT = "the host's certificate bytes".getBytes(); + private static final byte[] OTHER_CERT = "somebody else's certificate".getBytes(); + + private X509Certificate certificateOf(byte[] encoded) throws Exception { + X509Certificate certificate = mock(X509Certificate.class); + when(certificate.getEncoded()).thenReturn(encoded); + return certificate; + } + + private String fingerprintOf(byte[] encoded) throws Exception { + return SessionCertificate.formatFingerprint(MessageDigest.getInstance("SHA-256").digest(encoded)); + } + + @Test public void refusesToBuildWithoutAFingerprint() { + assertThrows(GeneralSecurityException.class, () -> PinnedCertificateTrust.socketFactoryFor(null)); + assertThrows(GeneralSecurityException.class, () -> PinnedCertificateTrust.socketFactoryFor("")); + assertThrows(GeneralSecurityException.class, () -> PinnedCertificateTrust.socketFactoryFor(" ")); + } + + @Test public void buildsASocketFactoryForAPinnedFingerprint() throws Exception { + assertNotNull(PinnedCertificateTrust.socketFactoryFor(fingerprintOf(HOST_CERT))); + } + + @Test public void fingerprintOf_matchesTheHostSideFormat() throws Exception { + String fingerprint = PinnedCertificateTrust.fingerprintOf(certificateOf(HOST_CERT)); + + assertTrue(fingerprint, fingerprint.matches("([0-9A-F]{2}:)+[0-9A-F]{2}")); + org.junit.Assert.assertEquals(fingerprintOf(HOST_CERT), fingerprint); + } + + @Test public void matches_ignoresCaseAndSeparators() { + assertTrue(PinnedCertificateTrust.matches("AB:CD:EF", "abcdef")); + assertTrue(PinnedCertificateTrust.matches("ab cd ef", "AB:CD:EF")); + } + + @Test public void matches_rejectsADifferentFingerprint() { + assertFalse(PinnedCertificateTrust.matches("AB:CD:EF", "AB:CD:E0")); + assertFalse(PinnedCertificateTrust.matches("AB:CD:EF", "")); + assertFalse(PinnedCertificateTrust.matches("AB:CD:EF", null)); + } + + @Test public void verifyPinned_acceptsTheCertificateFromTheQrCode() throws Exception { + PinnedCertificateTrust.verifyPinned( + new X509Certificate[] { certificateOf(HOST_CERT) }, fingerprintOf(HOST_CERT)); + } + + /** The whole point: a different device answering on the host's address must be refused. */ + @Test public void verifyPinned_rejectsAnImpostor() throws Exception { + CertificateException thrown = assertThrows(CertificateException.class, + () -> PinnedCertificateTrust.verifyPinned( + new X509Certificate[] { certificateOf(OTHER_CERT) }, fingerprintOf(HOST_CERT))); + + assertTrue(thrown.getMessage(), thrown.getMessage().contains("does not match")); + } + + @Test public void verifyPinned_rejectsAnEmptyChain() throws Exception { + assertThrows(CertificateException.class, + () -> PinnedCertificateTrust.verifyPinned(new X509Certificate[0], fingerprintOf(HOST_CERT))); + assertThrows(CertificateException.class, + () -> PinnedCertificateTrust.verifyPinned(null, fingerprintOf(HOST_CERT))); + } + + /** Only the leaf is pinned, so an extra certificate in the chain must not rescue an impostor. */ + @Test public void verifyPinned_onlyTrustsTheLeafCertificate() throws Exception { + assertThrows(CertificateException.class, + () -> PinnedCertificateTrust.verifyPinned( + new X509Certificate[] { certificateOf(OTHER_CERT), certificateOf(HOST_CERT) }, + fingerprintOf(HOST_CERT))); + } +} diff --git a/src/test/java/org/medicmobile/webapp/mobile/p2p/QrCodeHelperTest.java b/src/test/java/org/medicmobile/webapp/mobile/p2p/QrCodeHelperTest.java index f29f2356..0b96ab66 100644 --- a/src/test/java/org/medicmobile/webapp/mobile/p2p/QrCodeHelperTest.java +++ b/src/test/java/org/medicmobile/webapp/mobile/p2p/QrCodeHelperTest.java @@ -17,9 +17,10 @@ public class QrCodeHelperTest { private static final String PASSWORD = "a-password"; private static final String IP = "192.168.49.1"; private static final int PORT = 8443; + private static final String FINGERPRINT = "AB:CD:EF:01:23:45"; private QrCodeHelper.HotspotCredentials credentials() { - return new QrCodeHelper.HotspotCredentials(SSID, PASSWORD, IP, PORT); + return new QrCodeHelper.HotspotCredentials(SSID, PASSWORD, IP, PORT, FINGERPRINT); } @Test public void buildPayload_carriesWhatAPeerNeedsToConnect() throws Exception { @@ -49,6 +50,36 @@ private QrCodeHelper.HotspotCredentials credentials() { assertNotNull(result.getReason()); } + @Test public void buildPayload_carriesTheCertificateFingerprint() throws Exception { + JSONObject payload = new JSONObject(QrCodeHelper.buildPayload(credentials())); + + assertEquals(FINGERPRINT, payload.getString("fp")); + } + + /** Pairing without a fingerprint would mean trusting whatever answers on that IP. */ + @Test public void buildPayload_refusesToBuildWithoutAFingerprint() { + org.junit.Assert.assertThrows(org.json.JSONException.class, + () -> QrCodeHelper.buildPayload(SSID, PASSWORD, IP, PORT, "")); + org.junit.Assert.assertThrows(org.json.JSONException.class, + () -> QrCodeHelper.buildPayload(SSID, PASSWORD, IP, PORT, null)); + } + + @Test public void validateQrPayload_rejectsAPayloadWithoutAFingerprint() throws Exception { + JSONObject payload = new JSONObject(QrCodeHelper.buildPayload(credentials())); + payload.remove("fp"); + + QrValidation result = QrCodeHelper.validateQrPayload(payload.toString()); + + assertFalse(result.isAccepted()); + assertTrue(result.getReason(), result.getReason().contains("fp")); + } + + @Test public void parsePayload_exposesTheFingerprintToPinAgainst() throws Exception { + QrCodeHelper.QrPayload parsed = QrCodeHelper.parsePayload(QrCodeHelper.buildPayload(credentials())); + + assertEquals(FINGERPRINT, parsed.getCertFingerprint()); + } + @Test public void validateQrPayload_rejectsAPayloadMissingTheSsid() throws Exception { JSONObject payload = new JSONObject(QrCodeHelper.buildPayload(credentials())); payload.remove("ssid"); From 76aafed55531a4d4fb4f5234d08fa59014023e84 Mon Sep 17 00:00:00 2001 From: paulpascal Date: Thu, 17 Sep 2026 13:34:41 +0000 Subject: [PATCH 03/68] feat(#11281): ask for the permissions a session needs --- src/main/AndroidManifest.xml | 9 +- .../mobile/EmbeddedBrowserActivity.java | 30 +++- .../webapp/mobile/MedicAndroidJavascript.java | 6 + .../mobile/RequestP2pPermissionsActivity.java | 132 ++++++++++++++++++ .../mobile/p2p/P2pForegroundService.java | 131 ----------------- .../mobile/p2p/P2pNotificationChannel.java | 80 ----------- .../res/layout/request_p2p_permission.xml | 63 +++++++++ src/main/res/values/strings.xml | 5 + .../RequestP2pPermissionsActivityTest.java | 69 +++++++++ 9 files changed, 308 insertions(+), 217 deletions(-) create mode 100644 src/main/java/org/medicmobile/webapp/mobile/RequestP2pPermissionsActivity.java delete mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/P2pForegroundService.java delete mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/P2pNotificationChannel.java create mode 100644 src/main/res/layout/request_p2p_permission.xml create mode 100644 src/test/java/org/medicmobile/webapp/mobile/RequestP2pPermissionsActivityTest.java diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 1f890a9a..d4945e77 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -31,8 +31,6 @@ - - @@ -115,9 +113,10 @@ - + PRIVATE HELPERS + private void p2pPermissionsResolved(boolean granted) { + evaluateJavascript(String.format( + "window.CHTCore.AndroidApi.v1.p2pPermissionsResolved(%s);", granted)); + } + private void locationRequestResolved() { evaluateJavascript("window.CHTCore.AndroidApi.v1.locationPermissionRequestResolved();"); } @@ -467,7 +494,8 @@ 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_P2P_PERMISSIONS(106); 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 cd672ecd..e7abc226 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java +++ b/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java @@ -203,6 +203,12 @@ public void p2p_start_hosting() { respondToP2p(false, "hotspot_unsupported"); return; } + // Asks if it is missing, and reports back through p2pPermissionsResolved so the webapp can + // retry. Without this the hotspot call fails with a SecurityException the user cannot act on. + if(!parent.getP2pPermissions()) { + respondToP2p(false, "permissions_required"); + return; + } p2pManager.startHosting(new P2pManager.HostingCallback() { @Override public void onReady(String qrPayload) { respondToP2p(true, qrPayload); diff --git a/src/main/java/org/medicmobile/webapp/mobile/RequestP2pPermissionsActivity.java b/src/main/java/org/medicmobile/webapp/mobile/RequestP2pPermissionsActivity.java new file mode 100644 index 00000000..29cfa007 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/RequestP2pPermissionsActivity.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 a P2P 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 RequestP2pPermissionsActivity extends FragmentActivity { + + private final ActivityResultLauncher requestPermissionLauncher = + registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), grantedMap -> { + if (hasP2pPermissions(this)) { + trace(this, "RequestP2pPermissionsActivity :: User granted the P2P permissions."); + setResult(RESULT_OK); + finish(); + return; + } + + if (shouldSendToAppSettings()) { + trace( + this, + "RequestP2pPermissionsActivity :: 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, "RequestP2pPermissionsActivity :: User refused the P2P permissions."); + setResult(RESULT_CANCELED); + finish(); + }); + + private final ActivityResultLauncher appSettingsLauncher = + registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { + boolean granted = hasP2pPermissions(this); + trace(this, "RequestP2pPermissionsActivity :: 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 hasP2pPermissions(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_p2p_permission); + + String appName = getResources().getString(R.string.app_name); + String message = getResources().getString(R.string.p2pRequestMessage); + TextView field = findViewById(R.id.p2pMessageText); + field.setText(String.format(message, appName)); + } + + public void onClickOk(View view) { + trace(this, "RequestP2pPermissionsActivity :: User agreed with the disclosure message."); + requestPermissionLauncher.launch(requiredPermissions()); + } + + public void onClickNegative(View view) { + trace(this, "RequestP2pPermissionsActivity :: User disagreed with the disclosure message."); + setResult(RESULT_CANCELED); + finish(); + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pForegroundService.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pForegroundService.java deleted file mode 100644 index 83da37e7..00000000 --- a/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pForegroundService.java +++ /dev/null @@ -1,131 +0,0 @@ -package org.medicmobile.webapp.mobile.p2p; - -import static org.medicmobile.webapp.mobile.MedicLog.log; -import static org.medicmobile.webapp.mobile.MedicLog.warn; - -import android.app.Notification; -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.os.Build; -import android.os.IBinder; - -/** - * Foreground service to keep P2P sync running when the app is in background. - * - * Android aggressively kills background services. A foreground service with - * a persistent notification keeps the process alive during the sync session. - * - * Usage: - * // Start - * Intent i = new Intent(context, P2pForegroundService.class); - * i.setAction(P2pForegroundService.ACTION_START); - * context.startForegroundService(i); // API 26+ - * - * // Stop - * Intent i = new Intent(context, P2pForegroundService.class); - * i.setAction(P2pForegroundService.ACTION_STOP); - * context.startService(i); - */ -public class P2pForegroundService extends Service { - - - public static final String ACTION_START = "org.medicmobile.webapp.mobile.p2p.START"; - public static final String ACTION_STOP = "org.medicmobile.webapp.mobile.p2p.STOP"; - - private P2pNotificationChannel notificationChannel; - - @Override - public void onCreate() { - super.onCreate(); - notificationChannel = new P2pNotificationChannel(this); - notificationChannel.createChannel(); - log(this, "P2P foreground service created"); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - if (intent == null) { - warn(this, "Received null intent, stopping"); - stopSelf(); - return START_NOT_STICKY; - } - - String action = intent.getAction(); - if (ACTION_START.equals(action)) { - handleStart(); - } else if (ACTION_STOP.equals(action)) { - handleStop(); - } else { - warn(this, "Unknown action: " + action); - } - - return START_STICKY; - } - - @Override - public IBinder onBind(Intent intent) { - // Not a bound service - return null; - } - - @Override - public void onDestroy() { - log(this, "P2P foreground service destroyed"); - if (notificationChannel != null) { - notificationChannel.dismiss(); - } - super.onDestroy(); - } - - // --- Static helpers --- - - /** - * Convenience method to start the foreground service. - */ - public static void start(Context context) { - Intent intent = new Intent(context, P2pForegroundService.class); - intent.setAction(ACTION_START); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(intent); - } else { - context.startService(intent); - } - } - - /** - * Convenience method to stop the foreground service. - */ - public static void stop(Context context) { - Intent intent = new Intent(context, P2pForegroundService.class); - intent.setAction(ACTION_STOP); - context.startService(intent); - } - - // --- Private --- - - private void handleStart() { - log(this, "Starting foreground P2P sync service"); - Notification notification = notificationChannel.buildSyncNotification( - "P2P Sync Active", - "Waiting for peer connections..." - ); - startForeground(P2pNotificationChannel.NOTIFICATION_ID, notification); - } - - /** - * Stops the service and removes its notification. - * - * stopForeground(int) only exists from SDK 24, so the deprecated boolean overload is the - * only option on SDK 21 to 23. The deprecation warning it raises is expected. - */ - private void handleStop() { - log(this, "Stopping foreground P2P sync service"); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - stopForeground(STOP_FOREGROUND_REMOVE); - } else { - stopForeground(true); - } - stopSelf(); - } -} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pNotificationChannel.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pNotificationChannel.java deleted file mode 100644 index 20087d6e..00000000 --- a/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pNotificationChannel.java +++ /dev/null @@ -1,80 +0,0 @@ -package org.medicmobile.webapp.mobile.p2p; - -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.content.Context; - -/** - * Manages notifications for P2P sync progress. - * - * Creates a dedicated notification channel (required for Android 8.0+) - * and builds notifications for the foreground service and progress updates. - */ -@android.annotation.TargetApi(26) -public class P2pNotificationChannel { - - static final String CHANNEL_ID = "cht_p2p_sync"; - private static final String CHANNEL_NAME = "P2P Sync"; - private static final String CHANNEL_DESCRIPTION = "Notifications for peer-to-peer data sync"; - static final int NOTIFICATION_ID = 42001; - - private final Context context; - private final NotificationManager notificationManager; - - public P2pNotificationChannel(Context context) { - if (context == null) { - throw new IllegalArgumentException("context must not be null"); - } - this.context = context.getApplicationContext(); - this.notificationManager = (NotificationManager) this.context - .getSystemService(Context.NOTIFICATION_SERVICE); - } - - /** - * Create the notification channel. Must be called before showing any notification. - * Safe to call multiple times — Android ignores duplicate channel creation. - */ - public void createChannel() { - // @TargetApi(26) guarantees API 26+ — no version check needed - NotificationChannel channel = new NotificationChannel( - CHANNEL_ID, - CHANNEL_NAME, - NotificationManager.IMPORTANCE_LOW - ); - channel.setDescription(CHANNEL_DESCRIPTION); - channel.setShowBadge(false); - channel.enableVibration(false); - notificationManager.createNotificationChannel(channel); - } - - /** - * Build a basic sync notification (used by the foreground service). - * - * @param title notification title - * @param message notification body text - * @return the built notification - */ - public Notification buildSyncNotification(String title, String message) { - // P2P requires API 26+ — channel constructor always available - Notification.Builder builder = new Notification.Builder(context, CHANNEL_ID); - - builder.setContentTitle(title) - .setContentText(message) - .setSmallIcon(android.R.drawable.stat_notify_sync) - .setOngoing(true) - .setOnlyAlertOnce(true); - - return builder.build(); - } - - - - /** - * Dismiss the notification. - */ - public void dismiss() { - notificationManager.cancel(NOTIFICATION_ID); - } - -} diff --git a/src/main/res/layout/request_p2p_permission.xml b/src/main/res/layout/request_p2p_permission.xml new file mode 100644 index 00000000..9c19ecd2 --- /dev/null +++ b/src/main/res/layout/request_p2p_permission.xml @@ -0,0 +1,63 @@ + + + + + + + + + +