diff --git a/build.gradle b/build.gradle index 524f786b..bfa34b62 100644 --- a/build.gradle +++ b/build.gradle @@ -493,6 +493,11 @@ dependencies { implementation 'androidx.fragment:fragment:1.8.8' implementation "androidx.datastore:datastore-preferences:1.1.7" implementation "androidx.datastore:datastore-preferences-rxjava3:1.1.7" + // P2P WiFi Hotspot Sync dependencies + implementation 'org.nanohttpd:nanohttpd:2.3.1' + implementation 'com.google.zxing:core:3.5.3' + implementation 'com.journeyapps:zxing-android-embedded:4.3.0' + compileOnly 'com.github.spotbugs:spotbugs-annotations:4.9.3' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' testImplementation 'junit:junit:4.13.2' diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 20c4e672..c8c15d00 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ + @@ -26,6 +27,16 @@ --> + + + + + + + + + + @@ -98,6 +110,23 @@ android:permission="android.permission.BIND_JOB_SERVICE" android:exported="true" tools:targetApi="23" /> + + + + + + diff --git a/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java b/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java index 7c18e935..4c532a72 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java +++ b/src/main/java/org/medicmobile/webapp/mobile/EmbeddedBrowserActivity.java @@ -228,6 +228,12 @@ protected void onActivityResult(int requestCd, int resultCode, Intent intent) { case ACCESS_SEND_SMS_PERMISSION: this.smsSender.resumeProcess(resultCode); return; + case ACCESS_P2P_PERMISSION: + p2pPermissionResolved(resultCode); + return; + case P2P_QR_SCAN: + processP2pQrScanResult(resultCode, intent); + return; default: trace(this, "onActivityResult() :: no handling for requestCode=%s", requestCode.name()); } @@ -320,6 +326,37 @@ public boolean getLocationPermissions() { } //> PRIVATE HELPERS + public boolean getP2pPermissions() { + String[] perms = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + ? new String[]{ "android.permission.CAMERA", ACCESS_FINE_LOCATION, "android.permission.NEARBY_WIFI_DEVICES" } + : new String[]{ "android.permission.CAMERA", ACCESS_FINE_LOCATION }; + + boolean allGranted = true; + for (String perm : perms) { + if (ContextCompat.checkSelfPermission(this, perm) != PERMISSION_GRANTED) { + allGranted = false; + break; + } + } + + if (allGranted) { + trace(this, "getP2pPermissions() :: All P2P permissions already granted"); + return true; + } + + trace(this, "getP2pPermissions() :: P2P permissions not granted, requesting access..."); + startActivityForResult( + new Intent(this, RequestP2pPermissionsActivity.class), + RequestCode.ACCESS_P2P_PERMISSION.getCode() + ); + return false; + } + + private void p2pPermissionResolved(int resultCode) { + String granted = resultCode == RESULT_OK ? "true" : "false"; + evaluateJavascript("window.CHTCore.AndroidApi.v1.p2pPermissionRequestResolved(" + granted + ");"); + } + private void locationRequestResolved() { evaluateJavascript("window.CHTCore.AndroidApi.v1.locationPermissionRequestResolved();"); } @@ -406,6 +443,18 @@ private void enableJavascript(WebView container) { maj.setConnectivityManager((ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE)); + // Initialize P2P bridge (singleton — survives activity recreation) + org.medicmobile.webapp.mobile.p2p.P2pManager p2pManager = + org.medicmobile.webapp.mobile.p2p.P2pManager.getInstance(this); + org.medicmobile.webapp.mobile.p2p.TransitDocManager transitDocManager = + new org.medicmobile.webapp.mobile.p2p.TransitDocManager(); + org.medicmobile.webapp.mobile.p2p.P2pTracker tracker = + new org.medicmobile.webapp.mobile.p2p.P2pTracker(); + org.medicmobile.webapp.mobile.p2p.P2pBridgeMethods p2pBridge = + new org.medicmobile.webapp.mobile.p2p.P2pBridgeMethods(p2pManager, transitDocManager, tracker); + maj.setP2pBridge(p2pBridge); + p2pBridge.setWebView(container); + container.addJavascriptInterface(maj, "medicmobile_android"); } @@ -444,6 +493,43 @@ private void registerRetryConnectionBroadcastReceiver() { ); } + /** + * Launch ZXing QR scanner for P2P peer connection. + * The scan result is returned via JavaScript callback. + */ + public void scanP2pQrCode() { + trace(this, "scanP2pQrCode() :: launching ZXing scanner"); + com.journeyapps.barcodescanner.ScanOptions options = + new com.journeyapps.barcodescanner.ScanOptions(); + options.setDesiredBarcodeFormats(com.journeyapps.barcodescanner.ScanOptions.QR_CODE); + options.setPrompt("Scan the Supervisor's QR code"); + options.setCameraId(0); + options.setBeepEnabled(true); + options.setOrientationLocked(true); + com.journeyapps.barcodescanner.ScanContract scanContract = + new com.journeyapps.barcodescanner.ScanContract(); + Intent scanIntent = scanContract.createIntent(this, options); + startActivityForResult(scanIntent, RequestCode.P2P_QR_SCAN.getCode()); + } + + private void processP2pQrScanResult(int resultCode, Intent intent) { + if (resultCode != RESULT_OK || intent == null) { + trace(this, "processP2pQrScanResult() :: scan cancelled or no result"); + evaluateJavascript("window.CHTCore.P2p.onQrScanResult(null);"); + return; + } + com.journeyapps.barcodescanner.ScanIntentResult result = + com.journeyapps.barcodescanner.ScanIntentResult.parseActivityResult(resultCode, intent); + String contents = result.getContents(); + if (contents == null || contents.isEmpty()) { + evaluateJavascript("window.CHTCore.P2p.onQrScanResult(null);"); + return; + } + // Escape for JavaScript string + String escaped = contents.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n"); + evaluateJavascript("window.CHTCore.P2p.onQrScanResult('" + escaped + "');"); + } + //> ENUMS public enum RequestCode { ACCESS_LOCATION_PERMISSION(100), @@ -451,7 +537,9 @@ public enum RequestCode { ACCESS_SEND_SMS_PERMISSION(102), CHT_EXTERNAL_APP_ACTIVITY(103), GRAB_MRDT_PHOTO_ACTIVITY(104), - FILE_PICKER_ACTIVITY(105); + FILE_PICKER_ACTIVITY(105), + ACCESS_P2P_PERMISSION(106), + P2P_QR_SCAN(107); private final int requestCode; diff --git a/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java b/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java index 88f8453b..991c6720 100644 --- a/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java +++ b/src/main/java/org/medicmobile/webapp/mobile/MedicAndroidJavascript.java @@ -125,6 +125,11 @@ public boolean getLocationPermissions() { return this.parent.getLocationPermissions(); } + @android.webkit.JavascriptInterface + public boolean getP2pPermissions() { + return this.parent.getP2pPermissions(); + } + @android.webkit.JavascriptInterface public void datePicker(final String targetElement) { try { @@ -312,6 +317,127 @@ public String getDeviceInfo() { } } +//> P2P SYNC BRIDGE METHODS + private static final String P2P_NOT_INITIALIZED = "P2P not initialized"; + private org.medicmobile.webapp.mobile.p2p.P2pBridgeMethods p2pBridge; + + public void setP2pBridge(org.medicmobile.webapp.mobile.p2p.P2pBridgeMethods bridge) { + this.p2pBridge = bridge; + } + + @android.webkit.JavascriptInterface + public String p2pStartHostMode() { + if (p2pBridge == null) return jsonError(P2P_NOT_INITIALIZED); + return p2pBridge.p2pStartHostMode(); + } + + @android.webkit.JavascriptInterface + public String p2pStartClientMode(String qrPayloadJson) { + if (p2pBridge == null) return jsonError(P2P_NOT_INITIALIZED); + return p2pBridge.p2pStartClientMode(qrPayloadJson); + } + + @android.webkit.JavascriptInterface + public void p2pStop() { + if (p2pBridge != null) p2pBridge.p2pStop(); + } + + @android.webkit.JavascriptInterface + public String p2pGetStatus() { + if (p2pBridge == null) return jsonError(P2P_NOT_INITIALIZED); + return p2pBridge.p2pGetStatus(); + } + + @android.webkit.JavascriptInterface + public String p2pCheckConnection() { + if (p2pBridge == null) return "{\"connected\":false}"; + return p2pBridge.p2pCheckConnection(); + } + + @android.webkit.JavascriptInterface + public String p2pGetTransitDocIds() { + if (p2pBridge == null) return "[]"; + return p2pBridge.p2pGetTransitDocIds(); + } + + @android.webkit.JavascriptInterface + public boolean p2pIsTransitDoc(String docId) { + return p2pBridge != null && p2pBridge.p2pIsTransitDoc(docId); + } + + @android.webkit.JavascriptInterface + public String p2pPurgeTransitDocs() { + if (p2pBridge == null) return jsonError(P2P_NOT_INITIALIZED); + return p2pBridge.p2pPurgeTransitDocs(); + } + + @android.webkit.JavascriptInterface + public void p2pConfirmBatchPurged(String batchId) { + if (p2pBridge != null) p2pBridge.p2pConfirmBatchPurged(batchId); + } + + @android.webkit.JavascriptInterface + public String p2pGetSyncHistory() { + if (p2pBridge == null) return "{}"; + return p2pBridge.p2pGetSyncHistory(); + } + + @android.webkit.JavascriptInterface + public boolean p2pHasStaleTransitDocs() { + return p2pBridge != null && p2pBridge.p2pHasStaleTransitDocs(); + } + + @android.webkit.JavascriptInterface + public String p2pGetCapability() { + if (p2pBridge == null) return jsonError(P2P_NOT_INITIALIZED); + return p2pBridge.p2pGetCapability(); + } + + @android.webkit.JavascriptInterface + public String p2pGetBatteryGuidance() { + if (p2pBridge == null) return ""; + return p2pBridge.p2pGetBatteryGuidance(); + } + + @android.webkit.JavascriptInterface + public boolean p2pNeedsBatteryGuidance() { + return p2pBridge != null && p2pBridge.p2pNeedsBatteryGuidance(); + } + + @android.webkit.JavascriptInterface + public String p2pInitialize(String configJson) { + if (p2pBridge == null) return jsonError(P2P_NOT_INITIALIZED); + return p2pBridge.p2pInitialize(configJson); + } + + @android.webkit.JavascriptInterface + public void p2pScanQrCode() { + this.parent.scanP2pQrCode(); + } + + @android.webkit.JavascriptInterface + public String p2pRetrySync() { + if (p2pBridge == null) return jsonError(P2P_NOT_INITIALIZED); + return p2pBridge.p2pRetrySync(); + } + + @android.webkit.JavascriptInterface + public String p2pProceedSync() { + if (p2pBridge == null) return jsonError(P2P_NOT_INITIALIZED); + return p2pBridge.p2pProceedSync(); + } + + @android.webkit.JavascriptInterface + public String p2pIsActive() { + if (p2pBridge == null) return jsonError(P2P_NOT_INITIALIZED); + return p2pBridge.p2pIsActive(); + } + + @android.webkit.JavascriptInterface + public void p2pAsyncCallback(String callbackId, String result) { + if (p2pBridge != null) p2pBridge.p2pAsyncCallback(callbackId, result); + } + //> PRIVATE HELPER METHODS private void datePicker(String targetElement, Calendar initialDate) { // Remove single-quotes from the `targetElement` CSS selecter, as 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..bea55853 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/RequestP2pPermissionsActivity.java @@ -0,0 +1,148 @@ +package org.medicmobile.webapp.mobile; + +import static android.Manifest.permission.ACCESS_FINE_LOCATION; +import static android.Manifest.permission.CAMERA; +import static android.content.pm.PackageManager.PERMISSION_GRANTED; +import static org.medicmobile.webapp.mobile.MedicLog.trace; + +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; + +/** + * Requests runtime permissions needed for P2P WiFi Hotspot Sync: + * - CAMERA (for QR code scanning) + * - NEARBY_WIFI_DEVICES (API 33+) or ACCESS_FINE_LOCATION (API < 33) for hotspot + * + * Follows the same pattern as RequestLocationPermissionActivity. + */ +public class RequestP2pPermissionsActivity extends FragmentActivity { + + /** + * Permissions to request depend on API level: + * - API 33+: CAMERA + NEARBY_WIFI_DEVICES + * - API < 33: CAMERA + ACCESS_FINE_LOCATION (needed for LocalOnlyHotspot) + */ + private static String[] getRequiredPermissions() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return new String[]{ CAMERA, ACCESS_FINE_LOCATION, "android.permission.NEARBY_WIFI_DEVICES" }; + } + return new String[]{ CAMERA, ACCESS_FINE_LOCATION }; + } + + private final ActivityResultLauncher requestPermissionLauncher = + registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), grantedMap -> { + boolean allGranted = true; + for (Boolean granted : grantedMap.values()) { + if (!Boolean.TRUE.equals(granted)) { + allGranted = false; + break; + } + } + + if (allGranted) { + trace(this, "RequestP2pPermissionsActivity :: All P2P permissions granted."); + setResult(RESULT_OK); + finish(); + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + boolean anyNeverAskAgain = false; + for (String perm : getRequiredPermissions()) { + if (!shouldShowRequestPermissionRationale(perm) + && ContextCompat.checkSelfPermission(this, perm) != PERMISSION_GRANTED) { + anyNeverAskAgain = true; + break; + } + } + + if (anyNeverAskAgain) { + trace( + this, + "RequestP2pPermissionsActivity :: User selected \"never ask again\"." + + " Sending user to the app's settings to manually grant permissions." + ); + Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + intent.setData(Uri.fromParts("package", getPackageName(), null)); + this.appSettingsLauncher.launch(intent); + return; + } + } + + trace(this, "RequestP2pPermissionsActivity :: User rejected P2P permissions."); + setResult(RESULT_CANCELED); + finish(); + }); + + private final ActivityResultLauncher appSettingsLauncher = + registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { + boolean allGranted = true; + for (String perm : getRequiredPermissions()) { + if (ContextCompat.checkSelfPermission(this, perm) != PERMISSION_GRANTED) { + allGranted = false; + break; + } + } + + if (allGranted) { + trace(this, "RequestP2pPermissionsActivity :: User granted P2P permissions from app's settings."); + setResult(RESULT_OK); + finish(); + return; + } + + trace(this, "RequestP2pPermissionsActivity :: User didn't grant P2P permissions from app's settings."); + setResult(RESULT_CANCELED); + finish(); + }); + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // If all permissions already granted, return immediately + boolean allGranted = true; + for (String perm : getRequiredPermissions()) { + if (ContextCompat.checkSelfPermission(this, perm) != PERMISSION_GRANTED) { + allGranted = false; + break; + } + } + if (allGranted) { + trace(this, "RequestP2pPermissionsActivity :: All P2P permissions already granted."); + setResult(RESULT_OK); + finish(); + return; + } + + 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 P2P permission disclosure."); + requestPermissionLauncher.launch(getRequiredPermissions()); + } + + public void onClickNegative(View view) { + trace(this, "RequestP2pPermissionsActivity :: User declined P2P permissions."); + setResult(RESULT_CANCELED); + finish(); + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/AcceptDocsEndpoint.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/AcceptDocsEndpoint.java new file mode 100644 index 00000000..67dfdb9c --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/AcceptDocsEndpoint.java @@ -0,0 +1,603 @@ +package org.medicmobile.webapp.mobile.p2p; + +import android.util.Log; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * POST /_p2p/accept-docs — Receive docs from an authenticated CHW. + * + * Two-phase approach: + * Phase 1: Accept ALL docs from JWT-authenticated CHWs (write to PouchDB). + * Only reject: _deleted docs (additive only) and oversized docs. + * Phase 2: After write, query PouchDB's contacts_by_depth view to classify + * which docs are beyond the Supervisor's replication_depth (TRANSIT). + * Transit doc IDs are tracked in _local/p2p-transit-docs for UI filtering. + * + * This uses the same contacts_by_depth view that CHT's server uses for replication + * scope, making classification config-driven and hierarchy-agnostic. + * + * Guards: (deterministic classification), (transit hidden from UI) + */ +public final class AcceptDocsEndpoint { + + private static final String TAG = "AcceptDocsEndpoint"; + private static final int MAX_BATCH_SIZE = 500; + private static final String KEY_ID = "_id"; + private static final String KEY_PARENT = "parent"; + private static final String KEY_ERROR = "error"; + + private final PouchDbBridge bridge; + private final TransitDocCallback transitCallback; + private final ScopeManifest supervisorScope; + private final P2pConfig config; + + public AcceptDocsEndpoint(PouchDbBridge bridge, TransitDocCallback transitCallback, + ScopeManifest supervisorScope, P2pConfig config) { + this.bridge = bridge; + this.transitCallback = transitCallback; + this.supervisorScope = supervisorScope; + this.config = config; + } + + /** + * Handle POST /_p2p/accept-docs request. + * + * @param requestBody Raw JSON request body + * @param session Active P2P session (provides CHW's scope) + * @return JSON response object + */ + public JSONObject handle(String requestBody, P2pSession session) { + try { + return doHandle(requestBody, session); + } catch (JSONException e) { + Log.e(TAG, "Error processing accept-docs", e); + return errorResponse("malformed_request"); + } + } + + private JSONObject doHandle(String requestBody, P2pSession session) throws JSONException { + JSONObject validationError = validateRequest(requestBody, session); + if (validationError != null) { + return validationError; + } + + JSONObject body = new JSONObject(requestBody); + JSONArray docs = body.optJSONArray("docs"); + if (docs == null || docs.length() == 0) { + return buildResponse(0, 0, 0, new JSONArray()); + } + if (docs.length() > MAX_BATCH_SIZE) { + return errorResponse("batch_too_large: max " + MAX_BATCH_SIZE + " docs per request"); + } + + return processDocs(docs, session); + } + + private JSONObject validateRequest(String requestBody, P2pSession session) { + if (requestBody == null || requestBody.isEmpty()) { + return errorResponse("empty_request"); + } + if (session == null) { + return errorResponse("no_active_session"); + } + return null; + } + + private JSONObject processDocs(JSONArray docs, P2pSession session) throws JSONException { + JSONArray errors = new JSONArray(); + List acceptedDocs = new ArrayList<>(); + long totalBytes = validateAndAcceptDocs(docs, acceptedDocs, errors); + + long maxRelayBytes = config.getMaxRelaySizeBytes(); + if (maxRelayBytes > 0 && (session.getBytesTransferred() + totalBytes) > maxRelayBytes) { + return errorResponse("relay_size_exceeded: max " + (maxRelayBytes / (1024 * 1024)) + " MB"); + } + + if (!acceptedDocs.isEmpty()) { + hydrateParentLineage(acceptedDocs); + writeDocsToPouchDb(acceptedDocs); + trackAcceptedDocs(acceptedDocs, session); + } + + int transitCount = acceptedDocs.isEmpty() ? 0 : classifyTransitDocs(acceptedDocs); + int inScopeCount = acceptedDocs.size() - transitCount; + + updateSessionCounters(session, inScopeCount, transitCount, errors.length(), totalBytes); + + Log.i(TAG, "accept-docs: in_scope=" + inScopeCount + + " transit=" + transitCount + + " rejected=" + errors.length()); + + return buildResponse(inScopeCount, transitCount, errors.length(), errors); + } + + private long validateAndAcceptDocs(JSONArray docs, List acceptedDocs, + JSONArray errors) throws JSONException { + long totalBytes = 0; + for (int i = 0; i < docs.length(); i++) { + JSONObject doc = docs.getJSONObject(i); + totalBytes += validateSingleDoc(doc, acceptedDocs, errors); + } + return totalBytes; + } + + private long validateSingleDoc(JSONObject doc, List acceptedDocs, + JSONArray errors) throws JSONException { + String docId = doc.optString(KEY_ID, ""); + + if (doc.optBoolean("_deleted", false)) { + addError(errors, docId, "deleted_not_allowed"); + return 0; + } + + int docSize = doc.toString().length(); + if (docSize > config.getMaxDocSizeBytes()) { + addError(errors, docId, "doc_too_large: " + docSize + " bytes"); + return 0; + } + + acceptedDocs.add(doc); + return docSize; + } + + private void addError(JSONArray errors, String docId, String reason) throws JSONException { + JSONObject err = new JSONObject(); + err.put("id", docId); + err.put("reason", reason); + errors.put(err); + } + + private void writeDocsToPouchDb(List acceptedDocs) { + JSONArray docsToWrite = new JSONArray(); + for (JSONObject doc : acceptedDocs) { + docsToWrite.put(doc); + } + String writeResult = bridge.writeDocs(docsToWrite.toString()); + Log.i(TAG, "writeDocs: " + acceptedDocs.size() + " docs, result=" + + (writeResult != null ? writeResult.substring(0, Math.min(200, writeResult.length())) : "null")); + } + + private void trackAcceptedDocs(List acceptedDocs, P2pSession session) { + if (transitCallback == null) { + return; + } + List allAcceptedIds = new ArrayList<>(); + for (JSONObject doc : acceptedDocs) { + String docId = doc.optString(KEY_ID, ""); + if (!docId.isEmpty()) { + allAcceptedIds.add(docId); + } + } + if (!allAcceptedIds.isEmpty()) { + transitCallback.trackTransitDocs( + allAcceptedIds, + session.getPeerDeviceId(), + session.getPeerUserId() + ); + Log.i(TAG, "Tracked " + allAcceptedIds.size() + " accepted doc IDs for purge"); + } + } + + @SuppressWarnings("java:S107") // Updates multiple session counters atomically + private void updateSessionCounters(P2pSession session, int inScopeCount, int transitCount, + int rejectedCount, long totalBytes) { + session.incrementDocsPulled(inScopeCount); + session.incrementTransitDocs(transitCount); + session.incrementDocsRejected(rejectedCount); + session.addBytesTransferred(totalBytes); + session.updateLastActivity(); + } + + /** + * Hydrate truncated parent lineages in incoming docs. + * + * CHW-created docs store a truncated parent chain that stops at the CHW's facility. + * When the Supervisor replicates to the server, server-side auth walks parent.parent... + * to verify the doc belongs to the Supervisor's subtree. A truncated chain causes rejection. + * + * This method fetches the missing parent docs from PouchDB and extends each doc's + * parent chain to include the full hierarchy up to the top. + */ + private void hydrateParentLineage(List docs) { + try { + Map batchMap = buildBatchMap(docs); + Set terminalParentIds = collectTerminalParentIds(docs, batchMap); + + if (terminalParentIds.isEmpty()) { + return; + } + + Map parentChainCache = new HashMap<>(); + fetchAndCacheParentChains(terminalParentIds, batchMap, parentChainCache); + + int hydrated = extendDocParentChains(docs, parentChainCache); + if (hydrated > 0) { + Log.i(TAG, "Hydrated parent lineage for " + hydrated + " docs"); + } + } catch (JSONException e) { + Log.e(TAG, "Error hydrating parent lineage, docs will be written as-is", e); + } + } + + private Map buildBatchMap(List docs) { + Map batchMap = new HashMap<>(); + for (JSONObject doc : docs) { + String id = doc.optString(KEY_ID, ""); + if (!id.isEmpty()) { + batchMap.put(id, doc); + } + } + return batchMap; + } + + private Set collectTerminalParentIds(List docs, + Map batchMap) throws JSONException { + Set terminalParentIds = new HashSet<>(); + for (JSONObject doc : docs) { + String terminalId = getTerminalParentId(doc); + if (terminalId != null && !batchMap.containsKey(terminalId)) { + terminalParentIds.add(terminalId); + } + } + return terminalParentIds; + } + + private String getTerminalParentId(JSONObject doc) throws JSONException { + JSONObject deepest = getDeepestParent(doc); + if (deepest != null && deepest.has(KEY_ID) && !deepest.has(KEY_PARENT)) { + return deepest.getString(KEY_ID); + } + return null; + } + + private int extendDocParentChains(List docs, + Map parentChainCache) throws JSONException { + int hydrated = 0; + for (JSONObject doc : docs) { + if (tryExtendChain(doc, parentChainCache)) { + hydrated++; + } + } + return hydrated; + } + + private boolean tryExtendChain(JSONObject doc, + Map parentChainCache) throws JSONException { + JSONObject deepest = getDeepestParent(doc); + if (deepest == null || !deepest.has(KEY_ID) || deepest.has(KEY_PARENT)) { + return false; + } + String parentId = deepest.getString(KEY_ID); + JSONObject cachedParent = parentChainCache.get(parentId); + if (cachedParent != null && cachedParent.has(KEY_PARENT)) { + deepest.put(KEY_PARENT, cachedParent.getJSONObject(KEY_PARENT)); + return true; + } + return false; + } + + /** + * Walk to the deepest parent in a doc's parent chain. + * Returns the JSONObject of the last parent that has an _id but no further parent. + */ + private JSONObject getDeepestParent(JSONObject doc) { + JSONObject current = doc.optJSONObject(KEY_PARENT); + if (current == null) { + return null; + } + while (current.has(KEY_PARENT)) { + JSONObject next = current.optJSONObject(KEY_PARENT); + if (next == null) { + return current; + } + current = next; + } + return current; + } + + /** + * Fetch parent docs from PouchDB and build their lineage maps. + * Recursively fetches parents until chains are complete (no more parents to fetch). + */ + private static final int MAX_CHAIN_FETCH_ITERATIONS = 10; + + private void fetchAndCacheParentChains(Set idsToFetch, + Map batchMap, + Map cache) throws JSONException { + Set currentIds = new HashSet<>(idsToFetch); + + for (int i = 0; i < MAX_CHAIN_FETCH_ITERATIONS && !currentIds.isEmpty(); i++) { + currentIds.removeAll(cache.keySet()); + if (currentIds.isEmpty()) { + return; + } + currentIds = fetchAndCacheBatch(currentIds, batchMap, cache); + } + + stitchCachedChains(cache); + } + + private Set fetchAndCacheBatch(Set idsToFetch, + Map batchMap, + Map cache) throws JSONException { + JSONArray idsArray = new JSONArray(); + for (String id : idsToFetch) { + idsArray.put(id); + } + + String resultJson = bridge.getDocsByIds(idsArray.toString()); + if (resultJson == null || resultJson.isEmpty() || "[]".equals(resultJson)) { + return Collections.emptySet(); + } + + return parseFetchedDocs(resultJson, batchMap, cache); + } + + private Set parseFetchedDocs(String resultJson, + Map batchMap, + Map cache) throws JSONException { + JSONArray fetchedDocs = new JSONArray(resultJson); + Set nextIds = new HashSet<>(); + + for (int i = 0; i < fetchedDocs.length(); i++) { + JSONObject fetched = fetchedDocs.optJSONObject(i); + if (fetched != null) { + collectNextIds(fetched, batchMap, cache, nextIds); + } + } + return nextIds; + } + + private void collectNextIds(JSONObject fetched, Map batchMap, + Map cache, + Set nextIds) throws JSONException { + String id = fetched.optString(KEY_ID, ""); + if (id.isEmpty()) { + return; + } + + JSONObject parentRef = new JSONObject(); + parentRef.put(KEY_ID, id); + + JSONObject fetchedParent = fetched.optJSONObject(KEY_PARENT); + if (fetchedParent != null) { + parentRef.put(KEY_PARENT, cloneParentChain(fetchedParent)); + addTruncatedParentId(fetched, batchMap, cache, nextIds); + } + + cache.put(id, parentRef); + } + + private void addTruncatedParentId(JSONObject fetched, Map batchMap, + Map cache, + Set nextIds) throws JSONException { + JSONObject deepest = getDeepestParent(fetched); + if (deepest != null && deepest.has(KEY_ID) && !deepest.has(KEY_PARENT)) { + String nextId = deepest.getString(KEY_ID); + if (!cache.containsKey(nextId) && !batchMap.containsKey(nextId)) { + nextIds.add(nextId); + } + } + } + + /** + * Stitch together multi-level fetched chains. + * E.g., if we fetched clinic-1a (parent: {_id: hc-1}) and hc-1 (parent: {_id: district-1}), + * we need clinic-1a's chain to include hc-1's parent too. + */ + private void stitchCachedChains(Map cache) throws JSONException { + for (int pass = 0; pass < MAX_CHAIN_FETCH_ITERATIONS; pass++) { + boolean changed = stitchOnePass(cache); + if (!changed) { + return; + } + } + } + + private boolean stitchOnePass(Map cache) throws JSONException { + boolean changed = false; + for (Map.Entry entry : cache.entrySet()) { + if (tryStitchEntry(entry.getValue(), cache)) { + changed = true; + } + } + return changed; + } + + private boolean tryStitchEntry(JSONObject cached, + Map cache) throws JSONException { + JSONObject deepest = getDeepestParent(cached); + if (deepest == null || !deepest.has(KEY_ID) || deepest.has(KEY_PARENT)) { + return false; + } + String parentId = deepest.getString(KEY_ID); + JSONObject parentCached = cache.get(parentId); + if (parentCached != null && parentCached.has(KEY_PARENT)) { + deepest.put(KEY_PARENT, cloneParentChain(parentCached.getJSONObject(KEY_PARENT))); + return true; + } + return false; + } + + /** + * Deep-clone a parent chain, keeping only _id and parent fields. + */ + private JSONObject cloneParentChain(JSONObject parent) throws JSONException { + JSONObject clone = new JSONObject(); + clone.put(KEY_ID, parent.getString(KEY_ID)); + JSONObject next = parent.optJSONObject(KEY_PARENT); + if (next != null) { + clone.put(KEY_PARENT, cloneParentChain(next)); + } + return clone; + } + + /** + * Classify which accepted docs are TRANSIT (beyond Supervisor's replication_depth). + * + * Queries PouchDB's contacts_by_depth view with the Supervisor's facility_id + * and replication_depth. Any contact not in the result set, or any data_record + * whose subject is not in the set, is classified as TRANSIT. + * + * @return number of transit docs found + */ + private int classifyTransitDocs(List acceptedDocs) { + try { + Set inScopeIds = queryInScopeContactIds(); + if (inScopeIds.isEmpty()) { + return 0; + } + + List transitDocIds = identifyTransitDocs(acceptedDocs, inScopeIds); + + if (!transitDocIds.isEmpty()) { + Log.i(TAG, "Classified " + transitDocIds.size() + " transit docs (tracking already done in doHandle)"); + } + return transitDocIds.size(); + + } catch (JSONException e) { + Log.e(TAG, "Error classifying transit docs, treating all as in-scope", e); + return 0; + } + } + + /** + * Query PouchDB for in-scope contact IDs. Returns empty set if scope params are + * invalid or if contacts_by_depth returns empty. + */ + private Set queryInScopeContactIds() throws JSONException { + String facilityId = supervisorScope.getFacilitySubtreeRoot(); + int replicationDepth = supervisorScope.getReplicationDepth(); + + if (facilityId == null || facilityId.isEmpty() || replicationDepth < 0) { + Log.w(TAG, "Invalid scope params (facility=" + facilityId + + ", depth=" + replicationDepth + "), skipping transit classification"); + return Collections.emptySet(); + } + + String inScopeJson = bridge.queryContactsByDepth(facilityId, replicationDepth); + if (inScopeJson == null || inScopeJson.isEmpty() || "[]".equals(inScopeJson)) { + Log.w(TAG, "contacts_by_depth returned empty, treating all as in-scope"); + return Collections.emptySet(); + } + + Set inScopeIds = new HashSet<>(); + JSONArray inScopeArray = new JSONArray(inScopeJson); + for (int i = 0; i < inScopeArray.length(); i++) { + inScopeIds.add(inScopeArray.getString(i)); + } + Log.d(TAG, "contacts_by_depth: " + inScopeIds.size() + " in-scope contacts"); + return inScopeIds; + } + + private List identifyTransitDocs(List acceptedDocs, + Set inScopeIds) { + List transitDocIds = new ArrayList<>(); + for (JSONObject doc : acceptedDocs) { + if (isTransitDoc(doc, inScopeIds)) { + transitDocIds.add(doc.optString(KEY_ID, "")); + } + } + return transitDocIds; + } + + private boolean isTransitDoc(JSONObject doc, Set inScopeIds) { + String docId = doc.optString(KEY_ID, ""); + String type = doc.optString("type", ""); + + if (isContactType(type)) { + return !inScopeIds.contains(docId); + } + if ("data_record".equals(type)) { + String subjectId = getDataRecordSubject(doc); + return subjectId != null && !inScopeIds.contains(subjectId); + } + return false; + } + + /** + * Check if a doc type is a contact type in CHT. + */ + private boolean isContactType(String type) { + return "person".equals(type) + || "clinic".equals(type) + || "health_center".equals(type) + || "district_hospital".equals(type) + || "contact".equals(type); + } + + /** + * Extract the subject contact ID from a data_record. + * CHT data_records reference their subject via: + * - contact._id (the submitter) + * - fields.patient_id or patient_id (the patient) + * - parent._id (the facility) + */ + private String getDataRecordSubject(JSONObject doc) { + String contactId = getContactId(doc); + if (contactId != null) { + return contactId; + } + + String patientId = getPatientIdFromFields(doc); + if (patientId != null) { + return patientId; + } + + return getNonEmptyString(doc, "patient_id"); + } + + private String getContactId(JSONObject doc) { + JSONObject contact = doc.optJSONObject("contact"); + return contact != null ? contact.optString(KEY_ID, null) : null; + } + + private String getPatientIdFromFields(JSONObject doc) { + JSONObject fields = doc.optJSONObject("fields"); + if (fields == null) { + return null; + } + String patientId = getNonEmptyString(fields, "patient_id"); + return patientId != null ? patientId : getNonEmptyString(fields, "patient_uuid"); + } + + private static String getNonEmptyString(JSONObject obj, String key) { + String value = obj.optString(key, null); + return (value != null && !value.isEmpty()) ? value : null; + } + + private JSONObject buildResponse(int accepted, int transit, int rejected, + JSONArray errors) throws JSONException { + JSONObject response = new JSONObject(); + response.put("ok", true); + response.put("accepted", accepted); + response.put("transit", transit); + response.put("rejected", rejected); + if (errors != null && errors.length() > 0) { + response.put("errors", errors); + } + return response; + } + + private JSONObject errorResponse(String error) { + try { + JSONObject response = new JSONObject(); + response.put("ok", false); + response.put(KEY_ERROR, error); + return response; + } catch (JSONException e) { + throw new IllegalStateException("Failed to build error response", e); + } + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/AuthEndpoint.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/AuthEndpoint.java new file mode 100644 index 00000000..4e39092a --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/AuthEndpoint.java @@ -0,0 +1,205 @@ +package org.medicmobile.webapp.mobile.p2p; + +import android.util.Log; + +import org.json.JSONException; +import org.json.JSONObject; + +/** + * POST /_p2p/auth — Verify CHW's JWT and establish a P2P session. + * + * Request body: + * { "p2p_token": "jwt...", "device_id": "uuid" } + * + * Success response (200): + * { "ok": true, "session_id": "uuid", "user_id": "chw_alice", "scope": {...} } + * + * Failure response (401): + * { "ok": false, "error": "token_expired | peer_not_allowed | token_invalid | device_revoked" } + * + * Validates JWT signature, expiry, revocation, and peer authorization + */ +public final class AuthEndpoint { + + private static final String TAG = "AuthEndpoint"; + + private final P2pAuthenticator authenticator; + private final P2pConfig config; + private final ScopeManifest supervisorScope; + + public AuthEndpoint(P2pAuthenticator authenticator, P2pConfig config, + ScopeManifest supervisorScope) { + this.authenticator = authenticator; + this.config = config; + this.supervisorScope = supervisorScope; + } + + /** + * Handle POST /_p2p/auth request. + * + * @param requestBody The raw JSON request body string + * @return AuthResponse containing the HTTP status code and JSON body + */ + public AuthResponse handle(String requestBody) { + try { + return doHandle(requestBody); + } catch (JSONException e) { + Log.e(TAG, "Malformed auth request", e); + return AuthResponse.error(400, "malformed_request"); + } + } + + private AuthResponse doHandle(String requestBody) throws JSONException { + AuthResponse validationError = validateAuthRequest(requestBody); + if (validationError != null) { + return validationError; + } + + JSONObject body = new JSONObject(requestBody); + String p2pToken = body.optString("p2p_token", null); + String deviceId = body.optString("device_id", null); + + AuthResponse authCheckResult = verifyAuthAndPermissions(p2pToken, deviceId); + if (authCheckResult != null) { + return authCheckResult; + } + + P2pAuthenticator.AuthResult authResult = authenticator.authenticate(p2pToken, deviceId); + return buildSuccessResponse(authResult, deviceId); + } + + private AuthResponse validateAuthRequest(String requestBody) throws JSONException { + if (requestBody == null || requestBody.isEmpty()) { + return AuthResponse.error(400, "empty_request"); + } + JSONObject body = new JSONObject(requestBody); + + AuthResponse tokenCheck = requireField(body, "p2p_token", "missing_token"); + if (tokenCheck != null) { + return tokenCheck; + } + + return requireField(body, "device_id", "missing_device_id"); + } + + private AuthResponse requireField(JSONObject body, String fieldName, String errorCode) { + String value = body.optString(fieldName, null); + if (value == null || value.isEmpty()) { + return AuthResponse.error(400, errorCode); + } + return null; + } + + private AuthResponse verifyAuthAndPermissions(String p2pToken, String deviceId) { + P2pAuthenticator.AuthResult authResult = authenticator.authenticate(p2pToken, deviceId); + + if (!authResult.isAuthenticated()) { + Log.w(TAG, "Auth failed for device " + deviceId + ": " + authResult.getError()); + return AuthResponse.error(401, authResult.getError()); + } + + String userId = authResult.getUserId(); + if (!authenticator.isPeerAllowed(authResult.getTokenPayload(), deviceId)) { + Log.w(TAG, "Peer not allowed: " + userId + " (device: " + deviceId + ")"); + return AuthResponse.error(401, "peer_not_allowed"); + } + + String role = authResult.getRole(); + if (!config.isRoleAllowed(role)) { + Log.w(TAG, "Role not allowed for P2P: " + role); + return AuthResponse.error(401, "role_not_allowed"); + } + + return null; + } + + private AuthResponse buildSuccessResponse(P2pAuthenticator.AuthResult authResult, + String deviceId) throws JSONException { + String userId = authResult.getUserId(); + String role = authResult.getRole(); + ScopeManifest peerScope = buildPeerScope(authResult); + + P2pSession session = new P2pSession(deviceId, userId, role, peerScope); + session.setState(P2pSession.State.ACTIVE); + + Log.i(TAG, "Auth successful: user=" + userId + " session=" + session.getSessionId()); + + JSONObject responseBody = new JSONObject(); + responseBody.put("ok", true); + responseBody.put("session_id", session.getSessionId()); + responseBody.put("user_id", userId); + responseBody.put("facility_id", authResult.getFacilityId()); + + JSONObject scopeJson = new JSONObject(); + scopeJson.put("facility_subtree_root", supervisorScope.getFacilitySubtreeRoot()); + scopeJson.put("replication_depth", supervisorScope.getReplicationDepth()); + responseBody.put("scope", scopeJson); + + return AuthResponse.success(responseBody, session); + } + + /** + * Build the CHW peer's scope manifest from their JWT payload. + */ + private ScopeManifest buildPeerScope(P2pAuthenticator.AuthResult authResult) { + JSONObject payload = authResult.getTokenPayload(); + + String facilityId = authResult.getFacilityId(); + int replicationDepth = payload.optInt("replication_depth", 2); + + // Use supervisor's shared doc types as baseline + return new ScopeManifest( + facilityId, + replicationDepth, + supervisorScope.getSharedDocTypes(), + supervisorScope.getScopeVersion() + ); + } + + /** + * Response from the auth endpoint, carrying HTTP status + JSON body + optional session. + */ + public static final class AuthResponse { + private final int statusCode; + private final JSONObject body; + private final P2pSession session; // non-null only on success + + private AuthResponse(int statusCode, JSONObject body, P2pSession session) { + this.statusCode = statusCode; + this.body = body; + this.session = session; + } + + static AuthResponse success(JSONObject body, P2pSession session) { + return new AuthResponse(200, body, session); + } + + static AuthResponse error(int statusCode, String errorCode) { + try { + JSONObject body = new JSONObject(); + body.put("ok", false); + body.put("error", errorCode); + return new AuthResponse(statusCode, body, null); + } catch (JSONException e) { + // Should never happen with simple string puts + throw new IllegalStateException("Failed to build error response", e); + } + } + + public int getStatusCode() { + return statusCode; + } + + public JSONObject getBody() { + return body; + } + + public P2pSession getSession() { + return session; + } + + public boolean isSuccess() { + return session != null; + } + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/BulkGetEndpoint.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/BulkGetEndpoint.java new file mode 100644 index 00000000..fe439bbb --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/BulkGetEndpoint.java @@ -0,0 +1,171 @@ +package org.medicmobile.webapp.mobile.p2p; + +import android.util.Log; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * POST /_p2p/bulk-get — Return doc bodies by ID from Supervisor's PouchDB. + * + * Request: + * { "docs": [{"id": "doc1"}, {"id": "doc2"}] } + * + * Response (CouchDB _bulk_get format): + * { + * "results": [ + * { "id": "doc1", "docs": [{"ok": {...doc body...}}] }, + * { "id": "doc2", "docs": [{"ok": {...doc body...}}] } + * ] + * } + * + * Docs not found return: + * { "id": "missing1", "docs": [{"error": {"id": "missing1", "error": "not_found", "reason": "missing"}}] } + */ +public final class BulkGetEndpoint { + + private static final String TAG = "BulkGetEndpoint"; + private static final int MAX_BATCH_SIZE = 500; + private static final String KEY_ERROR = "error"; + private static final String KEY_ID = "id"; + private static final String KEY_DOCS = "docs"; + private static final String KEY_OK = "ok"; + private static final String KEY_RESULTS = "results"; + + private final PouchDbBridge bridge; + + public BulkGetEndpoint(PouchDbBridge bridge) { + this.bridge = bridge; + } + + /** + * Handle POST /_p2p/bulk-get request. + * + * @param requestBody Raw JSON request body + * @param session Active P2P session + * @return JSON response object + */ + public JSONObject handle(String requestBody, P2pSession session) { + try { + return doHandle(requestBody, session); + } catch (JSONException e) { + Log.e(TAG, "Error processing bulk-get", e); + return errorResponse("malformed_request"); + } + } + + private JSONObject doHandle(String requestBody, P2pSession session) throws JSONException { + if (requestBody == null || requestBody.isEmpty()) { + return errorResponse("empty_request"); + } + + JSONObject body = new JSONObject(requestBody); + JSONArray requestedDocs = body.optJSONArray(KEY_DOCS); + if (requestedDocs == null || requestedDocs.length() == 0) { + JSONObject response = new JSONObject(); + response.put(KEY_RESULTS, new JSONArray()); + return response; + } + + if (requestedDocs.length() > MAX_BATCH_SIZE) { + return errorResponse("batch_too_large: max " + MAX_BATCH_SIZE + " docs per request"); + } + + JSONArray idsArray = extractDocIds(requestedDocs); + JSONObject docMap = fetchAndIndexDocs(idsArray); + long[] totalBytes = { 0 }; + JSONArray results = buildBulkGetResults(idsArray, docMap, totalBytes); + + updateSessionCounters(session, docMap.length(), totalBytes[0]); + + Log.d(TAG, "bulk-get returning " + docMap.length() + " docs" + + " (" + (idsArray.length() - docMap.length()) + " not found)"); + + JSONObject response = new JSONObject(); + response.put(KEY_RESULTS, results); + return response; + } + + private JSONArray extractDocIds(JSONArray requestedDocs) throws JSONException { + JSONArray idsArray = new JSONArray(); + for (int i = 0; i < requestedDocs.length(); i++) { + JSONObject docReq = requestedDocs.getJSONObject(i); + String id = docReq.optString(KEY_ID, null); + if (id != null) { + idsArray.put(id); + } + } + return idsArray; + } + + private JSONObject fetchAndIndexDocs(JSONArray idsArray) throws JSONException { + String docsJson = bridge.getDocsByIds(idsArray.toString()); + JSONArray fetchedDocs = (docsJson != null && !docsJson.isEmpty()) + ? new JSONArray(docsJson) : new JSONArray(); + + JSONObject docMap = new JSONObject(); + for (int i = 0; i < fetchedDocs.length(); i++) { + JSONObject doc = fetchedDocs.getJSONObject(i); + String docId = doc.optString("_id", null); + if (docId != null) { + docMap.put(docId, doc); + } + } + return docMap; + } + + private JSONArray buildBulkGetResults(JSONArray idsArray, JSONObject docMap, + long[] totalBytes) throws JSONException { + JSONArray results = new JSONArray(); + for (int i = 0; i < idsArray.length(); i++) { + String requestedId = idsArray.getString(i); + JSONObject result = new JSONObject(); + result.put(KEY_ID, requestedId); + + JSONArray docsArray = new JSONArray(); + if (docMap.has(requestedId)) { + JSONObject doc = docMap.getJSONObject(requestedId); + JSONObject okWrapper = new JSONObject(); + okWrapper.put(KEY_OK, doc); + docsArray.put(okWrapper); + totalBytes[0] += doc.toString().length(); + } else { + docsArray.put(buildNotFoundError(requestedId)); + } + + result.put(KEY_DOCS, docsArray); + results.put(result); + } + return results; + } + + private JSONObject buildNotFoundError(String docId) throws JSONException { + JSONObject errorWrapper = new JSONObject(); + JSONObject errorDetail = new JSONObject(); + errorDetail.put(KEY_ID, docId); + errorDetail.put(KEY_ERROR, "not_found"); + errorDetail.put("reason", "missing"); + errorWrapper.put(KEY_ERROR, errorDetail); + return errorWrapper; + } + + private void updateSessionCounters(P2pSession session, int docCount, long totalBytes) { + if (session != null) { + session.incrementDocsPushed(docCount); + session.addBytesTransferred(totalBytes); + session.updateLastActivity(); + } + } + + private JSONObject errorResponse(String error) { + try { + JSONObject response = new JSONObject(); + response.put(KEY_OK, false); + response.put(KEY_ERROR, error); + return response; + } catch (JSONException e) { + throw new IllegalStateException("Failed to build error response", e); + } + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/DocScope.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/DocScope.java new file mode 100644 index 00000000..d7bc8894 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/DocScope.java @@ -0,0 +1,20 @@ +package org.medicmobile.webapp.mobile.p2p; + +/** + * Classification of a document during P2P sync scope evaluation. + * + * IN_SCOPE: depth from supervisor's facility <= replication_depth. + * Written to PouchDB normally, visible in UI. + * + * TRANSIT: depth from supervisor's facility > replication_depth. + * Written to PouchDB for server sync + tracked in _local/p2p-transit-docs. + * Hidden from UI (contacts, search, tasks, reports, targets). + * + * REJECTED: Doc's facility branch != supervisor's facility branch, or doc is invalid. + * Discarded with logged rejection reason. + */ +public enum DocScope { + IN_SCOPE, + TRANSIT, + REJECTED +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/GetDeletesEndpoint.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/GetDeletesEndpoint.java new file mode 100644 index 00000000..a08eea05 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/GetDeletesEndpoint.java @@ -0,0 +1,33 @@ +package org.medicmobile.webapp.mobile.p2p; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * GET /_p2p/get-deletes — Always returns an empty list. + * + * Per RFC: No purging during P2P sync. Deletions are not propagated + * via P2P to avoid data loss. Rule 5: P2P is ADDITIVE ONLY. + * + * Response (200): + * { "doc_ids": [] } + */ +public final class GetDeletesEndpoint { + + /** + * Handle GET /_p2p/get-deletes request. + * Always returns empty — no deletions via P2P. + * + * @return JSON response object + */ + public JSONObject handle() { + try { + JSONObject response = new JSONObject(); + response.put("doc_ids", new JSONArray()); + return response; + } catch (JSONException e) { + throw new IllegalStateException("Failed to build get-deletes response", e); + } + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/GetIdsEndpoint.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/GetIdsEndpoint.java new file mode 100644 index 00000000..277cef28 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/GetIdsEndpoint.java @@ -0,0 +1,98 @@ +package org.medicmobile.webapp.mobile.p2p; + +import android.util.Log; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * GET /_p2p/get-ids — Return doc IDs from Supervisor's PouchDB, + * filtered by the CHW's scope. + * + * Response (200): + * { "doc_ids": [{"_id": "...", "_rev": "..."}, ...], "total": number } + * + * The endpoint fetches all doc IDs from PouchDB via the bridge, then + * filters them to only include docs relevant to the CHW's scope. + * In practice, the Supervisor offers IDs that the CHW might want to pull. + */ +public final class GetIdsEndpoint { + + private static final String TAG = "GetIdsEndpoint"; + + private final PouchDbBridge bridge; + + public GetIdsEndpoint(PouchDbBridge bridge) { + this.bridge = bridge; + } + + /** + * Handle GET /_p2p/get-ids request. + * + * @param session The active P2P session (provides CHW's scope) + * @return JSON response string + */ + public JSONObject handle(P2pSession session) { + try { + return doHandle(session); + } catch (JSONException e) { + Log.e(TAG, "Error building get-ids response", e); + return errorResponse("internal_error"); + } + } + + private JSONObject doHandle(P2pSession session) throws JSONException { + if (session == null) { + return errorResponse("no_active_session"); + } + + String allIdsJson = bridge.getAllDocIds(); + if (allIdsJson == null || allIdsJson.isEmpty()) { + return buildEmptyResponse(); + } + + JSONArray allIds = new JSONArray(allIdsJson); + JSONArray filteredIds = filterSystemDocs(allIds); + + JSONObject response = new JSONObject(); + response.put("doc_ids", filteredIds); + response.put("total", filteredIds.length()); + + Log.d(TAG, "get-ids returning " + filteredIds.length() + " doc IDs" + + " (filtered from " + allIds.length() + " total)"); + + session.updateLastActivity(); + return response; + } + + private JSONObject buildEmptyResponse() throws JSONException { + JSONObject response = new JSONObject(); + response.put("doc_ids", new JSONArray()); + response.put("total", 0); + return response; + } + + private JSONArray filterSystemDocs(JSONArray allIds) throws JSONException { + JSONArray filteredIds = new JSONArray(); + for (int i = 0; i < allIds.length(); i++) { + JSONObject idEntry = allIds.getJSONObject(i); + String docId = idEntry.optString("_id", ""); + if (!docId.startsWith("_design/") && !docId.startsWith("_local/")) { + filteredIds.put(idEntry); + } + } + return filteredIds; + } + + private JSONObject errorResponse(String error) { + try { + JSONObject response = new JSONObject(); + response.put("ok", false); + response.put("error", error); + return response; + } catch (JSONException e) { + throw new IllegalStateException("Failed to build error response", e); + } + } +} 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..fdbf71d2 --- /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. + * + * Enables swapping between real WiFi (production) and localhost (dev/emulator). + * Production: {@link WifiHotspotProvider} — real LocalOnlyHotspot (API 26+) + * Development: {@link LoopbackHotspotProvider} — localhost HTTP server + */ +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/JwtVerifier.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/JwtVerifier.java new file mode 100644 index 00000000..b9d5ec7a --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/JwtVerifier.java @@ -0,0 +1,204 @@ +package org.medicmobile.webapp.mobile.p2p; + +import org.json.JSONObject; + +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.X509EncodedKeySpec; +import java.util.Arrays; + +/** + * Verifies P2P JWT tokens using ECDSA P-256 (ES256). + * Uses only standard Java crypto — no external dependencies. + * + * Guards: G6 (signature valid), G7 (not expired) + */ +public final class JwtVerifier { + + private final PublicKey serverPublicKey; + + /** + * @param pemPublicKey PEM-encoded ECDSA P-256 public key (with or without headers) + */ + public JwtVerifier(String pemPublicKey) throws JwtVerificationException { + try { + this.serverPublicKey = parsePemPublicKey(pemPublicKey); + } catch (Exception e) { + throw new JwtVerificationException("Failed to parse public key: " + e.getMessage()); + } + } + + /** + * Verify a JWT token and return the decoded payload. + * + * @param jwt The complete JWT string (header.payload.signature) + * @return Decoded payload as JSONObject + * @throws JwtVerificationException if verification fails for any reason + */ + public JSONObject verify(String jwt) throws JwtVerificationException { + if (jwt == null || jwt.isEmpty()) { + throw new JwtVerificationException("token_empty"); + } + + String[] parts = jwt.split("\\."); + if (parts.length != 3) { + throw new JwtVerificationException("token_malformed: expected 3 parts, got " + parts.length); + } + + verifyHeader(parts[0]); + verifySignature(parts[0], parts[1], parts[2]); + JSONObject payload = decodePayload(parts[1]); + verifyExpiry(payload); + + return payload; + } + + private void verifyHeader(String headerPart) throws JwtVerificationException { + JSONObject header; + try { + header = new JSONObject(new String(base64UrlDecode(headerPart))); + } catch (Exception e) { + throw new JwtVerificationException("token_malformed: invalid header"); + } + + String alg = header.optString("alg", ""); + if (!"ES256".equals(alg)) { + throw new JwtVerificationException("token_invalid: unsupported algorithm " + alg); + } + } + + private void verifySignature(String headerPart, String payloadPart, String signaturePart) + throws JwtVerificationException { + try { + byte[] signatureInput = (headerPart + "." + payloadPart).getBytes("UTF-8"); + byte[] signatureBytes = base64UrlDecode(signaturePart); + byte[] derSignature = rawToDer(signatureBytes); + + Signature sig = Signature.getInstance("SHA256withECDSA"); + sig.initVerify(serverPublicKey); + sig.update(signatureInput); + + if (!sig.verify(derSignature)) { + throw new JwtVerificationException("token_invalid: signature verification failed"); + } + } catch (JwtVerificationException e) { + throw e; + } catch (Exception e) { + throw new JwtVerificationException("token_invalid: " + e.getMessage()); + } + } + + private JSONObject decodePayload(String payloadPart) throws JwtVerificationException { + try { + return new JSONObject(new String(base64UrlDecode(payloadPart))); + } catch (Exception e) { + throw new JwtVerificationException("token_malformed: invalid payload"); + } + } + + private static void verifyExpiry(JSONObject payload) throws JwtVerificationException { + long exp = payload.optLong("exp", 0); + long now = System.currentTimeMillis() / 1000; + if (exp > 0 && exp < now) { + throw new JwtVerificationException("token_expired"); + } + } + + /** + * Parse a PEM-encoded public key to a Java PublicKey object. + */ + private static PublicKey parsePemPublicKey(String pem) + throws java.security.NoSuchAlgorithmException, + java.security.spec.InvalidKeySpecException { + String cleaned = pem + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll("\\s+", ""); + + byte[] keyBytes = android.util.Base64.decode(cleaned, android.util.Base64.DEFAULT); + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("EC"); + return keyFactory.generatePublic(keySpec); + } + + /** + * Base64URL decode (RFC 7515). + */ + private static byte[] base64UrlDecode(String input) { + String base64 = input + .replace('-', '+') + .replace('_', '/'); + // Add padding if needed + int remainder = base64.length() % 4; + if (remainder == 2) { + base64 += "=="; + } else if (remainder == 3) { + base64 += "="; + } + return android.util.Base64.decode(base64, android.util.Base64.DEFAULT); + } + + /** + * Convert raw R||S signature (64 bytes for P-256) to DER format + * that Java's Signature class expects. + */ + private static byte[] rawToDer(byte[] raw) throws JwtVerificationException { + if (raw.length != 64) { + throw new JwtVerificationException("token_invalid: unexpected signature length " + raw.length + + ", ES256 requires 64 bytes (R||S)"); + } + + byte[] r = Arrays.copyOfRange(raw, 0, 32); + byte[] s = Arrays.copyOfRange(raw, 32, 64); + + r = trimLeadingZeros(r); + s = trimLeadingZeros(s); + + // Add leading zero if high bit set (positive integer in DER) + if ((r[0] & 0x80) != 0) { + byte[] padded = new byte[r.length + 1]; + System.arraycopy(r, 0, padded, 1, r.length); + r = padded; + } + if ((s[0] & 0x80) != 0) { + byte[] padded = new byte[s.length + 1]; + System.arraycopy(s, 0, padded, 1, s.length); + s = padded; + } + + int seqLen = 2 + r.length + 2 + s.length; + byte[] der = new byte[2 + seqLen]; + int offset = 0; + der[offset++] = 0x30; // SEQUENCE + der[offset++] = (byte) seqLen; + der[offset++] = 0x02; // INTEGER + der[offset++] = (byte) r.length; + System.arraycopy(r, 0, der, offset, r.length); + offset += r.length; + der[offset++] = 0x02; // INTEGER + der[offset++] = (byte) s.length; + System.arraycopy(s, 0, der, offset, s.length); + + return der; + } + + private static byte[] trimLeadingZeros(byte[] bytes) { + int start = 0; + while (start < bytes.length - 1 && bytes[start] == 0) { + start++; + } + if (start == 0) return bytes; + return Arrays.copyOfRange(bytes, start, bytes.length); + } + + /** + * Exception thrown when JWT verification fails. + */ + public static class JwtVerificationException extends Exception { + private static final long serialVersionUID = 1L; + public JwtVerificationException(String message) { + super(message); + } + } +} 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..6a709621 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/LocalHttpServer.java @@ -0,0 +1,443 @@ +package org.medicmobile.webapp.mobile.p2p; + +import android.util.Log; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import fi.iki.elonen.NanoHTTPD; + +/** + * Local HTTP server for P2P sync using NanoHTTPD. + * Runs on the Supervisor's phone, serving sync endpoints to connected CHWs. + * + * Endpoints: + * POST /_p2p/auth → AuthEndpoint (no session required) + * GET /_p2p/get-ids → GetIdsEndpoint + * POST /_p2p/bulk-get → BulkGetEndpoint + * POST /_p2p/accept-docs → AcceptDocsEndpoint + * GET /_p2p/get-deletes → GetDeletesEndpoint + * GET /_p2p/status → StatusEndpoint (no auth required) + * + * All endpoints except /status require a valid JWT in the + * Authorization: Bearer header. The /auth endpoint validates the JWT + * and creates a session; subsequent endpoints check that a session exists. + */ +public class LocalHttpServer extends NanoHTTPD { + + private static final String TAG = "LocalHttpServer"; + private static final int DEFAULT_PORT = 8443; + private static final String CONTENT_TYPE_JSON = "application/json"; + private static final String P2P_PREFIX = "/_p2p/"; + + private final P2pAuthenticator authenticator; + private final P2pConfig config; + private final ScopeManifest supervisorScope; + + // Endpoint handlers + private final AuthEndpoint authEndpoint; + private final GetIdsEndpoint getIdsEndpoint; + private final BulkGetEndpoint bulkGetEndpoint; + private final AcceptDocsEndpoint acceptDocsEndpoint; + private final GetDeletesEndpoint getDeletesEndpoint; + private final StatusEndpoint statusEndpoint; + + // Active session — only one CHW session at a time + private volatile P2pSession activeSession; + private volatile int connectedPeers; + + // Callback to notify P2pManager when sync completes (for tracker persistence) + private volatile SessionCompleteCallback sessionCompleteCallback; + + /** + * Callback interface for sync session completion notifications. + * Allows P2pManager to be notified when a peer signals sync-complete, + * so the tracker can record the session for history persistence. + */ + public interface SessionCompleteCallback { + void onSessionComplete(P2pSession session); + } + + /** + * Dependencies needed to construct a LocalHttpServer. + */ + @SuppressWarnings("java:S107") // Data class — groups server dependencies + public static class ServerDeps { + final P2pAuthenticator authenticator; + final P2pConfig config; + final ScopeManifest supervisorScope; + final PouchDbBridge bridge; + final TransitDocCallback transitCallback; + + public ServerDeps(P2pAuthenticator authenticator, P2pConfig config, + ScopeManifest supervisorScope, PouchDbBridge bridge, + TransitDocCallback transitCallback) { + if (authenticator == null) { + throw new IllegalArgumentException("authenticator must not be null"); + } + if (config == null) { + throw new IllegalArgumentException("config must not be null"); + } + if (supervisorScope == null) { + throw new IllegalArgumentException("supervisorScope must not be null"); + } + if (bridge == null) { + throw new IllegalArgumentException("bridge must not be null"); + } + this.authenticator = authenticator; + this.config = config; + this.supervisorScope = supervisorScope; + this.bridge = bridge; + this.transitCallback = transitCallback; + } + } + + /** + * Create a LocalHttpServer with default port (8443). + */ + public LocalHttpServer(ServerDeps deps) { + this(DEFAULT_PORT, deps); + } + + /** + * Create a LocalHttpServer on a specific port. + */ + public LocalHttpServer(int port, ServerDeps deps) { + super(port); + + this.authenticator = deps.authenticator; + this.config = deps.config; + this.supervisorScope = deps.supervisorScope; + this.activeSession = null; + this.connectedPeers = 0; + + // Initialize endpoint handlers + this.authEndpoint = new AuthEndpoint(deps.authenticator, deps.config, deps.supervisorScope); + this.getIdsEndpoint = new GetIdsEndpoint(deps.bridge); + this.bulkGetEndpoint = new BulkGetEndpoint(deps.bridge); + this.acceptDocsEndpoint = new AcceptDocsEndpoint(deps.bridge, deps.transitCallback, + deps.supervisorScope, deps.config); + this.getDeletesEndpoint = new GetDeletesEndpoint(); + this.statusEndpoint = new StatusEndpoint(); + } + + /** + * Start the HTTP server. + * + * @throws IOException if the server cannot bind to the port + */ + public void startServer() throws IOException { + start(NanoHTTPD.SOCKET_READ_TIMEOUT, false); + Log.i(TAG, "P2P HTTP server started on port " + getListeningPort()); + } + + /** + * Stop the HTTP server and clean up the active session. + */ + public void stopServer() { + if (activeSession != null && activeSession.getState() == P2pSession.State.ACTIVE) { + activeSession.fail("server_stopped"); + } + stop(); + activeSession = null; + connectedPeers = 0; + Log.i(TAG, "P2P HTTP server stopped"); + } + + /** + * Get the currently active P2P session, or null if none. + */ + public P2pSession getActiveSession() { + return activeSession; + } + + /** + * Set a callback to be notified when a sync session completes. + */ + public void setSessionCompleteCallback(SessionCompleteCallback callback) { + this.sessionCompleteCallback = callback; + } + + /** + * Get the number of connected peers (0 or 1). + */ + public int getConnectedPeerCount() { + return connectedPeers; + } + + /** + * Get the port this server is listening on. + */ + public int getPort() { + return getListeningPort(); + } + + @Override + public Response serve(IHTTPSession session) { + String uri = session.getUri(); + Method method = session.getMethod(); + + Log.d(TAG, method + " " + uri); + + if (isCaptivePortalCheck(uri)) { + Log.d(TAG, "Captive portal check intercepted: " + uri); + return newFixedLengthResponse(Response.Status.NO_CONTENT, "text/plain", ""); + } + + if (!uri.startsWith(P2P_PREFIX)) { + return jsonResponse(Response.Status.NOT_FOUND, errorJson("not_found")); + } + + return handleP2pRequest(uri, method, session); + } + + private Response handleP2pRequest(String uri, Method method, IHTTPSession session) { + String endpoint = uri.substring(P2P_PREFIX.length()); + + if ("status".equals(endpoint) && method == Method.GET) { + return handleStatus(); + } + if ("auth".equals(endpoint) && method == Method.POST) { + return handleAuth(session); + } + + Response sessionCheck = checkActiveSession(); + if (sessionCheck != null) { + return sessionCheck; + } + + return routeEndpoint(endpoint, method, session); + } + + private boolean isCaptivePortalCheck(String uri) { + return uri.contains("generate_204") || uri.contains("gen_204") + || uri.contains("connectivitycheck") || uri.contains("captive-portal"); + } + + private Response checkActiveSession() { + if (activeSession == null || activeSession.getState() != P2pSession.State.ACTIVE) { + return jsonResponse(Response.Status.UNAUTHORIZED, errorJson("no_active_session")); + } + if (activeSession.isTimedOut()) { + activeSession.fail("session_timeout"); + activeSession = null; + return jsonResponse(Response.Status.UNAUTHORIZED, errorJson("session_timeout")); + } + return null; + } + + private Response routeEndpoint(String endpoint, Method method, IHTTPSession session) { + Response response = dispatchEndpoint(endpoint, method, session); + if (response != null) { + return response; + } + return jsonResponse(Response.Status.METHOD_NOT_ALLOWED, + errorJson("method_not_allowed: " + method + " " + session.getUri())); + } + + private Response dispatchEndpoint(String endpoint, Method method, IHTTPSession session) { + Response result = tryDispatchGet(endpoint, method); + if (result != null) { + return result; + } + result = tryDispatchPost(endpoint, method, session); + if (result != null) { + return result; + } + return jsonResponse(Response.Status.NOT_FOUND, errorJson("unknown_endpoint")); + } + + private Response tryDispatchGet(String endpoint, Method method) { + if (method != Method.GET) { + return null; + } + if ("get-ids".equals(endpoint)) { + return handleGetIds(); + } + if ("get-deletes".equals(endpoint)) { + return handleGetDeletes(); + } + return null; + } + + private Response tryDispatchPost(String endpoint, Method method, IHTTPSession session) { + if (method != Method.POST) { + return null; + } + if ("bulk-get".equals(endpoint)) { + return handleBulkGet(session); + } + if ("accept-docs".equals(endpoint)) { + return handleAcceptDocs(session); + } + if ("sync-complete".equals(endpoint)) { + return handleSyncComplete(session); + } + return null; + } + + // --- Endpoint handlers --- + + private Response handleStatus() { + JSONObject body = statusEndpoint.handle(activeSession, connectedPeers); + return jsonResponse(Response.Status.OK, body); + } + + private synchronized Response handleAuth(IHTTPSession session) { + // Only allow one session at a time + if (activeSession != null && activeSession.getState() == P2pSession.State.ACTIVE) { + return jsonResponse(Response.Status.CONFLICT, + errorJson("session_already_active")); + } + + String requestBody = readRequestBody(session); + AuthEndpoint.AuthResponse authResponse = authEndpoint.handle(requestBody); + + Response.IStatus status = authResponse.getStatusCode() == 200 + ? Response.Status.OK : Response.Status.UNAUTHORIZED; + + if (authResponse.isSuccess()) { + activeSession = authResponse.getSession(); + connectedPeers = 1; + Log.i(TAG, "Session established: " + activeSession.getSessionId()); + } + + return jsonResponse(status, authResponse.getBody()); + } + + private Response handleGetIds() { + JSONObject body = getIdsEndpoint.handle(activeSession); + return jsonResponse(Response.Status.OK, body); + } + + private Response handleBulkGet(IHTTPSession session) { + String requestBody = readRequestBody(session); + JSONObject body = bulkGetEndpoint.handle(requestBody, activeSession); + return jsonResponse(Response.Status.OK, body); + } + + private Response handleAcceptDocs(IHTTPSession session) { + String requestBody = readRequestBody(session); + JSONObject body = acceptDocsEndpoint.handle(requestBody, activeSession); + return jsonResponse(Response.Status.OK, body); + } + + private Response handleGetDeletes() { + JSONObject body = getDeletesEndpoint.handle(); + return jsonResponse(Response.Status.OK, body); + } + + private synchronized Response handleSyncComplete(IHTTPSession session) { + if (activeSession == null) { + return jsonResponse(Response.Status.BAD_REQUEST, errorJson("no_active_session")); + } + try { + String requestBody = readRequestBody(session); + JSONObject body = new JSONObject(requestBody); + int docsPushed = body.optInt("docs_pushed", 0); + long bytesTransferred = body.optLong("bytes_transferred", 0); + activeSession.complete(); + Log.i(TAG, "Sync completed by peer: " + docsPushed + " docs, " + + bytesTransferred + " bytes"); + + notifySessionComplete(); + + JSONObject response = new JSONObject(); + response.put("ok", true); + return jsonResponse(Response.Status.OK, response); + } catch (JSONException e) { + Log.e(TAG, "Error handling sync-complete", e); + return jsonResponse(Response.Status.INTERNAL_ERROR, errorJson("sync_complete_failed")); + } + } + + private void notifySessionComplete() { + if (sessionCompleteCallback != null) { + try { + sessionCompleteCallback.onSessionComplete(activeSession); + } catch (RuntimeException callbackErr) { + Log.e(TAG, "Error in session complete callback", callbackErr); + } + } + } + + // --- Helpers --- + + /** + * Read the full request body from a NanoHTTPD session. + * NanoHTTPD requires calling parseBody() first for POST requests. + */ + private String readRequestBody(IHTTPSession session) { + try { + Map bodyMap = new HashMap<>(); + session.parseBody(bodyMap); + String postData = bodyMap.get("postData"); + if (postData != null) { + return postData; + } + return readFromInputStream(session); + } catch (IOException | ResponseException e) { + Log.e(TAG, "Error reading request body", e); + return ""; + } + } + + private String readFromInputStream(IHTTPSession session) throws IOException { + long contentLength = getContentLength(session); + if (contentLength <= 0) { + return ""; + } + byte[] buf = new byte[4096]; + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + long remaining = contentLength; + int read; + while (remaining > 0 && (read = session.getInputStream().read(buf, 0, + (int) Math.min(buf.length, remaining))) != -1) { + baos.write(buf, 0, read); + remaining -= read; + } + return baos.toString("UTF-8"); + } + + /** + * Extract Content-Length from request headers. + */ + private long getContentLength(IHTTPSession session) { + String contentLengthStr = session.getHeaders().get("content-length"); + if (contentLengthStr != null) { + try { + return Long.parseLong(contentLengthStr); + } catch (NumberFormatException e) { + return 0; + } + } + return 0; + } + + /** + * Build a JSON error response body. + */ + private JSONObject errorJson(String error) { + try { + JSONObject json = new JSONObject(); + json.put("ok", false); + json.put("error", error); + return json; + } catch (JSONException e) { + throw new IllegalStateException("Failed to build error JSON", e); + } + } + + /** + * Build a NanoHTTPD Response with JSON content type. + */ + private Response jsonResponse(Response.IStatus status, JSONObject body) { + String bodyStr = body != null ? body.toString() : "{}"; + return newFixedLengthResponse(status, CONTENT_TYPE_JSON, bodyStr); + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/LoopbackHotspotProvider.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/LoopbackHotspotProvider.java new file mode 100644 index 00000000..4aeec36a --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/LoopbackHotspotProvider.java @@ -0,0 +1,50 @@ +package org.medicmobile.webapp.mobile.p2p; + +import android.util.Log; + +/** + * Dev/emulator HotspotProvider that uses localhost instead of real WiFi. + * + * Useful for testing on emulators that don't support LocalOnlyHotspot. + * Immediately reports success with loopback credentials — no actual + * WiFi hotspot is created. + */ +public class LoopbackHotspotProvider implements HotspotProvider { + + private static final String TAG = "LoopbackHotspot"; + private static final String LOOPBACK_SSID = "CHT-P2P-loopback"; + private static final String LOOPBACK_PASSWORD = "dev-password"; + private static final String LOOPBACK_IP = "127.0.0.1"; + + private volatile boolean running = false; + + @Override + public void start(HotspotCallback callback) { + if (callback == null) { + throw new IllegalArgumentException("callback must not be null"); + } + + if (running) { + Log.w(TAG, "Loopback hotspot already running"); + callback.onStarted(LOOPBACK_SSID, LOOPBACK_PASSWORD, LOOPBACK_IP); + return; + } + + running = true; + Log.i(TAG, "Loopback hotspot started (dev mode)"); + callback.onStarted(LOOPBACK_SSID, LOOPBACK_PASSWORD, LOOPBACK_IP); + } + + @Override + public void stop() { + if (running) { + running = false; + Log.i(TAG, "Loopback hotspot stopped"); + } + } + + @Override + public boolean isRunning() { + return running; + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/OemBatteryHelper.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/OemBatteryHelper.java new file mode 100644 index 00000000..5bf5b77d --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/OemBatteryHelper.java @@ -0,0 +1,156 @@ +package org.medicmobile.webapp.mobile.p2p; + +import android.os.Build; + +import java.util.Locale; + +/** + * Provides per-OEM guidance for battery optimization settings. + * + * Android OEMs (especially popular in Africa: Tecno, Infinix, Samsung) + * aggressively kill background services. Users must whitelist the app + * for reliable P2P sync. + * + * See RFC Section 14.4 for the OEM Kill Matrix. + */ +public class OemBatteryHelper { + + public enum RiskLevel { + /** Google Pixel, stock Android — minimal intervention needed. */ + LOW, + /** Nokia — moderate battery optimization. */ + MEDIUM, + /** Samsung, Huawei, Xiaomi — aggressive battery management. */ + HIGH, + /** Tecno, Infinix (Transsion) — extremely aggressive, kills services fast. */ + EXTREME + } + + private OemBatteryHelper() { + // Static utility class + } + + /** + * Get battery kill risk level for the current device manufacturer. + * + * @return the risk level based on known OEM behavior + */ + private static final String[][] EXTREME_BRANDS = { {"tecno"}, {"infinix"}, {"itel"} }; + private static final String[][] HIGH_BRANDS = { + {"samsung"}, {"huawei"}, {"xiaomi"}, {"oppo"}, {"vivo"}, {"realme"}, {"oneplus"} + }; + private static final String[][] MEDIUM_BRANDS = { {"nokia"}, {"motorola"}, {"lenovo"} }; + + public static RiskLevel getRiskLevel() { + String manufacturer = Build.MANUFACTURER.toLowerCase(Locale.ROOT); + + if (matchesBrand(manufacturer, EXTREME_BRANDS)) { + return RiskLevel.EXTREME; + } + if (matchesBrand(manufacturer, HIGH_BRANDS)) { + return RiskLevel.HIGH; + } + if (matchesBrand(manufacturer, MEDIUM_BRANDS)) { + return RiskLevel.MEDIUM; + } + return RiskLevel.LOW; + } + + private static boolean matchesBrand(String manufacturer, String[][] brands) { + for (String[] brand : brands) { + if (manufacturer.contains(brand[0])) { + return true; + } + } + return false; + } + + /** + * Get human-readable guidance for the user to disable battery optimization. + * Instructions are specific to the device manufacturer. + * + * @return localized guidance string + */ + public static String getGuidance() { + String manufacturer = Build.MANUFACTURER.toLowerCase(Locale.ROOT); + return getGuidanceForManufacturer(manufacturer); + } + + private static String getGuidanceForManufacturer(String manufacturer) { + if (matchesBrand(manufacturer, EXTREME_BRANDS)) { + return "Go to Phone Master > Battery Manager > " + + "tap this app > select 'Allow background activity'. " + + "Also: Settings > Apps > this app > Battery > Unrestricted."; + } + + String specific = getSpecificBrandGuidance(manufacturer); + if (specific != null) { + return specific; + } + + if (matchesBrand(manufacturer, MEDIUM_BRANDS)) { + return "Go to Settings > Apps & notifications > this app > " + + "Battery > Unrestricted."; + } + + return "Go to Settings > Battery > this app > " + + "disable battery optimization for reliable P2P sync."; + } + + private static final String[][] BRAND_GUIDANCE = { + {"samsung", "Go to Settings > Battery and device care > Battery > " + + "Background usage limits > Never sleeping apps > Add this app."}, + {"huawei", "Go to Settings > Battery > App launch > " + + "find this app > disable 'Manage automatically' > " + + "enable all three toggles (Auto-launch, Secondary launch, Run in background)."}, + {"xiaomi", "Go to Settings > Apps > Manage apps > this app > " + + "Battery saver > No restrictions. " + + "Also enable Autostart in Security app."}, + {"oppo", "Go to Settings > Battery > this app > " + + "Allow background activity. " + + "Also: Settings > App management > this app > Battery > Allow."}, + {"realme", "Go to Settings > Battery > this app > " + + "Allow background activity. " + + "Also: Settings > App management > this app > Battery > Allow."}, + {"vivo", "Go to Settings > Battery > High background power consumption > " + + "enable for this app."}, + {"oneplus", "Go to Settings > Battery > Battery optimization > " + + "this app > Don't optimize."}, + }; + + private static String getSpecificBrandGuidance(String manufacturer) { + for (String[] entry : BRAND_GUIDANCE) { + if (manufacturer.contains(entry[0])) { + return entry[1]; + } + } + return null; + } + + /** + * Check if the app needs battery optimization whitelisting for reliable P2P. + * + * @return true if the OEM is known to aggressively kill background services + */ + public static boolean needsBatteryGuidance() { + return getRiskLevel() != RiskLevel.LOW; + } + + /** + * Get the device manufacturer name (for telemetry/logging). + * + * @return lowercase manufacturer string + */ + public static String getManufacturer() { + return Build.MANUFACTURER.toLowerCase(Locale.ROOT); + } + + /** + * Get the device model (for telemetry/logging). + * + * @return device model string + */ + public static String getModel() { + return Build.MODEL; + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pAuthenticator.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pAuthenticator.java new file mode 100644 index 00000000..4a025699 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pAuthenticator.java @@ -0,0 +1,159 @@ +package org.medicmobile.webapp.mobile.p2p; + +import org.json.JSONArray; +import org.json.JSONObject; + +/** + * Orchestrates the full P2P authentication flow. + * Verifies JWT + checks revocation + validates peer authorization. + * + * Guards: G6 (JWT signature), G7 (not expired), G8 (not revoked), G9 (peer allowed) + */ +public final class P2pAuthenticator { + + private final JwtVerifier jwtVerifier; + private final RevocationList revocationList; + + public P2pAuthenticator(JwtVerifier jwtVerifier, RevocationList revocationList) { + if (jwtVerifier == null) { + throw new IllegalArgumentException("jwtVerifier must not be null"); + } + if (revocationList == null) { + throw new IllegalArgumentException("revocationList must not be null"); + } + this.jwtVerifier = jwtVerifier; + this.revocationList = revocationList; + } + + /** + * Full authentication check: verify JWT + check revocation + check permissions. + * + * @param jwt The JWT token string + * @param deviceId The device ID of the peer + * @return AuthResult with success/failure details + */ + public AuthResult authenticate(String jwt, String deviceId) { + // G6 + G7: Verify JWT signature and expiry + JSONObject payload; + try { + payload = jwtVerifier.verify(jwt); + } catch (JwtVerifier.JwtVerificationException e) { + return AuthResult.failure(e.getMessage()); + } + + // G8: Check device revocation + if (revocationList.isDeviceRevoked(deviceId)) { + return AuthResult.failure("device_revoked"); + } + + // G8: Check user revocation + String userId = payload.optString("sub", null); + if (revocationList.isUserRevoked(userId)) { + return AuthResult.failure("user_revoked"); + } + + String role = payload.optString("role", null); + + String facilityId = extractFacilityId(payload); + + return AuthResult.success(payload, userId, role, facilityId); + } + + @SuppressWarnings("java:S6201") // Pattern matching instanceof requires Java 16+ + private String extractFacilityId(JSONObject payload) { + Object rawFacility = payload.opt("facility_id"); + if (rawFacility instanceof JSONArray) { + return ((JSONArray) rawFacility).optString(0, null); + } + return rawFacility != null ? rawFacility.toString() : null; + } + + /** + * G9: Check if a specific peer is in the allowed_relay_peers list. + * + * @param tokenPayload The decoded JWT payload + * @param peerId The peer user ID or device ID to check + * @return true if the peer is allowed + */ + public boolean isPeerAllowed(JSONObject tokenPayload, String peerId) { + if (tokenPayload == null || peerId == null) { + return false; + } + return isInAllowedPeersList(tokenPayload, peerId); + } + + private boolean isInAllowedPeersList(JSONObject tokenPayload, String peerId) { + JSONArray allowedPeers = tokenPayload.optJSONArray("allowed_relay_peers"); + if (allowedPeers == null || allowedPeers.length() == 0) { + return true; + } + for (int i = 0; i < allowedPeers.length(); i++) { + if (peerId.equals(allowedPeers.optString(i))) { + return true; + } + } + return false; + } + + /** + * Result of an authentication attempt. + */ + public static final class AuthResult { + private final boolean authenticated; + private final String error; + private final JSONObject tokenPayload; + private final String userId; + private final String role; + private final String facilityId; + + private AuthResult(String error) { + this.authenticated = false; + this.error = error; + this.tokenPayload = null; + this.userId = null; + this.role = null; + this.facilityId = null; + } + + private AuthResult(JSONObject payload, String userId, String role, String facilityId) { + this.authenticated = true; + this.error = null; + this.tokenPayload = payload; + this.userId = userId; + this.role = role; + this.facilityId = facilityId; + } + + public static AuthResult success(JSONObject payload, String userId, String role, String facilityId) { + return new AuthResult(payload, userId, role, facilityId); + } + + public static AuthResult failure(String error) { + return new AuthResult(error); + } + + public boolean isAuthenticated() { + return authenticated; + } + + public String getError() { + return error; + } + + public JSONObject getTokenPayload() { + return tokenPayload; + } + + public String getUserId() { + return userId; + } + + public String getRole() { + return role; + } + + public String getFacilityId() { + return facilityId; + } + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pBridgeMethods.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pBridgeMethods.java new file mode 100644 index 00000000..d511e861 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pBridgeMethods.java @@ -0,0 +1,1686 @@ +package org.medicmobile.webapp.mobile.p2p; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.os.Build; +import android.util.Log; +import android.webkit.JavascriptInterface; + +import java.io.IOException; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Set; +import java.util.Locale; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +/** + * JavaScript bridge methods for P2P sync operations. + * Bound to the WebView as part of the 'medicmobile_android' interface. + * + * The webapp calls these methods to: + * - Start/stop P2P host or client mode + * - Get transit doc IDs for UI filtering + * - Trigger transit doc purge after server push + * - Get P2P sync status and history + * + * All methods are called on the WebView JS thread and must return quickly. + * Async operations (hotspot start, etc.) use CountDownLatch to block until + * the callback fires, with a timeout to prevent indefinite blocking. + */ +public class P2pBridgeMethods { + + private static final String TAG = "P2pBridgeMethods"; + private static final String KEY_OK = "ok"; + private static final String KEY_ERROR = "error"; + private static final String KEY_STATUS = "status"; + private static final String KEY_SESSIONS = "sessions"; + private static final String KEY_DOCS_SYNCED = "docs_synced"; + private static final String KEY_TOTAL_DOCS = "total_docs"; + private static final String KEY_BYTES_TRANSFERRED = "bytes_transferred"; + private static final String KEY_HOST = "host"; + private static final String KEY_DOC_ID = "_id"; + private static final String SYNC_LOG_DOC_ID = "_local/p2p-sync-log"; + private static final String RELAY_LOG_DOC_ID = "_local/p2p-relay-log"; + private static final String LOG_RESULT_PREFIX = " result="; + private static final String STATE_IDLE = "idle"; + private static final String STATE_CONNECTING = "connecting"; + private static final String STATE_WAITING_WIFI = "waiting_wifi"; + private static final String STATE_PREVIEW = "preview"; + private static final String STATE_SYNCING = "syncing"; + private static final String STATE_COMPLETED = "completed"; + private static final String STATE_FAILED = "failed"; + private static final long HOST_START_TIMEOUT_SEC = 30; + private static final long CLIENT_START_TIMEOUT_SEC = 15; + + private final P2pManager p2pManager; + private final TransitDocManager transitDocManager; + private final P2pTracker tracker; + + // WebView reference for PouchDB evaluation + private volatile android.webkit.WebView webView; + + // Bridge callback support for reliable evalPouchDb (Fix 1) + // JS Promise results are delivered via p2pAsyncCallback() instead of timed polling + private final ConcurrentHashMap asyncCallbackResults = new ConcurrentHashMap<>(); + private final ConcurrentHashMap asyncCallbackLatches = new ConcurrentHashMap<>(); + + // Cached QR data URL for status polling + private volatile String cachedQrDataUrl = null; + + // Cached JWT token for peer auth with supervisor + private volatile String cachedJwt = null; + + // Active sync client (peer side) + private volatile P2pSyncClient activeSyncClient = null; + + // Sync progress for peer side + private volatile int clientDocsSynced = 0; + private volatile int clientTotalDocs = 0; + private final AtomicLong clientBytesTransferred = new AtomicLong(0); + private volatile String clientSyncState = STATE_IDLE; // idle, waiting_wifi, connecting, syncing, completed, failed + private volatile String clientSyncError = null; + + // Cached peer connection info (set after QR scan, used when WiFi connects) + private volatile String cachedPeerHost = null; + private volatile int cachedPeerPort = 0; + + // Concurrency guard — prevents multiple sync threads + private volatile boolean clientSyncRunning = false; + + // Preview: pending doc entries from changes feed, awaiting user confirmation + private volatile JSONArray pendingDocIds = null; + private volatile int previewContactCount = 0; + private volatile int previewReportCount = 0; + + // PouchDB sequence at time of preview query — saved as checkpoint after successful push. + // Used with max(lastServerSeq, p2pLastSeq) to enable incremental sync: + // only docs created/modified since the last successful server replication or P2P sync. + private volatile long changesLastSeq = 0; + + public P2pBridgeMethods(P2pManager p2pManager, TransitDocManager transitDocManager, + P2pTracker tracker) { + this.p2pManager = p2pManager; + this.transitDocManager = transitDocManager; + this.tracker = tracker; + } + + public void setWebView(android.webkit.WebView webView) { + this.webView = webView; + } + + /** + * Bridge callback for reliable async JS evaluation. + * Called from JS via: medicmobile_android.p2pAsyncCallback(id, result) + * This replaces the 50ms postDelayed timing hack in evalPouchDb. + */ + @JavascriptInterface + public void p2pAsyncCallback(String callbackId, String result) { + if (callbackId == null) return; + asyncCallbackResults.put(callbackId, result != null ? result : "null"); + CountDownLatch latch = asyncCallbackLatches.get(callbackId); + if (latch != null) { + latch.countDown(); + } + } + + /** + * Start P2P in supervisor mode. + * Starts hotspot, HTTP server, and generates QR code payload. + * Called from webapp when supervisor taps "Start P2P Sync". + * + * Blocks until hotspot + server are ready or timeout. + * + * @return JSON string: { "ok": true, "qr_payload": "..." } + * or { "ok": false, "error": "reason" } + */ + @JavascriptInterface + public String p2pStartHostMode() { + String initError = checkManagerInitialized(); + if (initError != null) { + return initError; + } + try { + return doStartHostMode(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Log.e(TAG, "Host start interrupted", e); + p2pManager.shutdown(); + return errorJson("interrupted"); + } catch (RuntimeException e) { + Log.e(TAG, "Error starting supervisor mode", e); + return errorJson("start_failed: " + e.getMessage()); + } + } + + private String doStartHostMode() throws InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference resultRef = new AtomicReference<>(); + + p2pManager.startHostMode(createHostModeCallback(latch, resultRef)); + + boolean completed = latch.await(HOST_START_TIMEOUT_SEC, TimeUnit.SECONDS); + if (!completed) { + Log.w(TAG, "Host start timed out, shutting down to release mutex"); + p2pManager.shutdown(); + return errorJson("supervisor_start_timeout"); + } + + JSONObject result = resultRef.get(); + return result != null ? result.toString() : errorJson("no_result"); + } + + private String checkManagerInitialized() { + if (p2pManager == null) { + return errorJson("p2p_not_initialized"); + } + if (!p2pManager.isInitialized()) { + return errorJson("not_initialized"); + } + return null; + } + + private P2pManager.HostModeCallback createHostModeCallback( + final CountDownLatch latch, final AtomicReference resultRef) { + return new P2pManager.HostModeCallback() { + @Override + public void onQrCodeReady(String qrPayloadJson) { + resultRef.set(buildQrResponse(qrPayloadJson)); + latch.countDown(); + } + + @Override + public void onPeerConnected(String peerId) { + // No-op: callback not used in this context + } + + @Override + public void onSyncProgress(int docsSynced, int totalDocs) { + // No-op: callback not used in this context + } + + @Override + public void onSyncComplete(P2pSession session) { + // No-op: callback not used in this context + } + + @Override + public void onError(String error) { + try { + JSONObject response = new JSONObject(); + response.put(KEY_OK, false); + response.put(KEY_ERROR, error); + resultRef.set(response); + } catch (JSONException e) { + Log.e(TAG, "Error building error response", e); + } + latch.countDown(); + } + + @Override + public void onBatteryGuidance(OemBatteryHelper.RiskLevel riskLevel, + String guidance) { + Log.i(TAG, "Battery guidance (" + riskLevel + "): " + guidance); + } + }; + } + + private JSONObject buildQrResponse(String qrPayloadJson) { + try { + String qrDataUrl = QrCodeHelper.generateQrDataUrl(qrPayloadJson); + cachedQrDataUrl = qrDataUrl; + + JSONObject response = new JSONObject(); + response.put(KEY_OK, true); + response.put("qr_payload", qrPayloadJson); + response.put("qr_data_url", qrDataUrl != null ? qrDataUrl : ""); + return response; + } catch (JSONException e) { + Log.e(TAG, "Error building QR response", e); + return null; + } + } + + /** + * Start P2P in client mode with scanned QR data. + * Validates QR, returns WiFi credentials for manual connection. + * User connects to WiFi manually, then webapp calls p2pCheckConnection() to detect it. + * + * @param qrPayloadJson the scanned QR payload JSON string + * @return JSON: { "ok": true, "ssid": "...", "password": "...", "host": "...", "port": N } + * or { "ok": false, "error": "reason" } + */ + @JavascriptInterface + public String p2pStartClientMode(String qrPayloadJson) { + String initError = checkManagerInitialized(); + if (initError != null) { + return initError; + } + String validationError = validateQrInput(qrPayloadJson); + if (validationError != null) { + return validationError; + } + try { + return doStartClientMode(qrPayloadJson); + } catch (JSONException e) { + Log.e(TAG, "Error starting client mode", e); + clientSyncState = STATE_FAILED; + return errorJson("start_failed: " + e.getMessage()); + } + } + + private String validateQrInput(String qrPayloadJson) { + if (qrPayloadJson == null || qrPayloadJson.isEmpty()) { + return errorJson("empty_qr_payload"); + } + ValidationResult validation = QrCodeHelper.validateQrPayload(qrPayloadJson); + if (!validation.isAccepted()) { + return errorJson("invalid_qr: " + validation.getReason()); + } + return null; + } + + private String doStartClientMode(String qrPayloadJson) throws JSONException { + JSONObject qr = new JSONObject(qrPayloadJson); + String ssid = qr.optString("ssid", ""); + String password = qr.optString("pwd", ""); + String host = qr.optString("ip", ""); + int port = qr.optInt("port", 8443); + + cachedPeerHost = host; + cachedPeerPort = port; + clientSyncState = STATE_CONNECTING; + + Log.i(TAG, "Client mode: auto-connecting to SSID=" + ssid + + " then sync with " + host + ":" + port); + + p2pManager.startClientMode(qrPayloadJson, createClientModeCallback()); + + JSONObject result = new JSONObject(); + result.put(KEY_OK, true); + result.put("auto_connecting", true); + result.put("ssid", ssid); + result.put("password", password); + result.put(KEY_HOST, host); + result.put("port", port); + return result.toString(); + } + + /** + * Find the WiFi network from ConnectivityManager. + * On Android, when connected to a LocalOnlyHotspot (no internet), + * the default network remains cellular. We must explicitly bind HTTP + * connections to the WiFi network to reach the hotspot's HTTP server. + */ + private P2pManager.ClientModeCallback createClientModeCallback() { + return new P2pManager.ClientModeCallback() { + @Override + public void onConnectionInfoReady(String s, String p, String ip, int pt, String tls) { + Log.i(TAG, "WiFi connected to host, starting client sync"); + cachedPeerHost = ip; + cachedPeerPort = pt; + clientSyncState = STATE_CONNECTING; + startClientSync(ip, pt); + } + + @Override + public void onError(String error) { + if (error != null && error.contains("wifi_connection_failed")) { + Log.w(TAG, "WiFi auto-connect failed, falling back to manual"); + clientSyncState = STATE_WAITING_WIFI; + } else { + Log.e(TAG, "Client mode error: " + error); + clientSyncError = error; + clientSyncState = STATE_FAILED; + } + } + + @Override + public void onConnected(String hostId) { + Log.d(TAG, "Client connected to host: " + hostId); + } + + @Override + public void onPreviewReady(int contactCount, int reportCount, int totalCount) { + Log.d(TAG, "Preview ready: " + totalCount + " docs"); + } + + @Override + public void onSyncProgress(int docsSynced, int totalDocs) { + Log.d(TAG, "Sync progress: " + docsSynced + "/" + totalDocs); + } + + @Override + public void onSyncComplete(P2pSession session) { + Log.d(TAG, "Sync complete: " + session); + } + }; + } + + private Network findWifiNetwork() { + if (p2pManager == null) return null; + + // Primary: exact network from WifiNetworkSpecifier auto-connect + Network autoNetwork = p2pManager.getP2pWifiNetwork(); + if (autoNetwork != null) { + Log.d(TAG, "Using auto-connected WiFi network: " + autoNetwork); + return autoNetwork; + } + + // Fallback: find WiFi network on same subnet as supervisor + if (cachedPeerHost == null || cachedPeerHost.isEmpty()) return null; + return findWifiNetworkBySubnet(); + } + + /** + * Find WiFi network on the same subnet as the supervisor. + * Critical for dual-WiFi devices (Pixel has wlan0 + wlan1). + */ + private Network findWifiNetworkBySubnet() { + String targetPrefix = cachedPeerHost.substring(0, cachedPeerHost.lastIndexOf('.') + 1); + try { + Context ctx = p2pManager.getContext(); + if (ctx == null) return null; + ConnectivityManager cm = (ConnectivityManager) ctx.getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm == null) return null; + + return scanNetworksForSubnetMatch(cm, targetPrefix); + } catch (RuntimeException e) { + Log.w(TAG, "Failed to find WiFi network", e); + } + Log.w(TAG, "No WiFi network found at all"); + return null; + } + + @SuppressWarnings("deprecation") // getAllNetworks deprecated API 31; NetworkCallback alternative is async and complex + private Network scanNetworksForSubnetMatch(ConnectivityManager cm, String targetPrefix) { + Network[] wifiNetworks = getWifiNetworks(cm); + Network fallbackWifi = null; + for (Network network : wifiNetworks) { + Network result = findSubnetMatch(cm, network, targetPrefix); + if (result != null) { + return result; + } + if (fallbackWifi == null) { + fallbackWifi = network; + } + } + return logAndReturnFallback(fallbackWifi, targetPrefix); + } + + @SuppressWarnings("deprecation") + private Network[] getWifiNetworks(ConnectivityManager cm) { + java.util.List wifi = new java.util.ArrayList<>(); + for (Network network : cm.getAllNetworks()) { + if (isWifiNetwork(cm, network)) { + wifi.add(network); + } + } + return wifi.toArray(new Network[0]); + } + + private static boolean isWifiNetwork(ConnectivityManager cm, Network network) { + NetworkCapabilities caps = cm.getNetworkCapabilities(network); + return caps != null && caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI); + } + + private Network logAndReturnFallback(Network fallbackWifi, String targetPrefix) { + if (fallbackWifi != null) { + Log.w(TAG, "No subnet match for " + targetPrefix + "*, using fallback WiFi: " + fallbackWifi); + } + return fallbackWifi; + } + + private Network findSubnetMatch(ConnectivityManager cm, Network network, String targetPrefix) { + android.net.LinkProperties lp = cm.getLinkProperties(network); + if (lp == null) return null; + + for (android.net.LinkAddress addr : lp.getLinkAddresses()) { + if (isIpv4OnSubnet(addr, targetPrefix)) { + Log.i(TAG, "Found WiFi on supervisor subnet: " + + addr.getAddress().getHostAddress() + + " (target=" + targetPrefix + "*) → " + network); + return network; + } + } + return null; + } + + private static boolean isIpv4OnSubnet(android.net.LinkAddress addr, String targetPrefix) { + java.net.InetAddress inetAddr = addr.getAddress(); + if (!(inetAddr instanceof java.net.Inet4Address)) return false; + String ip = inetAddr.getHostAddress(); + return ip != null && ip.startsWith(targetPrefix); + } + + /** + * Check if the peer can reach the supervisor's HTTP server. + * Called by webapp polling after user manually connects to the hotspot WiFi. + * When reachable, automatically starts the sync. + * + * @return JSON: { "connected": true/false } + */ + @JavascriptInterface + public String p2pCheckConnection() { + if (cachedPeerHost == null || cachedPeerHost.isEmpty()) { + Log.d(TAG, "p2pCheckConnection: no host cached"); + return "{\"connected\":false}"; + } + try { + boolean reachable = checkPeerReachability(); + JSONObject result = new JSONObject(); + result.put("connected", reachable); + return result.toString(); + } catch (JSONException e) { + Log.w(TAG, "p2pCheckConnection error", e); + return "{\"connected\":false}"; + } + } + + private boolean checkPeerReachability() { + P2pSyncClient client = new P2pSyncClient(cachedPeerHost, cachedPeerPort); + Network wifiNetwork = findWifiNetwork(); + if (wifiNetwork != null) { + client.setNetwork(wifiNetwork); + } else { + Log.d(TAG, "p2pCheckConnection: no WiFi network found, trying default"); + } + + boolean reachable = client.isReachable(); + Log.d(TAG, "p2pCheckConnection: host=" + cachedPeerHost + ":" + cachedPeerPort + + " reachable=" + reachable + " wifiNet=" + (wifiNetwork != null)); + + if (reachable && STATE_WAITING_WIFI.equals(clientSyncState)) { + Log.i(TAG, "Host reachable at " + cachedPeerHost + ":" + cachedPeerPort + + " — starting sync"); + clientSyncState = STATE_CONNECTING; + startClientSync(cachedPeerHost, cachedPeerPort); + } + return reachable; + } + + /** + * Retry P2P sync using cached connection info from previous QR scan. + * Does NOT re-scan QR or re-request WiFi — preserves existing WiFi connection. + */ + @JavascriptInterface + public String p2pRetrySync() { + try { + if (cachedPeerHost == null || cachedPeerHost.isEmpty()) { + return errorJson("no_cached_connection: scan QR code first"); + } + if (clientSyncRunning) { + return errorJson("sync_already_running"); + } + + // Reset state for retry + clientSyncState = STATE_CONNECTING; + clientSyncError = null; + clientDocsSynced = 0; + clientTotalDocs = 0; + clientBytesTransferred.set(0); + pendingDocIds = null; + previewContactCount = 0; + previewReportCount = 0; + + Log.i(TAG, "Retrying peer sync with cached host: " + cachedPeerHost + ":" + cachedPeerPort); + startClientSync(cachedPeerHost, cachedPeerPort); + + JSONObject result = new JSONObject(); + result.put(KEY_OK, true); + return result.toString(); + } catch (JSONException e) { + Log.e(TAG, "Error retrying sync", e); + return errorJson("retry_failed: " + e.getMessage()); + } + } + + /** + * Stop P2P sync (both host and client modes). + */ + @JavascriptInterface + public void p2pStop() { + try { + saveStateBeforeShutdown(); + resetClientState(); + if (p2pManager != null) { + p2pManager.shutdown(); + } + } catch (RuntimeException e) { + Log.e(TAG, "Error stopping P2P", e); + } + } + + private void saveStateBeforeShutdown() { + boolean wasHost = isManagerInMode(true); + boolean wasPeer = isManagerInMode(false); + if (wasHost || wasPeer) { + saveSyncLogBeforeShutdown(wasHost); + } + if (wasHost) { + persistTransitStateAsync(); + } + } + + private boolean isManagerInMode(boolean hostMode) { + if (p2pManager == null) { + return false; + } + return hostMode ? p2pManager.isHostModeActive() : p2pManager.isClientModeActive(); + } + + private void resetClientState() { + cachedQrDataUrl = null; + clientSyncRunning = false; + clientSyncState = STATE_IDLE; + clientSyncError = null; + clientDocsSynced = 0; + clientTotalDocs = 0; + clientBytesTransferred.set(0); + activeSyncClient = null; + cachedPeerHost = null; + cachedPeerPort = 0; + pendingDocIds = null; + previewContactCount = 0; + previewReportCount = 0; + } + + /** + * Build and save sync log to PouchDB on a background thread before shutdown. + */ + private void saveSyncLogBeforeShutdown(boolean wasHost) { + try { + final JSONObject logToSave; + final String docId; + if (wasHost) { + logToSave = tracker.buildRelayLog(); + docId = RELAY_LOG_DOC_ID; + } else { + logToSave = tracker.buildSyncLog(); + docId = SYNC_LOG_DOC_ID; + } + new Thread(() -> { + try { + savePrebuiltLogToPouchDb(logToSave, docId); + } catch (RuntimeException e) { + Log.e(TAG, "Error saving sync log on stop", e); + } + }, "P2pSaveSyncLog").start(); + } catch (JSONException e) { + Log.e(TAG, "Error building sync log before shutdown", e); + } + } + + /** + * Persist transit state to PouchDB asynchronously. + */ + private void persistTransitStateAsync() { + final JSONObject transitState = p2pManager.getTransitStateJson(); + if (transitState != null) { + new Thread(() -> { + try { + saveTransitStateToPouchDb(transitState); + } catch (RuntimeException e) { + Log.e(TAG, "Error saving transit state on stop", e); + } + }, "P2pSaveTransitState").start(); + } + } + + /** + * Start the P2P client flow: connect, auth, fetch IDs, then show preview. + * Does NOT start downloading docs — waits for user to call p2pProceedSync(). + * + * Flow: connect → auth → get-ids → preview (STOP) → user confirms → bulk-get + */ + private static final int REACHABILITY_MAX_ATTEMPTS = 10; + private static final long REACHABILITY_RETRY_MS = 1000; + + private void startClientSync(final String host, final int port) { + if (clientSyncRunning) { + Log.w(TAG, "startClientSync: already running, skipping"); + return; + } + clientSyncRunning = true; + new Thread(() -> runClientSyncThread(host, port), "P2pClientSync").start(); + } + + private void runClientSyncThread(String host, int port) { + try { + doClientSync(host, port); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Log.e(TAG, "Client sync interrupted during preview phase", e); + clientSyncError = "Sync interrupted"; + clientSyncState = STATE_FAILED; + } catch (JSONException | IOException e) { + Log.e(TAG, "Client sync failed during preview phase", e); + clientSyncError = "Sync error: " + e.getMessage(); + clientSyncState = STATE_FAILED; + } finally { + // Do NOT clear clientSyncRunning here — we're still in preview + // It will be cleared when proceedClientSync completes or user cancels + if (!STATE_PREVIEW.equals(clientSyncState)) { + clientSyncRunning = false; + } + } + } + + private void doClientSync(String host, int port) + throws InterruptedException, JSONException, IOException { + clientSyncState = STATE_CONNECTING; + clientSyncError = null; + + P2pSyncClient client = createAndBindClient(host, port); + activeSyncClient = client; + + if (!waitForServerReachable(client, host, port)) { + return; + } + if (!authenticateClient(client, host, port)) { + return; + } + + startTrackerSession(host); + Log.i(TAG, "Client sync: authenticated, querying unsynced docs"); + + queryAndSetPreview(); + } + + private P2pSyncClient createAndBindClient(String host, int port) { + P2pSyncClient client = new P2pSyncClient(host, port); + Network wifiNet = findWifiNetwork(); + if (wifiNet != null) { + client.setNetwork(wifiNet); + Log.i(TAG, "Client sync: bound to WiFi network " + wifiNet); + } + return client; + } + + private boolean waitForServerReachable(P2pSyncClient client, String host, int port) + throws InterruptedException { + for (int attempt = 1; attempt <= REACHABILITY_MAX_ATTEMPTS; attempt++) { + if (client.isReachable()) { + Log.i(TAG, "Server reachable on attempt " + attempt); + return true; + } + Log.d(TAG, "Server not reachable, attempt " + attempt + "/" + REACHABILITY_MAX_ATTEMPTS + ", waiting 1s..."); + Thread.sleep(REACHABILITY_RETRY_MS); + } + Log.e(TAG, "Server unreachable after " + REACHABILITY_MAX_ATTEMPTS + " attempts"); + Network wifiNet = findWifiNetwork(); + clientSyncError = "Server unreachable after " + REACHABILITY_MAX_ATTEMPTS + " attempts at " + host + ":" + port + + ". WiFi network=" + (wifiNet != null ? wifiNet.toString() : "none"); + clientSyncState = STATE_FAILED; + return false; + } + + private boolean authenticateClient(P2pSyncClient client, String host, int port) + throws IOException, JSONException { + if (cachedJwt == null || cachedJwt.isEmpty()) { + Log.e(TAG, "No JWT token cached, cannot authenticate with host"); + clientSyncError = "No authentication token. Please re-initialize P2P sync."; + clientSyncState = STATE_FAILED; + return false; + } + if (p2pManager != null) { + client.setDeviceId(p2pManager.getLocalDeviceId()); + } + Log.i(TAG, "Client sync: authenticating with host at " + host + ":" + port); + String authError = client.authenticate(cachedJwt); + if (authError != null) { + Log.e(TAG, "Client sync: authentication failed: " + authError); + clientSyncError = "Authentication failed: " + authError; + clientSyncState = STATE_FAILED; + return false; + } + return true; + } + + private void startTrackerSession(String host) { + if (tracker != null) { + tracker.startSession("host-" + host, "host", "host", null); + } + } + + private void queryAndSetPreview() throws JSONException { + // Get docs not yet synced to server. + // Uses only lastServerReplicatedSeq from CHT's db-sync.service.ts. + // Duplicate pushes are harmless (new_edits: false on the host). + String changesJs = + "(async function() {" + + " var db = window.CHTCore.DB.get();" + + " var since = Number(localStorage.getItem('medic-last-replicated-seq')) || 0;" + + " var result = await db.changes({ since: since });" + + " var docs = result.results" + + " .filter(function(r) { return !r.id.startsWith('_design/') && !r.id.startsWith('_local/') && !r.deleted; })" + + " .map(function(r) { return { _id: r.id, _rev: r.changes[0].rev }; });" + + " return JSON.stringify({ docs: docs, last_seq: result.last_seq });" + + "})()"; + + String changesJson = evalPouchDb(changesJs); + JSONObject changesResult = new JSONObject(changesJson); + JSONArray docsToSync = changesResult.getJSONArray("docs"); + changesLastSeq = changesResult.getLong("last_seq"); + + int[] counts = countDocTypes(docsToSync); + clientTotalDocs = docsToSync.length(); + previewContactCount = counts[0]; + previewReportCount = counts[1]; + pendingDocIds = docsToSync; + + Log.i(TAG, "Client sync: CHW has " + docsToSync.length() + " unsynced docs to push (" + + counts[0] + " contacts, " + counts[1] + " reports) — awaiting user confirmation"); + clientSyncState = STATE_PREVIEW; + } + + private static int[] countDocTypes(JSONArray docsToSync) throws JSONException { + int contacts = 0; + int reports = 0; + for (int i = 0; i < docsToSync.length(); i++) { + JSONObject entry = docsToSync.getJSONObject(i); + String docId = entry.optString(KEY_DOC_ID, ""); + if (docId.startsWith("report:") || docId.startsWith("report~")) { + reports++; + } else { + contacts++; + } + } + return new int[]{contacts, reports}; + } + + /** + * Proceed with sync after user confirms preview. + * Pushes CHW's docs to the Supervisor via POST /_p2p/accept-docs. + * + * @return JSON: {"ok": true} or {"ok": false, "error": "..."} + */ + @JavascriptInterface + public String p2pProceedSync() { + String precondError = checkProceedPreconditions(); + if (precondError != null) { + return precondError; + } + try { + final JSONArray docEntries = pendingDocIds; + final P2pSyncClient client = activeSyncClient; + pendingDocIds = null; + + new Thread(() -> runPushThread(docEntries, client), "P2pClientSyncPush").start(); + + JSONObject result = new JSONObject(); + result.put(KEY_OK, true); + return result.toString(); + } catch (JSONException e) { + Log.e(TAG, "Error proceeding with sync", e); + return errorJson("proceed_failed: " + e.getMessage()); + } + } + + private String checkProceedPreconditions() { + if (pendingDocIds == null || pendingDocIds.length() == 0) { + return errorJson("no_pending_docs: nothing to sync"); + } + if (activeSyncClient == null) { + return errorJson("no_active_client: connection lost"); + } + if (!STATE_PREVIEW.equals(clientSyncState)) { + return errorJson("invalid_state: expected preview, got " + clientSyncState); + } + return null; + } + + private void runPushThread(JSONArray docEntries, P2pSyncClient client) { + try { + clientSyncState = STATE_SYNCING; + clientTotalDocs = docEntries.length(); + + int pushed = pushDocsInBatches(docEntries, client); + + Log.i(TAG, "Client sync: push complete, " + + pushed + " docs sent to Supervisor"); + + signalSyncComplete(client, pushed); + updateTrackerAfterPush(pushed); + + clientSyncState = STATE_COMPLETED; + Log.i(TAG, "Client sync: completed successfully"); + + saveSyncLogSafe(); + } catch (JSONException | IOException e) { + Log.e(TAG, "Client sync failed", e); + clientSyncError = "Sync error: " + e.getMessage(); + clientSyncState = STATE_FAILED; + } finally { + clientSyncRunning = false; + } + } + + private static final int PUSH_BATCH_SIZE = 50; + + /** + * Push docs to the Supervisor in batches. + * @return total number of docs accepted by the host + */ + private int pushDocsInBatches(JSONArray docEntries, P2pSyncClient client) + throws JSONException, IOException { + int totalDocs = docEntries.length(); + int pushed = 0; + for (int i = 0; i < totalDocs; i += PUSH_BATCH_SIZE) { + int end = Math.min(i + PUSH_BATCH_SIZE, totalDocs); + pushed += pushOneBatch(docEntries, i, end, client); + clientDocsSynced = pushed; + Log.d(TAG, "Client sync: pushed " + pushed + "/" + totalDocs); + } + return pushed; + } + + private int pushOneBatch(JSONArray docEntries, int start, int end, P2pSyncClient client) + throws JSONException, IOException { + JSONArray idStrings = new JSONArray(); + for (int j = start; j < end; j++) { + idStrings.put(docEntries.getJSONObject(j).getString(KEY_DOC_ID)); + } + + String docsJson = p2pManager.getBridge().getDocsByIds(idStrings.toString()); + JSONArray docs = new JSONArray(docsJson); + + if (docs.length() == 0) { + return 0; + } + + JSONObject result = client.acceptDocs(docs); + int accepted = result.optInt("accepted", 0); + int transit = result.optInt("transit", 0); + int rejected = result.optInt("rejected", 0); + if (rejected > 0) { + Log.w(TAG, "Client sync: " + rejected + " docs REJECTED by host. Errors: " + + result.optJSONArray("errors")); + } + clientBytesTransferred.addAndGet(docsJson.length()); + return accepted + transit; + } + + /** + * Signal sync completion to the Supervisor (non-fatal on failure). + */ + private void signalSyncComplete(P2pSyncClient client, int pushed) { + try { + client.syncComplete(pushed, clientBytesTransferred.get()); + Log.i(TAG, "Client sync: sent sync-complete to Supervisor"); + } catch (JSONException | IOException e) { + Log.w(TAG, "Client sync: failed to signal sync-complete (non-fatal)", e); + } + } + + /** + * Update tracker session counters after a successful push. + */ + private void updateTrackerAfterPush(int pushed) { + if (tracker != null) { + P2pSession trackerSession = tracker.getCurrentSession(); + if (trackerSession != null) { + trackerSession.incrementDocsPushed(pushed); + trackerSession.addBytesTransferred(clientBytesTransferred.get()); + } + tracker.completeSession(); + } + } + + /** + * Save sync log to PouchDB, logging errors without throwing. + */ + private void saveSyncLogSafe() { + try { + saveSyncLogToPouchDb(false); + } catch (RuntimeException saveErr) { + Log.e(TAG, "Client sync: failed to save sync log", saveErr); + } + } + + /** + * Get P2P sync status. + * + * @return JSON string with status fields from P2pManager and tracker + */ + @JavascriptInterface + public String p2pGetStatus() { + try { + if (p2pManager == null) { + return buildIdleStatus(); + } + + JSONObject status = new JSONObject(); + String state = populateTrackerState(status); + state = populateModeState(status, state); + + status.put("state", state); + if (clientSyncError != null) { + status.put(KEY_ERROR, clientSyncError); + } + status.put("initialized", p2pManager.isInitialized()); + + populateHotspotInfo(status); + populateConnectedPeers(status); + + if (cachedQrDataUrl != null) { + status.put("qr_code_data_url", cachedQrDataUrl); + } + + return status.toString(); + } catch (JSONException e) { + Log.e(TAG, "Error building status JSON", e); + return buildIdleStatus(); + } + } + + private String populateTrackerState(JSONObject status) throws JSONException { + if (tracker != null && tracker.hasActiveSession()) { + P2pSession session = tracker.getCurrentSession(); + status.put(KEY_DOCS_SYNCED, session.getDocsPushed() + session.getDocsPulled()); + status.put(KEY_TOTAL_DOCS, session.getDocsPushed() + session.getDocsPulled() + + session.getTransitDocs()); + status.put(KEY_BYTES_TRANSFERRED, session.getBytesTransferred()); + return session.getState().name().toLowerCase(Locale.ROOT); + } + status.put(KEY_DOCS_SYNCED, 0); + status.put(KEY_TOTAL_DOCS, 0); + status.put(KEY_BYTES_TRANSFERRED, 0); + return STATE_IDLE; + } + + private String populateModeState(JSONObject status, String state) throws JSONException { + if (p2pManager.isHostModeActive()) { + return populateHostModeState(status, state); + } + if (p2pManager.isClientModeActive()) { + return populateClientModeState(status); + } + return state; + } + + private String populateHostModeState(JSONObject status, String state) throws JSONException { + int peerCount = p2pManager.getConnectedPeerCount(); + P2pSession httpSession = p2pManager.getHttpSession(); + + state = resolveHostState(status, state, httpSession, peerCount); + logHostStatus(state, httpSession); + return state; + } + + private String resolveHostState(JSONObject status, String state, + P2pSession httpSession, int peerCount) throws JSONException { + if (httpSession != null && httpSession.getState() == P2pSession.State.COMPLETED) { + populateSessionDocs(status, httpSession); + return STATE_COMPLETED; + } + if (peerCount > 0 && httpSession != null + && httpSession.getState() == P2pSession.State.ACTIVE) { + return deriveActiveHostState(status, httpSession); + } + return STATE_IDLE.equals(state) ? "waiting" : state; + } + + private void populateSessionDocs(JSONObject status, P2pSession httpSession) throws JSONException { + int sessionDocs = httpSession.getDocsPulled() + httpSession.getTransitDocs(); + status.put(KEY_DOCS_SYNCED, sessionDocs); + status.put(KEY_TOTAL_DOCS, sessionDocs); + status.put(KEY_BYTES_TRANSFERRED, httpSession.getBytesTransferred()); + } + + private void logHostStatus(String state, P2pSession httpSession) { + if (httpSession != null) { + Log.d(TAG, "Host status: state=" + state + + " pulled=" + httpSession.getDocsPulled() + + " transit=" + httpSession.getTransitDocs() + + " bytes=" + httpSession.getBytesTransferred()); + } + } + + private String deriveActiveHostState(JSONObject status, P2pSession httpSession) throws JSONException { + if (httpSession.getDocsPulled() > 0 || httpSession.getDocsPushed() > 0 + || httpSession.getTransitDocs() > 0) { + int sessionDocs = httpSession.getDocsPulled() + httpSession.getTransitDocs(); + status.put(KEY_DOCS_SYNCED, sessionDocs); + status.put(KEY_TOTAL_DOCS, sessionDocs); + status.put(KEY_BYTES_TRANSFERRED, httpSession.getBytesTransferred()); + return STATE_SYNCING; + } + return "peer_connected"; + } + + private String populateClientModeState(JSONObject status) throws JSONException { + String state = clientSyncState; + status.put(KEY_DOCS_SYNCED, clientDocsSynced); + status.put(KEY_TOTAL_DOCS, clientTotalDocs); + status.put(KEY_BYTES_TRANSFERRED, clientBytesTransferred.get()); + if (STATE_PREVIEW.equals(state)) { + status.put("preview_contacts", previewContactCount); + status.put("preview_reports", previewReportCount); + status.put("preview_total", clientTotalDocs); + } + return state; + } + + private void populateHotspotInfo(JSONObject status) throws JSONException { + JSONObject managerStatus = p2pManager.getStatusJson(); + status.put("hotspot_active", managerStatus.optBoolean("hotspot_active", false)); + String ssid = managerStatus.optString("hotspot_ssid", null); + if (ssid != null) { + status.put("hotspot_ssid", ssid); + } + String pwd = p2pManager.getHotspotPassword(); + if (pwd != null) { + status.put("hotspot_password", pwd); + } + } + + private void populateConnectedPeers(JSONObject status) throws JSONException { + JSONArray peersArray = buildConnectedPeersArray(); + status.put("connected_peers", peersArray); + } + + private JSONArray buildConnectedPeersArray() { + JSONArray peersArray = new JSONArray(); + int peerCount = p2pManager.getConnectedPeerCount(); + if (peerCount == 0) return peersArray; + + P2pSession httpSession = p2pManager.getHttpSession(); + if (httpSession != null) { + String userId = httpSession.getPeerUserId(); + peersArray.put(userId != null ? userId : "peer"); + } + return peersArray; + } + + /** + * Get all transit doc IDs for UI filtering. + * Returns JSON array of doc IDs that should be hidden from the UI. + * Must return in <50ms (uses in-memory HashMap lookup). + * + * @return JSON array string: ["doc_id_1", "doc_id_2", ...] + */ + @JavascriptInterface + public String p2pGetTransitDocIds() { + try { + if (transitDocManager == null) { + return "[]"; + } + + Set transitIds = transitDocManager.getAllTransitDocIds(); + JSONArray array = new JSONArray(); + for (String docId : transitIds) { + array.put(docId); + } + return array.toString(); + } catch (RuntimeException e) { + Log.e(TAG, "Error getting transit doc IDs", e); + return "[]"; + } + } + + /** + * Check if a specific doc is a transit doc. + * Used for single-doc checks in the webapp. + * + * @param docId the document ID to check + * @return true if the doc is a transit doc that should be hidden + */ + @JavascriptInterface + public boolean p2pIsTransitDoc(String docId) { + return transitDocManager != null + && docId != null + && transitDocManager.isTransitDoc(docId); + } + + /** + * Get transit docs that are ready to be purged (pushed to server but not yet purged). + * Returns JSON with batch info so the webapp can call db.purge() for each doc. + * MUST use db.purge(), never db.remove(). + * + * @return JSON string: { + * "batches": [ + * { "batch_id": "uuid", "doc_ids": ["doc1", "doc2", ...] } + * ], + * "total_docs": number + * } + */ + @JavascriptInterface + public String p2pPurgeTransitDocs() { + try { + if (transitDocManager == null) { + return buildEmptyPurgeResponse(); + } + + Set purgeableBatchIds = transitDocManager.getPurgeableBatchIds(); + if (purgeableBatchIds.isEmpty()) { + return buildEmptyPurgeResponse(); + } + + return buildPurgeResponse(purgeableBatchIds); + } catch (JSONException e) { + Log.e(TAG, "Error building purge response", e); + return buildEmptyPurgeResponse(); + } + } + + private String buildPurgeResponse(Set purgeableBatchIds) throws JSONException { + JSONArray batchesArray = new JSONArray(); + int totalDocs = 0; + + for (String batchId : purgeableBatchIds) { + Set docIds = transitDocManager.getDocIdsForBatch(batchId); + if (docIds.isEmpty()) continue; + + batchesArray.put(buildBatchObject(batchId, docIds)); + totalDocs += docIds.size(); + } + + JSONObject response = new JSONObject(); + response.put("batches", batchesArray); + response.put(KEY_TOTAL_DOCS, totalDocs); + return response.toString(); + } + + private static JSONObject buildBatchObject(String batchId, Set docIds) throws JSONException { + JSONObject batchObj = new JSONObject(); + batchObj.put("batch_id", batchId); + + JSONArray docIdsArray = new JSONArray(); + for (String docId : docIds) { + docIdsArray.put(docId); + } + batchObj.put("doc_ids", docIdsArray); + return batchObj; + } + + /** + * Confirm that a batch has been purged by the webapp. + * Called after webapp successfully runs db.purge() for all docs in a batch. + * The webapp MUST use db.purge(id, rev), never db.remove(). + * + * @param batchId the batch ID that was purged + */ + @JavascriptInterface + public void p2pConfirmBatchPurged(String batchId) { + if (transitDocManager != null && batchId != null && !batchId.isEmpty()) { + transitDocManager.markBatchPurged(batchId); + Log.i(TAG, "Batch purged confirmed: " + batchId); + + // Archive old purged batches if transit doc is oversized + if (transitDocManager.isOversized()) { + transitDocManager.archivePurgedBatches(); + Log.i(TAG, "Archived purged batches due to size limit ()"); + } + } + } + + /** + * Get P2P sync history (completed sessions). + * Returns JSON matching _local/p2p-sync-log schema from . + * + * @return JSON string with sessions array + */ + @JavascriptInterface + public String p2pGetSyncHistory() { + try { + if (tracker == null) { + return buildEmptySyncHistory(); + } + + JSONObject syncLog = tracker.buildSyncLog(); + return syncLog.toString(); + } catch (JSONException e) { + Log.e(TAG, "Error building sync history", e); + return buildEmptySyncHistory(); + } + } + + /** + * Check if there are stale transit docs (unpushed for >30 days). + * The webapp should show a notification to the user if true. + * + * @return true if there are stale transit docs + */ + @JavascriptInterface + public boolean p2pHasStaleTransitDocs() { + return transitDocManager != null && transitDocManager.hasStaleTransitDocs(); + } + + /** + * Get device P2P capability. + * Checks Android API level, WiFi support, storage, battery, etc. + * + * @return JSON string: { "capable": boolean, "capability": string, "reason": string | null } + */ + @JavascriptInterface + public String p2pGetCapability() { + try { + JSONObject result = new JSONObject(); + + if (p2pManager == null) { + result.put("capable", false); + result.put("capability", "unknown"); + result.put("reason", "P2P manager not initialized"); + return result.toString(); + } + + P2pManager.P2pCapability capability = p2pManager.checkCapability(); + + boolean capable = capability == P2pManager.P2pCapability.FULLY_SUPPORTED + || capability == P2pManager.P2pCapability.SUPPORTED_NO_CAMERA + || capability == P2pManager.P2pCapability.LOW_RAM_WARNING + || capability == P2pManager.P2pCapability.LOW_BATTERY_WARNING; + + result.put("capable", capable); + result.put("capability", capability.name().toLowerCase(Locale.ROOT)); + result.put("reason", getCapabilityReason(capability)); + + result.put("api_level", Build.VERSION.SDK_INT); + result.put("manufacturer", OemBatteryHelper.getManufacturer()); + result.put("model", OemBatteryHelper.getModel()); + result.put("battery_risk", OemBatteryHelper.getRiskLevel().name().toLowerCase(Locale.ROOT)); + + return result.toString(); + } catch (JSONException e) { + Log.e(TAG, "Error checking capability", e); + return errorJson("capability_check_failed"); + } + } + + /** + * Get OEM-specific battery optimization guidance text. + * Users need to disable battery optimization for reliable P2P sync. + * + * @return human-readable guidance string for the current device manufacturer + */ + @JavascriptInterface + public String p2pGetBatteryGuidance() { + return OemBatteryHelper.getGuidance(); + } + + /** + * Check if the device needs battery optimization guidance. + * + * @return true if the OEM is known to aggressively kill background services + */ + @JavascriptInterface + public boolean p2pNeedsBatteryGuidance() { + return OemBatteryHelper.needsBatteryGuidance(); + } + + /** + * Initialize the P2P manager with config from the webapp. + * Must be called before startHostMode/startClientMode. + * + * Expected JSON: + * { + * "config": { ... p2p_sync config ... }, + * "scope_manifest": { "facility_subtree_root": "...", "replication_depth": 1, ... }, + * "server_public_key": "base64-encoded-key", + * "revocation_list": { "version": 0, "revoked_devices": [], "revoked_users": [] }, + * "device_id": "...", + * "user_id": "..." + * } + * + * @param configJson full initialization config from webapp + * @return JSON: {"ok": true} or {"ok": false, "error": "..."} + */ + @JavascriptInterface + public String p2pInitialize(String configJson) { + try { + return doInitialize(new JSONObject(configJson)); + } catch (P2pManager.P2pInitException e) { + Log.e(TAG, "P2P initialization failed", e); + return errorJson("init_failed: " + e.getMessage()); + } catch (JSONException e) { + Log.e(TAG, "Invalid config JSON", e); + return errorJson("invalid_config: " + e.getMessage()); + } catch (RuntimeException e) { + Log.e(TAG, "Unexpected error during P2P initialization", e); + return errorJson("init_error: " + e.getMessage()); + } + } + + private String doInitialize(JSONObject json) throws JSONException, P2pManager.P2pInitException { + JSONObject configObj = json.optJSONObject("config"); + P2pConfig config = configObj != null ? P2pConfig.fromJson(configObj) : P2pConfig.defaults(); + + ScopeManifest scopeManifest = ScopeManifest.fromJson(json.getJSONObject("scope_manifest")); + String serverPublicKey = json.getString("server_public_key"); + + JSONObject revObj = json.optJSONObject("revocation_list"); + RevocationList revocationList = revObj != null + ? RevocationList.fromJson(revObj) + : RevocationList.empty(); + + String deviceId = json.getString("device_id"); + String userId = json.getString("user_id"); + + cacheJwtIfPresent(json); + + PouchDbBridge realBridge = createPouchDbBridge(); + p2pManager.initialize(config, serverPublicKey, revocationList, + scopeManifest, realBridge, deviceId, userId); + + JSONObject result = new JSONObject(); + result.put(KEY_OK, true); + return result.toString(); + } + + private void cacheJwtIfPresent(JSONObject json) { + String jwt = json.optString("token", null); + if (jwt != null && !jwt.isEmpty()) { + cachedJwt = jwt; + Log.d(TAG, "JWT token cached for P2P auth"); + } + } + + /** + * Check if P2P sync is currently active (host or client mode). + * Used by webapp to conditionally pause server replication. + */ + @JavascriptInterface + public String p2pIsActive() { + try { + JSONObject result = new JSONObject(); + result.put("active", p2pManager.isHostModeActive() || p2pManager.isClientModeActive()); + return result.toString(); + } catch (JSONException e) { + return errorJson("check_failed: " + e.getMessage()); + } + } + + // --- Private helpers --- + + private static final long POUCHDB_TIMEOUT_SEC = 30; + + /** + * Synchronously evaluate a JS expression against PouchDB from a background thread. + * Uses a bridge callback (p2pAsyncCallback) instead of timed polling — + * the Promise .then() calls back into Java directly, so there's no race condition. + */ + private String evalPouchDb(String jsExpression) { + if (webView == null) { + Log.e(TAG, "evalPouchDb: webView is null"); + return "[]"; + } + + String callbackId = "__p2p_cb_" + System.nanoTime(); + CountDownLatch latch = new CountDownLatch(1); + asyncCallbackLatches.put(callbackId, latch); + + webView.post(() -> { + // Promise resolves → calls bridge callback directly (no timing dependency) + String js = "Promise.resolve(" + jsExpression + ").then(function(r) {" + + " medicmobile_android.p2pAsyncCallback('" + callbackId + "', r);" + + "}).catch(function(e) {" + + " medicmobile_android.p2pAsyncCallback('" + callbackId + "'," + + " JSON.stringify({error: e.message || 'unknown'}));" + + "});"; + + webView.evaluateJavascript(js, ignored -> { /* fire and forget */ }); + }); + + try { + if (!latch.await(POUCHDB_TIMEOUT_SEC, TimeUnit.SECONDS)) { + Log.e(TAG, "evalPouchDb: timed out waiting for callback " + callbackId); + return "[]"; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return "[]"; + } finally { + asyncCallbackLatches.remove(callbackId); + } + + String result = asyncCallbackResults.remove(callbackId); + return result != null ? result : "[]"; + } + + /** + * Save sync log to PouchDB so history persists across app restarts. + * Host (supervisor) saves to _local/p2p-relay-log. + * Peer (CHW) saves to _local/p2p-sync-log. + * Merges new sessions with existing ones, keeping last 50. + */ + private void saveSyncLogToPouchDb(boolean isHost) { + if (tracker == null || webView == null) { + Log.w(TAG, "saveSyncLogToPouchDb: tracker or webView is null, skipping"); + return; + } + try { + JSONObject log = isHost ? tracker.buildRelayLog() : tracker.buildSyncLog(); + String docId = isHost ? RELAY_LOG_DOC_ID : SYNC_LOG_DOC_ID; + saveMergedSessionsToPouch(log, docId, "saveSyncLogToPouchDb"); + } catch (JSONException e) { + Log.e(TAG, "saveSyncLogToPouchDb: failed to save", e); + } + } + + private void saveMergedSessionsToPouch(JSONObject log, String docId, String callerTag) { + JSONArray newSessions = log.optJSONArray(KEY_SESSIONS); + if (newSessions == null || newSessions.length() == 0) { + Log.d(TAG, callerTag + ": no sessions to save"); + return; + } + + String sessionsJsonStr = newSessions.toString() + .replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\n", "\\n"); + + String js = buildMergeSessionsJs(docId, sessionsJsonStr); + String result = evalPouchDb(js); + Log.i(TAG, callerTag + ": saved " + docId + LOG_RESULT_PREFIX + result); + } + + private static String buildMergeSessionsJs(String docId, String sessionsJsonStr) { + return "(async function() {" + + " var db = window.CHTCore.DB.get();" + + " var docId = '" + docId + "';" + + " var newSessions = JSON.parse('" + sessionsJsonStr + "');" + + " var doc;" + + " try { doc = await db.get(docId); } catch(e) { doc = { _id: docId, sessions: [] }; }" + + " var existing = doc.sessions || [];" + + " var existingIds = new Set(existing.map(function(s) { return s.session_id; }));" + + " for (var i = 0; i < newSessions.length; i++) {" + + " if (!existingIds.has(newSessions[i].session_id)) {" + + " existing.push(newSessions[i]);" + + " }" + + " }" + + " doc.sessions = existing.slice(-50);" + + " await db.put(doc);" + + " return JSON.stringify({ ok: true, total: doc.sessions.length });" + + "})()"; + } + + /** + * Save a pre-built log JSON to PouchDB. + * Used by p2pStop() to avoid race condition — the log is built synchronously + * before shutdown clears state, then this method just writes the pre-built data. + */ + private void savePrebuiltLogToPouchDb(JSONObject log, String docId) { + if (webView == null) { + Log.w(TAG, "savePrebuiltLogToPouchDb: webView is null, skipping"); + return; + } + try { + saveMergedSessionsToPouch(log, docId, "savePrebuiltLogToPouchDb"); + } catch (RuntimeException e) { + Log.e(TAG, "savePrebuiltLogToPouchDb: failed to save", e); + } + } + + /** + * Persist transit doc state to PouchDB (_local/p2p-transit-docs). + * Called from p2pStop() on a background thread so the transit index survives + * app restart. Merges with any existing doc (preserves _rev for update). + */ + private void saveTransitStateToPouchDb(JSONObject transitJson) { + if (webView == null) { + Log.w(TAG, "saveTransitStateToPouchDb: webView is null, skipping"); + return; + } + try { + String docId = TransitDocManager.TRANSIT_DOC_ID; + String escaped = transitJson.toString() + .replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\n", "\\n"); + + String js = + "(async function() {" + + " var db = window.CHTCore.DB.get();" + + " var newDoc = JSON.parse('" + escaped + "');" + + " try {" + + " var existing = await db.get('" + docId + "');" + + " newDoc._rev = existing._rev;" + + " } catch(e) {}" + + " await db.put(newDoc);" + + " return JSON.stringify({ ok: true });" + + "})()"; + + String result = evalPouchDb(js); + Log.i(TAG, "saveTransitStateToPouchDb: saved " + docId + LOG_RESULT_PREFIX + result); + } catch (RuntimeException e) { + Log.e(TAG, "saveTransitStateToPouchDb: failed to save", e); + } + } + + private PouchDbBridge createPouchDbBridge() { + return new PouchDbBridge() { + @Override + public String getAllDocIds() { + return evalPouchDb( + "window.CHTCore.DB.get().allDocs().then(function(result) {" + + " return JSON.stringify(result.rows.map(function(r) {" + + " return { _id: r.id, _rev: r.value.rev };" + + " }));" + + "})" + ); + } + + @Override + public String getDocsByIds(String idsJson) { + String escaped = idsJson.replace("\\", "\\\\").replace("'", "\\'"); + return evalPouchDb( + "window.CHTCore.DB.get().allDocs({ keys: JSON.parse('" + escaped + "'), include_docs: true }).then(function(result) {" + + " return JSON.stringify(result.rows.filter(function(r) { return r.doc; }).map(function(r) { return r.doc; }));" + + "})" + ); + } + + @Override + public String writeDocs(String docsJson) { + String escaped = docsJson.replace("\\", "\\\\").replace("'", "\\'"); + return evalPouchDb( + "window.CHTCore.DB.get().bulkDocs(JSON.parse('" + escaped + "'), { new_edits: false }).then(function(result) {" + + " return JSON.stringify(result);" + + "})" + ); + } + + @Override + public String queryContactsByDepth(String facilityId, int maxDepth) { + String escapedFacility = facilityId.replace("\\", "\\\\").replace("'", "\\'"); + return evalPouchDb( + "window.CHTCore.DB.get().query('medic-client/contacts_by_depth', {" + + " startkey: ['" + escapedFacility + "']," + + " endkey: ['" + escapedFacility + "', " + maxDepth + ", {}]" + + "}).then(function(result) {" + + " var ids = [];" + + " var seen = {};" + + " result.rows.forEach(function(r) {" + + " if (!seen[r.id]) { seen[r.id] = true; ids.push(r.id); }" + + " });" + + " return JSON.stringify(ids);" + + "})" + ); + } + }; + } + + private Object getCapabilityReason(P2pManager.P2pCapability capability) { + switch (capability) { + case FULLY_SUPPORTED: + return JSONObject.NULL; + case SUPPORTED_NO_CAMERA: + return "No camera available. QR scanning disabled; Host mode only."; + case UNSUPPORTED_API_LEVEL: + return "Android 8.0 (API 26) or higher required. Current API level: " + + Build.VERSION.SDK_INT; + case NO_WIFI_HARDWARE: + return "WiFi hardware not available on this device."; + case LOW_RAM_WARNING: + return "Low RAM detected. P2P sync may be slow."; + case LOW_STORAGE: + return "Insufficient storage. Free up space before syncing."; + case LOW_BATTERY_WARNING: + return "Low battery. Charge device before starting P2P sync."; + case PERMISSION_NEEDED: + return "WiFi permissions required. Please grant permissions."; + case LOCATION_SERVICES_OFF: + return "Location services must be enabled to start WiFi hotspot. " + + "Please turn on Location in Settings."; + default: + return JSONObject.NULL; + } + } + + private String errorJson(String error) { + try { + JSONObject json = new JSONObject(); + json.put(KEY_OK, false); + json.put(KEY_ERROR, error); + return json.toString(); + } catch (JSONException e) { + Log.e(TAG, "Error building error JSON", e); + return "{\"ok\":false,\"error\":\"internal_error\"}"; + } + } + + private String buildEmptyPurgeResponse() { + try { + JSONObject response = new JSONObject(); + response.put("batches", new JSONArray()); + response.put(KEY_TOTAL_DOCS, 0); + return response.toString(); + } catch (JSONException e) { + return "{\"batches\":[],\"total_docs\":0}"; + } + } + + private String buildEmptySyncHistory() { + try { + JSONObject log = new JSONObject(); + log.put(KEY_DOC_ID, SYNC_LOG_DOC_ID); + log.put(KEY_SESSIONS, new JSONArray()); + return log.toString(); + } catch (JSONException e) { + return "{\"_id\":\"_local/p2p-sync-log\",\"sessions\":[]}"; + } + } + + private String buildIdleStatus() { + try { + JSONObject status = new JSONObject(); + status.put("initialized", false); + status.put("supervisor_mode_active", false); + status.put("chw_mode_active", false); + status.put("p2p_enabled", false); + status.put("pending_transit_docs", 0); + return status.toString(); + } catch (JSONException e) { + return "{\"initialized\":false}"; + } + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pConfig.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pConfig.java new file mode 100644 index 00000000..d87405c0 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pConfig.java @@ -0,0 +1,230 @@ +package org.medicmobile.webapp.mobile.p2p; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * P2P configuration parsed from the p2p_sync section of app_settings. + * + * Default values match . + * All size getters provide both human-readable (MB/KB) and byte values. + */ +public class P2pConfig { + + private static final boolean DEFAULT_ENABLED = true; + private static final boolean DEFAULT_WIFI_HOTSPOT_ENABLED = true; + private static final int DEFAULT_MAX_RELAY_SIZE_MB = 50; + private static final int DEFAULT_MAX_DOC_SIZE_KB = 256; + private static final int DEFAULT_MAX_ATTACHMENT_SIZE_MB = 5; + private static final int DEFAULT_TOKEN_EXPIRY_DAYS = 30; + private static final int DEFAULT_WIFI_HOTSPOT_IDLE_TIMEOUT_SEC = 300; + private static final List DEFAULT_ALLOWED_ROLES = + Collections.unmodifiableList(Collections.emptyList()); + private static final boolean DEFAULT_AUDIT_LOGGING = true; + + private final boolean enabled; + private final boolean wifiHotspotEnabled; + private final int maxRelaySizeMb; + private final int maxDocSizeKb; + private final int maxAttachmentSizeMb; + private final int tokenExpiryDays; + private final int wifiHotspotIdleTimeoutSec; + private final List allowedRoles; + private final boolean auditLogging; + + private P2pConfig(Builder builder) { + this.enabled = builder.enabled; + this.wifiHotspotEnabled = builder.wifiHotspotEnabled; + this.maxRelaySizeMb = builder.maxRelaySizeMb; + this.maxDocSizeKb = builder.maxDocSizeKb; + this.maxAttachmentSizeMb = builder.maxAttachmentSizeMb; + this.tokenExpiryDays = builder.tokenExpiryDays; + this.wifiHotspotIdleTimeoutSec = builder.wifiHotspotIdleTimeoutSec; + this.allowedRoles = Collections.unmodifiableList(new ArrayList<>(builder.allowedRoles)); + this.auditLogging = builder.auditLogging; + } + + static class Builder { + boolean enabled = DEFAULT_ENABLED; + boolean wifiHotspotEnabled = DEFAULT_WIFI_HOTSPOT_ENABLED; + int maxRelaySizeMb = DEFAULT_MAX_RELAY_SIZE_MB; + int maxDocSizeKb = DEFAULT_MAX_DOC_SIZE_KB; + int maxAttachmentSizeMb = DEFAULT_MAX_ATTACHMENT_SIZE_MB; + int tokenExpiryDays = DEFAULT_TOKEN_EXPIRY_DAYS; + int wifiHotspotIdleTimeoutSec = DEFAULT_WIFI_HOTSPOT_IDLE_TIMEOUT_SEC; + List allowedRoles = new ArrayList<>(DEFAULT_ALLOWED_ROLES); + boolean auditLogging = DEFAULT_AUDIT_LOGGING; + + P2pConfig build() { + return new P2pConfig(this); + } + } + + /** + * Parse P2P config from the "p2p_sync" section of app_settings JSON. + * Missing fields fall back to defaults. + * + * Expected JSON structure: + * { + * "enabled": true, + * "transports": { "wifi_hotspot": true }, + * "max_relay_size_mb": 50, + * "max_doc_size_kb": 256, + * "max_attachment_size_mb": 5, + * "token_expiry_days": 30, + * "wifi_hotspot_idle_timeout_sec": 300, + * "host_roles": ["community_health_assistant"], + * "peer_roles": ["community_health_volunteer"], + * "audit_logging": true + * } + */ + public static P2pConfig fromJson(JSONObject p2pSyncSection) { + if (p2pSyncSection == null) { + return defaults(); + } + + Builder builder = new Builder(); + builder.enabled = p2pSyncSection.optBoolean("enabled", DEFAULT_ENABLED); + + JSONObject transports = p2pSyncSection.optJSONObject("transports"); + if (transports != null) { + builder.wifiHotspotEnabled = transports.optBoolean("wifi_hotspot", DEFAULT_WIFI_HOTSPOT_ENABLED); + } + + builder.maxRelaySizeMb = p2pSyncSection.optInt("max_relay_size_mb", DEFAULT_MAX_RELAY_SIZE_MB); + builder.maxDocSizeKb = p2pSyncSection.optInt("max_doc_size_kb", DEFAULT_MAX_DOC_SIZE_KB); + builder.maxAttachmentSizeMb = p2pSyncSection.optInt("max_attachment_size_mb", DEFAULT_MAX_ATTACHMENT_SIZE_MB); + builder.tokenExpiryDays = p2pSyncSection.optInt("token_expiry_days", DEFAULT_TOKEN_EXPIRY_DAYS); + builder.wifiHotspotIdleTimeoutSec = p2pSyncSection.optInt("wifi_hotspot_idle_timeout_sec", DEFAULT_WIFI_HOTSPOT_IDLE_TIMEOUT_SEC); + builder.auditLogging = p2pSyncSection.optBoolean("audit_logging", DEFAULT_AUDIT_LOGGING); + builder.allowedRoles = parseRoles(p2pSyncSection.optJSONArray("allowed_roles")); + + return builder.build(); + } + + /** + * Create a config with all default values. + */ + public static P2pConfig defaults() { + return new Builder().build(); + } + + // --- Getters --- + + public boolean isEnabled() { + return enabled; + } + + public boolean isWifiHotspotEnabled() { + return wifiHotspotEnabled; + } + + public int getMaxRelaySizeMb() { + return maxRelaySizeMb; + } + + public int getMaxDocSizeKb() { + return maxDocSizeKb; + } + + public int getMaxAttachmentSizeMb() { + return maxAttachmentSizeMb; + } + + public int getTokenExpiryDays() { + return tokenExpiryDays; + } + + public int getWifiHotspotIdleTimeoutSec() { + return wifiHotspotIdleTimeoutSec; + } + + public List getAllowedRoles() { + return allowedRoles; + } + + public boolean isAuditLogging() { + return auditLogging; + } + + // --- Validation --- + + /** + * Check if a given role is allowed to participate in P2P sync. + */ + public boolean isRoleAllowed(String role) { + if (role == null || role.isEmpty()) { + return false; + } + return allowedRoles.contains(role); + } + + // --- Size limit helpers (return bytes) --- + + public long getMaxDocSizeBytes() { + return (long) maxDocSizeKb * 1024; + } + + public long getMaxAttachmentSizeBytes() { + return (long) maxAttachmentSizeMb * 1024 * 1024; + } + + public long getMaxRelaySizeBytes() { + return (long) maxRelaySizeMb * 1024 * 1024; + } + + // --- Serialization --- + + /** + * Convert config back to JSON (useful for caching in _local/p2p-config-cache). + */ + public JSONObject toJson() throws JSONException { + JSONObject json = new JSONObject(); + json.put("enabled", enabled); + + JSONObject transports = new JSONObject(); + transports.put("wifi_hotspot", wifiHotspotEnabled); + json.put("transports", transports); + + json.put("max_relay_size_mb", maxRelaySizeMb); + json.put("max_doc_size_kb", maxDocSizeKb); + json.put("max_attachment_size_mb", maxAttachmentSizeMb); + json.put("token_expiry_days", tokenExpiryDays); + json.put("wifi_hotspot_idle_timeout_sec", wifiHotspotIdleTimeoutSec); + + JSONArray rolesArray = new JSONArray(); + for (String role : allowedRoles) { + rolesArray.put(role); + } + json.put("allowed_roles", rolesArray); + + json.put("audit_logging", auditLogging); + return json; + } + + // --- Private helpers --- + + private static List parseRoles(JSONArray rolesArray) { + if (rolesArray == null || rolesArray.length() == 0) { + return new ArrayList<>(DEFAULT_ALLOWED_ROLES); + } + List roles = extractNonEmptyStrings(rolesArray); + return roles.isEmpty() ? new ArrayList<>(DEFAULT_ALLOWED_ROLES) : roles; + } + + private static List extractNonEmptyStrings(JSONArray array) { + List result = new ArrayList<>(array.length()); + for (int i = 0; i < array.length(); i++) { + String value = array.optString(i, null); + if (value != null && !value.isEmpty()) { + result.add(value); + } + } + return result; + } +} 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..ea2c3e55 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pForegroundService.java @@ -0,0 +1,124 @@ +package org.medicmobile.webapp.mobile.p2p; + +import android.app.Notification; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.IBinder; +import android.util.Log; + +/** + * 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 { + + private static final String TAG = "P2pForegroundService"; + + 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.i(TAG, "P2P foreground service created"); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null) { + Log.w(TAG, "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 { + Log.w(TAG, "Unknown action: " + action); + } + + return START_STICKY; + } + + @Override + public IBinder onBind(Intent intent) { + // Not a bound service + return null; + } + + @Override + public void onDestroy() { + Log.i(TAG, "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.i(TAG, "Starting foreground P2P sync service"); + Notification notification = notificationChannel.buildSyncNotification( + "P2P Sync Active", + "Waiting for peer connections..." + ); + startForeground(P2pNotificationChannel.NOTIFICATION_ID, notification); + } + + private void handleStop() { + Log.i(TAG, "Stopping foreground P2P sync service"); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE); + } else { + stopForeground(true); //NOLINT: deprecated but needed for API 21-23 + } + 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..3c1d8ca3 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pManager.java @@ -0,0 +1,1415 @@ +package org.medicmobile.webapp.mobile.p2p; + +import android.app.ActivityManager; +import android.content.Context; +import android.content.pm.PackageManager; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkRequest; +import android.net.wifi.WifiManager; +import android.net.wifi.WifiNetworkSpecifier; +import android.os.BatteryManager; +import android.os.Build; +import android.os.Environment; +import android.os.StatFs; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.util.List; + +/** + * P2pManager orchestrates the entire P2P sync lifecycle: + * + * 1. checkCapability() -- verify device supports P2P + * 2. initialize() -- setup components from cached config + * 3. startHostMode() -- start hotspot + HTTP server + show QR + * 4. startClientMode() -- scan QR + connect to host + sync + * 5. shutdown() -- tear down everything cleanly + * + * Host flow: capability check -> OEM guidance -> acquire mutex -> + * start foreground service -> start hotspot -> start HTTP server -> + * generate QR -> wait for clients -> sync -> teardown + * + * Client flow: capability check -> scan QR -> connect WiFi -> + * authenticate -> preview -> sync docs -> disconnect + * + * This is the single entry point for all P2P operations. All errors are + * handled gracefully with cleanup -- no unhandled exceptions escape. + */ +public class P2pManager { + + private static final String TAG = "P2pManager"; + + // Singleton instance — survives activity recreation + private static volatile P2pManager instance; + + // QR payload constants + private static final String QR_TYPE = "cht-p2p"; + private static final int QR_VERSION = 1; + private static final long QR_MAX_AGE_MS = 10L * 60 * 1000; // 10 minutes + + // Emulator detection + private static final String GENERIC_BRAND = "generic"; + + // QR payload keys + private static final String KEY_SSID = "ssid"; + private static final String KEY_IP = "ip"; + private static final String KEY_PORT = "port"; + + // Device resource thresholds + private static final int MIN_API_LEVEL = Build.VERSION_CODES.O; // API 26 + private static final long MIN_RAM_MB = 1024; // 1 GB + private static final long LOW_RAM_MB = 2048; // 2 GB warning threshold + private static final long MIN_STORAGE_MB = 100; // 100 MB + private static final int MIN_BATTERY_PERCENT = 15; + + private final Context context; + + // Components -- set during initialize() + private P2pConfig config; + private SyncMutex syncMutex; + private P2pAuthenticator authenticator; + private ScopeManifest localScope; + private TransitDocManager transitDocManager; + private P2pTracker tracker; + private P2pTelemetryReporter telemetryReporter; + private WifiHotspotManager hotspotManager; + private LocalHttpServer httpServer; + private P2pNotificationChannel notificationChannel; + private PouchDbBridge pouchDbBridge; + private String localDeviceId; + private String localUserId; + + // State + private volatile boolean initialized = false; + private volatile boolean hostModeActive = false; + private volatile boolean clientModeActive = false; + private volatile String currentQrSessionTs = null; // tracks current QR timestamp + private ConnectivityManager.NetworkCallback wifiNetworkCallback = null; + private volatile Network p2pWifiNetwork = null; + + public static P2pManager getInstance(Context context) { + if (instance == null) { + synchronized (P2pManager.class) { + if (instance == null) { + instance = new P2pManager(context); + } + } + } + return instance; + } + + private P2pManager(Context context) { + if (context == null) { + throw new IllegalArgumentException("context must not be null"); + } + this.context = context.getApplicationContext(); + } + + // ----------------------------------------------------------------------- + // Capability + // ----------------------------------------------------------------------- + + /** + * P2pCapability check result -- can this device do P2P? + */ + public enum P2pCapability { + FULLY_SUPPORTED, + SUPPORTED_NO_CAMERA, + UNSUPPORTED_API_LEVEL, + NO_WIFI_HARDWARE, + LOW_RAM_WARNING, + LOW_STORAGE, + LOW_BATTERY_WARNING, + PERMISSION_NEEDED, + LOCATION_SERVICES_OFF + } + + /** + * Check whether the device can participate in P2P sync. + * Returns the most severe issue found, or FULLY_SUPPORTED / SUPPORTED_NO_CAMERA. + */ + public P2pCapability checkCapability() { + P2pCapability hardBlock = checkHardBlocks(); + if (hardBlock != null) { + return hardBlock; + } + P2pCapability softWarning = checkSoftWarnings(); + if (softWarning != null) { + return softWarning; + } + + // Check camera (needed for QR scanning on CHW side, but not a hard block) + boolean hasCamera = context.getPackageManager() + .hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY); + if (!hasCamera) { + Log.i(TAG, "No camera -- Supervisor mode only (no QR scanning)"); + return P2pCapability.SUPPORTED_NO_CAMERA; + } + + Log.i(TAG, "Device fully supports P2P sync"); + return P2pCapability.FULLY_SUPPORTED; + } + + private P2pCapability checkHardBlocks() { + if (Build.VERSION.SDK_INT < MIN_API_LEVEL) { + Log.w(TAG, "Unsupported API level: " + Build.VERSION.SDK_INT + + " (min " + MIN_API_LEVEL + ")"); + return P2pCapability.UNSUPPORTED_API_LEVEL; + } + WifiManager wifiManager = (WifiManager) context.getApplicationContext() + .getSystemService(Context.WIFI_SERVICE); + if (wifiManager == null) { + Log.w(TAG, "No WifiManager available"); + return P2pCapability.NO_WIFI_HARDWARE; + } + long freeStorageMb = getAvailableStorageMb(); + if (freeStorageMb < MIN_STORAGE_MB) { + Log.w(TAG, "Low storage: " + freeStorageMb + " MB free"); + return P2pCapability.LOW_STORAGE; + } + if (!hasWifiPermissions()) { + Log.w(TAG, "Missing WiFi permissions"); + return P2pCapability.PERMISSION_NEEDED; + } + return checkLocationServices(); + } + + private P2pCapability checkLocationServices() { + android.location.LocationManager lm = (android.location.LocationManager) + context.getSystemService(Context.LOCATION_SERVICE); + if (lm == null || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && !lm.isLocationEnabled())) { + Log.w(TAG, "Location services disabled (required for hotspot)"); + return P2pCapability.LOCATION_SERVICES_OFF; + } + return null; + } + + private P2pCapability checkSoftWarnings() { + P2pCapability batteryCheck = checkBatteryRisk(); + if (batteryCheck != null) { + return batteryCheck; + } + long totalRamMb = getTotalRamMb(); + if (totalRamMb > 0 && totalRamMb < LOW_RAM_MB) { + if (totalRamMb < MIN_RAM_MB) { + Log.w(TAG, "Very low RAM: " + totalRamMb + " MB"); + } + Log.w(TAG, "Low RAM warning: " + totalRamMb + " MB"); + return P2pCapability.LOW_RAM_WARNING; + } + return null; + } + + private P2pCapability checkBatteryRisk() { + int batteryPercent = getBatteryPercent(); + if (batteryPercent >= 0 && batteryPercent < MIN_BATTERY_PERCENT) { + Log.w(TAG, "Low battery: " + batteryPercent + "%"); + return P2pCapability.LOW_BATTERY_WARNING; + } + return null; + } + + // ----------------------------------------------------------------------- + // Initialize + // ----------------------------------------------------------------------- + + /** + * Parameters needed to initialize P2P components. + * Groups the 7 initialize() params to comply with max-params rule. + */ + @SuppressWarnings("java:S107") // Data class — groups initialization parameters + public static class InitParams { + public final P2pConfig config; + public final String serverPublicKey; + public final RevocationList revocationList; + public final ScopeManifest localScope; + public final PouchDbBridge bridge; + public final String deviceId; + public final String userId; + + public InitParams(P2pConfig config, String serverPublicKey, + RevocationList revocationList, ScopeManifest localScope, + PouchDbBridge bridge, String deviceId, String userId) { + this.config = config; + this.serverPublicKey = serverPublicKey; + this.revocationList = revocationList; + this.localScope = localScope; + this.bridge = bridge; + this.deviceId = deviceId; + this.userId = userId; + } + } + + /** + * Initialize P2P components from cached config. + * Must be called before startHostMode() or startClientMode(). + * + * @param config P2P config from app_settings + * @param serverPublicKey PEM-encoded ECDSA P-256 public key for JWT verification + * @param revocationList cached device/user revocation list + * @param localScope this device's scope manifest + * @param bridge PouchDB bridge for data access + * @param deviceId this device's unique identifier + * @param userId current user's ID + * @throws P2pInitException if initialization fails + */ + @SuppressWarnings("java:S107") // Delegates to InitParams — public API kept for backward compatibility + public void initialize(P2pConfig config, String serverPublicKey, + RevocationList revocationList, ScopeManifest localScope, + PouchDbBridge bridge, String deviceId, String userId) + throws P2pInitException { + + if (initialized) { + Log.w(TAG, "Already initialized, re-initializing"); + shutdownInternal(); + } + + validateInitParams(config, serverPublicKey, revocationList, localScope, + bridge, deviceId, userId); + + try { + this.config = config; + this.localScope = localScope; + this.pouchDbBridge = bridge; + this.localDeviceId = deviceId; + this.localUserId = userId; + + // Auth chain: JwtVerifier -> P2pAuthenticator + JwtVerifier jwtVerifier = new JwtVerifier(serverPublicKey); + this.authenticator = new P2pAuthenticator(jwtVerifier, revocationList); + + // Transit + this.transitDocManager = new TransitDocManager(); + + // Mutex with server reachability check (always returns false when + // in P2P mode since we are offline by definition) + this.syncMutex = new SyncMutex(new SyncMutex.ServerReachabilityChecker() { + @Override + public boolean isServerReachable() { + // During P2P sync, we assume server is not reachable. + // The webapp's normal replication handles server connectivity. + return false; + } + }); + + // Tracking and telemetry + this.tracker = new P2pTracker(); + this.telemetryReporter = new P2pTelemetryReporter(deviceId, userId, context); + + // Notifications + this.notificationChannel = new P2pNotificationChannel(context); + this.notificationChannel.createChannel(); + + this.initialized = true; + Log.i(TAG, "P2P components initialized for user=" + userId + + " device=" + deviceId); + + } catch (JwtVerifier.JwtVerificationException e) { + throw new P2pInitException("Failed to initialize JWT verifier: " + e.getMessage()); + } catch (RuntimeException e) { + throw new P2pInitException("Initialization failed: " + e.getMessage()); + } + } + + @SuppressWarnings("java:S107") // Validates all init params — mirrors the public API + private static void validateInitParams(P2pConfig config, String serverPublicKey, + RevocationList revocationList, ScopeManifest localScope, + PouchDbBridge bridge, String deviceId, String userId) + throws P2pInitException { + requireNonNull(config, "config"); + if (!config.isEnabled()) { + throw new P2pInitException("P2P sync is disabled in config"); + } + requireNonEmpty(serverPublicKey, "serverPublicKey"); + requireNonNull(revocationList, "revocationList"); + requireNonNull(localScope, "localScope"); + requireNonNull(bridge, "bridge"); + requireNonEmpty(deviceId, "deviceId"); + requireNonEmpty(userId, "userId"); + } + + private static void requireNonNull(Object value, String name) throws P2pInitException { + if (value == null) { + throw new P2pInitException(name + " must not be null"); + } + } + + private static void requireNonEmpty(String value, String name) throws P2pInitException { + if (value == null || value.isEmpty()) { + throw new P2pInitException(name + " must not be null or empty"); + } + } + + /** + * Load persisted transit doc state from PouchDB. + * Call after initialize() to restore transit tracking across restarts. + * + * @param transitDocJson the _local/p2p-transit-docs JSON, or null if none exists + */ + public void loadTransitState(JSONObject transitDocJson) { + ensureInitialized(); + if (transitDocJson != null && transitDocManager != null) { + try { + transitDocManager.loadFromJson(transitDocJson); + Log.i(TAG, "Loaded transit doc state, pending=" + + transitDocManager.getPendingPushCount()); + } catch (JSONException e) { + Log.e(TAG, "Failed to load transit doc state", e); + } + } + } + + /** + * Load persisted sync log from PouchDB. + * Call after initialize() to restore session history across restarts. + * + * @param syncLogJson the _local/p2p-sync-log or _local/p2p-relay-log JSON + */ + public void loadSyncLog(JSONObject syncLogJson) { + ensureInitialized(); + if (syncLogJson != null && tracker != null) { + try { + tracker.loadFromSyncLog(syncLogJson); + } catch (JSONException e) { + Log.e(TAG, "Failed to load sync log", e); + } + } + } + + // ----------------------------------------------------------------------- + // Supervisor Mode + // ----------------------------------------------------------------------- + + /** + * Start Supervisor mode: hotspot + HTTP server + QR code. + * + * Flow: + * 1. Check capability + * 2. Show OEM battery guidance if needed (via callback) + * 3. Acquire sync mutex + * 4. Start foreground service + * 5. Start hotspot + * 6. Start HTTP server on hotspot IP + * 7. Generate QR code payload + * 8. Callback with QR data + * + * @param callback receives lifecycle events + */ + public void startHostMode(final HostModeCallback callback) { + if (callback == null) { + throw new IllegalArgumentException("callback must not be null"); + } + ensureInitialized(); + + String preCheckError = performPreHostChecks(callback); + if (preCheckError != null) { + callback.onError(preCheckError); + return; + } + + // Acquire sync mutex + if (!syncMutex.tryAcquire(SyncMutex.SyncType.P2P)) { + callback.onError("sync_mutex_busy: another sync is already active"); + return; + } + + try { + P2pForegroundService.start(context); + Log.i(TAG, "Foreground service started"); + + hostModeActive = true; + startHotspotForSupervisor(callback); + + } catch (RuntimeException e) { + Log.e(TAG, "Failed to start supervisor mode", e); + cleanupFailedHostStart(); + callback.onError("supervisor_start_failed: " + e.getMessage()); + } + } + + private void cleanupFailedHostStart() { + hostModeActive = false; + syncMutex.release(); + try { + P2pForegroundService.stop(context); + } catch (RuntimeException ignored) { + // Best-effort cleanup + } + } + + /** + * Pre-checks before starting host mode: shutdown conflicts, capability, OEM guidance. + * @return error string if a hard block is found, null if all checks pass + */ + private String performPreHostChecks(HostModeCallback callback) { + if (hostModeActive) { + Log.w(TAG, "Supervisor mode already active, shutting down previous session"); + shutdownInternal(); + } + if (clientModeActive) { + Log.w(TAG, "CHW mode active, shutting down before starting supervisor"); + shutdownClientMode(); + } + + P2pCapability capability = checkCapability(); + if (isHardBlock(capability)) { + return "device_not_capable: " + capability.name(); + } + + if (OemBatteryHelper.needsBatteryGuidance()) { + String guidance = OemBatteryHelper.getGuidance(); + Log.i(TAG, "OEM battery guidance (" + OemBatteryHelper.getManufacturer() + + "): " + guidance); + callback.onBatteryGuidance(OemBatteryHelper.getRiskLevel(), guidance); + } + + return null; + } + + /** + * Internal: start the hotspot and wire up the HTTP server on success. + */ + private void startHotspotForSupervisor(final HostModeCallback callback) { + // Choose hotspot provider: loopback for emulators, real WiFi otherwise + HotspotProvider provider = createHotspotProvider(); + hotspotManager = new WifiHotspotManager(provider, config); + + hotspotManager.startHotspot(new HotspotProvider.HotspotCallback() { + @Override + public void onStarted(String ssid, String password, String ipAddress) { + Log.i(TAG, "Hotspot started: SSID=" + ssid + " IP=" + ipAddress); + + try { + // Step 6: Start HTTP server + startHttpServer(); + + // Step 7: Generate QR payload + String qrPayload = buildQrPayload(ssid, password, + ipAddress, httpServer.getPort()); + currentQrSessionTs = String.valueOf(System.currentTimeMillis()); + + Log.i(TAG, "Supervisor mode ready, QR generated"); + callback.onQrCodeReady(qrPayload); + + } catch (IOException | JSONException e) { + Log.e(TAG, "Failed after hotspot started", e); + shutdownInternal(); + callback.onError("server_start_failed: " + e.getMessage()); + } + } + + @Override + public void onFailed(String reason) { + Log.e(TAG, "Hotspot failed to start: " + reason); + hostModeActive = false; + syncMutex.release(); + try { + P2pForegroundService.stop(context); + } catch (RuntimeException ignored) { + // Best-effort cleanup + } + callback.onError("hotspot_failed: " + reason); + } + }); + } + + /** + * Internal: create and start the LocalHttpServer. + */ + private void startHttpServer() throws IOException { + LocalHttpServer.ServerDeps deps = createServerDeps(); + httpServer = new LocalHttpServer(deps); + + httpServer.setSessionCompleteCallback(session -> { + if (tracker != null && session != null) { + tracker.recordCompletedSession(session); + Log.i(TAG, "Tracker recorded host-side sync session: " + + session.getSessionId()); + } + }); + + httpServer.startServer(); + Log.i(TAG, "HTTP server started on port " + httpServer.getPort()); + } + + private LocalHttpServer.ServerDeps createServerDeps() { + TransitDocCallback transitCallback = new TransitDocCallback() { + @Override + public void trackTransitDocs(List docIds, + String sourceDeviceId, + String sourceUserId) { + if (transitDocManager == null || docIds == null || docIds.isEmpty()) { + return; + } + String batchId = transitDocManager.startBatch(sourceDeviceId, sourceUserId); + for (String docId : docIds) { + transitDocManager.trackTransitDoc(batchId, docId); + } + Log.d(TAG, "Tracked " + docIds.size() + + " transit docs in batch " + batchId); + } + }; + + return new LocalHttpServer.ServerDeps(authenticator, config, localScope, + pouchDbBridge, transitCallback); + } + + /** + * Notify the manager that a peer has connected (called from HTTP server layer). + * Updates the tracker and relays to the callback. + */ + public void onPeerConnected(String peerId, HostModeCallback callback) { + if (!hostModeActive) { + return; + } + syncMutex.touchActivity(); + if (hotspotManager != null) { + hotspotManager.recordActivity(); + } + Log.i(TAG, "Peer connected: " + peerId); + if (callback != null) { + callback.onPeerConnected(peerId); + } + } + + /** + * Notify the manager of sync progress (called from HTTP server layer). + * Updates the notification and relays to the callback. + */ + public void onSyncProgress(int docsSynced, int totalDocs, + HostModeCallback callback) { + if (!hostModeActive) { + return; + } + syncMutex.touchActivity(); + if (hotspotManager != null) { + hotspotManager.recordActivity(); + } + if (notificationChannel != null) { + notificationChannel.updateProgress(docsSynced, totalDocs); + } + if (callback != null) { + callback.onSyncProgress(docsSynced, totalDocs); + } + } + + /** + * Notify the manager that a sync session completed. + */ + public void onSyncComplete(P2pSession session, HostModeCallback callback) { + if (session != null) { + Log.i(TAG, "Sync session completed: " + session); + } + if (notificationChannel != null && session != null) { + int total = session.getDocsPushed() + session.getDocsPulled() + + session.getTransitDocs(); + notificationChannel.showComplete(total); + } + if (callback != null) { + callback.onSyncComplete(session); + } + } + + // ----------------------------------------------------------------------- + // CHW Mode + // ----------------------------------------------------------------------- + + /** + * Start CHW mode: parse QR payload, validate, then connect to supervisor. + * + * Flow: + * 1. Parse QR payload + * 2. Validate QR (timestamp, type, version) + * 3. Connect to supervisor's WiFi (out-of-band -- caller handles WiFi connection) + * 4. Authenticate with supervisor (caller calls authenticateWithSupervisor) + * + * Note: actual WiFi connection is handled by the Android system when the user + * selects the network. This method validates the QR and returns connection info. + * + * @param qrPayloadJson the JSON string scanned from the QR code + * @param callback receives lifecycle events + */ + public void startClientMode(String qrPayloadJson, final ClientModeCallback callback) { + if (callback == null) { + throw new IllegalArgumentException("callback must not be null"); + } + ensureInitialized(); + + String preCheckError = validateClientModeParams(); + if (preCheckError != null) { + callback.onError(preCheckError); + return; + } + + // Parse QR payload + JSONObject qrPayload; + try { + qrPayload = new JSONObject(qrPayloadJson); + } catch (JSONException e) { + callback.onError("qr_parse_failed: invalid JSON"); + return; + } + + // Validate QR payload + String validationError = validateQrPayload(qrPayload); + if (validationError != null) { + callback.onError(validationError); + return; + } + + // Acquire sync mutex + if (!syncMutex.tryAcquire(SyncMutex.SyncType.P2P)) { + callback.onError("sync_mutex_busy: another sync is already active"); + return; + } + + clientModeActive = true; + + String ssid = qrPayload.optString(KEY_SSID, ""); + String password = qrPayload.optString("pwd", ""); + String ip = qrPayload.optString(KEY_IP, ""); + int port = qrPayload.optInt(KEY_PORT, 8443); + String tlsFingerprint = qrPayload.optString("tls", ""); + + Log.i(TAG, "CHW mode started: supervisor at " + ip + ":" + port + + " via SSID=" + ssid + " — attempting auto WiFi connection"); + + QrCodeHelper.HotspotCredentials creds = + new QrCodeHelper.HotspotCredentials(ssid, password, ip, port, tlsFingerprint); + connectToHotspotWifi(creds, callback); + } + + /** + * Validate preconditions for client mode, handling conflicting active modes. + * @return error string if client mode cannot start, null if preconditions are met + */ + private String validateClientModeParams() { + if (clientModeActive) { + Log.w(TAG, "CHW mode already active, shutting down previous session"); + shutdownClientMode(); + } + if (hostModeActive) { + return "host_mode_active: shutdown supervisor mode first"; + } + return null; + } + + /** + * Connect to the supervisor's WiFi hotspot using WifiNetworkSpecifier (API 29+). + */ + private void connectToHotspotWifi(final QrCodeHelper.HotspotCredentials creds, + final ClientModeCallback callback) { + final String ssid = creds.ssid; + final String password = creds.password; + final String ip = creds.ipAddress; + final int port = creds.port; + final String tlsFingerprint = creds.tlsFingerprint; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + Log.w(TAG, "WifiNetworkSpecifier requires API 29+, skipping auto-connect"); + callback.onConnectionInfoReady(ssid, password, ip, port, tlsFingerprint); + return; + } + + ConnectivityManager cm = (ConnectivityManager) context + .getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm == null) { + Log.e(TAG, "No ConnectivityManager, falling back to manual connect"); + callback.onConnectionInfoReady(ssid, password, ip, port, tlsFingerprint); + return; + } + + WifiNetworkSpecifier specifier = new WifiNetworkSpecifier.Builder() + .setSsid(ssid) + .setWpa2Passphrase(password) + .build(); + + // Build request WITHOUT internet capability — hotspot has no internet + NetworkRequest request = new NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .setNetworkSpecifier(specifier) + .build(); + + wifiNetworkCallback = new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(@NonNull Network network) { + Log.i(TAG, "Connected to hotspot WiFi: " + ssid); + p2pWifiNetwork = network; + // Per-connection routing via network.openConnection() in P2pSyncClient + // DO NOT call bindProcessToNetwork — it breaks WebView + callback.onConnectionInfoReady(ssid, password, ip, port, tlsFingerprint); + } + + @Override + public void onUnavailable() { + Log.w(TAG, "WiFi auto-connect unavailable for SSID=" + ssid + + " — falling back to manual connect"); + // Don't shut down — let user connect manually + // Provide credentials so webapp can display them + callback.onError("wifi_connection_failed"); + } + + @Override + public void onLost(@NonNull Network network) { + Log.w(TAG, "Lost hotspot WiFi connection: " + ssid); + p2pWifiNetwork = null; + } + }; + + Log.i(TAG, "Requesting WiFi connection to SSID=" + ssid + + " (no internet capability required)"); + cm.requestNetwork(request, wifiNetworkCallback); + } + + /** + * Authenticate with the supervisor's HTTP server (CHW side). + * Called after the CHW has connected to the supervisor's WiFi network. + * + * @param supervisorHost supervisor's IP address + * @param supervisorPort supervisor's HTTP server port + * @param jwt this device's P2P JWT token + * @param callback receives lifecycle events + */ + public void authenticateWithSupervisor(String supervisorHost, int supervisorPort, + String jwt, final ClientModeCallback callback) { + if (callback == null) { + throw new IllegalArgumentException("callback must not be null"); + } + if (!clientModeActive) { + callback.onError("chw_mode_not_active: call startClientMode() first"); + return; + } + ensureInitialized(); + + if (jwt == null || jwt.isEmpty()) { + shutdownClientMode(); + callback.onError("jwt_empty"); + return; + } + + syncMutex.touchActivity(); + + // The actual HTTP request to POST /_p2p/auth on the supervisor is + // handled by the webapp's JavaScript layer via the PouchDB bridge. + // This method records the intent and prepares the tracker. + Log.i(TAG, "CHW authenticating with supervisor at " + + supervisorHost + ":" + supervisorPort); + + callback.onConnected(supervisorHost); + } + + /** + * Notify the manager of CHW-side sync progress. + */ + public void onClientSyncProgress(int docsSynced, int totalDocs, + ClientModeCallback callback) { + if (!clientModeActive) { + return; + } + syncMutex.touchActivity(); + if (callback != null) { + callback.onSyncProgress(docsSynced, totalDocs); + } + } + + /** + * Notify the manager that CHW-side sync completed. + */ + public void onClientSyncComplete(P2pSession session, ClientModeCallback callback) { + if (session != null) { + Log.i(TAG, "CHW sync session completed: " + session); + if (tracker != null) { + tracker.completeSession(); + } + } + clientModeActive = false; + syncMutex.release(); + if (callback != null) { + callback.onSyncComplete(session); + } + } + + // ----------------------------------------------------------------------- + // Shutdown + // ----------------------------------------------------------------------- + + /** + * Shutdown all P2P components cleanly. + * Safe to call at any time, including if not initialized. + */ + public void shutdown() { + shutdownInternal(); + } + + /** + * Internal shutdown — tears down all P2P components cleanly. + */ + private void shutdownInternal() { + Log.i(TAG, "Shutting down P2P components"); + + stopHttpServerSafe(); + stopHotspotSafe(); + stopForegroundServiceSafe(); + releaseMutexSafe(); + failActiveTrackerSession(); + saveTransitState(); + dismissNotificationsSafe(); + unregisterWifiCallbackSafe(); + + // Reset state + hostModeActive = false; + clientModeActive = false; + currentQrSessionTs = null; + + Log.i(TAG, "P2P shutdown complete"); + } + + private void stopHttpServerSafe() { + if (httpServer != null) { + try { + httpServer.stopServer(); + Log.d(TAG, "HTTP server stopped"); + } catch (RuntimeException e) { + Log.e(TAG, "Error stopping HTTP server", e); + } + httpServer = null; + } + } + + private void stopHotspotSafe() { + if (hotspotManager != null) { + try { + hotspotManager.stopHotspot(); + Log.d(TAG, "Hotspot stopped"); + } catch (RuntimeException e) { + Log.e(TAG, "Error stopping hotspot", e); + } + hotspotManager = null; + } + } + + private void stopForegroundServiceSafe() { + try { + P2pForegroundService.stop(context); + } catch (RuntimeException e) { + Log.e(TAG, "Error stopping foreground service", e); + } + } + + private void releaseMutexSafe() { + if (syncMutex != null && syncMutex.isActive()) { + try { + syncMutex.release(); + Log.d(TAG, "Sync mutex released"); + } catch (RuntimeException e) { + Log.e(TAG, "Error releasing sync mutex", e); + } + } + } + + private void failActiveTrackerSession() { + if (tracker != null && tracker.hasActiveSession()) { + tracker.failSession("shutdown"); + } + } + + private void dismissNotificationsSafe() { + if (notificationChannel != null) { + try { + notificationChannel.dismiss(); + } catch (RuntimeException e) { + Log.e(TAG, "Error dismissing notification", e); + } + } + } + + private void unregisterWifiCallbackSafe() { + if (wifiNetworkCallback != null) { + try { + ConnectivityManager cm = (ConnectivityManager) context + .getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm != null) { + cm.unregisterNetworkCallback(wifiNetworkCallback); + } + } catch (RuntimeException e) { + Log.e(TAG, "Error cleaning up WiFi callback", e); + } + wifiNetworkCallback = null; + } + } + + /** + * Shutdown CHW mode only (does not affect supervisor components). + */ + private void shutdownClientMode() { + clientModeActive = false; + p2pWifiNetwork = null; + if (syncMutex != null && syncMutex.isActive()) { + syncMutex.release(); + } + if (tracker != null && tracker.hasActiveSession()) { + tracker.failSession("chw_mode_shutdown"); + } + } + + /** + * Get the WiFi network obtained via auto-connect (WifiNetworkSpecifier). + * Returns null if auto-connect was not used or the network was lost. + */ + public Network getP2pWifiNetwork() { + return p2pWifiNetwork; + } + + // ----------------------------------------------------------------------- + // QR Payload + // ----------------------------------------------------------------------- + + /** + * Build the QR code payload JSON string for Supervisor mode. + * Format matches . + */ + String buildQrPayload(String ssid, String password, + String ipAddress, int port) throws JSONException { + JSONObject qr = new JSONObject(); + qr.put("type", QR_TYPE); + qr.put("v", QR_VERSION); + qr.put(KEY_SSID, ssid); + qr.put("pwd", password); + qr.put(KEY_IP, ipAddress); + qr.put(KEY_PORT, port); + qr.put("tls", ""); // TLS fingerprint set when HTTPS is configured + qr.put("ts", System.currentTimeMillis()); + return qr.toString(); + } + + /** + * Validate a QR payload scanned by the CHW. + * Enforces guards , , . + * + * @return null if valid, or an error string describing the issue + */ + String validateQrPayload(JSONObject qr) { + if (qr == null) { + return "qr_invalid: null payload"; + } + + String headerError = validateQrHeader(qr); + if (headerError != null) { + return headerError; + } + + String timestampError = validateQrTimestamp(qr); + if (timestampError != null) { + return timestampError; + } + + return checkQrRequiredFields(qr); + } + + private String checkQrRequiredFields(JSONObject qr) { + String ssid = qr.optString(KEY_SSID, ""); + if (ssid.isEmpty()) { + return "qr_missing_ssid"; + } + String ip = qr.optString(KEY_IP, ""); + if (ip.isEmpty()) { + return "qr_missing_ip"; + } + int port = qr.optInt(KEY_PORT, 0); + if (port <= 0 || port > 65535) { + return "qr_invalid_port: " + port; + } + return null; + } + + private String validateQrHeader(JSONObject qr) { + String type = qr.optString("type", ""); + if (!QR_TYPE.equals(type)) { + return "qr_invalid_type: expected '" + QR_TYPE + "', got '" + type + "'"; + } + int version = qr.optInt("v", 0); + if (version < 1) { + return "qr_invalid_version: " + version; + } + return null; + } + + private String validateQrTimestamp(JSONObject qr) { + long ts = qr.optLong("ts", 0); + if (ts <= 0) { + return "qr_missing_timestamp"; + } + long age = Math.abs(System.currentTimeMillis() - ts); + if (age > QR_MAX_AGE_MS) { + return "qr_expired: age=" + (age / 1000) + "s (max " + + (QR_MAX_AGE_MS / 1000) + "s)"; + } + return null; + } + + // ----------------------------------------------------------------------- + // State Queries + // ----------------------------------------------------------------------- + + /** Check if P2P components have been initialized. */ + public boolean isInitialized() { + return initialized; + } + + /** Check if host mode is currently active. */ + public boolean isHostModeActive() { + return hostModeActive; + } + + /** Check if client mode is currently active. */ + public boolean isClientModeActive() { + return clientModeActive; + } + + /** Get the current P2P config, or null if not initialized. */ + public P2pConfig getConfig() { + return config; + } + + /** Get the tracker for session history. */ + public P2pTracker getTracker() { + return tracker; + } + + /** Get the transit doc manager. */ + public TransitDocManager getTransitDocManager() { + return transitDocManager; + } + + /** Get the telemetry reporter. */ + public P2pTelemetryReporter getTelemetryReporter() { + return telemetryReporter; + } + + /** Get connected peer count from HTTP server (0 if no server). */ + public int getConnectedPeerCount() { + return httpServer != null ? httpServer.getConnectedPeerCount() : 0; + } + + /** Get active HTTP session (peer authenticated), or null. */ + public P2pSession getHttpSession() { + return httpServer != null ? httpServer.getActiveSession() : null; + } + + /** Get local device ID (set during initialize). */ + public String getLocalDeviceId() { + return localDeviceId; + } + + /** Get the application context (needed for ConnectivityManager in peer sync). */ + public Context getContext() { + return context; + } + + /** Get the PouchDB bridge for direct data access. */ + public PouchDbBridge getBridge() { + return pouchDbBridge; + } + + /** Get the hotspot password (for display in UI). */ + public String getHotspotPassword() { + return hotspotManager != null ? hotspotManager.getActivePassword() : null; + } + + /** Check if there are stale transit docs needing attention. */ + public boolean hasStaleTransitDocs() { + return transitDocManager != null && transitDocManager.hasStaleTransitDocs(); + } + + /** + * Get the current status as a JSON object for the webapp bridge. + */ + public JSONObject getStatusJson() { + try { + JSONObject status = new JSONObject(); + status.put("initialized", initialized); + status.put("host_mode_active", hostModeActive); + status.put("client_mode_active", clientModeActive); + status.put("p2p_enabled", config != null && config.isEnabled()); + + if (transitDocManager != null) { + status.put("pending_transit_docs", + transitDocManager.getPendingPushCount()); + status.put("has_stale_transit", transitDocManager.hasStaleTransitDocs()); + } + if (tracker != null) { + status.put("session_count", tracker.getSessionCount()); + status.put("has_active_session", tracker.hasActiveSession()); + } + if (hotspotManager != null) { + status.put("hotspot_active", hotspotManager.isActive()); + status.put("hotspot_ssid", hotspotManager.getActiveSsid()); + } + + return status; + } catch (JSONException e) { + Log.e(TAG, "Failed to build status JSON", e); + return new JSONObject(); + } + } + + // ----------------------------------------------------------------------- + // Transit State Persistence + // ----------------------------------------------------------------------- + + /** + * Save the current transit doc state to JSON. + * Caller should persist this via PouchDB bridge to _local/p2p-transit-docs. + * + * @return the transit doc JSON, or null if no transit state exists + */ + public JSONObject getTransitStateJson() { + if (transitDocManager == null) { + return null; + } + try { + // archive purged batches if oversized + if (transitDocManager.isOversized()) { + transitDocManager.archivePurgedBatches(); + } + return transitDocManager.toJson(); + } catch (JSONException e) { + Log.e(TAG, "Failed to serialize transit state", e); + return null; + } + } + + /** + * Build telemetry payload for reporting to server. + */ + public JSONObject buildTelemetryPayload() { + if (telemetryReporter == null || tracker == null) { + return null; + } + try { + return telemetryReporter.buildTelemetryPayload(tracker); + } catch (JSONException e) { + Log.e(TAG, "Failed to build telemetry payload", e); + return null; + } + } + + /** + * Clear completed sessions from tracker after successful telemetry upload. + */ + public void clearReportedSessions() { + if (tracker != null) { + tracker.clearCompletedSessions(); + } + } + + // ----------------------------------------------------------------------- + // Callbacks + // ----------------------------------------------------------------------- + + /** Callback for host mode events. */ + public interface HostModeCallback { + /** QR code payload is ready for display. */ + void onQrCodeReady(String qrPayloadJson); + + /** A client peer has connected and authenticated. */ + void onPeerConnected(String peerId); + + /** Sync progress update. */ + void onSyncProgress(int docsSynced, int totalDocs); + + /** Sync session completed successfully. */ + void onSyncComplete(P2pSession session); + + /** An error occurred. */ + void onError(String error); + + /** + * OEM battery optimization guidance for the user. + * Called before sync starts if the device manufacturer is known + * to aggressively kill background services. + */ + void onBatteryGuidance(OemBatteryHelper.RiskLevel riskLevel, String guidance); + } + + /** Callback for client mode events. */ + @SuppressWarnings("java:S107") // Callback delivers all connection params at once + public interface ClientModeCallback { + /** + * QR payload validated, connection info ready. + * The caller should now connect to the WiFi network and verify TLS. + */ + void onConnectionInfoReady(String ssid, String password, + String host, int port, + String tlsFingerprint); + + /** Successfully connected and authenticated with host. */ + void onConnected(String hostId); + + /** Preview data ready — doc counts fetched from host before sync starts. */ + void onPreviewReady(int contactCount, int reportCount, int totalCount); + + /** Sync progress update. */ + void onSyncProgress(int docsSynced, int totalDocs); + + /** Sync session completed successfully. */ + void onSyncComplete(P2pSession session); + + /** An error occurred. */ + void onError(String error); + } + + // ----------------------------------------------------------------------- + // Exceptions + // ----------------------------------------------------------------------- + + /** Thrown when P2P initialization fails. */ + public static class P2pInitException extends Exception { + private static final long serialVersionUID = 1L; + public P2pInitException(String message) { + super(message); + } + } + + // ----------------------------------------------------------------------- + // Private Helpers + // ----------------------------------------------------------------------- + + private boolean isHardBlock(P2pCapability capability) { + return capability == P2pCapability.UNSUPPORTED_API_LEVEL + || capability == P2pCapability.NO_WIFI_HARDWARE + || capability == P2pCapability.LOW_STORAGE + || capability == P2pCapability.PERMISSION_NEEDED + || capability == P2pCapability.LOCATION_SERVICES_OFF; + } + + private void ensureInitialized() { + if (!initialized) { + throw new IllegalStateException( + "P2pManager not initialized. Call initialize() first."); + } + } + + /** + * Create the appropriate HotspotProvider based on the environment. + * Uses LoopbackHotspotProvider for emulators (no real WiFi hardware), + * WifiHotspotProvider for real devices. + */ + private HotspotProvider createHotspotProvider() { + if (isEmulator()) { + Log.i(TAG, "Emulator detected, using LoopbackHotspotProvider"); + return new LoopbackHotspotProvider(); + } + WifiManager wifiManager = (WifiManager) context.getApplicationContext() + .getSystemService(Context.WIFI_SERVICE); + return new WifiHotspotProvider(wifiManager); + } + + /** + * Best-effort emulator detection. + */ + private boolean isEmulator() { + return Build.FINGERPRINT.startsWith(GENERIC_BRAND) + || Build.FINGERPRINT.startsWith("unknown") + || Build.MODEL.contains("google_sdk") + || Build.MODEL.contains("Emulator") + || Build.MODEL.contains("Android SDK built for x86") + || Build.MANUFACTURER.contains("Genymotion") + || "goldfish".equals(Build.HARDWARE) + || "ranchu".equals(Build.HARDWARE) + || Build.BRAND.startsWith(GENERIC_BRAND) + || Build.DEVICE.startsWith(GENERIC_BRAND); + } + + /** + * Save transit doc state. Best-effort -- errors are logged, not thrown. + */ + private void saveTransitState() { + if (transitDocManager == null) { + return; + } + try { + JSONObject transitJson = getTransitStateJson(); + if (transitJson != null) { + Log.d(TAG, "Transit state ready for persistence (" + + transitDocManager.getPendingPushCount() + " pending docs)"); + } + } catch (RuntimeException e) { + Log.e(TAG, "Failed to save transit state", e); + } + } + + private boolean hasWifiPermissions() { + // Basic WiFi permissions (normal/install-time — always granted via manifest) + PackageManager pm = context.getPackageManager(); + boolean hasWifiState = pm.checkPermission( + android.Manifest.permission.ACCESS_WIFI_STATE, + context.getPackageName()) == PackageManager.PERMISSION_GRANTED; + boolean hasChangeWifi = pm.checkPermission( + android.Manifest.permission.CHANGE_WIFI_STATE, + context.getPackageName()) == PackageManager.PERMISSION_GRANTED; + + if (!hasWifiState || !hasChangeWifi) { + return false; + } + + // Runtime (dangerous) permissions — must use ContextCompat.checkSelfPermission() + // ACCESS_FINE_LOCATION is required on ALL versions (no neverForLocation flag) + if (ContextCompat.checkSelfPermission(context, + android.Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + return false; + } + + // API 33+: also needs NEARBY_WIFI_DEVICES + return Build.VERSION.SDK_INT < 33 + || ContextCompat.checkSelfPermission(context, + "android.permission.NEARBY_WIFI_DEVICES") == PackageManager.PERMISSION_GRANTED; + } + + private long getAvailableStorageMb() { + try { + StatFs stat = new StatFs(Environment.getDataDirectory().getPath()); + long availableBytes = stat.getAvailableBlocksLong() * stat.getBlockSizeLong(); + return availableBytes / (1024 * 1024); + } catch (RuntimeException e) { + Log.w(TAG, "Failed to check storage", e); + return Long.MAX_VALUE; // Don't block on check failure + } + } + + private long getTotalRamMb() { + try { + ActivityManager am = (ActivityManager) context + .getSystemService(Context.ACTIVITY_SERVICE); + if (am != null) { + ActivityManager.MemoryInfo memInfo = new ActivityManager.MemoryInfo(); + am.getMemoryInfo(memInfo); + return memInfo.totalMem / (1024 * 1024); + } + } catch (RuntimeException e) { + Log.w(TAG, "Failed to check RAM", e); + } + return 0; + } + + private int getBatteryPercent() { + try { + BatteryManager bm = (BatteryManager) context + .getSystemService(Context.BATTERY_SERVICE); + if (bm != null) { + return bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY); + } + } catch (RuntimeException e) { + Log.w(TAG, "Failed to check battery", e); + } + return -1; // Unknown + } +} 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..11025227 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pNotificationChannel.java @@ -0,0 +1,130 @@ +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(); + } + + /** + * Update the notification with sync progress. + * + * @param docsSynced number of docs synced so far + * @param totalDocs total docs to sync (0 if unknown) + */ + public void updateProgress(int docsSynced, int totalDocs) { + // P2P requires API 26+ — channel constructor always available + Notification.Builder builder = new Notification.Builder(context, CHANNEL_ID); + + String text; + if (totalDocs > 0) { + text = "Syncing: " + docsSynced + " / " + totalDocs + " docs"; + builder.setProgress(totalDocs, docsSynced, false); + } else { + text = "Syncing: " + docsSynced + " docs"; + builder.setProgress(0, 0, true); + } + + builder.setContentTitle(CHANNEL_NAME) + .setContentText(text) + .setSmallIcon(android.R.drawable.stat_notify_sync) + .setOngoing(true) + .setOnlyAlertOnce(true); + + notificationManager.notify(NOTIFICATION_ID, builder.build()); + } + + /** + * Show a completion notification. + * + * @param totalDocs total number of docs synced + */ + public void showComplete(int totalDocs) { + // P2P requires API 26+ — channel constructor always available + Notification.Builder builder = new Notification.Builder(context, CHANNEL_ID); + + builder.setContentTitle("P2P Sync Complete") + .setContentText(totalDocs + " docs synced successfully") + .setSmallIcon(android.R.drawable.stat_notify_sync_noanim) + .setOngoing(false) + .setAutoCancel(true); + + notificationManager.notify(NOTIFICATION_ID, builder.build()); + } + + /** + * Dismiss the notification. + */ + public void dismiss() { + notificationManager.cancel(NOTIFICATION_ID); + } + + /** + * Get the notification ID used by this channel. + */ + public int getNotificationId() { + return NOTIFICATION_ID; + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pSession.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pSession.java new file mode 100644 index 00000000..32c687c6 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pSession.java @@ -0,0 +1,290 @@ +package org.medicmobile.webapp.mobile.p2p; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.UUID; + +/** + * Data model for an active P2P sync session. + * + * Tracks session state, counters, and timing. Serializable to JSON + * for storage in _local/p2p-sync-log. + * + * Session timeout after idle period (checked via isTimedOut) + */ +public class P2pSession { + + public enum State { + AUTHENTICATING, + ACTIVE, + COMPLETING, + COMPLETED, + FAILED + } + + private static final int DEFAULT_IDLE_TIMEOUT_SEC = 30 * 60; // 30 minutes + + private final String sessionId; + private final String peerDeviceId; + private final String peerUserId; + private final String peerRole; + private final ScopeManifest peerScope; + private final long startedAt; + + private volatile long lastActivityAt; + private volatile State state; + + // Counters + private int docsPushed; + private int docsPulled; + private int docsRejected; + private int transitDocs; + private long bytesTransferred; + private String error; + private long completedAt; + + public P2pSession(String peerDeviceId, String peerUserId, String peerRole, ScopeManifest peerScope) { + if (peerDeviceId == null || peerDeviceId.isEmpty()) { + throw new IllegalArgumentException("peerDeviceId must not be null or empty"); + } + if (peerUserId == null || peerUserId.isEmpty()) { + throw new IllegalArgumentException("peerUserId must not be null or empty"); + } + + this.sessionId = UUID.randomUUID().toString(); + this.peerDeviceId = peerDeviceId; + this.peerUserId = peerUserId; + this.peerRole = peerRole; + this.peerScope = peerScope; + this.startedAt = System.currentTimeMillis(); + this.lastActivityAt = this.startedAt; + this.state = State.AUTHENTICATING; + + this.docsPushed = 0; + this.docsPulled = 0; + this.docsRejected = 0; + this.transitDocs = 0; + this.bytesTransferred = 0; + this.error = null; + this.completedAt = 0; + } + + // --- Getters --- + + public String getSessionId() { + return sessionId; + } + + public String getPeerDeviceId() { + return peerDeviceId; + } + + public String getPeerUserId() { + return peerUserId; + } + + public String getPeerRole() { + return peerRole; + } + + public ScopeManifest getPeerScope() { + return peerScope; + } + + public long getStartedAt() { + return startedAt; + } + + public long getLastActivityAt() { + return lastActivityAt; + } + + public synchronized State getState() { + return state; + } + + public int getDocsPushed() { + return docsPushed; + } + + public int getDocsPulled() { + return docsPulled; + } + + public int getDocsRejected() { + return docsRejected; + } + + public int getTransitDocs() { + return transitDocs; + } + + public long getBytesTransferred() { + return bytesTransferred; + } + + public String getError() { + return error; + } + + public long getCompletedAt() { + return completedAt; + } + + // --- Increment methods --- + + public synchronized void incrementDocsPushed(int count) { + this.docsPushed += count; + updateLastActivity(); + } + + public synchronized void incrementDocsPulled(int count) { + this.docsPulled += count; + updateLastActivity(); + } + + public synchronized void incrementDocsRejected(int count) { + this.docsRejected += count; + updateLastActivity(); + } + + public synchronized void incrementTransitDocs(int count) { + this.transitDocs += count; + updateLastActivity(); + } + + public synchronized void addBytesTransferred(long bytes) { + this.bytesTransferred += bytes; + updateLastActivity(); + } + + /** + * Record activity to prevent timeout. + */ + public void updateLastActivity() { + this.lastActivityAt = System.currentTimeMillis(); + } + + // --- Timeout check --- + + /** + * Check if this session has timed out due to inactivity. + * + * @param timeoutSec idle timeout in seconds (use P2pConfig.getWifiHotspotIdleTimeoutSec() + * or DEFAULT_IDLE_TIMEOUT_SEC for the 30-min guard) + * @return true if the session has been idle longer than the timeout + */ + public boolean isTimedOut(int timeoutSec) { + long elapsed = System.currentTimeMillis() - lastActivityAt; + return elapsed > (long) timeoutSec * 1000; + } + + /** + * Check timeout using the default 30-minute guard. + */ + public boolean isTimedOut() { + return isTimedOut(DEFAULT_IDLE_TIMEOUT_SEC); + } + + // --- State transitions --- + + public synchronized void setState(State state) { + if (state == null) { + throw new IllegalArgumentException("State must not be null"); + } + this.state = state; + updateLastActivity(); + } + + /** + * Mark session as failed with an error message. + */ + public synchronized void fail(String error) { + this.state = State.FAILED; + this.error = error; + this.completedAt = System.currentTimeMillis(); + } + + /** + * Mark session as successfully completed. + */ + public synchronized void complete() { + this.state = State.COMPLETED; + this.completedAt = System.currentTimeMillis(); + } + + // --- Serialization --- + + /** + * Convert to JSON for _local/p2p-sync-log. + * + * Output matches the session entry format: + * { + * "session_id": "uuid", + * "peer_device_id": "...", + * "peer_user": "...", + * "peer_role": "...", + * "started_at": 1711152000000, + * "completed_at": 1711152300000, + * "docs_pushed": 47, + * "docs_pulled": 3, + * "docs_rejected": 0, + * "transit_docs": 42, + * "bytes_transferred": 245000, + * "status": "completed", + * "error": null + * } + */ + public JSONObject toJson() throws JSONException { + JSONObject json = new JSONObject(); + json.put("session_id", sessionId); + json.put("peer_device_id", peerDeviceId); + json.put("peer_user", peerUserId); + json.put("peer_role", peerRole); + json.put("started_at", startedAt); + json.put("completed_at", completedAt > 0 ? completedAt : JSONObject.NULL); + json.put("docs_pushed", docsPushed); + json.put("docs_pulled", docsPulled); + json.put("docs_rejected", docsRejected); + json.put("transit_docs", transitDocs); + json.put("bytes_transferred", bytesTransferred); + json.put("status", mapStateToStatus()); + json.put("error", error != null ? error : JSONObject.NULL); + return json; + } + + /** + * Duration in milliseconds from start to now (if active) or start to completion. + */ + public long getDurationMs() { + long end = completedAt > 0 ? completedAt : System.currentTimeMillis(); + return end - startedAt; + } + + @Override + public String toString() { + return "P2pSession{" + + "id=" + sessionId + + ", peer=" + peerUserId + + ", state=" + state + + ", pushed=" + docsPushed + + ", pulled=" + docsPulled + + ", transit=" + transitDocs + + ", rejected=" + docsRejected + + '}'; + } + + // --- Private helpers --- + + private String mapStateToStatus() { + switch (state) { + case COMPLETED: + return "completed"; + case FAILED: + return "failed"; + default: + return "in_progress"; + } + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pSyncClient.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pSyncClient.java new file mode 100644 index 00000000..7ed5ab8b --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pSyncClient.java @@ -0,0 +1,291 @@ +package org.medicmobile.webapp.mobile.p2p; + +import android.net.Network; +import android.util.Log; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +/** + * HTTP sync client for the CHW (peer) side. + * Connects to the Supervisor's LocalHttpServer endpoints to exchange docs. + * + * Flow: + * 1. authenticate() → POST /_p2p/auth with JWT + * 2. getIds() → GET /_p2p/get-ids (supervisor's doc IDs) + * 3. bulkGet(ids) → POST /_p2p/bulk-get (download docs from supervisor) + * 4. acceptDocs() → POST /_p2p/accept-docs (upload CHW docs to supervisor) + * + * All calls are synchronous — caller must run on a background thread. + */ +public class P2pSyncClient { + + private static final String TAG = "P2pSyncClient"; + private static final String CONTENT_TYPE_JSON = "application/json"; + private static final String KEY_OK = "ok"; + private static final String KEY_DOCS = "docs"; + private static final int CONNECT_TIMEOUT_MS = 10_000; + private static final int READ_TIMEOUT_MS = 30_000; + + private final String baseUrl; + private String sessionToken; // Set after successful auth + private String deviceId; + private Network boundNetwork; // When set, HTTP calls route through this network + + /** + * @param host supervisor's IP address (e.g., "192.168.43.1") + * @param port supervisor's HTTP server port (e.g., 8443) + */ + public P2pSyncClient(String host, int port) { + this.baseUrl = "http://" + host + ":" + port + "/_p2p"; + } + + public void setDeviceId(String deviceId) { + this.deviceId = deviceId; + } + + /** + * Bind all HTTP connections to a specific network. + * Required on Android when the hotspot WiFi (no internet) is not the default network. + * Without this, HttpURLConnection routes through cellular/previous WiFi. + */ + public void setNetwork(Network network) { + this.boundNetwork = network; + } + + /** + * Step 1: Authenticate with the supervisor. + * Sends JWT to POST /_p2p/auth. + * + * @param jwt the P2P JWT token from /api/v1/p2p/authorize + * @return null on success, or an error string describing the failure + */ + public String authenticate(String jwt) throws IOException, JSONException { + JSONObject body = new JSONObject(); + body.put("p2p_token", jwt); + body.put("device_id", deviceId != null ? deviceId : "unknown"); + + JSONObject response = postJson("/auth", body, null); + boolean ok = response.optBoolean(KEY_OK, false); + if (ok) { + sessionToken = response.optString("session_id", null); + Log.i(TAG, "Authenticated with supervisor, session=" + sessionToken + + ", user=" + response.optString("user_id", "?")); + return null; + } else { + String error = response.optString("error", "unknown"); + Log.e(TAG, "Auth failed: " + error); + return error; + } + } + + /** + * Step 2: Get document IDs from the supervisor. + * + * @return JSONArray of doc ID strings + */ + public JSONArray getIds() throws IOException, JSONException { + JSONObject response = getJson("/get-ids"); + return response.optJSONArray("doc_ids"); + } + + /** + * Step 3: Download docs from supervisor by ID. + * Sends in CouchDB _bulk_get format: { "docs": [{"id": "doc1"}, ...] } + * + * @param docIds array of doc ID strings to fetch + * @return JSONArray of doc objects from results + */ + public JSONArray bulkGet(JSONArray docIds) throws IOException, JSONException { + JSONArray docsArray = buildBulkGetRequest(docIds); + + JSONObject body = new JSONObject(); + body.put(KEY_DOCS, docsArray); + + JSONObject response = postJson("/bulk-get", body, sessionToken); + return extractDocsFromBulkGetResponse(response); + } + + private JSONArray buildBulkGetRequest(JSONArray docIds) throws JSONException { + JSONArray docsArray = new JSONArray(); + for (int i = 0; i < docIds.length(); i++) { + JSONObject entry = new JSONObject(); + entry.put("id", docIds.getString(i)); + docsArray.put(entry); + } + return docsArray; + } + + private JSONArray extractDocsFromBulkGetResponse(JSONObject response) throws JSONException { + JSONArray results = response.optJSONArray("results"); + JSONArray docs = new JSONArray(); + if (results == null) { + return docs; + } + for (int i = 0; i < results.length(); i++) { + extractFirstOkDoc(results.getJSONObject(i), docs); + } + return docs; + } + + private void extractFirstOkDoc(JSONObject result, JSONArray docs) throws JSONException { + JSONArray resultDocs = result.optJSONArray(KEY_DOCS); + if (resultDocs != null && resultDocs.length() > 0) { + JSONObject firstDoc = resultDocs.getJSONObject(0); + if (firstDoc.has(KEY_OK)) { + docs.put(firstDoc.getJSONObject("ok")); + } + } + } + + /** + * Step 4: Upload CHW docs to supervisor. + * + * @param docs JSONArray of doc objects + * @return JSONObject with results + */ + public JSONObject acceptDocs(JSONArray docs) throws IOException, JSONException { + JSONObject body = new JSONObject(); + body.put(KEY_DOCS, docs); + + return postJson("/accept-docs", body, sessionToken); + } + + /** + * Step 5: Signal sync completion to the supervisor. + * POST /_p2p/sync-complete tells the host that the CHW is done pushing. + * + * @param docsPushed total docs pushed (in-scope + transit) + * @param bytesTransferred total bytes transferred + * @return JSONObject with server response + */ + public JSONObject syncComplete(int docsPushed, long bytesTransferred) + throws IOException, JSONException { + JSONObject body = new JSONObject(); + body.put("docs_pushed", docsPushed); + body.put("bytes_transferred", bytesTransferred); + return postJson("/sync-complete", body, sessionToken); + } + + /** + * Check server status (no auth required). + */ + public JSONObject getStatus() throws IOException, JSONException { + return getJson("/status"); + } + + /** + * Quick connectivity check with short timeout. + * Returns true if the server is reachable. + */ + public boolean isReachable() { + try { + URL url = new URL(baseUrl + "/status"); + HttpURLConnection conn = openConnection(url); + try { + conn.setRequestMethod("GET"); + conn.setConnectTimeout(2000); + conn.setReadTimeout(2000); + int code = conn.getResponseCode(); + return code == 200; + } finally { + conn.disconnect(); + } + } catch (IOException e) { + Log.d(TAG, "isReachable(" + baseUrl + "): " + e.getClass().getSimpleName() + + ": " + e.getMessage()); + return false; + } + } + + // --- HTTP helpers --- + + /** + * Open an HttpURLConnection, routing through boundNetwork if set. + * This is critical: without network binding, Android routes through the default + * network (cellular), not the hotspot WiFi which has no internet. + */ + private HttpURLConnection openConnection(URL url) throws IOException { + if (boundNetwork != null) { + return (HttpURLConnection) boundNetwork.openConnection(url); + } + return (HttpURLConnection) url.openConnection(); + } + + private JSONObject getJson(String endpoint) throws IOException, JSONException { + URL url = new URL(baseUrl + endpoint); + HttpURLConnection conn = openConnection(url); + try { + conn.setRequestMethod("GET"); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setRequestProperty("Accept", CONTENT_TYPE_JSON); + if (sessionToken != null) { + conn.setRequestProperty("Authorization", "Bearer " + sessionToken); + } + + int code = conn.getResponseCode(); + String body = readBody(conn, code); + Log.d(TAG, "GET " + endpoint + " → " + code); + return new JSONObject(body); + } finally { + conn.disconnect(); + } + } + + private JSONObject postJson(String endpoint, JSONObject body, String token) + throws IOException, JSONException { + URL url = new URL(baseUrl + endpoint); + HttpURLConnection conn = openConnection(url); + try { + conn.setRequestMethod("POST"); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", CONTENT_TYPE_JSON); + conn.setRequestProperty("Accept", CONTENT_TYPE_JSON); + if (token != null) { + conn.setRequestProperty("Authorization", "Bearer " + token); + } + + byte[] payload = body.toString().getBytes(StandardCharsets.UTF_8); + conn.setFixedLengthStreamingMode(payload.length); + + try (OutputStream os = conn.getOutputStream()) { + os.write(payload); + } + + int code = conn.getResponseCode(); + String responseBody = readBody(conn, code); + Log.d(TAG, "POST " + endpoint + " → " + code); + return new JSONObject(responseBody); + } finally { + conn.disconnect(); + } + } + + private String readBody(HttpURLConnection conn, int code) throws IOException { + InputStream stream = (code >= 200 && code < 400) + ? conn.getInputStream() + : conn.getErrorStream(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(stream, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + return sb.toString(); + } + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pTelemetryReporter.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pTelemetryReporter.java new file mode 100644 index 00000000..6f824847 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pTelemetryReporter.java @@ -0,0 +1,191 @@ +package org.medicmobile.webapp.mobile.p2p; + +import android.app.ActivityManager; +import android.content.Context; +import android.os.Build; +import android.util.Log; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.List; +import java.util.Locale; + +/** + * Reports P2P telemetry to the server endpoint POST /api/v1/p2p/telemetry. + * + * Telemetry is fire-and-forget (async, no retry on failure). + * Schema from : + * { + * "type": "telemetry", + * "metadata": { "device_id": "uuid", "user": "chw-mary" }, + * "p2p_session": { + * "session_id": "uuid", + * "role": "sender | receiver", + * "transport": "wifi_hotspot", + * "peer_count": 1, + * "docs_transferred": 47, + * "transit_docs": 42, + * "bytes_transferred": 245000, + * "duration_ms": 300000, + * "status": "completed", + * "error": null, + * "device_info": { + * "manufacturer": "tecno", + * "model": "Spark 10C", + * "android_api": 33, + * "ram_mb": 4096 + * } + * } + * } + * + * The server endpoint returns 202 Accepted. We don't retry on failure — + * telemetry is best-effort. + */ +public class P2pTelemetryReporter { + + private static final String TAG = "P2pTelemetryReporter"; + + private final String deviceId; + private final String userId; + private final Context context; + + /** + * @param deviceId the device's unique identifier + * @param userId the current user's ID + * @param context Android context for reading device info (RAM) + */ + public P2pTelemetryReporter(String deviceId, String userId, Context context) { + this.deviceId = deviceId; + this.userId = userId; + this.context = context; + } + + /** + * Build telemetry payload from completed sessions. + * + * Returns an array of telemetry entries (one per session), each matching + * the format. The server telemetry endpoint + * (POST /api/v1/p2p/telemetry) expects: + * { device_id: string, sessions: P2pSessionLog[] } + * + * @param tracker the P2pTracker containing completed sessions + * @return the full telemetry request payload + */ + public JSONObject buildTelemetryPayload(P2pTracker tracker) throws JSONException { + JSONObject payload = new JSONObject(); + payload.put("device_id", deviceId); + + JSONArray sessions = new JSONArray(); + List completedSessions = tracker.getCompletedSessions(); + + for (P2pSession session : completedSessions) { + sessions.put(buildSessionEntry(session)); + } + + payload.put("sessions", sessions); + Log.d(TAG, "Built telemetry payload with " + sessions.length() + " sessions"); + return payload; + } + + /** + * Build a single telemetry entry matching . + * + * { + * "type": "telemetry", + * "metadata": { "device_id": "uuid", "user": "chw-mary" }, + * "p2p_session": { ... } + * } + */ + private JSONObject buildSessionEntry(P2pSession session) throws JSONException { + JSONObject entry = new JSONObject(); + entry.put("type", "telemetry"); + + // metadata + JSONObject metadata = new JSONObject(); + metadata.put("device_id", deviceId); + metadata.put("user", userId); + entry.put("metadata", metadata); + + // p2p_session + JSONObject p2pSession = new JSONObject(); + p2pSession.put("session_id", session.getSessionId()); + p2pSession.put("role", inferRole(session)); + p2pSession.put("transport", "wifi_hotspot"); + p2pSession.put("peer_count", 1); + p2pSession.put("docs_transferred", + session.getDocsPushed() + session.getDocsPulled() + session.getTransitDocs()); + p2pSession.put("transit_docs", session.getTransitDocs()); + p2pSession.put("bytes_transferred", session.getBytesTransferred()); + p2pSession.put("duration_ms", session.getDurationMs()); + p2pSession.put("status", mapStatus(session)); + p2pSession.put("error", session.getError() != null ? session.getError() : JSONObject.NULL); + p2pSession.put("device_info", getDeviceInfo()); + + entry.put("p2p_session", p2pSession); + return entry; + } + + /** + * Get device info for telemetry. + * + * Returns: { manufacturer, model, android_api, ram_mb } + */ + private JSONObject getDeviceInfo() throws JSONException { + JSONObject info = new JSONObject(); + info.put("manufacturer", Build.MANUFACTURER.toLowerCase(Locale.ROOT)); + info.put("model", Build.MODEL); + info.put("android_api", Build.VERSION.SDK_INT); + info.put("ram_mb", getTotalRamMb()); + return info; + } + + /** + * Get total device RAM in MB. + * Uses ActivityManager.MemoryInfo for accurate reporting. + */ + private long getTotalRamMb() { + if (context == null) { + return 0; + } + try { + ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + if (activityManager != null) { + ActivityManager.MemoryInfo memoryInfo = new ActivityManager.MemoryInfo(); + activityManager.getMemoryInfo(memoryInfo); + return memoryInfo.totalMem / (1024 * 1024); + } + } catch (Exception e) { + Log.w(TAG, "Failed to get device RAM", e); + } + return 0; + } + + /** + * Infer whether this device was sender or receiver based on session data. + * If docs were pushed > pulled, this device was the sender (CHW). + * If docs were pulled > pushed (or transit docs present), this was the receiver (Supervisor). + */ + private String inferRole(P2pSession session) { + if (session.getTransitDocs() > 0) { + return "receiver"; + } + if (session.getDocsPushed() >= session.getDocsPulled()) { + return "sender"; + } + return "receiver"; + } + + private String mapStatus(P2pSession session) { + switch (session.getState()) { + case COMPLETED: + return "completed"; + case FAILED: + return "failed"; + default: + return "interrupted"; + } + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pTracker.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pTracker.java new file mode 100644 index 00000000..cf8b75da --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pTracker.java @@ -0,0 +1,254 @@ +package org.medicmobile.webapp.mobile.p2p; + +import android.util.Log; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +/** + * Tracks P2P sync sessions and accumulates metrics. + * + * Session data is stored in: + * - _local/p2p-sync-log (CHW side) — + * - _local/p2p-relay-log (Supervisor side) — + * + * This class is the single source of truth for session history during + * the app lifecycle. Completed sessions are serialized to _local/ docs + * via the WebView bridge for persistence across app restarts. + */ +public class P2pTracker { + + private static final String TAG = "P2pTracker"; + private static final String KEY_SESSIONS = "sessions"; + + private final List completedSessions = new ArrayList<>(); + private P2pSession currentSession; + + /** + * Start tracking a new P2P session. + * + * @param peerDeviceId the peer's device ID + * @param peerUserId the peer's user ID + * @param peerRole the peer's role (chw, chw_supervisor) + * @param peerScope the peer's scope manifest + * @return the new session object for counter updates + */ + public synchronized P2pSession startSession(String peerDeviceId, String peerUserId, + String peerRole, ScopeManifest peerScope) { + if (currentSession != null) { + Log.w(TAG, "Starting new session while previous session is still active. " + + "Marking previous session as failed."); + failSession("Replaced by new session"); + } + currentSession = new P2pSession(peerDeviceId, peerUserId, peerRole, peerScope); + currentSession.setState(P2pSession.State.ACTIVE); + Log.i(TAG, "Started P2P session: " + currentSession.getSessionId() + + " with peer " + peerUserId); + return currentSession; + } + + /** + * Complete the current session successfully. + */ + public synchronized void completeSession() { + if (currentSession == null) { + Log.w(TAG, "completeSession() called with no active session"); + return; + } + currentSession.complete(); + completedSessions.add(currentSession); + Log.i(TAG, "Completed P2P session: " + currentSession); + currentSession = null; + } + + /** + * Fail the current session with an error. + * + * @param error human-readable error description + */ + public synchronized void failSession(String error) { + if (currentSession == null) { + Log.w(TAG, "failSession() called with no active session"); + return; + } + currentSession.fail(error); + completedSessions.add(currentSession); + Log.w(TAG, "Failed P2P session: " + currentSession + " error=" + error); + currentSession = null; + } + + /** + * Get current active session (may be null if no session in progress). + */ + public P2pSession getCurrentSession() { + return currentSession; + } + + /** + * Check if there is an active session in progress. + */ + public boolean hasActiveSession() { + return currentSession != null; + } + + /** + * Build the _local/p2p-sync-log JSON (CHW side). + * + * FORMAT from : + * { + * "_id": "_local/p2p-sync-log", + * KEY_SESSIONS: [ + * { + * "session_id": "uuid", + * "peer_device_id": "supervisor-device-uuid", + * "peer_user": "supervisor-jane", + * "started_at": 1711152000000, + * "completed_at": 1711152300000, + * "docs_pushed": 47, + * "docs_pulled": 3, + * "bytes_transferred": 245000, + * "status": "completed | failed | interrupted", + * "error": null + * } + * ] + * } + */ + public JSONObject buildSyncLog() throws JSONException { + JSONObject log = new JSONObject(); + log.put("_id", "_local/p2p-sync-log"); + log.put(KEY_SESSIONS, buildSessionsArray()); + return log; + } + + /** + * Build the _local/p2p-relay-log JSON (Supervisor side). + * + * FORMAT from : + * { + * "_id": "_local/p2p-relay-log", + * KEY_SESSIONS: [ + * { + * "session_id": "uuid", + * "source_device_id": "chw-device-uuid", + * "source_user": "chw-mary", + * "started_at": 1711152000000, + * "completed_at": 1711152300000, + * "docs_received": 47, + * "in_scope_count": 5, + * "transit_count": 42, + * "rejected_count": 0, + * "bytes_received": 245000, + * "status": "completed | failed | interrupted" + * } + * ] + * } + */ + public JSONObject buildRelayLog() throws JSONException { + JSONObject log = new JSONObject(); + log.put("_id", "_local/p2p-relay-log"); + JSONArray sessions = new JSONArray(); + for (P2pSession session : completedSessions) { + sessions.put(buildRelaySessionEntry(session)); + } + log.put(KEY_SESSIONS, sessions); + return log; + } + + /** + * Load existing sessions from a previously saved sync log JSON. + * This restores state after app restart. + * + * @param logJson the _local/p2p-sync-log or _local/p2p-relay-log JSON + */ + public void loadFromSyncLog(JSONObject logJson) throws JSONException { + // We only load completed session count for telemetry purposes. + // Actual session objects are not reconstructed — they are historical data. + // The JSON is passed through directly when building telemetry. + if (logJson.has(KEY_SESSIONS)) { + Log.i(TAG, "Loaded sync log with " + + logJson.getJSONArray(KEY_SESSIONS).length() + " historical sessions"); + } + } + + /** + * Record an externally-completed session (e.g., from LocalHttpServer). + * Used when the session lifecycle is managed outside the tracker + * (host-side sessions are managed by LocalHttpServer, not tracker). + */ + public synchronized void recordCompletedSession(P2pSession session) { + if (session == null) { + return; + } + completedSessions.add(session); + Log.i(TAG, "Recorded externally-completed session: " + session.getSessionId()); + } + + /** + * Get count of completed sessions tracked in memory. + */ + public int getSessionCount() { + return completedSessions.size(); + } + + /** + * Get all completed sessions (for telemetry reporting). + */ + public synchronized List getCompletedSessions() { + return new ArrayList<>(completedSessions); + } + + /** + * Clear completed sessions after successful telemetry upload. + */ + public synchronized void clearCompletedSessions() { + completedSessions.clear(); + Log.i(TAG, "Cleared completed sessions after telemetry upload"); + } + + // --- Private helpers --- + + private JSONArray buildSessionsArray() throws JSONException { + JSONArray sessions = new JSONArray(); + for (P2pSession session : completedSessions) { + sessions.put(session.toJson()); + } + return sessions; + } + + /** + * Build a relay log session entry — uses Supervisor-side field names + * from (_local/p2p-relay-log format). + */ + private JSONObject buildRelaySessionEntry(P2pSession session) throws JSONException { + JSONObject entry = new JSONObject(); + entry.put("session_id", session.getSessionId()); + entry.put("source_device_id", session.getPeerDeviceId()); + entry.put("source_user", session.getPeerUserId()); + entry.put("started_at", session.getStartedAt()); + entry.put("completed_at", session.getCompletedAt() > 0 + ? session.getCompletedAt() : JSONObject.NULL); + // Supervisor receives docs (pushed by CHW), so docs_pushed = docs_received from relay perspective + entry.put("docs_received", session.getDocsPushed() + session.getTransitDocs()); + entry.put("in_scope_count", session.getDocsPushed()); + entry.put("transit_count", session.getTransitDocs()); + entry.put("rejected_count", session.getDocsRejected()); + entry.put("bytes_received", session.getBytesTransferred()); + entry.put("status", mapSessionStatus(session)); + return entry; + } + + private String mapSessionStatus(P2pSession session) { + switch (session.getState()) { + case COMPLETED: + return "completed"; + case FAILED: + return "failed"; + default: + return "interrupted"; + } + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/PouchDbBridge.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/PouchDbBridge.java new file mode 100644 index 00000000..b3df414d --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/PouchDbBridge.java @@ -0,0 +1,51 @@ +package org.medicmobile.webapp.mobile.p2p; + +/** + * Callback interface for accessing PouchDB data from the WebView. + * + * All methods execute synchronously from the HTTP server thread and + * must internally coordinate with the WebView (e.g. via CountDownLatch + + * evaluateJavascript). The actual implementation lives in P2pBridgeMethods + * — this interface decouples the HTTP layer from the WebView layer. + * + * All inputs and outputs are JSON strings. + */ +public interface PouchDbBridge { + + /** + * Get all doc IDs and revisions from the local PouchDB. + * + * @return JSON array of objects: [{"_id": "...", "_rev": "..."}, ...] + */ + String getAllDocIds(); + + /** + * Get full documents by their IDs. + * + * @param idsJson JSON array of ID strings: ["doc1", "doc2", ...] + * @return JSON array of full doc objects + */ + String getDocsByIds(String idsJson); + + /** + * Write documents to PouchDB using bulkDocs with new_edits:false. + * + * @param docsJson JSON array of full doc objects (with _id and _rev) + * @return JSON array of results: [{"ok": true, "id": "...", "rev": "..."}, ...] + */ + String writeDocs(String docsJson); + + /** + * Query the medic-client/contacts_by_depth view to get all contact IDs + * within the Supervisor's replication scope. + * + * Uses the same view CHT's server uses for replication filtering: + * startkey: [facilityId] + * endkey: [facilityId, maxDepth, {}] + * + * @param facilityId The Supervisor's facility_id (subtree root) + * @param maxDepth The Supervisor's replication_depth + * @return JSON array of in-scope contact ID strings: ["id1", "id2", ...] + */ + String queryContactsByDepth(String facilityId, int maxDepth); +} 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..b33e786c --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/QrCodeHelper.java @@ -0,0 +1,400 @@ +package org.medicmobile.webapp.mobile.p2p; + +import android.graphics.Bitmap; +import android.graphics.Color; +import android.util.Base64; +import android.util.Log; + +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.io.ByteArrayOutputStream; +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, + * "tls": "sha256:AB:CD:EF:...", + * "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 String TAG = "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; + final String tlsFingerprint; + + public HotspotCredentials(String ssid, String password, String ipAddress, + int port, String tlsFingerprint) { + this.ssid = ssid; + this.password = password; + this.ipAddress = ipAddress; + this.port = port; + this.tlsFingerprint = tlsFingerprint; + } + } + + /** + * 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) { + Log.e(TAG, "Failed to generate QR code", e); + return null; + } + } + + /** + * Generate a QR code as a data URL string (data:image/png;base64,...). + * This is the format expected by the webapp for rendering in an img tag. + * + * @param payloadJson the QR payload JSON string to encode + * @return data URL string, or null if generation fails + */ + public static String generateQrDataUrl(String payloadJson) { + try { + Bitmap bitmap = encodeQrBitmap(payloadJson, QR_SIZE); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos); + bitmap.recycle(); + String base64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP); + return "data:image/png;base64," + base64; + } catch (WriterException e) { + Log.e(TAG, "Failed to generate QR data URL", e); + 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, creds.tlsFingerprint); + } + + /** + * 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 + * @param tlsFingerprint SHA-256 fingerprint of the server's TLS certificate + * @return JSON string matching + * @throws JSONException if JSON construction fails + */ + @SuppressWarnings("java:S107") // Convenience overload — delegates to HotspotCredentials variant + public static String buildPayload(String ssid, String password, + String ipAddress, int port, + String tlsFingerprint) 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("tls", tlsFingerprint != null ? tlsFingerprint : ""); + 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 ValidationResult.accept(IN_SCOPE) if valid, + * ValidationResult.reject(reason) if invalid + */ + public static ValidationResult validateQrPayload(String payloadJson) { + if (payloadJson == null || payloadJson.isEmpty()) { + return ValidationResult.reject("empty_payload"); + } + + JSONObject payload; + try { + payload = new JSONObject(payloadJson); + } catch (JSONException e) { + return ValidationResult.reject("invalid_json: " + e.getMessage()); + } + + ValidationResult headerCheck = checkQrTypeAndVersion(payload); + if (headerCheck != null) { + return headerCheck; + } + + ValidationResult tsCheck = checkQrTimestamp(payload); + if (tsCheck != null) { + return tsCheck; + } + + return validateRequiredFields(payload); + } + + private static ValidationResult checkQrTypeAndVersion(JSONObject payload) { + String type = payload.optString("type", ""); + if (!PAYLOAD_TYPE.equals(type)) { + return ValidationResult.reject("invalid type '" + type + + "', expected '" + PAYLOAD_TYPE + "'"); + } + int version = payload.optInt("v", -1); + if (version != PAYLOAD_VERSION) { + return ValidationResult.reject("unsupported version: " + version + + ", expected " + PAYLOAD_VERSION); + } + return null; + } + + private static ValidationResult checkQrTimestamp(JSONObject payload) { + long timestamp = payload.optLong(KEY_TS, 0); + if (timestamp == 0) { + return ValidationResult.reject("missing timestamp"); + } + long drift = Math.abs(System.currentTimeMillis() - timestamp); + if (drift > MAX_TIMESTAMP_DRIFT_MS) { + return ValidationResult.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.optString("tls", ""), + json.getLong(KEY_TS) + ); + } catch (JSONException e) { + Log.e(TAG, "Failed to parse QR payload", e); + return null; + } + } + + // --- Private helpers --- + + /** + * Validate required fields (ssid, pwd, ip, port) in the QR payload. + */ + private static ValidationResult validateRequiredFields(JSONObject payload) { + if (isEmptyField(payload, "ssid")) { + return ValidationResult.reject("missing required field: ssid"); + } + if (isEmptyField(payload, "pwd")) { + return ValidationResult.reject("missing required field: pwd"); + } + if (isEmptyField(payload, "ip")) { + return ValidationResult.reject("missing required field: ip"); + } + int port = payload.optInt(KEY_PORT, 0); + if (port <= 0 || port > 65535) { + return ValidationResult.reject("invalid port: " + port); + } + return ValidationResult.accept(DocScope.IN_SCOPE); + } + + private static boolean isEmptyField(JSONObject obj, String key) { + return obj.optString(key, "").isEmpty(); + } + + /** + * Encode a string into a QR code Bitmap using ZXing. + * + * @param content the string content to encode + * @param size bitmap width and height in pixels + * @return the QR code as a Bitmap + * @throws WriterException if ZXing encoding fails + */ + private static Bitmap encodeQrBitmap(String content, int size) throws WriterException { + Map hints = new EnumMap<>(EncodeHintType.class); + hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M); + hints.put(EncodeHintType.CHARACTER_SET, "UTF-8"); + hints.put(EncodeHintType.MARGIN, 2); + + QRCodeWriter writer = new QRCodeWriter(); + BitMatrix bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, size, size, hints); + + return renderBitmapPixels(bitMatrix); + } + + private static Bitmap renderBitmapPixels(BitMatrix bitMatrix) { + int width = bitMatrix.getWidth(); + int height = bitMatrix.getHeight(); + int[] pixels = fillPixelArray(bitMatrix, width, height); + + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565); + bitmap.setPixels(pixels, 0, width, 0, 0, width, height); + return bitmap; + } + + private static int[] fillPixelArray(BitMatrix bitMatrix, int width, int height) { + int[] pixels = new int[width * height]; + for (int y = 0; y < height; y++) { + fillRow(bitMatrix, pixels, y, width); + } + return pixels; + } + + private static void fillRow(BitMatrix bitMatrix, int[] pixels, int y, int width) { + int offset = y * width; + for (int x = 0; x < width; x++) { + pixels[offset + x] = bitMatrix.get(x, y) ? Color.BLACK : Color.WHITE; + } + } + + /** + * Parsed QR payload data class. + * Holds the extracted fields from a validated QR code. + */ + @SuppressWarnings("java:S107") // Data class — all fields are logically related + public static final class QrPayload { + private final String ssid; + private final String password; + private final String ipAddress; + private final int port; + private final String tlsFingerprint; + private final long timestamp; + + QrPayload(String ssid, String password, String ipAddress, + int port, String tlsFingerprint, long timestamp) { + this.ssid = ssid; + this.password = password; + this.ipAddress = ipAddress; + this.port = port; + this.tlsFingerprint = tlsFingerprint; + this.timestamp = timestamp; + } + + public String getSsid() { + return ssid; + } + + public String getPassword() { + return password; + } + + public String getIpAddress() { + return ipAddress; + } + + public int getPort() { + return port; + } + + public String getTlsFingerprint() { + return tlsFingerprint; + } + + 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..2d942507 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/QrScannerActivity.java @@ -0,0 +1,148 @@ +package org.medicmobile.webapp.mobile.p2p; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; +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 { + + private static final String TAG = "QrScannerActivity"; + + 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.i(TAG, "QR scan cancelled by user"); + setResult(RESULT_CANCELED); + finish(); + return; + } + + Log.i(TAG, "QR code scanned, validating payload"); + handleScannedPayload(scannedContent); + } else { + // Unexpected result — not from ZXing + super.onActivityResult(requestCode, resultCode, data); + Log.w(TAG, "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) { + ValidationResult validation = QrCodeHelper.validateQrPayload(scannedContent); + + if (validation.isAccepted()) { + Log.i(TAG, "QR payload validated successfully"); + Intent resultIntent = new Intent(); + resultIntent.putExtra(EXTRA_QR_RESULT, scannedContent); + setResult(RESULT_OK, resultIntent); + } else { + String reason = validation.getReason(); + Log.w(TAG, "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) { + Log.w(TAG, "ZXing CaptureActivity not found, falling back to default"); + return null; + } + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/RevocationList.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/RevocationList.java new file mode 100644 index 00000000..05e3fc70 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/RevocationList.java @@ -0,0 +1,82 @@ +package org.medicmobile.webapp.mobile.p2p; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Cached revocation list for P2P authentication. + * Checked during auth to enforce G8 (device/user not revoked). + * + * Format from : + * { + * "version": number, + * "revoked_devices": string[], + * "revoked_users": string[], + * "updated_at": ISO8601 + * } + */ +@SuppressWarnings("java:S6206") // Records require Java 16+; project targets Java 8 +public final class RevocationList { + + private final Set revokedDevices; + private final Set revokedUsers; + private final int version; + + public RevocationList(int version, Set revokedDevices, Set revokedUsers) { + this.version = version; + this.revokedDevices = Collections.unmodifiableSet(new HashSet<>(revokedDevices)); + this.revokedUsers = Collections.unmodifiableSet(new HashSet<>(revokedUsers)); + } + + /** + * Parse from the server's revocation-list response. + */ + public static RevocationList fromJson(JSONObject json) throws JSONException { + int ver = json.optInt("version", 0); + Set devices = parseStringSet(json.optJSONArray("revoked_devices")); + Set users = parseStringSet(json.optJSONArray("revoked_users")); + return new RevocationList(ver, devices, users); + } + + private static Set parseStringSet(JSONArray array) throws JSONException { + Set result = new HashSet<>(); + if (array != null) { + for (int i = 0; i < array.length(); i++) { + result.add(array.getString(i)); + } + } + return result; + } + + /** Returns an empty revocation list for first-time use. */ + public static RevocationList empty() { + return new RevocationList(0, Collections.emptySet(), Collections.emptySet()); + } + + /** G8: Check if device is revoked. */ + public boolean isDeviceRevoked(String deviceId) { + return deviceId != null && revokedDevices.contains(deviceId); + } + + /** G8: Check if user is revoked. */ + public boolean isUserRevoked(String userId) { + return userId != null && revokedUsers.contains(userId); + } + + public int getVersion() { + return version; + } + + public Set getRevokedDevices() { + return revokedDevices; + } + + public Set getRevokedUsers() { + return revokedUsers; + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/ScopeGuard.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/ScopeGuard.java new file mode 100644 index 00000000..f697615c --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/ScopeGuard.java @@ -0,0 +1,289 @@ +package org.medicmobile.webapp.mobile.p2p; + +import org.json.JSONObject; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * ScopeGuard is the critical classification engine for P2P sync. + * It enforces the 5 inviolable scope rules (RFC Section 10.1) and classifies + * each document as IN_SCOPE, TRANSIT, or REJECTED. + * + *

Guard classify() MUST be deterministic — same input always produces same output. + * This class has NO mutable state and all decisions are purely functional on inputs.

+ * + *

5 Inviolable Rules:

+ *
    + *
  1. CHW can only PUSH docs within their facility subtree
  2. + *
  3. Supervisor can only ACCEPT from CHW in their subtree
  4. + *
  5. Supervisor can only OFFER docs within CHW's scope
  6. + *
  7. Neither party requests docs outside their scope
  8. + *
  9. P2P is ADDITIVE ONLY — no deletions/purges/permission changes
  10. + *
+ */ +public final class ScopeGuard { + + private static final String TAG = "ScopeGuard"; + private static final String TYPE_DATA_RECORD = "data_record"; + private static final String REJECT_BRANCH_MISMATCH = "branch_mismatch"; + private static final String KEY_DOC_ID = "_id"; + private static final String KEY_CONTACT = "contact"; + + /** + * Allowed top-level doc types for P2P sync. + * "contact" is included because CHT uses it as a generic type with contact_type subtypes. + */ + private static final Set ALLOWED_DOC_TYPES = new HashSet<>(Arrays.asList( + TYPE_DATA_RECORD, + KEY_CONTACT, + "person", + "clinic", + "health_center", + "district_hospital", + "form", + "translation", + "resources" + )); + + /** + * Doc types that are classified as "contact" for scope purposes. + * These use parent-chain hierarchy for scope determination. + */ + private static final Set CONTACT_DOC_TYPES = new HashSet<>(Arrays.asList( + KEY_CONTACT, + "person", + "clinic", + "health_center", + "district_hospital" + )); + + /** + * Classify a single document for P2P sync scope. + * + * @param doc The document to classify (JSONObject with _id, type, parent, etc.) + * @param senderScope The sender's (CHW's) scope manifest + * @param receiverScope The receiver's (Supervisor's) scope manifest + * @param parentIndex Pre-built map of docId -> parentId for the entire batch, + * enabling parent chain traversal without database lookups + * @return ValidationResult with scope classification or rejection reason + */ + public ValidationResult classify(JSONObject doc, ScopeManifest senderScope, + ScopeManifest receiverScope, Map parentIndex) { + // Step 1: Reject invalid or system docs + ValidationResult basicCheck = rejectInvalidDocs(doc); + if (basicCheck != null) { + return basicCheck; + } + + DocFields fields = new DocFields( + doc.optString(KEY_DOC_ID, null), + doc.optString("type", null), + getParentId(doc)); + + // Step 2: Verify sender scope + ValidationResult senderCheck = checkSenderScope(fields, senderScope, parentIndex); + if (senderCheck != null) { + return senderCheck; + } + + // Step 3: Classify against receiver scope + return classifyForReceiver(fields, receiverScope, parentIndex); + } + + /** Extracted doc fields used during classification. Reduces parameter passing. */ + static final class DocFields { + final String docId; + final String docType; + final String parentId; + + DocFields(String docId, String docType, String parentId) { + this.docId = docId; + this.docType = docType; + this.parentId = parentId; + } + } + + private ValidationResult rejectInvalidDocs(JSONObject doc) { + String docId = doc.optString(KEY_DOC_ID, null); + if (docId == null || docId.isEmpty()) { + return ValidationResult.reject("missing_id"); + } + + ValidationResult systemDocCheck = rejectSystemDoc(docId); + if (systemDocCheck != null) { + return systemDocCheck; + } + + return rejectDisallowedContent(doc); + } + + private ValidationResult rejectDisallowedContent(JSONObject doc) { + String docType = doc.optString("type", null); + if (docType == null || !ALLOWED_DOC_TYPES.contains(docType)) { + return ValidationResult.reject("disallowed_type: " + docType); + } + if (doc.optBoolean("_deleted", false)) { + return ValidationResult.reject("deletion_not_allowed"); + } + return null; + } + + private ValidationResult rejectSystemDoc(String docId) { + if (docId.startsWith("_design/")) { + return ValidationResult.reject("design_doc"); + } + if (docId.startsWith("_local/")) { + return ValidationResult.reject("local_doc"); + } + return null; + } + + private ValidationResult checkSenderScope(DocFields fields, + ScopeManifest senderScope, Map parentIndex) { + if (isContactType(fields.docType) + && !isWithinFacilitySubtree(fields.docId, senderScope.getFacilitySubtreeRoot(), parentIndex)) { + return ValidationResult.reject("sender_scope_violation: not in sender facility subtree"); + } + if (TYPE_DATA_RECORD.equals(fields.docType) + && fields.parentId != null + && !isWithinFacilitySubtree(fields.parentId, senderScope.getFacilitySubtreeRoot(), parentIndex)) { + return ValidationResult.reject("sender_scope_violation: report parent not in sender facility subtree"); + } + return null; + } + + private ValidationResult classifyForReceiver(DocFields fields, + ScopeManifest receiverScope, Map parentIndex) { + if (isContactType(fields.docType)) { + return classifyContact(fields.docId, receiverScope, parentIndex); + } + if (TYPE_DATA_RECORD.equals(fields.docType)) { + return classifyDataRecord(fields.parentId, receiverScope, parentIndex); + } + // Shared doc types (form, translation, resources) — always in scope if type is allowed + return ValidationResult.accept(DocScope.IN_SCOPE); + } + + private ValidationResult classifyContact(String docId, ScopeManifest receiverScope, + Map parentIndex) { + if (!isWithinFacilitySubtree(docId, receiverScope.getFacilitySubtreeRoot(), parentIndex)) { + return ValidationResult.reject(REJECT_BRANCH_MISMATCH); + } + int depth = getDepthFromFacility(docId, receiverScope.getFacilitySubtreeRoot(), parentIndex); + if (depth < 0) { + return ValidationResult.reject(REJECT_BRANCH_MISMATCH); + } + if (depth <= receiverScope.getReplicationDepth()) { + return ValidationResult.accept(DocScope.IN_SCOPE); + } + return ValidationResult.accept(DocScope.TRANSIT); + } + + /** + * Classify a data_record based on its parent contact's scope. + * + * Special rule: if the data_record's DIRECT parent contact is IN_SCOPE, + * the report is also IN_SCOPE regardless of its own depth calculation. + */ + private ValidationResult classifyDataRecord(String parentId, ScopeManifest receiverScope, + Map parentIndex) { + if (parentId == null) { + return ValidationResult.reject("data_record_no_parent"); + } + + // Check if parent is within receiver's facility subtree + if (!isWithinFacilitySubtree(parentId, receiverScope.getFacilitySubtreeRoot(), parentIndex)) { + return ValidationResult.reject(REJECT_BRANCH_MISMATCH); + } + + // Get the parent contact's depth from receiver's facility root + int parentDepth = getDepthFromFacility(parentId, receiverScope.getFacilitySubtreeRoot(), parentIndex); + if (parentDepth < 0) { + return ValidationResult.reject(REJECT_BRANCH_MISMATCH); + } + + // Special case: if the direct parent contact is in-scope, report is in-scope too + if (parentDepth <= receiverScope.getReplicationDepth()) { + return ValidationResult.accept(DocScope.IN_SCOPE); + } + + // Parent is transit, so the report is also transit + return ValidationResult.accept(DocScope.TRANSIT); + } + + private static final int MAX_PARENT_CHAIN_DEPTH = 20; + + /** + * Check if a doc is within a facility subtree by walking its parent chain. + * Returns true if the parent chain reaches the facilityRoot. + * + * @param docId The document ID to check + * @param facilityRoot The facility subtree root ID + * @param parentIndex Map of docId -> parentId + */ + boolean isWithinFacilitySubtree(String docId, String facilityRoot, Map parentIndex) { + return getDepthFromFacility(docId, facilityRoot, parentIndex) >= 0; + } + + /** + * Calculate the depth of a doc from the facility root. + * + * Depth 0 = the facility root itself + * Depth 1 = direct child of facility root + * Depth 2 = grandchild, etc. + * + * @return depth >= 0, or -1 if the doc is not within the facility subtree + */ + int getDepthFromFacility(String docId, String facilityRoot, Map parentIndex) { + if (docId == null) { + return -1; + } + if (docId.equals(facilityRoot)) { + return 0; + } + return walkParentChain(docId, facilityRoot, parentIndex); + } + + /** + * Walk the parent chain from docId, counting depth until facilityRoot is found. + * + * @return depth >= 1 if facilityRoot is found, or -1 if not in subtree + */ + private int walkParentChain(String docId, String facilityRoot, Map parentIndex) { + String current = docId; + int depth = 0; + for (int i = 0; i < MAX_PARENT_CHAIN_DEPTH; i++) { + String parentId = parentIndex.get(current); + if (parentId == null) { + return -1; + } + depth++; + if (parentId.equals(facilityRoot)) { + return depth; + } + current = parentId; + } + return -1; + } + + /** + * Extract the parent._id from a doc's parent field. + */ + private String getParentId(JSONObject doc) { + JSONObject parent = doc.optJSONObject("parent"); + if (parent == null) { + return null; + } + return parent.optString(KEY_DOC_ID, null); + } + + /** + * Check if the doc type is a contact type (uses parent hierarchy for scope). + */ + private boolean isContactType(String docType) { + return CONTACT_DOC_TYPES.contains(docType); + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/ScopeManifest.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/ScopeManifest.java new file mode 100644 index 00000000..765a133d --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/ScopeManifest.java @@ -0,0 +1,127 @@ +package org.medicmobile.webapp.mobile.p2p; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Immutable scope manifest describing a user's P2P replication scope. + * + * Matches the contract in : + * { + * "facility_subtree_root": "", + * "replication_depth": 1, + * "shared_doc_types": ["person", "clinic", ...], + * "scope_version": "2026-03-23T00:00:00Z" + * } + */ +@SuppressWarnings("java:S6206") // Records require Java 16+; project targets Java 8 +public final class ScopeManifest { + + private final String facilitySubtreeRoot; + private final int replicationDepth; + private final List sharedDocTypes; + private final String scopeVersion; + + public ScopeManifest(String facilitySubtreeRoot, int replicationDepth, + List sharedDocTypes, String scopeVersion) { + validateConstructorArgs(facilitySubtreeRoot, replicationDepth, sharedDocTypes, scopeVersion); + this.facilitySubtreeRoot = facilitySubtreeRoot; + this.replicationDepth = replicationDepth; + this.sharedDocTypes = Collections.unmodifiableList(new ArrayList<>(sharedDocTypes)); + this.scopeVersion = scopeVersion; + } + + private static void validateConstructorArgs(String facilitySubtreeRoot, int replicationDepth, + List sharedDocTypes, String scopeVersion) { + requireNonEmpty(facilitySubtreeRoot, "facilitySubtreeRoot"); + if (replicationDepth < 0) { + throw new IllegalArgumentException("replicationDepth must be >= 0, got: " + replicationDepth); + } + if (sharedDocTypes == null) { + throw new IllegalArgumentException("sharedDocTypes must not be null"); + } + requireNonEmpty(scopeVersion, "scopeVersion"); + } + + private static void requireNonEmpty(String value, String name) { + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException(name + " must not be null or empty"); + } + } + + /** Parse a ScopeManifest from a JSON object ( format). */ + @SuppressWarnings("java:S6201") // Pattern matching instanceof requires Java 16+ + public static ScopeManifest fromJson(JSONObject json) throws JSONException { + String facilityRoot = parseFacilityRoot(json); + int depth = json.getInt("replication_depth"); + String version = json.getString("scope_version"); + + JSONArray typesArray = json.getJSONArray("shared_doc_types"); + List types = new ArrayList<>(typesArray.length()); + for (int i = 0; i < typesArray.length(); i++) { + types.add(typesArray.getString(i)); + } + + return new ScopeManifest(facilityRoot, depth, types, version); + } + + @SuppressWarnings("java:S6201") // Pattern matching instanceof requires Java 16+ + private static String parseFacilityRoot(JSONObject json) throws JSONException { + Object rawFacility = json.get("facility_subtree_root"); + if (rawFacility instanceof JSONArray) { + return ((JSONArray) rawFacility).getString(0); + } + return rawFacility.toString(); + } + + public String getFacilitySubtreeRoot() { + return facilitySubtreeRoot; + } + + public int getReplicationDepth() { + return replicationDepth; + } + + public List getSharedDocTypes() { + return sharedDocTypes; + } + + public String getScopeVersion() { + return scopeVersion; + } + + @Override + public String toString() { + return "ScopeManifest{" + + "facilitySubtreeRoot='" + facilitySubtreeRoot + '\'' + + ", replicationDepth=" + replicationDepth + + ", sharedDocTypes=" + sharedDocTypes + + ", scopeVersion='" + scopeVersion + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ScopeManifest that = (ScopeManifest) o; + return replicationDepth == that.replicationDepth + && facilitySubtreeRoot.equals(that.facilitySubtreeRoot) + && sharedDocTypes.equals(that.sharedDocTypes) + && scopeVersion.equals(that.scopeVersion); + } + + @Override + public int hashCode() { + int result = facilitySubtreeRoot.hashCode(); + result = 31 * result + replicationDepth; + result = 31 * result + sharedDocTypes.hashCode(); + result = 31 * result + scopeVersion.hashCode(); + return result; + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/StatusEndpoint.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/StatusEndpoint.java new file mode 100644 index 00000000..05567782 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/StatusEndpoint.java @@ -0,0 +1,46 @@ +package org.medicmobile.webapp.mobile.p2p; + +import org.json.JSONException; +import org.json.JSONObject; + +/** + * GET /_p2p/status — Health check and session info. + * + * This endpoint requires NO authentication (used for discovery/heartbeat). + * + * Response (200): + * { + * "ok": true, + * "server_type": "cht-p2p", + * "version": 1, + * "session_active": boolean, + * "connected_peers": number + * } + */ +public final class StatusEndpoint { + + private static final String SERVER_TYPE = "cht-p2p"; + private static final int PROTOCOL_VERSION = 1; + + /** + * Handle GET /_p2p/status request. + * + * @param activeSession Current active session, or null if none + * @param connectedPeers Number of currently connected peers + * @return JSON response object + */ + public JSONObject handle(P2pSession activeSession, int connectedPeers) { + try { + JSONObject response = new JSONObject(); + response.put("ok", true); + response.put("server_type", SERVER_TYPE); + response.put("version", PROTOCOL_VERSION); + response.put("session_active", activeSession != null + && activeSession.getState() == P2pSession.State.ACTIVE); + response.put("connected_peers", connectedPeers); + return response; + } catch (JSONException e) { + throw new IllegalStateException("Failed to build status response", e); + } + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/SyncMutex.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/SyncMutex.java new file mode 100644 index 00000000..b65ffb7a --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/SyncMutex.java @@ -0,0 +1,131 @@ +package org.medicmobile.webapp.mobile.p2p; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * Ensures only one sync operation (P2P or server) runs at a time. + * + * Guards: + * — Only one sync active at a time + * — Server sync has priority over P2P + * — Session timeout after 30 min idle + * + * States: IDLE -> SERVER_SYNC, IDLE -> P2P_SYNC + * If P2P is active and server becomes reachable, P2P finishes first then yields. + */ +public class SyncMutex { + + public enum SyncType { SERVER, P2P } + + private static final int DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes + + private final AtomicReference activeSyncType = new AtomicReference<>(null); + private volatile long lastActivityAt; + private final ServerReachabilityChecker reachabilityChecker; + + /** + * Callback interface for checking server connectivity. + * Implementations should perform a lightweight check (e.g. HEAD request). + */ + public interface ServerReachabilityChecker { + boolean isServerReachable(); + } + + public SyncMutex(ServerReachabilityChecker reachabilityChecker) { + this.reachabilityChecker = reachabilityChecker; + this.lastActivityAt = 0; + } + + /** + * Try to acquire the sync lock for the given type. + * Returns true if acquired, false if another sync is already active. + * + * only one sync active at a time. + */ + public synchronized boolean tryAcquire(SyncType type) { + if (type == null) { + throw new IllegalArgumentException("SyncType must not be null"); + } + + // If a timed-out sync is lingering, force-release it + if (isTimedOut()) { + activeSyncType.set(null); + } + + if (activeSyncType.get() != null) { + return false; + } + + activeSyncType.set(type); + lastActivityAt = System.currentTimeMillis(); + return true; + } + + /** + * Release the sync lock. + */ + public synchronized void release() { + activeSyncType.set(null); + lastActivityAt = 0; + } + + /** + * Check if any sync is currently active. + */ + public synchronized boolean isActive() { + if (isTimedOut()) { + activeSyncType.set(null); + lastActivityAt = 0; + return false; + } + return activeSyncType.get() != null; + } + + /** + * Get current sync type, or null if idle. + */ + public synchronized SyncType getCurrentType() { + if (isTimedOut()) { + activeSyncType.set(null); + lastActivityAt = 0; + return null; + } + return activeSyncType.get(); + } + + /** + * Server sync has priority. Returns true if the current P2P session + * should yield to server sync after completing its current operation. + * + * This does NOT interrupt the active P2P sync. The caller should: + * 1. Finish the current doc batch + * 2. Complete the P2P session gracefully + * 3. Release the mutex + * 4. Allow server sync to proceed + */ + public boolean shouldYieldToServer() { + if (activeSyncType.get() != SyncType.P2P) { + return false; + } + return reachabilityChecker != null && reachabilityChecker.isServerReachable(); + } + + /** + * Record activity to prevent timeout during long-running syncs. + * Call this periodically during sync (e.g. after each batch). + */ + public void touchActivity() { + lastActivityAt = System.currentTimeMillis(); + } + + /** + * Check if the current sync has timed out due to inactivity. + */ + private boolean isTimedOut() { + if (activeSyncType.get() == null || lastActivityAt == 0) { + return false; + } + long elapsed = System.currentTimeMillis() - lastActivityAt; + return elapsed > DEFAULT_IDLE_TIMEOUT_MS; + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/TransitDocCallback.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/TransitDocCallback.java new file mode 100644 index 00000000..4cfd51ef --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/TransitDocCallback.java @@ -0,0 +1,25 @@ +package org.medicmobile.webapp.mobile.p2p; + +import java.util.List; + +/** + * Callback interface for tracking transit documents. + * + * Transit docs are docs that are outside the Supervisor's replication_depth + * but need to pass through to reach the server. They are written to PouchDB + * but must be tracked in _local/p2p-transit-docs so the UI can hide them. + * + * The actual implementation lives in TransitDocManager (Wave 2, Agent C). + * This interface decouples AcceptDocsEndpoint from the transit tracking layer. + */ +public interface TransitDocCallback { + + /** + * Track a batch of doc IDs as transit documents. + * + * @param docIds List of document IDs classified as TRANSIT + * @param sourceDeviceId The device that sent these docs + * @param sourceUserId The user that sent these docs + */ + void trackTransitDocs(List docIds, String sourceDeviceId, String sourceUserId); +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/TransitDocManager.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/TransitDocManager.java new file mode 100644 index 00000000..71e1ae6e --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/TransitDocManager.java @@ -0,0 +1,327 @@ +package org.medicmobile.webapp.mobile.p2p; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.UnsupportedEncodingException; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Manages transit docs that pass through the Supervisor to the server. + * + * Transit docs are written to the main PouchDB (for server sync) but tracked + * in _local/p2p-transit-docs (never replicates) so the webapp can: + * 1. Filter them from UI + * 2. Purge them after server push (db.purge() ONLY, never db.remove()) + * + * CRITICAL INVARIANTS: + * - Classification is deterministic (handled by ScopeGuard) + * - Transit docs NEVER appear in UI + * - Transit index loads in <50ms + * - Purge uses db.purge() NOT db.remove() + * - _local/ doc must not exceed 1MB + * - Unpushed transit docs >30 days → user notification + * + * Schema from . + */ +public final class TransitDocManager { + + static final String TRANSIT_DOC_ID = "_local/p2p-transit-docs"; + private static final long MAX_LOCAL_DOC_SIZE = 1024L * 1024; // 1MB limit + private static final long STALE_THRESHOLD_MS = 30L * 24 * 60 * 60 * 1000; // 30 days + private static final String KEY_PUSHED_AT = "pushed_at"; + private static final String KEY_PURGED_AT = "purged_at"; + + private final Map batches = new ConcurrentHashMap<>(); + private final Map transitIndex = new ConcurrentHashMap<>(); // docId -> batchId + private final AtomicInteger totalReceived = new AtomicInteger(0); + private final AtomicInteger totalPushed = new AtomicInteger(0); + private final AtomicInteger totalPurged = new AtomicInteger(0); + + /** + * Load existing transit state from _local/p2p-transit-docs JSON. + * Called at startup to restore state. + */ + public synchronized void loadFromJson(JSONObject transitDoc) throws JSONException { + if (transitDoc == null) { + return; + } + + loadBatches(transitDoc.optJSONObject("batches")); + loadTransitIndex(transitDoc.optJSONObject("transit_index")); + loadStats(transitDoc.optJSONObject("stats")); + } + + private void loadBatches(JSONObject batchesJson) throws JSONException { + if (batchesJson == null) { + return; + } + Iterator keys = batchesJson.keys(); + while (keys.hasNext()) { + String batchId = keys.next(); + JSONObject batchJson = batchesJson.getJSONObject(batchId); + TransitBatch batch = parseBatch(batchId, batchJson); + batches.put(batchId, batch); + } + } + + private TransitBatch parseBatch(String batchId, JSONObject batchJson) throws JSONException { + TransitBatch batch = new TransitBatch( + batchId, + batchJson.optString("source_device_id", ""), + batchJson.optString("source_user", ""), + batchJson.optLong("received_at", 0) + ); + batch.docCount = batchJson.optInt("doc_count", 0); + batch.pushedToServer = batchJson.optBoolean("pushed_to_server", false); + batch.pushedAt = !batchJson.isNull(KEY_PUSHED_AT) ? batchJson.getLong(KEY_PUSHED_AT) : null; + batch.purged = batchJson.optBoolean("purged", false); + batch.purgedAt = !batchJson.isNull(KEY_PURGED_AT) ? batchJson.getLong(KEY_PURGED_AT) : null; + return batch; + } + + private void loadTransitIndex(JSONObject indexJson) throws JSONException { + if (indexJson == null) { + return; + } + Iterator keys = indexJson.keys(); + while (keys.hasNext()) { + String docId = keys.next(); + transitIndex.put(docId, indexJson.getString(docId)); + } + } + + private void loadStats(JSONObject stats) { + if (stats == null) { + return; + } + totalReceived.set(stats.optInt("total_received", 0)); + totalPushed.set(stats.optInt("total_pushed", 0)); + totalPurged.set(stats.optInt("total_purged", 0)); + } + + /** + * Start a new transit batch for incoming docs from a CHW. + * @return batch ID + */ + public String startBatch(String sourceDeviceId, String sourceUser) { + String batchId = UUID.randomUUID().toString(); + TransitBatch batch = new TransitBatch(batchId, sourceDeviceId, sourceUser, System.currentTimeMillis()); + batches.put(batchId, batch); + return batchId; + } + + /** + * Track a doc as transit within the current batch. + */ + public void trackTransitDoc(String batchId, String docId) { + transitIndex.put(docId, batchId); + TransitBatch batch = batches.get(batchId); + if (batch != null) { + synchronized (batch) { + batch.docCount++; + } + } + totalReceived.incrementAndGet(); + } + + /** + * Check if a doc ID is a transit doc (for UI filtering ). + * This method MUST be fast (<50ms for the entire index). + */ + public boolean isTransitDoc(String docId) { + return transitIndex.containsKey(docId); + } + + /** + * Get all transit doc IDs (for bulk UI filtering). + */ + public Set getAllTransitDocIds() { + return new HashSet<>(transitIndex.keySet()); + } + + /** + * Get transit doc IDs for a specific batch (for purging). + */ + public Set getDocIdsForBatch(String batchId) { + Set docIds = new HashSet<>(); + for (Map.Entry entry : transitIndex.entrySet()) { + if (batchId.equals(entry.getValue())) { + docIds.add(entry.getKey()); + } + } + return docIds; + } + + /** + * Mark a batch as successfully pushed to server. + */ + public void markBatchPushed(String batchId) { + TransitBatch batch = batches.get(batchId); + if (batch != null) { + synchronized (batch) { + batch.pushedToServer = true; + batch.pushedAt = System.currentTimeMillis(); + totalPushed.addAndGet(batch.docCount); + } + } + } + + /** + * Mark a batch as purged (after db.purge() confirmed). + * MUST use db.purge() — NEVER db.remove() + */ + public void markBatchPurged(String batchId) { + TransitBatch batch = batches.get(batchId); + if (batch != null) { + synchronized (batch) { + batch.purged = true; + batch.purgedAt = System.currentTimeMillis(); + totalPurged.addAndGet(batch.docCount); + } + // Remove from transit index (ConcurrentHashMap supports concurrent removeIf) + transitIndex.entrySet().removeIf(e -> batchId.equals(e.getValue())); + } + } + + /** + * Get batch IDs that are pushed but not yet purged. + */ + public Set getPurgeableBatchIds() { + Set purgeable = new HashSet<>(); + for (Map.Entry entry : batches.entrySet()) { + TransitBatch batch = entry.getValue(); + if (batch.pushedToServer && !batch.purged) { + purgeable.add(entry.getKey()); + } + } + return purgeable; + } + + /** + * Check if there are stale (unpushed >30 days) transit batches. + */ + public boolean hasStaleTransitDocs() { + long now = System.currentTimeMillis(); + for (TransitBatch batch : batches.values()) { + if (!batch.pushedToServer && !batch.purged && (now - batch.receivedAt) > STALE_THRESHOLD_MS) { + return true; + } + } + return false; + } + + /** Get count of pending (unpushed) transit docs. */ + public int getPendingPushCount() { + int pending = 0; + for (TransitBatch batch : batches.values()) { + if (!batch.pushedToServer && !batch.purged) { + pending += batch.docCount; + } + } + return pending; + } + + /** + * Build the _local/p2p-transit-docs JSON for saving. + * Schema from . + */ + public JSONObject toJson() throws JSONException { + JSONObject doc = new JSONObject(); + doc.put("_id", TRANSIT_DOC_ID); + doc.put("batches", batchesToJson()); + doc.put("transit_index", transitIndexToJson()); + doc.put("stats", statsToJson()); + return doc; + } + + private JSONObject batchesToJson() throws JSONException { + JSONObject batchesJson = new JSONObject(); + for (Map.Entry entry : batches.entrySet()) { + batchesJson.put(entry.getKey(), batchToJson(entry.getValue())); + } + return batchesJson; + } + + private JSONObject batchToJson(TransitBatch b) throws JSONException { + JSONObject batchJson = new JSONObject(); + batchJson.put("source_device_id", b.sourceDeviceId); + batchJson.put("source_user", b.sourceUser); + batchJson.put("received_at", b.receivedAt); + batchJson.put("doc_count", b.docCount); + batchJson.put("pushed_to_server", b.pushedToServer); + batchJson.put(KEY_PUSHED_AT, b.pushedAt != null ? b.pushedAt : JSONObject.NULL); + batchJson.put("purged", b.purged); + batchJson.put(KEY_PURGED_AT, b.purgedAt != null ? b.purgedAt : JSONObject.NULL); + return batchJson; + } + + private JSONObject transitIndexToJson() throws JSONException { + JSONObject indexJson = new JSONObject(); + for (Map.Entry entry : transitIndex.entrySet()) { + indexJson.put(entry.getKey(), entry.getValue()); + } + return indexJson; + } + + private JSONObject statsToJson() throws JSONException { + JSONObject stats = new JSONObject(); + stats.put("total_received", totalReceived.get()); + stats.put("total_pushed", totalPushed.get()); + stats.put("total_purged", totalPurged.get()); + stats.put("pending_push", getPendingPushCount()); + return stats; + } + + /** + * Estimate the size of the transit doc in bytes. + */ + public long estimateSize() { + try { + return toJson().toString().getBytes("UTF-8").length; + } catch (JSONException | UnsupportedEncodingException e) { + return 0; + } + } + + /** + * Check if the transit doc exceeds the 1MB limit. + * If so, archive old purged batches to reduce size. + */ + public boolean isOversized() { + return estimateSize() > MAX_LOCAL_DOC_SIZE; + } + + /** + * Remove purged batches to reduce doc size. + */ + public void archivePurgedBatches() { + batches.entrySet().removeIf(e -> e.getValue().purged); + } + + /** Inner class for batch tracking. */ + private static class TransitBatch { + final String batchId; + final String sourceDeviceId; + final String sourceUser; + final long receivedAt; + int docCount; + boolean pushedToServer; + Long pushedAt; + boolean purged; + Long purgedAt; + + TransitBatch(String batchId, String sourceDeviceId, String sourceUser, long receivedAt) { + this.batchId = batchId; + this.sourceDeviceId = sourceDeviceId; + this.sourceUser = sourceUser; + this.receivedAt = receivedAt; + } + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/ValidationResult.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/ValidationResult.java new file mode 100644 index 00000000..c53d9a24 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/ValidationResult.java @@ -0,0 +1,76 @@ +package org.medicmobile.webapp.mobile.p2p; + +/** + * Immutable result of ScopeGuard.classify() for a single document. + * + * Use the static factory methods: + * ValidationResult.accept(DocScope.IN_SCOPE) + * ValidationResult.reject("reason string") + */ +public final class ValidationResult { + + private final boolean accepted; + private final DocScope scope; + private final String reason; + + private ValidationResult(boolean accepted, DocScope scope, String reason) { + this.accepted = accepted; + this.scope = scope; + this.reason = reason; + } + + /** Create an accepted result with the given scope (IN_SCOPE or TRANSIT). */ + public static ValidationResult accept(DocScope scope) { + if (scope == null || scope == DocScope.REJECTED) { + throw new IllegalArgumentException("accept() requires IN_SCOPE or TRANSIT, got: " + scope); + } + return new ValidationResult(true, scope, null); + } + + /** Create a rejected result with a human-readable reason. */ + public static ValidationResult reject(String reason) { + if (reason == null || reason.isEmpty()) { + throw new IllegalArgumentException("reject() requires a non-empty reason"); + } + return new ValidationResult(false, DocScope.REJECTED, reason); + } + + public boolean isAccepted() { + return accepted; + } + + public DocScope getScope() { + return scope; + } + + /** Null when accepted, non-null when rejected. */ + public String getReason() { + return reason; + } + + @Override + public String toString() { + if (accepted) { + return "ValidationResult{accepted=true, scope=" + scope + "}"; + } + return "ValidationResult{accepted=false, reason=\"" + reason + "\"}"; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ValidationResult that = (ValidationResult) o; + return accepted == that.accepted + && scope == that.scope + && (reason == null ? that.reason == null : reason.equals(that.reason)); + } + + @Override + public int hashCode() { + int result = Boolean.hashCode(accepted); + result = 31 * result + (scope != null ? scope.hashCode() : 0); + result = 31 * result + (reason != null ? reason.hashCode() : 0); + return result; + } +} 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..16d73486 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotManager.java @@ -0,0 +1,165 @@ +package org.medicmobile.webapp.mobile.p2p; + +import android.util.Log; + +/** + * Manages the WiFi hotspot lifecycle for P2P sync. + * + * Wraps a {@link HotspotProvider} with: + * - Idle timeout detection (using P2pConfig.getWifiHotspotIdleTimeoutSec()) + * - Start/stop timing for telemetry + * - State tracking to prevent double-start + * + * Session timeout after idle period (delegated to P2pSession, + * but this class tracks hotspot-level idle for auto-shutdown). + */ +public class WifiHotspotManager { + + private static final String TAG = "WifiHotspotManager"; + + private final HotspotProvider provider; + private final P2pConfig config; + + private long startedAt; + private volatile long lastActivityAt; + private String activeSsid; + private String activePassword; + private String activeIpAddress; + + public WifiHotspotManager(HotspotProvider provider, P2pConfig config) { + if (provider == null) { + throw new IllegalArgumentException("provider must not be null"); + } + if (config == null) { + throw new IllegalArgumentException("config must not be null"); + } + this.provider = provider; + this.config = config; + } + + /** + * 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()) { + Log.w(TAG, "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.i(TAG, "Hotspot started: SSID=" + ssid + ", IP=" + ipAddress); + callback.onStarted(ssid, password, ipAddress); + } + + @Override + public void onFailed(String reason) { + Log.e(TAG, "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.i(TAG, "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 the idle timeout from config. + * Used to auto-shutdown the hotspot when no sync activity occurs. + * + * @return true if idle time exceeds wifi_hotspot_idle_timeout_sec + */ + public boolean isIdleTimedOut() { + if (!provider.isRunning() || lastActivityAt == 0) { + return false; + } + long idleMs = System.currentTimeMillis() - lastActivityAt; + long timeoutMs = (long) config.getWifiHotspotIdleTimeoutSec() * 1000; + return idleMs > timeoutMs; + } + + /** + * Get how long the hotspot has been running in milliseconds. + * + * @return uptime in ms, or 0 if not running + */ + public long getUptimeMs() { + if (!provider.isRunning() || startedAt == 0) { + return 0; + } + return System.currentTimeMillis() - startedAt; + } + + /** + * Get how long the hotspot has been idle in milliseconds. + * + * @return idle time in ms, or 0 if not running + */ + public long getIdleMs() { + if (!provider.isRunning() || lastActivityAt == 0) { + return 0; + } + return System.currentTimeMillis() - lastActivityAt; + } + + // --- Getters for active credentials --- + + public String getActiveSsid() { + return activeSsid; + } + + public String getActivePassword() { + return activePassword; + } + + public String getActiveIpAddress() { + return activeIpAddress; + } + + public long getStartedAt() { + return startedAt; + } +} 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..2de99cce --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotProvider.java @@ -0,0 +1,326 @@ +package org.medicmobile.webapp.mobile.p2p; + +import android.net.wifi.SoftApConfiguration; +import android.net.wifi.WifiManager; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +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 + */ +@android.annotation.TargetApi(26) +public class WifiHotspotProvider implements HotspotProvider { + + private static final String TAG = "WifiHotspotProvider"; + + /** 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; + } + + @Override + public void start(HotspotCallback callback) { + if (callback == null) { + throw new IllegalArgumentException("callback must not be null"); + } + + // @TargetApi(26) guarantees API 26+ — no version check needed + if (running) { + Log.w(TAG, "Hotspot already running"); + callback.onFailed("Hotspot already running"); + return; + } + + try { + wifiManager.startLocalOnlyHotspot(createHotspotCallback(callback), + new Handler(Looper.getMainLooper())); + } catch (SecurityException e) { + Log.e(TAG, "Hotspot SecurityException", e); + callback.onFailed("security_exception: " + e.getMessage()); + } catch (Exception e) { + Log.e(TAG, "Failed to start hotspot", e); + callback.onFailed("Unexpected error: " + e.getMessage()); + } + } + + 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.i(TAG, "LocalOnlyHotspot started: SSID=" + ssid + ", IP=" + ip); + callback.onStarted(ssid, password, ip); + } + + @Override + public void onStopped() { + Log.i(TAG, "LocalOnlyHotspot stopped by system"); + running = false; + reservation = null; + } + + @Override + public void onFailed(int reason) { + running = false; + String reasonStr = mapFailureReason(reason); + Log.e(TAG, "LocalOnlyHotspot failed: " + reasonStr); + callback.onFailed(reasonStr); + } + }; + } + + private void handleIpDetectionFailure(WifiManager.LocalOnlyHotspotReservation hotspotReservation, + HotspotCallback callback) { + Log.e(TAG, "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."); + } + + private static void closeReservationQuietly(WifiManager.LocalOnlyHotspotReservation res) { + try { + res.close(); + } catch (Exception e) { + Log.w(TAG, "Error closing hotspot reservation during cleanup", e); + } + } + + @Override + public void stop() { + if (reservation != null) { + try { + reservation.close(); + Log.i(TAG, "LocalOnlyHotspot reservation closed"); + } catch (Exception e) { + Log.e(TAG, "Error closing hotspot reservation", e); + } + reservation = null; + } + running = false; + } + + @Override + public boolean isRunning() { + return running; + } + + // --- Private helpers --- + + @SuppressWarnings("deprecation") + 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") + 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) { + Log.d(TAG, "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(); + Log.w(TAG, "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) { + Log.w(TAG, "Error detecting hotspot IP", e); + } + Log.e(TAG, "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.i(TAG, "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.i(TAG, "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.i(TAG, "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) { + Log.d(TAG, "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/main/res/layout/request_p2p_permission.xml b/src/main/res/layout/request_p2p_permission.xml new file mode 100644 index 00000000..eed9cd95 --- /dev/null +++ b/src/main/res/layout/request_p2p_permission.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + +