From baa6362d29b2ddadca677751d0132a3820c80ab4 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 11 Apr 2026 03:42:23 +0200 Subject: [PATCH] Native Android: biometric auth, foreground service bridge, mic fix Major Android native improvements: Biometric authentication: - Native AndroidX BiometricPrompt on app launch (2nd launch onwards) - Supports fingerprint, face, iris, and device PIN/password fallback - Gracefully skips if no biometric hardware or first launch - Uses SharedPreferences to track first launch Microphone permission: - Added MODIFY_AUDIO_SETTINGS permission (required for WebView audio) - Added androidScheme: "https" in Capacitor config (getUserMedia requires secure context) - WebChromeClient properly grants WebView permission after Android runtime permission is obtained - Handles pending permission request across the async flow Background recording bridge: - NativeRecording JavaScript interface exposed to WebView - startForegroundService() / stopForegroundService() callable from JS - Web app calls these on recording start/stop in liveEncounter.js - AudioRecordingService keeps CPU awake + shows notification when recording - Recording survives screen lock via foreground service + wake lock Also: - USE_BIOMETRIC permission added to manifest - androidx.biometric:biometric dependency added to build.gradle - Haptic fallback to navigator.vibrate when Capacitor plugins unavailable --- mobile/android/app/build.gradle | 1 + .../android/app/src/main/AndroidManifest.xml | 2 + .../app/src/main/assets/capacitor.config.json | 3 +- .../java/com/pedshub/scribe/MainActivity.java | 122 +++++++++++++++--- mobile/capacitor.config.json | 3 +- mobile/ios/App/App/capacitor.config.json | 3 +- public/js/app.js | 14 +- public/js/liveEncounter.js | 2 + 8 files changed, 129 insertions(+), 21 deletions(-) diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index d89fbab..9e1f484 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -40,6 +40,7 @@ dependencies { androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" implementation project(':capacitor-cordova-android-plugins') + implementation "androidx.biometric:biometric:1.2.0-alpha05" } apply from: 'capacitor.build.gradle' diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 6017cf1..994ba68 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -69,7 +69,9 @@ + + diff --git a/mobile/android/app/src/main/assets/capacitor.config.json b/mobile/android/app/src/main/assets/capacitor.config.json index 813d996..01292a3 100644 --- a/mobile/android/app/src/main/assets/capacitor.config.json +++ b/mobile/android/app/src/main/assets/capacitor.config.json @@ -30,7 +30,8 @@ "allowMixedContent": true, "backgroundColor": "#2563eb", "webContentsDebuggingEnabled": true, - "appendUserAgent": "PedScribe-Android" + "appendUserAgent": "PedScribe-Android", + "androidScheme": "https" }, "ios": { "backgroundColor": "#2563eb", diff --git a/mobile/android/app/src/main/java/com/pedshub/scribe/MainActivity.java b/mobile/android/app/src/main/java/com/pedshub/scribe/MainActivity.java index 234ee2a..cda4419 100644 --- a/mobile/android/app/src/main/java/com/pedshub/scribe/MainActivity.java +++ b/mobile/android/app/src/main/java/com/pedshub/scribe/MainActivity.java @@ -1,6 +1,8 @@ package com.pedshub.scribe; import android.Manifest; +import android.content.Intent; +import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.os.Bundle; import android.webkit.PermissionRequest; @@ -8,15 +10,20 @@ import android.webkit.WebChromeClient; import android.webkit.WebView; import androidx.annotation.NonNull; +import androidx.biometric.BiometricManager; +import androidx.biometric.BiometricPrompt; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import com.getcapacitor.BridgeActivity; +import java.util.concurrent.Executor; + public class MainActivity extends BridgeActivity { private static final int MIC_PERMISSION_CODE = 1001; private PermissionRequest pendingPermissionRequest; + private boolean biometricDone = false; @Override protected void onCreate(Bundle savedInstanceState) { @@ -29,25 +36,36 @@ public class MainActivity extends BridgeActivity { new String[]{ Manifest.permission.RECORD_AUDIO }, MIC_PERMISSION_CODE); } - // Override WebView to handle permission requests from the web page + // Setup WebView mic permission granting + setupWebViewPermissions(); + + // Show biometric prompt if available and user has used the app before + SharedPreferences prefs = getSharedPreferences("pedscribe", MODE_PRIVATE); + boolean hasLaunched = prefs.getBoolean("has_launched", false); + if (hasLaunched) { + showBiometricPrompt(); + } else { + prefs.edit().putBoolean("has_launched", true).apply(); + biometricDone = true; + } + + // Register JS interface for foreground service control + setupRecordingBridge(); + } + + // ── WebView Microphone Permission ────────────────────────── + + private void setupWebViewPermissions() { WebView webView = this.bridge.getWebView(); final MainActivity activity = this; webView.setWebChromeClient(new WebChromeClient() { @Override public void onPermissionRequest(final PermissionRequest request) { - // Check if we already have the Android permission if (ContextCompat.checkSelfPermission(activity, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { - // Grant the WebView permission - activity.runOnUiThread(new Runnable() { - @Override - public void run() { - request.grant(request.getResources()); - } - }); + activity.runOnUiThread(() -> request.grant(request.getResources())); } else { - // Save the request and ask for Android permission pendingPermissionRequest = request; ActivityCompat.requestPermissions(activity, new String[]{ Manifest.permission.RECORD_AUDIO }, MIC_PERMISSION_CODE); @@ -63,16 +81,88 @@ public class MainActivity extends BridgeActivity { if (requestCode == MIC_PERMISSION_CODE && pendingPermissionRequest != null) { if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { final PermissionRequest req = pendingPermissionRequest; - runOnUiThread(new Runnable() { - @Override - public void run() { - req.grant(req.getResources()); - } - }); + runOnUiThread(() -> req.grant(req.getResources())); } else { pendingPermissionRequest.deny(); } pendingPermissionRequest = null; } } + + // ── Biometric Authentication ────────────────────────────── + + private void showBiometricPrompt() { + BiometricManager biometricManager = BiometricManager.from(this); + int canAuth = biometricManager.canAuthenticate( + BiometricManager.Authenticators.BIOMETRIC_WEAK | + BiometricManager.Authenticators.DEVICE_CREDENTIAL); + + if (canAuth != BiometricManager.BIOMETRIC_SUCCESS) { + // No biometric or device credential available — skip + biometricDone = true; + return; + } + + Executor executor = ContextCompat.getMainExecutor(this); + + BiometricPrompt biometricPrompt = new BiometricPrompt(this, executor, + new BiometricPrompt.AuthenticationCallback() { + @Override + public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) { + super.onAuthenticationSucceeded(result); + biometricDone = true; + } + + @Override + public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) { + super.onAuthenticationError(errorCode, errString); + // Allow access even on error (user can cancel) + biometricDone = true; + } + + @Override + public void onAuthenticationFailed() { + super.onAuthenticationFailed(); + // Don't block — just let them retry or cancel + } + }); + + BiometricPrompt.PromptInfo promptInfo = new BiometricPrompt.PromptInfo.Builder() + .setTitle("PedScribe") + .setSubtitle("Verify your identity to continue") + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_WEAK | + BiometricManager.Authenticators.DEVICE_CREDENTIAL) + .build(); + + biometricPrompt.authenticate(promptInfo); + } + + // ── Background Recording Service Bridge ─────────────────── + + private void setupRecordingBridge() { + WebView webView = this.bridge.getWebView(); + webView.addJavascriptInterface(new RecordingBridge(this), "NativeRecording"); + } + + public static class RecordingBridge { + private final MainActivity activity; + + RecordingBridge(MainActivity activity) { + this.activity = activity; + } + + @android.webkit.JavascriptInterface + public void startForegroundService() { + Intent intent = new Intent(activity, AudioRecordingService.class); + ContextCompat.startForegroundService(activity, intent); + } + + @android.webkit.JavascriptInterface + public void stopForegroundService() { + Intent intent = new Intent(activity, AudioRecordingService.class); + intent.setAction(AudioRecordingService.ACTION_STOP); + activity.startService(intent); + } + } } diff --git a/mobile/capacitor.config.json b/mobile/capacitor.config.json index 4524111..24e66b8 100644 --- a/mobile/capacitor.config.json +++ b/mobile/capacitor.config.json @@ -24,7 +24,8 @@ "allowMixedContent": true, "backgroundColor": "#2563eb", "webContentsDebuggingEnabled": true, - "appendUserAgent": "PedScribe-Android" + "appendUserAgent": "PedScribe-Android", + "androidScheme": "https" }, "ios": { "backgroundColor": "#2563eb", diff --git a/mobile/ios/App/App/capacitor.config.json b/mobile/ios/App/App/capacitor.config.json index 293c1a2..e7e7c7e 100644 --- a/mobile/ios/App/App/capacitor.config.json +++ b/mobile/ios/App/App/capacitor.config.json @@ -30,7 +30,8 @@ "allowMixedContent": true, "backgroundColor": "#2563eb", "webContentsDebuggingEnabled": true, - "appendUserAgent": "PedScribe-Android" + "appendUserAgent": "PedScribe-Android", + "androidScheme": "https" }, "ios": { "backgroundColor": "#2563eb", diff --git a/public/js/app.js b/public/js/app.js index a1597a6..7c21841 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -668,16 +668,26 @@ AudioRecorder.prototype.stop = function() { }); }; -// ── Native mobile helpers (Capacitor) ── -// These only activate when running inside the Capacitor native app +// ── Native mobile helpers (Capacitor / Android bridge) ── +// These only activate when running inside the native app window.nativeHaptic = function(style) { try { if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Haptics) { window.Capacitor.Plugins.Haptics.impact({ style: style || 'medium' }); + } else if (navigator.vibrate) { + navigator.vibrate(style === 'heavy' ? 100 : 50); } } catch(e) {} }; +// Start/stop Android foreground service for background recording +window.nativeStartRecordingService = function() { + try { if (window.NativeRecording) window.NativeRecording.startForegroundService(); } catch(e) {} +}; +window.nativeStopRecordingService = function() { + try { if (window.NativeRecording) window.NativeRecording.stopForegroundService(); } catch(e) {} +}; + // Keep screen awake during recording (Capacitor KeepAwake or InsomniaCap) window.nativeKeepAwake = function(on) { try { diff --git a/public/js/liveEncounter.js b/public/js/liveEncounter.js index d3ccbdd..2a0bfd3 100644 --- a/public/js/liveEncounter.js +++ b/public/js/liveEncounter.js @@ -68,6 +68,7 @@ if (recognition) try { recognition.start(); } catch(e) {} if (typeof nativeHaptic === 'function') nativeHaptic('heavy'); if (typeof nativeKeepAwake === 'function') nativeKeepAwake(true); + if (typeof nativeStartRecordingService === 'function') nativeStartRecordingService(); showToast('Recording started', 'info'); document.dispatchEvent(new CustomEvent('recording-started', { detail: { module: 'enc' } })); }).catch(function() { showToast('Microphone denied', 'error'); }); @@ -84,6 +85,7 @@ if (recognition) try { recognition.stop(); } catch(e) {} if (typeof nativeHaptic === 'function') nativeHaptic('medium'); if (typeof nativeKeepAwake === 'function') nativeKeepAwake(false); + if (typeof nativeStopRecordingService === 'function') nativeStopRecordingService(); document.dispatchEvent(new CustomEvent('recording-stopped', { detail: { module: 'enc' } })); // If no server transcription API, use live transcript directly (no upload)