From 6f6f1f9e9aa23cbbef4dcabf4670c174981a93ca Mon Sep 17 00:00:00 2001 From: paulpascal Date: Thu, 26 Mar 2026 13:48:58 +0000 Subject: [PATCH 01/11] p2p: add foundation modules (scope guard, auth, mutex, config) Wave 1 zero-dependency modules: ScopeGuard for doc classification (IN_SCOPE/TRANSIT/REJECTED), JWT authentication with ECDSA P-256, revocation list, sync mutex, P2P config reader, and session model. --- .../webapp/mobile/p2p/DocScope.java | 20 ++ .../webapp/mobile/p2p/JwtVerifier.java | 192 ++++++++++++ .../webapp/mobile/p2p/P2pAuthenticator.java | 148 +++++++++ .../webapp/mobile/p2p/P2pConfig.java | 224 +++++++++++++ .../webapp/mobile/p2p/P2pSession.java | 295 ++++++++++++++++++ .../webapp/mobile/p2p/RevocationList.java | 86 +++++ .../webapp/mobile/p2p/ScopeGuard.java | 260 +++++++++++++++ .../webapp/mobile/p2p/ScopeManifest.java | 116 +++++++ .../webapp/mobile/p2p/SyncMutex.java | 131 ++++++++ .../webapp/mobile/p2p/ValidationResult.java | 76 +++++ 10 files changed, 1548 insertions(+) create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/DocScope.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/JwtVerifier.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/P2pAuthenticator.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/P2pConfig.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/P2pSession.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/RevocationList.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/ScopeGuard.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/ScopeManifest.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/SyncMutex.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/ValidationResult.java 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..44a1e094 --- /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/JwtVerifier.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/JwtVerifier.java new file mode 100644 index 00000000..f4f7c520 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/JwtVerifier.java @@ -0,0 +1,192 @@ +package org.medicmobile.webapp.mobile.p2p; + +import org.json.JSONException; +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); + } + + // Decode header + JSONObject header; + try { + header = new JSONObject(new String(base64UrlDecode(parts[0]))); + } catch (Exception e) { + throw new JwtVerificationException("token_malformed: invalid header"); + } + + // G6: Verify algorithm is ES256 + String alg = header.optString("alg", ""); + if (!"ES256".equals(alg)) { + throw new JwtVerificationException("token_invalid: unsupported algorithm " + alg); + } + + // G6: Verify ECDSA signature + try { + byte[] signatureInput = (parts[0] + "." + parts[1]).getBytes("UTF-8"); + byte[] signatureBytes = base64UrlDecode(parts[2]); + 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()); + } + + // Decode payload + JSONObject payload; + try { + payload = new JSONObject(new String(base64UrlDecode(parts[1]))); + } catch (Exception e) { + throw new JwtVerificationException("token_malformed: invalid payload"); + } + + // G7: Check expiry + long exp = payload.optLong("exp", 0); + long now = System.currentTimeMillis() / 1000; + if (exp > 0 && exp < now) { + throw new JwtVerificationException("token_expired"); + } + + return payload; + } + + /** + * Parse a PEM-encoded public key to a Java PublicKey object. + */ + private static PublicKey parsePemPublicKey(String pem) throws Exception { + 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 + switch (base64.length() % 4) { + case 2: base64 += "=="; break; + case 3: base64 += "="; break; + } + 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 { + public JwtVerificationException(String message) { + super(message); + } + } +} 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..66ca6f94 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pAuthenticator.java @@ -0,0 +1,148 @@ +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); + + // Handle facility_id as either string or array (CHT wraps in array via forceArray) + Object rawFacility = payload.opt("facility_id"); + String facilityId = null; + if (rawFacility instanceof JSONArray) { + facilityId = ((JSONArray) rawFacility).optString(0, null); + } else if (rawFacility != null) { + facilityId = rawFacility.toString(); + } + + return AuthResult.success(payload, userId, role, facilityId); + } + + /** + * 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; + } + + JSONArray allowedPeers = tokenPayload.optJSONArray("allowed_relay_peers"); + // null or empty = no restrictions, allow all 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(boolean authenticated, String error, JSONObject tokenPayload, + String userId, String role, String facilityId) { + this.authenticated = authenticated; + this.error = error; + this.tokenPayload = tokenPayload; + this.userId = userId; + this.role = role; + this.facilityId = facilityId; + } + + public static AuthResult success(JSONObject payload, String userId, String role, String facilityId) { + return new AuthResult(true, null, payload, userId, role, facilityId); + } + + public static AuthResult failure(String error) { + return new AuthResult(false, error, null, null, null, null); + } + + 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/P2pConfig.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pConfig.java new file mode 100644 index 00000000..977c5bfe --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pConfig.java @@ -0,0 +1,224 @@ +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.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * P2P configuration parsed from the p2p_sync section of app_settings. + * + * Default values match CONTRACT.md Section 4. + * 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(Arrays.asList("chw", "chw_supervisor")); + 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(boolean enabled, boolean wifiHotspotEnabled, int maxRelaySizeMb, + int maxDocSizeKb, int maxAttachmentSizeMb, int tokenExpiryDays, + int wifiHotspotIdleTimeoutSec, List allowedRoles, + boolean auditLogging) { + this.enabled = enabled; + this.wifiHotspotEnabled = wifiHotspotEnabled; + this.maxRelaySizeMb = maxRelaySizeMb; + this.maxDocSizeKb = maxDocSizeKb; + this.maxAttachmentSizeMb = maxAttachmentSizeMb; + this.tokenExpiryDays = tokenExpiryDays; + this.wifiHotspotIdleTimeoutSec = wifiHotspotIdleTimeoutSec; + this.allowedRoles = Collections.unmodifiableList(new ArrayList<>(allowedRoles)); + this.auditLogging = auditLogging; + } + + /** + * Parse P2P config from the "p2p_sync" section of app_settings JSON. + * Missing fields fall back to defaults. + * + * Expected JSON structure (CONTRACT.md Section 4): + * { + * "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, + * "allowed_roles": ["chw", "chw_supervisor"], + * "audit_logging": true + * } + */ + public static P2pConfig fromJson(JSONObject p2pSyncSection) { + if (p2pSyncSection == null) { + return defaults(); + } + + boolean enabled = p2pSyncSection.optBoolean("enabled", DEFAULT_ENABLED); + + boolean wifiHotspot = DEFAULT_WIFI_HOTSPOT_ENABLED; + JSONObject transports = p2pSyncSection.optJSONObject("transports"); + if (transports != null) { + wifiHotspot = transports.optBoolean("wifi_hotspot", DEFAULT_WIFI_HOTSPOT_ENABLED); + } + + int maxRelaySizeMb = p2pSyncSection.optInt("max_relay_size_mb", DEFAULT_MAX_RELAY_SIZE_MB); + int maxDocSizeKb = p2pSyncSection.optInt("max_doc_size_kb", DEFAULT_MAX_DOC_SIZE_KB); + int maxAttachmentSizeMb = p2pSyncSection.optInt("max_attachment_size_mb", DEFAULT_MAX_ATTACHMENT_SIZE_MB); + int tokenExpiryDays = p2pSyncSection.optInt("token_expiry_days", DEFAULT_TOKEN_EXPIRY_DAYS); + int idleTimeoutSec = p2pSyncSection.optInt("wifi_hotspot_idle_timeout_sec", DEFAULT_WIFI_HOTSPOT_IDLE_TIMEOUT_SEC); + boolean auditLogging = p2pSyncSection.optBoolean("audit_logging", DEFAULT_AUDIT_LOGGING); + + List allowedRoles = parseRoles(p2pSyncSection.optJSONArray("allowed_roles")); + + return new P2pConfig(enabled, wifiHotspot, maxRelaySizeMb, maxDocSizeKb, + maxAttachmentSizeMb, tokenExpiryDays, idleTimeoutSec, allowedRoles, auditLogging); + } + + /** + * Create a config with all default values (CONTRACT.md Section 4). + */ + public static P2pConfig defaults() { + return new P2pConfig( + DEFAULT_ENABLED, + DEFAULT_WIFI_HOTSPOT_ENABLED, + DEFAULT_MAX_RELAY_SIZE_MB, + DEFAULT_MAX_DOC_SIZE_KB, + DEFAULT_MAX_ATTACHMENT_SIZE_MB, + DEFAULT_TOKEN_EXPIRY_DAYS, + DEFAULT_WIFI_HOTSPOT_IDLE_TIMEOUT_SEC, + DEFAULT_ALLOWED_ROLES, + DEFAULT_AUDIT_LOGGING + ); + } + + // --- 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 = new ArrayList<>(rolesArray.length()); + for (int i = 0; i < rolesArray.length(); i++) { + String role = rolesArray.optString(i, null); + if (role != null && !role.isEmpty()) { + roles.add(role); + } + } + return roles.isEmpty() ? new ArrayList<>(DEFAULT_ALLOWED_ROLES) : roles; + } +} 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..e3f490b0 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pSession.java @@ -0,0 +1,295 @@ +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 (CONTRACT.md Section 5). + * + * Guards: + * G12 — 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 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 G12 timeout. + */ + public void updateLastActivity() { + this.lastActivityAt = System.currentTimeMillis(); + } + + // --- G12: 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 G12 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 (G12). + */ + 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 (CONTRACT.md Section 5). + * + * 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"; + case AUTHENTICATING: + case ACTIVE: + case COMPLETING: + return "in_progress"; + default: + return "unknown"; + } + } +} 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..2d999189 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/RevocationList.java @@ -0,0 +1,86 @@ +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 CONTRACT.md: + * { + * "version": number, + * "revoked_devices": string[], + * "revoked_users": string[], + * "updated_at": ISO8601 + * } + */ +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 version = json.optInt("version", 0); + + Set devices = new HashSet<>(); + JSONArray devicesArray = json.optJSONArray("revoked_devices"); + if (devicesArray != null) { + for (int i = 0; i < devicesArray.length(); i++) { + devices.add(devicesArray.getString(i)); + } + } + + Set users = new HashSet<>(); + JSONArray usersArray = json.optJSONArray("revoked_users"); + if (usersArray != null) { + for (int i = 0; i < usersArray.length(); i++) { + users.add(usersArray.getString(i)); + } + } + + return new RevocationList(version, devices, users); + } + + /** 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..815b0d28 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/ScopeGuard.java @@ -0,0 +1,260 @@ +package org.medicmobile.webapp.mobile.p2p; + +import org.json.JSONException; +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 G22: 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"; + + /** + * 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( + "data_record", + "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( + "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) { + try { + return doClassify(doc, senderScope, receiverScope, parentIndex); + } catch (JSONException e) { + return ValidationResult.reject("malformed_doc: " + e.getMessage()); + } + } + + private ValidationResult doClassify(JSONObject doc, ScopeManifest senderScope, + ScopeManifest receiverScope, Map parentIndex) + throws JSONException { + + // Step 1: Extract _id and reject system docs + String docId = doc.optString("_id", null); + if (docId == null || docId.isEmpty()) { + return ValidationResult.reject("missing_id"); + } + if (docId.startsWith("_design/")) { + return ValidationResult.reject("design_doc"); + } + if (docId.startsWith("_local/")) { + return ValidationResult.reject("local_doc"); + } + + // Step 2: Reject docs with disallowed types + String docType = doc.optString("type", null); + if (docType == null || !ALLOWED_DOC_TYPES.contains(docType)) { + return ValidationResult.reject("disallowed_type: " + docType); + } + + // Step 3: Rule 5 — reject deletions + if (doc.optBoolean("_deleted", false)) { + return ValidationResult.reject("deletion_not_allowed"); + } + + // Step 4: Get the parent ID from the doc + String parentId = getParentId(doc); + + // Step 5: Verify sender scope — doc must be within sender's facility subtree + if (isContactType(docType)) { + if (!isWithinFacilitySubtree(docId, senderScope.getFacilitySubtreeRoot(), parentIndex)) { + return ValidationResult.reject("sender_scope_violation: not in sender facility subtree"); + } + } else if ("data_record".equals(docType)) { + if (parentId != null && !isWithinFacilitySubtree(parentId, senderScope.getFacilitySubtreeRoot(), parentIndex)) { + return ValidationResult.reject("sender_scope_violation: report parent not in sender facility subtree"); + } + } + + // Step 6: Check receiver scope — doc must be within receiver's facility subtree + if (isContactType(docType)) { + if (!isWithinFacilitySubtree(docId, receiverScope.getFacilitySubtreeRoot(), parentIndex)) { + return ValidationResult.reject("branch_mismatch"); + } + int depth = getDepthFromFacility(docId, receiverScope.getFacilitySubtreeRoot(), parentIndex); + if (depth < 0) { + return ValidationResult.reject("branch_mismatch"); + } + if (depth <= receiverScope.getReplicationDepth()) { + return ValidationResult.accept(DocScope.IN_SCOPE); + } + return ValidationResult.accept(DocScope.TRANSIT); + } + + if ("data_record".equals(docType)) { + return classifyDataRecord(parentId, receiverScope, parentIndex); + } + + // Shared doc types (form, translation, resources) — always in scope if type is allowed + return ValidationResult.accept(DocScope.IN_SCOPE); + } + + /** + * 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("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("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); + } + + /** + * 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) { + if (docId == null) { + return false; + } + if (docId.equals(facilityRoot)) { + return true; + } + + String current = docId; + int maxDepth = 20; // safety limit to prevent infinite loops + for (int i = 0; i < maxDepth; i++) { + String parentId = parentIndex.get(current); + if (parentId == null) { + // Reached the top without finding facilityRoot + return false; + } + if (parentId.equals(facilityRoot)) { + return true; + } + current = parentId; + } + + return false; + } + + /** + * 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; + } + + String current = docId; + int depth = 0; + int maxDepth = 20; // safety limit + for (int i = 0; i < maxDepth; i++) { + String parentId = parentIndex.get(current); + if (parentId == null) { + return -1; // not in subtree + } + 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("_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..8f9e750f --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/ScopeManifest.java @@ -0,0 +1,116 @@ +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 CONTRACT.md Section 3: + * { + * "facility_subtree_root": "", + * "replication_depth": 1, + * "shared_doc_types": ["person", "clinic", ...], + * "scope_version": "2026-03-23T00:00:00Z" + * } + */ +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) { + if (facilitySubtreeRoot == null || facilitySubtreeRoot.isEmpty()) { + throw new IllegalArgumentException("facilitySubtreeRoot must not be null or empty"); + } + if (replicationDepth < 0) { + throw new IllegalArgumentException("replicationDepth must be >= 0, got: " + replicationDepth); + } + if (sharedDocTypes == null) { + throw new IllegalArgumentException("sharedDocTypes must not be null"); + } + if (scopeVersion == null || scopeVersion.isEmpty()) { + throw new IllegalArgumentException("scopeVersion must not be null or empty"); + } + + this.facilitySubtreeRoot = facilitySubtreeRoot; + this.replicationDepth = replicationDepth; + this.sharedDocTypes = Collections.unmodifiableList(new ArrayList<>(sharedDocTypes)); + this.scopeVersion = scopeVersion; + } + + /** Parse a ScopeManifest from a JSON object (CONTRACT.md Section 3 format). */ + public static ScopeManifest fromJson(JSONObject json) throws JSONException { + Object rawFacility = json.get("facility_subtree_root"); + String facilityRoot; + if (rawFacility instanceof JSONArray) { + facilityRoot = ((JSONArray) rawFacility).getString(0); + } else { + facilityRoot = rawFacility.toString(); + } + 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); + } + + 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/SyncMutex.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/SyncMutex.java new file mode 100644 index 00000000..87ab3381 --- /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: + * G10 — Only one sync active at a time + * G11 — Server sync has priority over P2P + * G12 — 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. + * + * G10: 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 (G12) + 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(); + } + + /** + * G11: 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 G12 timeout during long-running syncs. + * Call this periodically during sync (e.g. after each batch). + */ + public void touchActivity() { + lastActivityAt = System.currentTimeMillis(); + } + + /** + * G12: 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/ValidationResult.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/ValidationResult.java new file mode 100644 index 00000000..1b1fda01 --- /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; + } +} From 50f8fdf222ded7d8508dc46bb80a3bd46a044bbb Mon Sep 17 00:00:00 2001 From: paulpascal Date: Thu, 26 Mar 2026 13:49:14 +0000 Subject: [PATCH 02/11] p2p: add HTTP server, endpoints, WiFi, and foreground service Wave 2 core integration: NanoHTTPD local server with auth, get-ids, bulk-get, accept-docs, get-deletes, and status endpoints. WiFi hotspot management (production + loopback for dev). Foreground service, notification channel, OEM battery helper. Session tracking, telemetry reporter, transit doc manager with PouchDB bridge. --- .../webapp/mobile/p2p/AcceptDocsEndpoint.java | 492 ++++++++++++++++++ .../webapp/mobile/p2p/AuthEndpoint.java | 178 +++++++ .../webapp/mobile/p2p/BulkGetEndpoint.java | 154 ++++++ .../webapp/mobile/p2p/GetDeletesEndpoint.java | 33 ++ .../webapp/mobile/p2p/GetIdsEndpoint.java | 99 ++++ .../webapp/mobile/p2p/HotspotProvider.java | 54 ++ .../webapp/mobile/p2p/LocalHttpServer.java | 408 +++++++++++++++ .../mobile/p2p/LoopbackHotspotProvider.java | 50 ++ .../webapp/mobile/p2p/OemBatteryHelper.java | 147 ++++++ .../mobile/p2p/P2pForegroundService.java | 124 +++++ .../mobile/p2p/P2pNotificationChannel.java | 143 +++++ .../mobile/p2p/P2pTelemetryReporter.java | 190 +++++++ .../webapp/mobile/p2p/P2pTracker.java | 253 +++++++++ .../webapp/mobile/p2p/PouchDbBridge.java | 51 ++ .../webapp/mobile/p2p/StatusEndpoint.java | 46 ++ .../webapp/mobile/p2p/TransitDocCallback.java | 25 + .../webapp/mobile/p2p/TransitDocManager.java | 306 +++++++++++ .../webapp/mobile/p2p/WifiHotspotManager.java | 165 ++++++ .../mobile/p2p/WifiHotspotProvider.java | 260 +++++++++ 19 files changed, 3178 insertions(+) create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/AcceptDocsEndpoint.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/AuthEndpoint.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/BulkGetEndpoint.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/GetDeletesEndpoint.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/GetIdsEndpoint.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/HotspotProvider.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/LocalHttpServer.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/LoopbackHotspotProvider.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/OemBatteryHelper.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/P2pForegroundService.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/P2pNotificationChannel.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/P2pTelemetryReporter.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/P2pTracker.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/PouchDbBridge.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/StatusEndpoint.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/TransitDocCallback.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/TransitDocManager.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotManager.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotProvider.java 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..19f8112d --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/AcceptDocsEndpoint.java @@ -0,0 +1,492 @@ +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.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: G22 (deterministic classification), G23 (transit hidden from UI) + */ +public final class AcceptDocsEndpoint { + + private static final String TAG = "AcceptDocsEndpoint"; + private static final int MAX_BATCH_SIZE = 500; + + 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 { + if (requestBody == null || requestBody.isEmpty()) { + return errorResponse("empty_request"); + } + if (session == null) { + return errorResponse("no_active_session"); + } + + JSONObject body = new JSONObject(requestBody); + JSONArray docs = body.optJSONArray("docs"); + if (docs == null || docs.length() == 0) { + return buildResponse(0, 0, 0, new JSONArray()); + } + + // Guard: limit batch size + if (docs.length() > MAX_BATCH_SIZE) { + return errorResponse("batch_too_large: max " + MAX_BATCH_SIZE + " docs per request"); + } + + // Phase 1: Accept all valid docs + List acceptedDocs = new ArrayList<>(); + JSONArray errors = new JSONArray(); + long totalBytes = 0; + + for (int i = 0; i < docs.length(); i++) { + JSONObject doc = docs.getJSONObject(i); + String docId = doc.optString("_id", ""); + + // Rule 5: reject _deleted docs (additive only) + if (doc.optBoolean("_deleted", false)) { + JSONObject err = new JSONObject(); + err.put("id", docId); + err.put("reason", "deleted_not_allowed"); + errors.put(err); + continue; + } + + // Check individual doc size limit + int docSize = doc.toString().length(); + if (docSize > config.getMaxDocSizeBytes()) { + JSONObject err = new JSONObject(); + err.put("id", docId); + err.put("reason", "doc_too_large: " + docSize + " bytes"); + errors.put(err); + continue; + } + + totalBytes += docSize; + acceptedDocs.add(doc); + } + + // Enforce cumulative relay size limit before writing + long maxRelayBytes = config.getMaxRelaySizeBytes(); + if (maxRelayBytes > 0 && (session.getBytesTransferred() + totalBytes) > maxRelayBytes) { + return errorResponse("relay_size_exceeded: max " + (maxRelayBytes / (1024 * 1024)) + " MB"); + } + + // Hydrate truncated parent lineages so server-side auth doesn't reject docs + if (!acceptedDocs.isEmpty()) { + hydrateParentLineage(acceptedDocs); + } + + // Write all accepted docs to PouchDB + if (!acceptedDocs.isEmpty()) { + 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")); + } + + // Track ALL accepted doc IDs for post-sync purge (decoupled from transit classification) + if (!acceptedDocs.isEmpty() && transitCallback != null) { + List allAcceptedIds = new ArrayList<>(); + for (JSONObject doc : acceptedDocs) { + String docId = doc.optString("_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"); + } + } + + // Phase 2: Classify transit docs using contacts_by_depth view + int transitCount = 0; + if (!acceptedDocs.isEmpty()) { + transitCount = classifyTransitDocs(acceptedDocs, session); + } + int inScopeCount = acceptedDocs.size() - transitCount; + + // Update session counters + session.incrementDocsPulled(inScopeCount); + session.incrementTransitDocs(transitCount); + session.incrementDocsRejected(errors.length()); + session.addBytesTransferred(totalBytes); + session.updateLastActivity(); + + Log.i(TAG, "accept-docs: in_scope=" + inScopeCount + + " transit=" + transitCount + + " rejected=" + errors.length()); + + return buildResponse(inScopeCount, transitCount, errors.length(), errors); + } + + /** + * 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 { + // Build a map of docs in the current batch (by _id) for quick lookup + Map batchMap = new HashMap<>(); + for (JSONObject doc : docs) { + String id = doc.optString("_id", ""); + if (!id.isEmpty()) { + batchMap.put(id, doc); + } + } + + // Collect terminal parent IDs — the deepest parent that has no further parent + Set terminalParentIds = new HashSet<>(); + for (JSONObject doc : docs) { + JSONObject deepest = getDeepestParent(doc); + if (deepest != null && deepest.has("_id") && !deepest.has("parent")) { + String parentId = deepest.getString("_id"); + // Only fetch if not already in the batch + if (!batchMap.containsKey(parentId)) { + terminalParentIds.add(parentId); + } + } + } + + if (terminalParentIds.isEmpty()) { + return; + } + + // Fetch parent docs from PouchDB and cache their parent chains + Map parentChainCache = new HashMap<>(); + fetchAndCacheParentChains(terminalParentIds, batchMap, parentChainCache); + + // Extend each doc's parent chain + int hydrated = 0; + for (JSONObject doc : docs) { + JSONObject deepest = getDeepestParent(doc); + if (deepest != null && deepest.has("_id") && !deepest.has("parent")) { + String parentId = deepest.getString("_id"); + JSONObject cachedParent = parentChainCache.get(parentId); + if (cachedParent != null && cachedParent.has("parent")) { + // Attach the parent's parent chain to extend the lineage + deepest.put("parent", cachedParent.getJSONObject("parent")); + hydrated++; + } + } + } + + if (hydrated > 0) { + Log.i(TAG, "Hydrated parent lineage for " + hydrated + " docs"); + } + } catch (Exception e) { + Log.e(TAG, "Error hydrating parent lineage, docs will be written as-is", e); + } + } + + /** + * 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("parent"); + if (current == null) { + return null; + } + while (current.has("parent")) { + JSONObject next = current.optJSONObject("parent"); + if (next == null) { + break; + } + 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 void fetchAndCacheParentChains(Set idsToFetch, + Map batchMap, + Map cache) throws JSONException { + Set currentIds = new HashSet<>(idsToFetch); + int maxIterations = 10; // prevent infinite loops in circular references + + while (!currentIds.isEmpty() && maxIterations-- > 0) { + // Remove IDs we already have cached + currentIds.removeAll(cache.keySet()); + if (currentIds.isEmpty()) { + break; + } + + // Fetch from PouchDB + JSONArray idsArray = new JSONArray(); + for (String id : currentIds) { + idsArray.put(id); + } + + String resultJson = bridge.getDocsByIds(idsArray.toString()); + if (resultJson == null || resultJson.isEmpty() || "[]".equals(resultJson)) { + break; + } + + 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) continue; + + String id = fetched.optString("_id", ""); + if (id.isEmpty()) continue; + + // Build a lightweight parent reference (just _id + parent chain) + JSONObject parentRef = new JSONObject(); + parentRef.put("_id", id); + + JSONObject fetchedParent = fetched.optJSONObject("parent"); + if (fetchedParent != null) { + parentRef.put("parent", cloneParentChain(fetchedParent)); + + // Check if this parent's chain is also truncated + JSONObject deepest = getDeepestParent(fetched); + if (deepest != null && deepest.has("_id") && !deepest.has("parent")) { + String nextId = deepest.getString("_id"); + if (!cache.containsKey(nextId) && !batchMap.containsKey(nextId)) { + nextIds.add(nextId); + } + } + } + + cache.put(id, parentRef); + } + + currentIds = nextIds; + } + + // Second pass: 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 + for (int pass = 0; pass < maxIterations; pass++) { + boolean changed = false; + for (Map.Entry entry : cache.entrySet()) { + JSONObject cached = entry.getValue(); + JSONObject deepest = getDeepestParent(cached); + if (deepest != null && deepest.has("_id") && !deepest.has("parent")) { + String parentId = deepest.getString("_id"); + JSONObject parentCached = cache.get(parentId); + if (parentCached != null && parentCached.has("parent")) { + deepest.put("parent", cloneParentChain(parentCached.getJSONObject("parent"))); + changed = true; + } + } + } + if (!changed) break; + } + } + + /** + * Deep-clone a parent chain, keeping only _id and parent fields. + */ + private JSONObject cloneParentChain(JSONObject parent) throws JSONException { + JSONObject clone = new JSONObject(); + clone.put("_id", parent.getString("_id")); + JSONObject next = parent.optJSONObject("parent"); + if (next != null) { + clone.put("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, P2pSession session) { + try { + 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 0; + } + + // Query PouchDB's contacts_by_depth view for in-scope contact IDs + 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 0; + } + + // Build set of in-scope contact IDs + 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"); + + // Classify each accepted doc + List transitDocIds = new ArrayList<>(); + for (JSONObject doc : acceptedDocs) { + String docId = doc.optString("_id", ""); + String type = doc.optString("type", ""); + + if (isContactType(type)) { + // Contact doc: transit if its _id is NOT in the in-scope set + if (!inScopeIds.contains(docId)) { + transitDocIds.add(docId); + } + } else if ("data_record".equals(type)) { + // Data record: transit if its subject contact is NOT in scope + String subjectId = getDataRecordSubject(doc); + if (subjectId != null && !inScopeIds.contains(subjectId)) { + transitDocIds.add(docId); + } + } + // Other doc types (e.g. task, target) → always in-scope + } + + if (!transitDocIds.isEmpty()) { + Log.i(TAG, "Classified " + transitDocIds.size() + " transit docs (tracking already done in doHandle)"); + } + + return transitDocIds.size(); + + } catch (Exception e) { + Log.e(TAG, "Error classifying transit docs, treating all as in-scope", e); + return 0; + } + } + + /** + * 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) { + // Try contact._id first (most common for reports about a person) + JSONObject contact = doc.optJSONObject("contact"); + if (contact != null) { + String contactId = contact.optString("_id", null); + if (contactId != null) return contactId; + } + + // Try fields.patient_id + JSONObject fields = doc.optJSONObject("fields"); + if (fields != null) { + String patientId = fields.optString("patient_id", null); + if (patientId != null && !patientId.isEmpty()) return patientId; + patientId = fields.optString("patient_uuid", null); + if (patientId != null && !patientId.isEmpty()) return patientId; + } + + // Try top-level patient_id + String patientId = doc.optString("patient_id", null); + if (patientId != null && !patientId.isEmpty()) return patientId; + + return 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("error", error); + return response; + } catch (JSONException e) { + throw new RuntimeException("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..5bb78cc4 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/AuthEndpoint.java @@ -0,0 +1,178 @@ +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" } + * + * Guards: G6 (JWT signature), G7 (not expired), G8 (not revoked), G9 (peer allowed) + */ +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 { + if (requestBody == null || requestBody.isEmpty()) { + return AuthResponse.error(400, "empty_request"); + } + + JSONObject body = new JSONObject(requestBody); + String p2pToken = body.optString("p2p_token", null); + String deviceId = body.optString("device_id", null); + + if (p2pToken == null || p2pToken.isEmpty()) { + return AuthResponse.error(400, "missing_token"); + } + if (deviceId == null || deviceId.isEmpty()) { + return AuthResponse.error(400, "missing_device_id"); + } + + // G6 + G7 + G8: Verify JWT, check expiry, check revocation + 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()); + } + + // G9: Check if the peer is allowed to relay with this Supervisor + 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"); + } + + // Check if role is allowed by config + String role = authResult.getRole(); + if (!config.isRoleAllowed(role)) { + Log.w(TAG, "Role not allowed for P2P: " + role); + return AuthResponse.error(401, "role_not_allowed"); + } + + // Build peer scope from JWT payload + ScopeManifest peerScope = buildPeerScope(authResult); + + // Create session + P2pSession session = new P2pSession(deviceId, userId, role, peerScope); + session.setState(P2pSession.State.ACTIVE); + + Log.i(TAG, "Auth successful: user=" + userId + " session=" + session.getSessionId()); + + // Build success response + 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 RuntimeException("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..7f0d4de5 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/BulkGetEndpoint.java @@ -0,0 +1,154 @@ +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 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("docs"); + if (requestedDocs == null || requestedDocs.length() == 0) { + JSONObject response = new JSONObject(); + response.put("results", new JSONArray()); + return response; + } + + // Guard: limit batch size to prevent memory issues + if (requestedDocs.length() > MAX_BATCH_SIZE) { + return errorResponse("batch_too_large: max " + MAX_BATCH_SIZE + " docs per request"); + } + + // Extract IDs from request + JSONArray idsArray = new JSONArray(); + for (int i = 0; i < requestedDocs.length(); i++) { + JSONObject docReq = requestedDocs.getJSONObject(i); + String id = docReq.optString("id", null); + if (id != null) { + idsArray.put(id); + } + } + + // Fetch docs from PouchDB via bridge + String docsJson = bridge.getDocsByIds(idsArray.toString()); + JSONArray fetchedDocs = (docsJson != null && !docsJson.isEmpty()) + ? new JSONArray(docsJson) : new JSONArray(); + + // Build a lookup map: _id -> doc + 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); + } + } + + // Build CouchDB _bulk_get format response + JSONArray results = new JSONArray(); + long totalBytes = 0; + + for (int i = 0; i < idsArray.length(); i++) { + String requestedId = idsArray.getString(i); + JSONObject result = new JSONObject(); + result.put("id", requestedId); + + JSONArray docsArray = new JSONArray(); + + if (docMap.has(requestedId)) { + JSONObject doc = docMap.getJSONObject(requestedId); + JSONObject okWrapper = new JSONObject(); + okWrapper.put("ok", doc); + docsArray.put(okWrapper); + + totalBytes += doc.toString().length(); + } else { + // Doc not found + JSONObject errorWrapper = new JSONObject(); + JSONObject errorDetail = new JSONObject(); + errorDetail.put("id", requestedId); + errorDetail.put("error", "not_found"); + errorDetail.put("reason", "missing"); + errorWrapper.put("error", errorDetail); + docsArray.put(errorWrapper); + } + + result.put("docs", docsArray); + results.put(result); + } + + // Update session counters + if (session != null) { + session.incrementDocsPushed(docMap.length()); + session.addBytesTransferred(totalBytes); + session.updateLastActivity(); + } + + Log.d(TAG, "bulk-get returning " + docMap.length() + " docs" + + " (" + (idsArray.length() - docMap.length()) + " not found)"); + + JSONObject response = new JSONObject(); + response.put("results", results); + return response; + } + + 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 RuntimeException("Failed to build error response", e); + } + } +} 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..b773c452 --- /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 RuntimeException("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..b371b597 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/GetIdsEndpoint.java @@ -0,0 +1,99 @@ +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"); + } + + // Get all doc IDs from PouchDB + String allIdsJson = bridge.getAllDocIds(); + if (allIdsJson == null || allIdsJson.isEmpty()) { + JSONObject response = new JSONObject(); + response.put("doc_ids", new JSONArray()); + response.put("total", 0); + return response; + } + + JSONArray allIds = new JSONArray(allIdsJson); + + // Filter: skip _design/ and _local/ docs, include only docs + // that a CHW might need (shared types + docs in their facility subtree). + // Full scope filtering requires doc content, which is expensive. + // Here we do a lightweight pre-filter on doc ID patterns. + JSONArray filteredIds = new JSONArray(); + for (int i = 0; i < allIds.length(); i++) { + JSONObject idEntry = allIds.getJSONObject(i); + String docId = idEntry.optString("_id", ""); + + // Skip system docs + if (docId.startsWith("_design/") || docId.startsWith("_local/")) { + continue; + } + + filteredIds.put(idEntry); + } + + 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 errorResponse(String error) { + try { + JSONObject response = new JSONObject(); + response.put("ok", false); + response.put("error", error); + return response; + } catch (JSONException e) { + throw new RuntimeException("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..7bea44fb --- /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/LocalHttpServer.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/LocalHttpServer.java new file mode 100644 index 00000000..5de1aec0 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/LocalHttpServer.java @@ -0,0 +1,408 @@ +package org.medicmobile.webapp.mobile.p2p; + +import android.util.Log; + +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +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); + } + + /** + * Create a LocalHttpServer with default port (8443). + */ + public LocalHttpServer(P2pAuthenticator authenticator, P2pConfig config, + ScopeManifest supervisorScope, PouchDbBridge bridge, + TransitDocCallback transitCallback) { + this(DEFAULT_PORT, authenticator, config, supervisorScope, bridge, + transitCallback); + } + + /** + * Create a LocalHttpServer on a specific port. + */ + public LocalHttpServer(int port, P2pAuthenticator authenticator, P2pConfig config, + ScopeManifest supervisorScope, PouchDbBridge bridge, + TransitDocCallback transitCallback) { + super(port); + + 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.activeSession = null; + this.connectedPeers = 0; + + // Initialize endpoint handlers + this.authEndpoint = new AuthEndpoint(authenticator, config, supervisorScope); + this.getIdsEndpoint = new GetIdsEndpoint(bridge); + this.bulkGetEndpoint = new BulkGetEndpoint(bridge); + this.acceptDocsEndpoint = new AcceptDocsEndpoint(bridge, transitCallback, + supervisorScope, 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); + + // Respond to Android/iOS captive portal checks with HTTP 204 + // This prevents the OS from thinking the hotspot has no internet + // and auto-disconnecting the WiFi + if (uri.contains("generate_204") || uri.contains("gen_204") + || uri.contains("connectivitycheck") || uri.contains("captive-portal")) { + Log.d(TAG, "Captive portal check intercepted: " + uri); + return newFixedLengthResponse(Response.Status.NO_CONTENT, "text/plain", ""); + } + + // Only serve /_p2p/ endpoints + if (!uri.startsWith(P2P_PREFIX)) { + return jsonResponse(Response.Status.NOT_FOUND, errorJson("not_found")); + } + + String endpoint = uri.substring(P2P_PREFIX.length()); + + // GET /_p2p/status — no auth required + if ("status".equals(endpoint) && method == Method.GET) { + return handleStatus(); + } + + // POST /_p2p/auth — validates JWT, creates session + if ("auth".equals(endpoint) && method == Method.POST) { + return handleAuth(session); + } + + // All other endpoints require an active session with valid auth + if (activeSession == null || activeSession.getState() != P2pSession.State.ACTIVE) { + return jsonResponse(Response.Status.UNAUTHORIZED, errorJson("no_active_session")); + } + + // Check session timeout (G12) + if (activeSession.isTimedOut()) { + activeSession.fail("session_timeout"); + activeSession = null; + return jsonResponse(Response.Status.UNAUTHORIZED, errorJson("session_timeout")); + } + + // Route to endpoint handlers + switch (endpoint) { + case "get-ids": + if (method == Method.GET) { + return handleGetIds(); + } + break; + + case "bulk-get": + if (method == Method.POST) { + return handleBulkGet(session); + } + break; + + case "accept-docs": + if (method == Method.POST) { + return handleAcceptDocs(session); + } + break; + + case "get-deletes": + if (method == Method.GET) { + return handleGetDeletes(); + } + break; + + case "sync-complete": + if (method == Method.POST) { + return handleSyncComplete(session); + } + break; + + default: + return jsonResponse(Response.Status.NOT_FOUND, errorJson("unknown_endpoint")); + } + + // Method not allowed for the endpoint + return jsonResponse(Response.Status.METHOD_NOT_ALLOWED, + errorJson("method_not_allowed: " + method + " " + uri)); + } + + // --- 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); + // Peer reports its counts — log for diagnostics but do NOT add bytes + // to session (AcceptDocsEndpoint already tracked bytes when accepting docs) + 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"); + + // Notify P2pManager so tracker can record the session for history + if (sessionCompleteCallback != null) { + try { + sessionCompleteCallback.onSessionComplete(activeSession); + } catch (Exception callbackErr) { + Log.e(TAG, "Error in session complete callback", callbackErr); + } + } + + JSONObject response = new JSONObject(); + response.put("ok", true); + return jsonResponse(Response.Status.OK, response); + } catch (Exception e) { + Log.e(TAG, "Error handling sync-complete", e); + return jsonResponse(Response.Status.INTERNAL_ERROR, errorJson("sync_complete_failed")); + } + } + + // --- 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); + // NanoHTTPD stores POST body under "postData" key + String postData = bodyMap.get("postData"); + if (postData != null) { + return postData; + } + // Fallback: try reading from the input stream directly + long contentLength = getContentLength(session); + if (contentLength > 0) { + BufferedReader reader = new BufferedReader( + new InputStreamReader(session.getInputStream())); + StringBuilder sb = new StringBuilder(); + char[] buffer = new char[4096]; + int read; + long remaining = contentLength; + while (remaining > 0 && (read = reader.read(buffer, 0, + (int) Math.min(buffer.length, remaining))) != -1) { + sb.append(buffer, 0, read); + remaining -= read; + } + return sb.toString(); + } + return ""; + } catch (IOException | ResponseException e) { + Log.e(TAG, "Error reading request body", e); + return ""; + } + } + + /** + * 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 (Exception e) { + throw new RuntimeException("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..51150daa --- /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..693907a8 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/OemBatteryHelper.java @@ -0,0 +1,147 @@ +package org.medicmobile.webapp.mobile.p2p; + +import android.os.Build; + +/** + * 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 + */ + public static RiskLevel getRiskLevel() { + String manufacturer = Build.MANUFACTURER.toLowerCase(); + + // Transsion brands (dominant in Africa) — most aggressive + if (manufacturer.contains("tecno") || manufacturer.contains("infinix") + || manufacturer.contains("itel")) { + return RiskLevel.EXTREME; + } + + // Major OEMs with aggressive battery management + if (manufacturer.contains("samsung") || manufacturer.contains("huawei") + || manufacturer.contains("xiaomi") || manufacturer.contains("oppo") + || manufacturer.contains("vivo") || manufacturer.contains("realme") + || manufacturer.contains("oneplus")) { + return RiskLevel.HIGH; + } + + // Moderate + if (manufacturer.contains("nokia") || manufacturer.contains("motorola") + || manufacturer.contains("lenovo")) { + return RiskLevel.MEDIUM; + } + + // Google Pixel, stock Android, unknown + return RiskLevel.LOW; + } + + /** + * 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(); + + if (manufacturer.contains("tecno") || manufacturer.contains("infinix") + || manufacturer.contains("itel")) { + return "Go to Phone Master > Battery Manager > " + + "tap this app > select 'Allow background activity'. " + + "Also: Settings > Apps > this app > Battery > Unrestricted."; + } + + if (manufacturer.contains("samsung")) { + return "Go to Settings > Battery and device care > Battery > " + + "Background usage limits > Never sleeping apps > Add this app."; + } + + if (manufacturer.contains("huawei")) { + return "Go to Settings > Battery > App launch > " + + "find this app > disable 'Manage automatically' > " + + "enable all three toggles (Auto-launch, Secondary launch, Run in background)."; + } + + if (manufacturer.contains("xiaomi")) { + return "Go to Settings > Apps > Manage apps > this app > " + + "Battery saver > No restrictions. " + + "Also enable Autostart in Security app."; + } + + if (manufacturer.contains("oppo") || manufacturer.contains("realme")) { + return "Go to Settings > Battery > this app > " + + "Allow background activity. " + + "Also: Settings > App management > this app > Battery > Allow."; + } + + if (manufacturer.contains("vivo")) { + return "Go to Settings > Battery > High background power consumption > " + + "enable for this app."; + } + + if (manufacturer.contains("oneplus")) { + return "Go to Settings > Battery > Battery optimization > " + + "this app > Don't optimize."; + } + + if (manufacturer.contains("nokia") || manufacturer.contains("motorola")) { + return "Go to Settings > Apps & notifications > this app > " + + "Battery > Unrestricted."; + } + + return "Go to Settings > Battery > this app > " + + "disable battery optimization for reliable P2P sync."; + } + + /** + * 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(); + } + + /** + * 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/P2pForegroundService.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pForegroundService.java new file mode 100644 index 00000000..78f8a0ca --- /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.TIRAMISU) { + stopForeground(STOP_FOREGROUND_REMOVE); + } else { + stopForeground(true); + } + stopSelf(); + } +} diff --git a/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pNotificationChannel.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pNotificationChannel.java new file mode 100644 index 00000000..4bb49b17 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pNotificationChannel.java @@ -0,0 +1,143 @@ +package org.medicmobile.webapp.mobile.p2p; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.os.Build; + +/** + * 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. + */ +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() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + 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) { + Notification.Builder builder; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + builder = new Notification.Builder(context, CHANNEL_ID); + } else { + builder = new Notification.Builder(context); + } + + 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) { + Notification.Builder builder; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + builder = new Notification.Builder(context, CHANNEL_ID); + } else { + builder = new Notification.Builder(context); + } + + 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("P2P Sync") + .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) { + Notification.Builder builder; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + builder = new Notification.Builder(context, CHANNEL_ID); + } else { + builder = new Notification.Builder(context); + } + + 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/P2pTelemetryReporter.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pTelemetryReporter.java new file mode 100644 index 00000000..745663f3 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pTelemetryReporter.java @@ -0,0 +1,190 @@ +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; + +/** + * Reports P2P telemetry to the server endpoint POST /api/v1/p2p/telemetry. + * + * Telemetry is fire-and-forget (async, no retry on failure). + * Schema from CONTRACT.md Section 6: + * { + * "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 CONTRACT.md Section 6 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 CONTRACT.md Section 6. + * + * { + * "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()); + 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..d79b4dc6 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pTracker.java @@ -0,0 +1,253 @@ +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) — CONTRACT.md Section 5 + * - _local/p2p-relay-log (Supervisor side) — CONTRACT.md Section 5 + * + * 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 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 CONTRACT.md Section 5: + * { + * "_id": "_local/p2p-sync-log", + * "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("sessions", buildSessionsArray()); + return log; + } + + /** + * Build the _local/p2p-relay-log JSON (Supervisor side). + * + * FORMAT from CONTRACT.md Section 5: + * { + * "_id": "_local/p2p-relay-log", + * "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("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("sessions")) { + Log.i(TAG, "Loaded sync log with " + + logJson.getJSONArray("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 CONTRACT.md Section 5 (_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..20ac67df --- /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 + * (Wave 3) — 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/StatusEndpoint.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/StatusEndpoint.java new file mode 100644 index 00000000..4fb1fccb --- /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 RuntimeException("Failed to build status response", e); + } + } +} 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..994618ed --- /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..80e20368 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/TransitDocManager.java @@ -0,0 +1,306 @@ +package org.medicmobile.webapp.mobile.p2p; + +import org.json.JSONArray; +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 (G23) + * 2. Purge them after server push (G25: db.purge() ONLY, never db.remove()) + * + * CRITICAL INVARIANTS: + * - G22: Classification is deterministic (handled by ScopeGuard) + * - G23: Transit docs NEVER appear in UI + * - G24: Transit index loads in <50ms + * - G25: Purge uses db.purge() NOT db.remove() + * - G26: _local/ doc must not exceed 1MB + * - G27: Unpushed transit docs >30 days → user notification + * + * Schema from CONTRACT.md Section 7. + */ +public final class TransitDocManager { + + static final String TRANSIT_DOC_ID = "_local/p2p-transit-docs"; + private static final long MAX_LOCAL_DOC_SIZE = 1024 * 1024; // G26: 1MB + private static final long STALE_THRESHOLD_MS = 30L * 24 * 60 * 60 * 1000; // G27: 30 days + + 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; + } + + // Parse batches + JSONObject batchesJson = transitDoc.optJSONObject("batches"); + if (batchesJson != null) { + Iterator keys = batchesJson.keys(); + while (keys.hasNext()) { + String batchId = keys.next(); + JSONObject batchJson = batchesJson.getJSONObject(batchId); + 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("pushed_at") ? batchJson.getLong("pushed_at") : null; + batch.purged = batchJson.optBoolean("purged", false); + batch.purgedAt = !batchJson.isNull("purged_at") ? batchJson.getLong("purged_at") : null; + batches.put(batchId, batch); + } + } + + // Parse transit index + JSONObject indexJson = transitDoc.optJSONObject("transit_index"); + if (indexJson != null) { + Iterator keys = indexJson.keys(); + while (keys.hasNext()) { + String docId = keys.next(); + transitIndex.put(docId, indexJson.getString(docId)); + } + } + + // Parse stats + JSONObject stats = transitDoc.optJSONObject("stats"); + if (stats != null) { + 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 — G23). + * This method MUST be fast (G24: <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). + * G25: 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; + } + + /** + * G27: 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 CONTRACT.md Section 7. + */ + public JSONObject toJson() throws JSONException { + JSONObject doc = new JSONObject(); + doc.put("_id", TRANSIT_DOC_ID); + + // Batches + JSONObject batchesJson = new JSONObject(); + for (Map.Entry entry : batches.entrySet()) { + TransitBatch b = entry.getValue(); + 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("pushed_at", b.pushedAt != null ? b.pushedAt : JSONObject.NULL); + batchJson.put("purged", b.purged); + batchJson.put("purged_at", b.purgedAt != null ? b.purgedAt : JSONObject.NULL); + batchesJson.put(entry.getKey(), batchJson); + } + doc.put("batches", batchesJson); + + // Transit index + JSONObject indexJson = new JSONObject(); + for (Map.Entry entry : transitIndex.entrySet()) { + indexJson.put(entry.getKey(), entry.getValue()); + } + doc.put("transit_index", indexJson); + + // Stats + 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()); + doc.put("stats", stats); + + return doc; + } + + /** + * G26: 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; + } + } + + /** + * G26: 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; + } + + /** + * G26: 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/WifiHotspotManager.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotManager.java new file mode 100644 index 00000000..275af75a --- /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 + * + * Guard: G12 — 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..a8d0f5c5 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/WifiHotspotProvider.java @@ -0,0 +1,260 @@ +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 + */ +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"); + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + callback.onFailed("LocalOnlyHotspot requires Android 8.0+ (API 26)"); + return; + } + + if (running) { + Log.w(TAG, "Hotspot already running"); + callback.onFailed("Hotspot already running"); + return; + } + + try { + wifiManager.startLocalOnlyHotspot(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) { + Log.e(TAG, "Hotspot started but could not detect IP — aborting"); + running = false; + try { hotspotReservation.close(); } catch (Exception ignored) {} + reservation = null; + callback.onFailed("Could not detect hotspot IP address. " + + "Please restart P2P sync."); + 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); + } + }, 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()); + } + } + + @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); + try { Thread.sleep(IP_DETECT_RETRY_DELAY_MS); } catch (InterruptedException ignored) {} + } + } + return null; + } + + /** + * 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 { + // Phase 1: Try known hotspot interface names first (fast path) + for (String ifName : KNOWN_HOTSPOT_INTERFACES) { + NetworkInterface nif = NetworkInterface.getByName(ifName); + if (nif != null && nif.isUp()) { + 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 + String wlan0Ip = null; + Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); + if (interfaces != null) { + while (interfaces.hasMoreElements()) { + NetworkInterface nif = interfaces.nextElement(); + String name = nif.getName(); + if (!nif.isUp() || nif.isLoopback()) { + continue; + } + String ip = getIpv4Address(nif); + if (ip != null) { + if ("wlan0".equals(name)) { + // Save as fallback — some devices reuse wlan0 for hotspot + wlan0Ip = ip; + Log.d(TAG, "Found wlan0 IP: " + ip + " (saving as fallback)"); + continue; + } + Log.i(TAG, "Detected hotspot IP: " + ip + " on " + name); + return ip; + } + } + } + + // Phase 3: Fall back to wlan0 if no other interface found + if (wlan0Ip != null) { + Log.i(TAG, "Using wlan0 fallback IP: " + wlan0Ip); + return wlan0Ip; + } + } 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; + } + + 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 + ")"; + } + } +} From 9d5fdad422fb9b98538183300d8d8e1139b7c37f Mon Sep 17 00:00:00 2001 From: paulpascal Date: Thu, 26 Mar 2026 13:49:28 +0000 Subject: [PATCH 03/11] p2p: add orchestrator, bridge, QR, and Android integration Wave 3 wiring: P2pManager orchestrates full sync lifecycle. P2pBridgeMethods exposes @JavascriptInterface to WebView. QR code generation (Supervisor) and scanning (CHW). P2pSyncClient for CHW-side HTTP sync. Permission activity for camera/WiFi/location. Integrates into build.gradle (NanoHTTPD + ZXing deps), AndroidManifest.xml, MedicAndroidJavascript bridge, and EmbeddedBrowserActivity. --- build.gradle | 5 + src/main/AndroidManifest.xml | 28 + .../mobile/EmbeddedBrowserActivity.java | 90 +- .../webapp/mobile/MedicAndroidJavascript.java | 125 ++ .../mobile/RequestP2pPermissionsActivity.java | 148 ++ .../webapp/mobile/p2p/P2pBridgeMethods.java | 1514 +++++++++++++++++ .../webapp/mobile/p2p/P2pManager.java | 1309 ++++++++++++++ .../webapp/mobile/p2p/P2pSyncClient.java | 280 +++ .../webapp/mobile/p2p/QrCodeHelper.java | 346 ++++ .../webapp/mobile/p2p/QrScannerActivity.java | 148 ++ .../res/layout/request_p2p_permission.xml | 73 + src/main/res/values/strings.xml | 6 + 12 files changed, 4071 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/medicmobile/webapp/mobile/RequestP2pPermissionsActivity.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/P2pBridgeMethods.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/P2pManager.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/P2pSyncClient.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/QrCodeHelper.java create mode 100644 src/main/java/org/medicmobile/webapp/mobile/p2p/QrScannerActivity.java create mode 100644 src/main/res/layout/request_p2p_permission.xml 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..c7b9eb9f 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ + @@ -26,6 +27,16 @@ --> + + + + + + + + + + @@ -98,6 +110,22 @@ 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..71b1ed01 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,126 @@ public String getDeviceInfo() { } } +//> P2P SYNC BRIDGE METHODS + 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/P2pBridgeMethods.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pBridgeMethods.java new file mode 100644 index 00000000..2531023c --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pBridgeMethods.java @@ -0,0 +1,1514 @@ +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 org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +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 (G23) + * - Trigger transit doc purge after server push (G25) + * - 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 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 volatile long clientBytesTransferred = 0; + private volatile String clientSyncState = "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() { + try { + if (p2pManager == null) { + return errorJson("p2p_not_initialized"); + } + if (!p2pManager.isInitialized()) { + return errorJson("not_initialized"); + } + + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference resultRef = new AtomicReference<>(); + + p2pManager.startHostMode(new P2pManager.HostModeCallback() { + @Override + public void onQrCodeReady(String qrPayloadJson) { + try { + // Generate QR code as data URL for webapp rendering + String qrDataUrl = QrCodeHelper.generateQrDataUrl(qrPayloadJson); + cachedQrDataUrl = qrDataUrl; + + JSONObject response = new JSONObject(); + response.put("ok", true); + response.put("qr_payload", qrPayloadJson); + response.put("qr_data_url", qrDataUrl != null ? qrDataUrl : ""); + resultRef.set(response); + } catch (JSONException e) { + Log.e(TAG, "Error building QR response", e); + } + latch.countDown(); + } + + @Override + public void onPeerConnected(String peerId) { + // Not relevant during startup + } + + @Override + public void onSyncProgress(int docsSynced, int totalDocs) { + // Not relevant during startup + } + + @Override + public void onSyncComplete(P2pSession session) { + // Not relevant during startup + } + + @Override + public void onError(String error) { + try { + JSONObject response = new JSONObject(); + response.put("ok", false); + response.put("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) { + // Informational, does not affect startup result. + // The webapp can query battery guidance separately. + Log.i(TAG, "Battery guidance (" + riskLevel + "): " + guidance); + } + }); + + 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"); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Log.e(TAG, "Host start interrupted", e); + p2pManager.shutdown(); + return errorJson("interrupted"); + } catch (Exception e) { + Log.e(TAG, "Error starting supervisor mode", e); + return errorJson("start_failed: " + e.getMessage()); + } + } + + /** + * 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) { + try { + if (p2pManager == null) { + return errorJson("p2p_not_initialized"); + } + if (!p2pManager.isInitialized()) { + return errorJson("not_initialized"); + } + + if (qrPayloadJson == null || qrPayloadJson.isEmpty()) { + return errorJson("empty_qr_payload"); + } + + // Validate the QR payload + ValidationResult validation = QrCodeHelper.validateQrPayload(qrPayloadJson); + if (!validation.isAccepted()) { + return errorJson("invalid_qr: " + validation.getReason()); + } + + // Parse QR to extract connection info + 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); + + // Store connection info for sync after WiFi connects + cachedPeerHost = host; + cachedPeerPort = port; + clientSyncState = "connecting"; + + Log.i(TAG, "Client mode: auto-connecting to SSID=" + ssid + + " then sync with " + host + ":" + port); + + // Start client mode — triggers async WiFi auto-connect via WifiNetworkSpecifier + p2pManager.startClientMode(qrPayloadJson, 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 = "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 = "waiting_wifi"; + } else { + Log.e(TAG, "Client mode error: " + error); + clientSyncError = error; + clientSyncState = "failed"; + } + } + + @Override + public void onConnected(String hostId) {} + + @Override + public void onPreviewReady(int contactCount, int reportCount, int totalCount) {} + + @Override + public void onSyncProgress(int docsSynced, int totalDocs) {} + + @Override + public void onSyncComplete(P2pSession session) {} + }); + + // Return immediately — WiFi connection is async + JSONObject result = new JSONObject(); + result.put("ok", true); + result.put("auto_connecting", true); + result.put("ssid", ssid); + result.put("password", password); + result.put("host", host); + result.put("port", port); + return result.toString(); + + } catch (Exception e) { + Log.e(TAG, "Error starting client mode", e); + clientSyncState = "failed"; + return errorJson("start_failed: " + e.getMessage()); + } + } + + /** + * 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 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 + // Critical for dual-WiFi devices (Pixel has wlan0 + wlan1) + if (cachedPeerHost == null || cachedPeerHost.isEmpty()) return null; + 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; + + Network fallbackWifi = null; + for (Network network : cm.getAllNetworks()) { + NetworkCapabilities caps = cm.getNetworkCapabilities(network); + if (caps == null || !caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) continue; + + // Check if this WiFi network's IP is on the same subnet as supervisor + android.net.LinkProperties lp = cm.getLinkProperties(network); + if (lp != null) { + for (android.net.LinkAddress addr : lp.getLinkAddresses()) { + java.net.InetAddress inetAddr = addr.getAddress(); + if (inetAddr instanceof java.net.Inet4Address) { + String ip = inetAddr.getHostAddress(); + if (ip != null && ip.startsWith(targetPrefix)) { + Log.i(TAG, "Found WiFi on supervisor subnet: " + ip + + " (target=" + targetPrefix + "*) → " + network); + return network; + } + } + } + } + // Keep first WiFi as last-resort fallback + if (fallbackWifi == null) { + fallbackWifi = network; + } + } + if (fallbackWifi != null) { + Log.w(TAG, "No subnet match for " + targetPrefix + "*, using fallback WiFi: " + fallbackWifi); + return fallbackWifi; + } + } catch (Exception e) { + Log.w(TAG, "Failed to find WiFi network", e); + } + Log.w(TAG, "No WiFi network found at all"); + return null; + } + + /** + * 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() { + try { + if (cachedPeerHost == null || cachedPeerHost.isEmpty()) { + Log.d(TAG, "p2pCheckConnection: no host cached"); + return "{\"connected\":false}"; + } + + P2pSyncClient client = new P2pSyncClient(cachedPeerHost, cachedPeerPort); + // Bind to WiFi network so HTTP calls route through the hotspot, not cellular + 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 && "waiting_wifi".equals(clientSyncState)) { + Log.i(TAG, "Host reachable at " + cachedPeerHost + ":" + cachedPeerPort + + " — starting sync"); + clientSyncState = "connecting"; + startClientSync(cachedPeerHost, cachedPeerPort); + } + + JSONObject result = new JSONObject(); + result.put("connected", reachable); + return result.toString(); + } catch (Exception e) { + Log.w(TAG, "p2pCheckConnection error", e); + return "{\"connected\":false}"; + } + } + + /** + * 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 = "connecting"; + clientSyncError = null; + clientDocsSynced = 0; + clientTotalDocs = 0; + clientBytesTransferred = 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("ok", true); + return result.toString(); + } catch (Exception 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 { + // Build log JSON BEFORE shutdown clears state (fixes race condition). + // The background thread only writes the pre-built data to PouchDB. + boolean wasHost = p2pManager != null && p2pManager.isHostModeActive(); + boolean wasPeer = p2pManager != null && p2pManager.isClientModeActive(); + if (wasHost || wasPeer) { + try { + final JSONObject logToSave; + final String docId; + if (wasHost) { + logToSave = tracker.buildRelayLog(); + docId = "_local/p2p-relay-log"; + } else { + logToSave = tracker.buildSyncLog(); + docId = "_local/p2p-sync-log"; + } + // Save pre-built data on background thread — immune to shutdown race + new Thread(() -> { + try { + savePrebuiltLogToPouchDb(logToSave, docId); + } catch (Exception 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 so it survives app restart (Gap 1 fix). + // Without this, TransitDocManager state only lives in Java memory and is lost + // on restart, so the webapp's purge service gets a 404 on _local/p2p-transit-docs. + if (wasHost && p2pManager != null) { + final JSONObject transitState = p2pManager.getTransitStateJson(); + if (transitState != null) { + new Thread(() -> { + try { + saveTransitStateToPouchDb(transitState); + } catch (Exception e) { + Log.e(TAG, "Error saving transit state on stop", e); + } + }, "P2pSaveTransitState").start(); + } + } + + cachedQrDataUrl = null; + clientSyncRunning = false; + clientSyncState = "idle"; + clientSyncError = null; + clientDocsSynced = 0; + clientTotalDocs = 0; + clientBytesTransferred = 0; + activeSyncClient = null; + cachedPeerHost = null; + cachedPeerPort = 0; + pendingDocIds = null; + previewContactCount = 0; + previewReportCount = 0; + if (p2pManager != null) { + p2pManager.shutdown(); + } + } catch (Exception e) { + Log.e(TAG, "Error stopping P2P", e); + } + } + + /** + * 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 void startClientSync(final String host, final int port) { + if (clientSyncRunning) { + Log.w(TAG, "startClientSync: already running, skipping"); + return; + } + clientSyncRunning = true; + new Thread(() -> { + try { + clientSyncState = "connecting"; + clientSyncError = null; + P2pSyncClient client = new P2pSyncClient(host, port); + // Bind to WiFi network — same reason as in p2pCheckConnection + Network wifiNet = findWifiNetwork(); + if (wifiNet != null) { + client.setNetwork(wifiNet); + Log.i(TAG, "Client sync: bound to WiFi network " + wifiNet); + } + activeSyncClient = client; + + // Probe reachability — L3 routing may need time after WiFi L2 connect + boolean reachable = false; + for (int attempt = 1; attempt <= 10; attempt++) { + if (client.isReachable()) { + reachable = true; + Log.i(TAG, "Server reachable on attempt " + attempt); + break; + } + Log.d(TAG, "Server not reachable, attempt " + attempt + "/10, waiting 1s..."); + Thread.sleep(1000); + } + if (!reachable) { + Log.e(TAG, "Server unreachable after 10 attempts"); + clientSyncError = "Server unreachable after 10 attempts at " + host + ":" + port + + ". WiFi network=" + (wifiNet != null ? wifiNet.toString() : "none"); + clientSyncState = "failed"; + return; + } + + // Step 1: Authenticate + 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 = "failed"; + return; + } + + // Pass device ID for session tracking + 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 = "failed"; + return; + } + + // Start a tracker session for the peer (CHW) side + if (tracker != null) { + String peerDeviceId = "supervisor-" + host; + String peerUserId = "supervisor"; + tracker.startSession(peerDeviceId, peerUserId, "chw_supervisor", null); + } + + Log.i(TAG, "Client sync: authenticated, querying unsynced docs"); + + // Step 2: Get docs not yet synced to server. + // + // Uses only lastServerReplicatedSeq from CHT's db-sync.service.ts. + // No P2P checkpoint — if Supervisor never syncs online, docs would be lost. + // Duplicate pushes are harmless (new_edits: false on the host). + // + // Returns: { docs: [{_id, _rev}, ...], last_seq: N } + 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"); + + // Count contacts vs reports for preview + int contacts = 0; + int reports = 0; + for (int i = 0; i < docsToSync.length(); i++) { + JSONObject entry = docsToSync.getJSONObject(i); + String docId = entry.optString("_id", ""); + if (docId.startsWith("report:") || docId.startsWith("report~")) { + reports++; + } else { + contacts++; + } + } + + clientTotalDocs = docsToSync.length(); + previewContactCount = contacts; + previewReportCount = reports; + pendingDocIds = docsToSync; + + Log.i(TAG, "Client sync: CHW has " + docsToSync.length() + " unsynced docs to push (" + + contacts + " contacts, " + reports + " reports) — awaiting user confirmation"); + + // Set state to preview — webapp will show counts and wait for user + clientSyncState = "preview"; + + } catch (Exception e) { + Log.e(TAG, "Client sync failed during preview phase", e); + clientSyncError = "Sync error: " + e.getMessage(); + clientSyncState = "failed"; + } finally { + // Do NOT clear clientSyncRunning here — we're still in preview + // It will be cleared when proceedClientSync completes or user cancels + if (!"preview".equals(clientSyncState)) { + clientSyncRunning = false; + } + } + }, "P2pClientSync").start(); + } + + /** + * 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() { + try { + 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 (!"preview".equals(clientSyncState)) { + return errorJson("invalid_state: expected preview, got " + clientSyncState); + } + + // Start push in background thread + final JSONArray docEntries = pendingDocIds; + final P2pSyncClient client = activeSyncClient; + pendingDocIds = null; + + new Thread(() -> { + try { + clientSyncState = "syncing"; + int totalDocs = docEntries.length(); + clientTotalDocs = totalDocs; + + // Push docs in batches + int batchSize = 50; + int pushed = 0; + for (int i = 0; i < totalDocs; i += batchSize) { + int end = Math.min(i + batchSize, totalDocs); + + // Collect IDs for this batch + JSONArray idStrings = new JSONArray(); + for (int j = i; j < end; j++) { + JSONObject entry = docEntries.getJSONObject(j); + idStrings.put(entry.getString("_id")); + } + + // Get full doc bodies from local PouchDB + Log.d(TAG, "Client sync: fetching doc IDs from PouchDB: " + idStrings.toString()); + String docsJson = p2pManager.getBridge().getDocsByIds(idStrings.toString()); + Log.d(TAG, "Client sync: PouchDB returned " + docsJson.length() + " chars: " + + docsJson.substring(0, Math.min(500, docsJson.length()))); + JSONArray docs = new JSONArray(docsJson); + Log.d(TAG, "Client sync: parsed " + docs.length() + " docs from PouchDB response"); + + if (docs.length() > 0) { + // Push to Supervisor + JSONObject result = client.acceptDocs(docs); + Log.d(TAG, "Client sync: accept-docs response: " + result.toString()); + 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")); + } + pushed += accepted + transit; + clientBytesTransferred += docsJson.length(); + } + clientDocsSynced = pushed; + Log.d(TAG, "Client sync: pushed " + pushed + "/" + totalDocs); + } + + Log.i(TAG, "Client sync: push complete, " + + pushed + " docs sent to Supervisor"); + + // Signal completion to the Supervisor so host transitions to "completed" + try { + client.syncComplete(pushed, clientBytesTransferred); + Log.i(TAG, "Client sync: sent sync-complete to Supervisor"); + } catch (Exception e) { + Log.w(TAG, "Client sync: failed to signal sync-complete (non-fatal)", e); + } + + // No P2P checkpoint saved — only server seq matters. + // Duplicate pushes are harmless (new_edits: false). + + // Update tracker session counters and complete + if (tracker != null) { + P2pSession trackerSession = tracker.getCurrentSession(); + if (trackerSession != null) { + trackerSession.incrementDocsPushed(pushed); + trackerSession.addBytesTransferred(clientBytesTransferred); + } + tracker.completeSession(); + } + + clientSyncState = "completed"; + Log.i(TAG, "Client sync: completed successfully"); + + // Save sync history to PouchDB (peer/CHW side) + try { + saveSyncLogToPouchDb(false); + } catch (Exception saveErr) { + Log.e(TAG, "Client sync: failed to save sync log", saveErr); + } + + } catch (Exception e) { + Log.e(TAG, "Client sync failed", e); + clientSyncError = "Sync error: " + e.getMessage(); + clientSyncState = "failed"; + } finally { + clientSyncRunning = false; + } + }, "P2pClientSyncPush").start(); + + JSONObject result = new JSONObject(); + result.put("ok", true); + return result.toString(); + } catch (Exception e) { + Log.e(TAG, "Error proceeding with sync", e); + return errorJson("proceed_failed: " + e.getMessage()); + } + } + + /** + * 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(); + + // Derive state from P2pManager and tracker + String state = "idle"; + if (tracker != null && tracker.hasActiveSession()) { + P2pSession session = tracker.getCurrentSession(); + state = session.getState().name().toLowerCase(); + status.put("docs_synced", session.getDocsPushed() + session.getDocsPulled()); + status.put("total_docs", session.getDocsPushed() + session.getDocsPulled() + + session.getTransitDocs()); + status.put("bytes_transferred", session.getBytesTransferred()); + } else { + status.put("docs_synced", 0); + status.put("total_docs", 0); + status.put("bytes_transferred", 0); + } + + if (p2pManager.isHostModeActive()) { + int peerCount = p2pManager.getConnectedPeerCount(); + P2pSession httpSession = p2pManager.getHttpSession(); + if (httpSession != null && httpSession.getState() == P2pSession.State.COMPLETED) { + state = "completed"; + int sessionDocs = httpSession.getDocsPulled() + httpSession.getTransitDocs(); + status.put("docs_synced", sessionDocs); + status.put("total_docs", sessionDocs); + status.put("bytes_transferred", httpSession.getBytesTransferred()); + } else if (peerCount > 0 && httpSession != null + && httpSession.getState() == P2pSession.State.ACTIVE) { + // Fix 5: Also check transitDocs — if ALL docs are transit, + // docsPulled/docsPushed may be 0 but sync IS happening. + if (httpSession.getDocsPulled() > 0 || httpSession.getDocsPushed() > 0 + || httpSession.getTransitDocs() > 0) { + state = "syncing"; + int sessionDocs = httpSession.getDocsPulled() + httpSession.getTransitDocs(); + status.put("docs_synced", sessionDocs); + status.put("total_docs", sessionDocs); + status.put("bytes_transferred", httpSession.getBytesTransferred()); + } else { + state = "peer_connected"; + } + } else if ("idle".equals(state)) { + state = "waiting"; + } + // Fix 4: Log host session counters for diagnostics + if (httpSession != null) { + Log.d(TAG, "Host status: state=" + state + + " pulled=" + httpSession.getDocsPulled() + + " transit=" + httpSession.getTransitDocs() + + " bytes=" + httpSession.getBytesTransferred()); + } + } else if (p2pManager.isClientModeActive()) { + // Use client sync state from the background sync thread + state = clientSyncState; + status.put("docs_synced", clientDocsSynced); + status.put("total_docs", clientTotalDocs); + status.put("bytes_transferred", clientBytesTransferred); + // Include preview counts when in preview state + if ("preview".equals(state)) { + status.put("preview_contacts", previewContactCount); + status.put("preview_reports", previewReportCount); + status.put("preview_total", clientTotalDocs); + } + } + + status.put("state", state); + if (clientSyncError != null) { + status.put("error", clientSyncError); + } + status.put("initialized", p2pManager.isInitialized()); + + // Hotspot + JSONObject managerStatus = p2pManager.getStatusJson(); + status.put("hotspot_active", managerStatus.optBoolean("hotspot_active", false)); + // Pass hotspot SSID and password for display restoration + 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); + } + + // Connected peers from HTTP server + int peerCount = p2pManager.getConnectedPeerCount(); + JSONArray peersArray = new JSONArray(); + if (peerCount > 0) { + P2pSession httpSession = p2pManager.getHttpSession(); + if (httpSession != null) { + peersArray.put(httpSession.getPeerUserId() != null + ? httpSession.getPeerUserId() : "peer"); + } + } + status.put("connected_peers", peersArray); + + // QR code data URL for webapp display + 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(); + } + } + + /** + * G23: Get all transit doc IDs for UI filtering. + * Returns JSON array of doc IDs that should be hidden from the UI. + * G24: 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 (Exception 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); + } + + /** + * G25: 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(); + } + + JSONArray batchesArray = new JSONArray(); + int totalDocs = 0; + + for (String batchId : purgeableBatchIds) { + Set docIds = transitDocManager.getDocIdsForBatch(batchId); + if (docIds.isEmpty()) { + continue; + } + + 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); + batchesArray.put(batchObj); + totalDocs += docIds.size(); + } + + JSONObject response = new JSONObject(); + response.put("batches", batchesArray); + response.put("total_docs", totalDocs); + return response.toString(); + } catch (JSONException e) { + Log.e(TAG, "Error building purge response", e); + return buildEmptyPurgeResponse(); + } + } + + /** + * Confirm that a batch has been purged by the webapp. + * Called after webapp successfully runs db.purge() for all docs in a batch. + * G25: 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); + + // G26: Archive old purged batches if transit doc is oversized + if (transitDocManager.isOversized()) { + transitDocManager.archivePurgedBatches(); + Log.i(TAG, "Archived purged batches due to size limit (G26)"); + } + } + } + + /** + * Get P2P sync history (completed sessions). + * Returns JSON matching _local/p2p-sync-log schema from CONTRACT.md Section 5. + * + * @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(); + } + } + + /** + * G27: 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()); + + switch (capability) { + case FULLY_SUPPORTED: + result.put("reason", JSONObject.NULL); + break; + case SUPPORTED_NO_CAMERA: + result.put("reason", "No camera available. QR scanning disabled; " + + "Host mode only."); + break; + case UNSUPPORTED_API_LEVEL: + result.put("reason", "Android 8.0 (API 26) or higher required. " + + "Current API level: " + Build.VERSION.SDK_INT); + break; + case NO_WIFI_HARDWARE: + result.put("reason", "WiFi hardware not available on this device."); + break; + case LOW_RAM_WARNING: + result.put("reason", "Low RAM detected. P2P sync may be slow."); + break; + case LOW_STORAGE: + result.put("reason", "Insufficient storage. Free up space before syncing."); + break; + case LOW_BATTERY_WARNING: + result.put("reason", "Low battery. Charge device before starting P2P sync."); + break; + case PERMISSION_NEEDED: + result.put("reason", "WiFi permissions required. Please grant permissions."); + break; + case LOCATION_SERVICES_OFF: + result.put("reason", "Location services must be enabled to start WiFi hotspot. Please turn on Location in Settings."); + break; + } + + 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()); + + 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 { + JSONObject json = new JSONObject(configJson); + + // Parse P2P config + JSONObject configObj = json.optJSONObject("config"); + P2pConfig config = configObj != null ? P2pConfig.fromJson(configObj) : P2pConfig.defaults(); + + // Parse scope manifest + JSONObject scopeObj = json.getJSONObject("scope_manifest"); + ScopeManifest scopeManifest = ScopeManifest.fromJson(scopeObj); + + // Server public key for JWT verification + String serverPublicKey = json.getString("server_public_key"); + + // Revocation list (optional, defaults to empty) + JSONObject revObj = json.optJSONObject("revocation_list"); + RevocationList revocationList = revObj != null + ? RevocationList.fromJson(revObj) + : RevocationList.empty(); + + // Device and user identifiers + String deviceId = json.getString("device_id"); + String userId = json.getString("user_id"); + + // Cache JWT for peer-side auth with supervisor + String jwt = json.optString("token", null); + if (jwt != null && !jwt.isEmpty()) { + cachedJwt = jwt; + Log.d(TAG, "JWT token cached for P2P auth"); + } + + // PouchDB bridge — evaluates JS in the WebView to access PouchDB + PouchDbBridge realBridge = 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);" + + "})" + ); + } + }; + p2pManager.initialize(config, serverPublicKey, revocationList, + scopeManifest, realBridge, deviceId, userId); + + JSONObject result = new JSONObject(); + result.put("ok", true); + return result.toString(); + + } 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 (Exception e) { + Log.e(TAG, "Unexpected error during P2P initialization", e); + return errorJson("init_error: " + e.getMessage()); + } + } + + /** + * 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 (Exception 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; + String docId; + if (isHost) { + log = tracker.buildRelayLog(); + docId = "_local/p2p-relay-log"; + } else { + log = tracker.buildSyncLog(); + docId = "_local/p2p-sync-log"; + } + + JSONArray newSessions = log.optJSONArray("sessions"); + if (newSessions == null || newSessions.length() == 0) { + Log.d(TAG, "saveSyncLogToPouchDb: no sessions to save"); + return; + } + + // Escape the sessions JSON for embedding in JS string + String sessionsJsonStr = newSessions.toString() + .replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\n", "\\n"); + + // JS: get existing doc (or create new), merge sessions, keep last 50, put + String js = + "(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 });" + + "})()"; + + String result = evalPouchDb(js); + Log.i(TAG, "saveSyncLogToPouchDb: saved " + docId + " result=" + result); + } catch (Exception e) { + Log.e(TAG, "saveSyncLogToPouchDb: failed to save", e); + } + } + + /** + * 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 { + JSONArray newSessions = log.optJSONArray("sessions"); + if (newSessions == null || newSessions.length() == 0) { + Log.d(TAG, "savePrebuiltLogToPouchDb: no sessions to save"); + return; + } + + String sessionsJsonStr = newSessions.toString() + .replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\n", "\\n"); + + String js = + "(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 });" + + "})()"; + + String result = evalPouchDb(js); + Log.i(TAG, "savePrebuiltLogToPouchDb: saved " + docId + " result=" + result); + } catch (Exception 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 + " result=" + result); + } catch (Exception e) { + Log.e(TAG, "saveTransitStateToPouchDb: failed to save", e); + } + } + + private String errorJson(String error) { + try { + JSONObject json = new JSONObject(); + json.put("ok", false); + json.put("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("total_docs", 0); + return response.toString(); + } catch (JSONException e) { + return "{\"batches\":[],\"total_docs\":0}"; + } + } + + private String buildEmptySyncHistory() { + try { + JSONObject log = new JSONObject(); + log.put("_id", "_local/p2p-sync-log"); + log.put("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/P2pManager.java b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pManager.java new file mode 100644 index 00000000..4c935433 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pManager.java @@ -0,0 +1,1309 @@ +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 (CONTRACT.md Section 9) + private static final String QR_TYPE = "cht-p2p"; + private static final int QR_VERSION = 1; + private static final long QR_MAX_AGE_MS = 10 * 60 * 1000; // G18: 10 minutes + + // 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; // G21: 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() { + // Hard block: API level + 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; + } + + // Hard block: WiFi hardware + WifiManager wifiManager = (WifiManager) context + .getSystemService(Context.WIFI_SERVICE); + if (wifiManager == null) { + Log.w(TAG, "No WifiManager available"); + return P2pCapability.NO_WIFI_HARDWARE; + } + + // Hard block: insufficient storage + long freeStorageMb = getAvailableStorageMb(); + if (freeStorageMb < MIN_STORAGE_MB) { + Log.w(TAG, "Low storage: " + freeStorageMb + " MB free"); + return P2pCapability.LOW_STORAGE; + } + + // Hard block: WiFi permission + if (!hasWifiPermissions()) { + Log.w(TAG, "Missing WiFi permissions"); + return P2pCapability.PERMISSION_NEEDED; + } + + // Hard block: Location services must be enabled for LocalOnlyHotspot on all API levels + android.location.LocationManager lm = (android.location.LocationManager) + context.getSystemService(Context.LOCATION_SERVICE); + if (lm == null || !lm.isLocationEnabled()) { + Log.w(TAG, "Location services disabled (required for hotspot)"); + return P2pCapability.LOCATION_SERVICES_OFF; + } + + // Soft warning: low battery + int batteryPercent = getBatteryPercent(); + if (batteryPercent >= 0 && batteryPercent < MIN_BATTERY_PERCENT) { + Log.w(TAG, "Low battery: " + batteryPercent + "%"); + return P2pCapability.LOW_BATTERY_WARNING; + } + + // Soft warning: low RAM + 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; + } + + // 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; + } + + // ----------------------------------------------------------------------- + // Initialize + // ----------------------------------------------------------------------- + + /** + * 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 + */ + 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(false); + } + + if (config == null) { + throw new P2pInitException("config must not be null"); + } + if (!config.isEnabled()) { + throw new P2pInitException("P2P sync is disabled in config"); + } + if (serverPublicKey == null || serverPublicKey.isEmpty()) { + throw new P2pInitException("serverPublicKey must not be null or empty"); + } + if (revocationList == null) { + throw new P2pInitException("revocationList must not be null"); + } + if (localScope == null) { + throw new P2pInitException("localScope must not be null"); + } + if (bridge == null) { + throw new P2pInitException("bridge must not be null"); + } + if (deviceId == null || deviceId.isEmpty()) { + throw new P2pInitException("deviceId must not be null or empty"); + } + if (userId == null || userId.isEmpty()) { + throw new P2pInitException("userId must not be null or empty"); + } + + 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 (Exception e) { + throw new P2pInitException("Initialization failed: " + e.getMessage()); + } + } + + /** + * 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 (G10) + * 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(); + + if (hostModeActive) { + // Allow re-entry: shutdown previous supervisor mode first + Log.w(TAG, "Supervisor mode already active, shutting down previous session"); + shutdownInternal(false); + } + if (clientModeActive) { + Log.w(TAG, "CHW mode active, shutting down before starting supervisor"); + shutdownClientMode(); + } + + // Step 1: Capability check + P2pCapability capability = checkCapability(); + if (capability == P2pCapability.UNSUPPORTED_API_LEVEL + || capability == P2pCapability.NO_WIFI_HARDWARE + || capability == P2pCapability.LOW_STORAGE + || capability == P2pCapability.PERMISSION_NEEDED) { + callback.onError("device_not_capable: " + capability.name()); + return; + } + + // Step 2: OEM battery guidance (informational, does not block) + if (OemBatteryHelper.needsBatteryGuidance()) { + String guidance = OemBatteryHelper.getGuidance(); + Log.i(TAG, "OEM battery guidance (" + OemBatteryHelper.getManufacturer() + + "): " + guidance); + callback.onBatteryGuidance(OemBatteryHelper.getRiskLevel(), guidance); + } + + // Step 3: Acquire sync mutex (G10) + if (!syncMutex.tryAcquire(SyncMutex.SyncType.P2P)) { + callback.onError("sync_mutex_busy: another sync is already active"); + return; + } + + boolean mutexAcquired = true; + try { + // Step 4: Start foreground service + P2pForegroundService.start(context); + Log.i(TAG, "Foreground service started"); + + // Step 5: Start hotspot + hostModeActive = true; + startHotspotForSupervisor(callback); + + } catch (Exception e) { + Log.e(TAG, "Failed to start supervisor mode", e); + hostModeActive = false; + if (mutexAcquired) { + syncMutex.release(); + } + try { + P2pForegroundService.stop(context); + } catch (Exception ignored) { + // Best-effort cleanup + } + callback.onError("supervisor_start_failed: " + e.getMessage()); + } + } + + /** + * 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(ipAddress, callback); + + // 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 (Exception e) { + Log.e(TAG, "Failed after hotspot started", e); + shutdownInternal(true); + 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 (Exception ignored) { + // Best-effort cleanup + } + callback.onError("hotspot_failed: " + reason); + } + }); + } + + /** + * Internal: create and start the LocalHttpServer. + */ + private void startHttpServer(String ipAddress, + final HostModeCallback callback) + throws IOException { + + // TransitDocCallback bridges AcceptDocsEndpoint -> TransitDocManager + 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); + } + }; + + httpServer = new LocalHttpServer( + authenticator, config, localScope, pouchDbBridge, + transitCallback); + + // Wire up session-complete callback so tracker records the session. + // Host-side sessions are managed by LocalHttpServer, not tracker, + // so we use recordCompletedSession() instead of completeSession(). + 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()); + } + + /** + * 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 (G18: timestamp, G19: type, G21: 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(); + + if (clientModeActive) { + // Allow re-entry: shutdown previous CHW mode first + Log.w(TAG, "CHW mode already active, shutting down previous session"); + shutdownClientMode(); + } + if (hostModeActive) { + callback.onError("host_mode_active: shutdown supervisor mode first"); + return; + } + + // Step 1: Parse QR payload + JSONObject qrPayload; + try { + qrPayload = new JSONObject(qrPayloadJson); + } catch (JSONException e) { + callback.onError("qr_parse_failed: invalid JSON"); + return; + } + + // Step 2: Validate QR payload + String validationError = validateQrPayload(qrPayload); + if (validationError != null) { + callback.onError(validationError); + return; + } + + // Step 3: Acquire sync mutex (G10) + if (!syncMutex.tryAcquire(SyncMutex.SyncType.P2P)) { + callback.onError("sync_mutex_busy: another sync is already active"); + return; + } + + clientModeActive = true; + + // Extract connection details from QR + String ssid = qrPayload.optString("ssid", ""); + String password = qrPayload.optString("pwd", ""); + String ip = qrPayload.optString("ip", ""); + int port = qrPayload.optInt("port", 8443); + String tlsFingerprint = qrPayload.optString("tls", ""); + + Log.i(TAG, "CHW mode started: supervisor at " + ip + ":" + port + + " via SSID=" + ssid + " — attempting auto WiFi connection"); + + // Try auto-connect via WifiNetworkSpecifier (API 29+) + // Falls back to manual if API < 29 or user declines dialog + connectToHotspotWifi(ssid, password, ip, port, tlsFingerprint, callback); + } + + /** + * Connect to the supervisor's WiFi hotspot using WifiNetworkSpecifier (API 29+). + */ + private void connectToHotspotWifi(final String ssid, final String password, + final String ip, final int port, + final String tlsFingerprint, + final ClientModeCallback callback) { + 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(true); + } + + /** + * Internal shutdown with option to report telemetry. + */ + private void shutdownInternal(boolean reportTelemetry) { + Log.i(TAG, "Shutting down P2P components"); + + // 1. Stop HTTP server + if (httpServer != null) { + try { + httpServer.stopServer(); + Log.d(TAG, "HTTP server stopped"); + } catch (Exception e) { + Log.e(TAG, "Error stopping HTTP server", e); + } + httpServer = null; + } + + // 2. Stop hotspot + if (hotspotManager != null) { + try { + hotspotManager.stopHotspot(); + Log.d(TAG, "Hotspot stopped"); + } catch (Exception e) { + Log.e(TAG, "Error stopping hotspot", e); + } + hotspotManager = null; + } + + // 3. Stop foreground service + try { + P2pForegroundService.stop(context); + } catch (Exception e) { + Log.e(TAG, "Error stopping foreground service", e); + } + + // 4. Release sync mutex + if (syncMutex != null && syncMutex.isActive()) { + try { + syncMutex.release(); + Log.d(TAG, "Sync mutex released"); + } catch (Exception e) { + Log.e(TAG, "Error releasing sync mutex", e); + } + } + + // 5. Fail any in-progress tracker session + if (tracker != null && tracker.hasActiveSession()) { + tracker.failSession("shutdown"); + } + + // 6. Save transit doc state (best-effort) + saveTransitState(); + + // 7. Dismiss notifications + if (notificationChannel != null) { + try { + notificationChannel.dismiss(); + } catch (Exception e) { + Log.e(TAG, "Error dismissing notification", e); + } + } + + // 8. Unbind WiFi network and unregister callback + if (wifiNetworkCallback != null) { + try { + ConnectivityManager cm = (ConnectivityManager) context + .getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm != null) { + cm.unregisterNetworkCallback(wifiNetworkCallback); + } + } catch (Exception e) { + Log.e(TAG, "Error cleaning up WiFi callback", e); + } + wifiNetworkCallback = null; + } + + // 9. Reset state + hostModeActive = false; + clientModeActive = false; + currentQrSessionTs = null; + + Log.i(TAG, "P2P shutdown complete"); + } + + /** + * 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 CONTRACT.md Section 9. + */ + 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("ssid", ssid); + qr.put("pwd", password); + qr.put("ip", ipAddress); + qr.put("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 G18, G19, G21. + * + * @return null if valid, or an error string describing the issue + */ + String validateQrPayload(JSONObject qr) { + if (qr == null) { + return "qr_invalid: null payload"; + } + + // G19: type must be "cht-p2p" + String type = qr.optString("type", ""); + if (!QR_TYPE.equals(type)) { + return "qr_invalid_type: expected '" + QR_TYPE + "', got '" + type + "'"; + } + + // Version check + int version = qr.optInt("v", 0); + if (version < 1) { + return "qr_invalid_version: " + version; + } + + // G18: timestamp must be within 10 minutes + 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)"; + } + + // Required fields + String ssid = qr.optString("ssid", ""); + if (ssid.isEmpty()) { + return "qr_missing_ssid"; + } + String ip = qr.optString("ip", ""); + if (ip.isEmpty()) { + return "qr_missing_ip"; + } + int port = qr.optInt("port", 0); + if (port <= 0 || port > 65535) { + return "qr_invalid_port: " + port; + } + + return null; // Valid + } + + // ----------------------------------------------------------------------- + // 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 (G27). */ + 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 { + // G26: 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. */ + public interface ClientModeCallback { + /** + * QR payload validated, connection info ready. + * The caller should now connect to the WiFi network and verify TLS (G20). + */ + 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 { + public P2pInitException(String message) { + super(message); + } + } + + // ----------------------------------------------------------------------- + // Private Helpers + // ----------------------------------------------------------------------- + + 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 + .getSystemService(Context.WIFI_SERVICE); + return new WifiHotspotProvider(wifiManager); + } + + /** + * Best-effort emulator detection. + */ + private boolean isEmulator() { + return Build.FINGERPRINT.startsWith("generic") + || 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") + || Build.DEVICE.startsWith("generic"); + } + + /** + * 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 (Exception 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 + if (Build.VERSION.SDK_INT >= 33) { + if (ContextCompat.checkSelfPermission(context, + "android.permission.NEARBY_WIFI_DEVICES") != PackageManager.PERMISSION_GRANTED) { + return false; + } + } + + return true; + } + + private long getAvailableStorageMb() { + try { + StatFs stat = new StatFs(Environment.getDataDirectory().getPath()); + long availableBytes = stat.getAvailableBlocksLong() * stat.getBlockSizeLong(); + return availableBytes / (1024 * 1024); + } catch (Exception 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 (Exception 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 (Exception e) { + Log.w(TAG, "Failed to check battery", e); + } + return -1; // Unknown + } +} 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..0a4883d9 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/P2pSyncClient.java @@ -0,0 +1,280 @@ +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.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 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("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 { + // Convert string IDs to {id: "..."} format expected by BulkGetEndpoint + 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); + } + + JSONObject body = new JSONObject(); + body.put("docs", docsArray); + + JSONObject response = postJson("/bulk-get", body, sessionToken); + + // Extract actual doc bodies from CouchDB _bulk_get format + JSONArray results = response.optJSONArray("results"); + JSONArray docs = new JSONArray(); + if (results != null) { + for (int i = 0; i < results.length(); i++) { + JSONObject result = results.getJSONObject(i); + JSONArray resultDocs = result.optJSONArray("docs"); + if (resultDocs != null && resultDocs.length() > 0) { + JSONObject firstDoc = resultDocs.getJSONObject(0); + if (firstDoc.has("ok")) { + docs.put(firstDoc.getJSONObject("ok")); + } + } + } + } + return docs; + } + + /** + * 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("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 (Exception 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", "application/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", "application/json"); + conn.setRequestProperty("Accept", "application/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 { + BufferedReader reader; + if (code >= 200 && code < 400) { + reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), + StandardCharsets.UTF_8)); + } else { + reader = new BufferedReader(new InputStreamReader(conn.getErrorStream(), + StandardCharsets.UTF_8)); + } + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + reader.close(); + return sb.toString(); + } +} 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..5eb5c929 --- /dev/null +++ b/src/main/java/org/medicmobile/webapp/mobile/p2p/QrCodeHelper.java @@ -0,0 +1,346 @@ +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.HashMap; +import java.util.Map; + +/** + * Generates and validates QR codes for P2P WiFi hotspot credential exchange. + * + * QR Payload (CONTRACT.md Section 9): + * { + * "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 + * } + * + * Guards: + * G18 — Timestamp must be within 10 minutes of current time + * G19 — type field must be "cht-p2p" + * G20 — TLS fingerprint must match server cert on connection + * G21 — 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 = 10 * 60 * 1000; // G18: 10 minutes + + private QrCodeHelper() { + // Static utility class + } + + /** + * Generate a QR code bitmap from hotspot credentials. + * + * @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 QR code bitmap, or null if generation fails + */ + public static Bitmap generateQrCode(String ssid, String password, + String ipAddress, int port, + String tlsFingerprint) { + try { + String payload = buildPayload(ssid, password, ipAddress, port, tlsFingerprint); + return encodeQrBitmap(payload, QR_SIZE); + } catch (Exception e) { + Log.e(TAG, "Failed to generate QR code", e); + return null; + } + } + + /** + * Generate a QR code bitmap with custom dimensions. + * + * @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 + * @param size bitmap width and height in pixels + * @return QR code bitmap, or null if generation fails + */ + public static Bitmap generateQrCode(String ssid, String password, + String ipAddress, int port, + String tlsFingerprint, int size) { + try { + String payload = buildPayload(ssid, password, ipAddress, port, tlsFingerprint); + return encodeQrBitmap(payload, size); + } catch (Exception 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 (Exception e) { + Log.e(TAG, "Failed to generate QR data URL", e); + return null; + } + } + + /** + * 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 CONTRACT.md Section 9 format + * @throws JSONException if JSON construction fails + */ + public static String buildPayload(String ssid, String password, + String ipAddress, int port, + String tlsFingerprint) throws JSONException { + if (ssid == null || ssid.isEmpty()) { + throw new IllegalArgumentException("ssid must not be null or empty"); + } + if (password == null || password.isEmpty()) { + throw new IllegalArgumentException("password must not be null or empty"); + } + if (ipAddress == null || ipAddress.isEmpty()) { + throw new IllegalArgumentException("ipAddress must not be null or empty"); + } + if (port <= 0 || port > 65535) { + throw new IllegalArgumentException("port must be between 1 and 65535, got: " + port); + } + + JSONObject payload = new JSONObject(); + payload.put("type", PAYLOAD_TYPE); + payload.put("v", PAYLOAD_VERSION); + payload.put("ssid", ssid); + payload.put("pwd", password); + payload.put("ip", ipAddress); + payload.put("port", port); + payload.put("tls", tlsFingerprint != null ? tlsFingerprint : ""); + payload.put("ts", System.currentTimeMillis()); + return payload.toString(); + } + + /** + * Validate a scanned QR payload. + * + * Checks: + * G19: type must be "cht-p2p" + * G18: 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()); + } + + // G19: type must be "cht-p2p" + String type = payload.optString("type", ""); + if (!PAYLOAD_TYPE.equals(type)) { + return ValidationResult.reject("G19: invalid type '" + type + + "', expected '" + PAYLOAD_TYPE + "'"); + } + + // Check version + int version = payload.optInt("v", -1); + if (version != PAYLOAD_VERSION) { + return ValidationResult.reject("unsupported version: " + version + + ", expected " + PAYLOAD_VERSION); + } + + // G18: timestamp within 10 minutes + long timestamp = payload.optLong("ts", 0); + if (timestamp == 0) { + return ValidationResult.reject("G18: missing timestamp"); + } + long now = System.currentTimeMillis(); + long drift = Math.abs(now - timestamp); + if (drift > MAX_TIMESTAMP_DRIFT_MS) { + return ValidationResult.reject("G18: QR code expired. Timestamp drift: " + + (drift / 1000) + "s (max " + (MAX_TIMESTAMP_DRIFT_MS / 1000) + "s)"); + } + + // Required fields + String ssid = payload.optString("ssid", ""); + if (ssid.isEmpty()) { + return ValidationResult.reject("missing required field: ssid"); + } + + String password = payload.optString("pwd", ""); + if (password.isEmpty()) { + return ValidationResult.reject("missing required field: pwd"); + } + + String ip = payload.optString("ip", ""); + if (ip.isEmpty()) { + return ValidationResult.reject("missing required field: ip"); + } + + int port = payload.optInt("port", 0); + if (port <= 0 || port > 65535) { + return ValidationResult.reject("invalid port: " + port); + } + + return ValidationResult.accept(DocScope.IN_SCOPE); + } + + /** + * 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("ssid"), + json.getString("pwd"), + json.getString("ip"), + json.getInt("port"), + json.optString("tls", ""), + json.getLong("ts") + ); + } catch (JSONException e) { + Log.e(TAG, "Failed to parse QR payload", e); + return null; + } + } + + // --- Private helpers --- + + /** + * 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 HashMap<>(); + 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); + + int width = bitMatrix.getWidth(); + int height = bitMatrix.getHeight(); + int[] pixels = new int[width * height]; + + for (int y = 0; y < height; y++) { + int offset = y * width; + for (int x = 0; x < width; x++) { + pixels[offset + x] = bitMatrix.get(x, y) ? Color.BLACK : Color.WHITE; + } + } + + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565); + bitmap.setPixels(pixels, 0, width, 0, 0, width, height); + return bitmap; + } + + /** + * Parsed QR payload data class. + * Holds the extracted fields from a validated QR code. + */ + 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..ff6992fb --- /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) + * + * Guards validated: + * G18 — Timestamp within 10 minutes + * G19 — 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) { + super.onSaveInstanceState(outState); + outState.putBoolean("scannerLaunched", scannerLaunched); + } + + /** + * Launch the ZXing barcode scanner configured for QR codes only. + */ + 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(); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + // Let IntentIntegrator parse the result + 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. + * Checks G18 (timestamp) and G19 (type) guards. + */ + 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/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 @@ + + + + + + + + + + + +