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
This commit is contained in:
Daniel 2026-04-11 03:42:23 +02:00
parent 7a957e856e
commit baa6362d29
8 changed files with 129 additions and 21 deletions

View file

@ -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'

View file

@ -69,7 +69,9 @@
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />

View file

@ -30,7 +30,8 @@
"allowMixedContent": true,
"backgroundColor": "#2563eb",
"webContentsDebuggingEnabled": true,
"appendUserAgent": "PedScribe-Android"
"appendUserAgent": "PedScribe-Android",
"androidScheme": "https"
},
"ios": {
"backgroundColor": "#2563eb",

View file

@ -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);
}
}
}

View file

@ -24,7 +24,8 @@
"allowMixedContent": true,
"backgroundColor": "#2563eb",
"webContentsDebuggingEnabled": true,
"appendUserAgent": "PedScribe-Android"
"appendUserAgent": "PedScribe-Android",
"androidScheme": "https"
},
"ios": {
"backgroundColor": "#2563eb",

View file

@ -30,7 +30,8 @@
"allowMixedContent": true,
"backgroundColor": "#2563eb",
"webContentsDebuggingEnabled": true,
"appendUserAgent": "PedScribe-Android"
"appendUserAgent": "PedScribe-Android",
"androidScheme": "https"
},
"ios": {
"backgroundColor": "#2563eb",

View file

@ -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 {

View file

@ -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)