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:
parent
7a957e856e
commit
baa6362d29
8 changed files with 129 additions and 21 deletions
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@
|
|||
"allowMixedContent": true,
|
||||
"backgroundColor": "#2563eb",
|
||||
"webContentsDebuggingEnabled": true,
|
||||
"appendUserAgent": "PedScribe-Android"
|
||||
"appendUserAgent": "PedScribe-Android",
|
||||
"androidScheme": "https"
|
||||
},
|
||||
"ios": {
|
||||
"backgroundColor": "#2563eb",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@
|
|||
"allowMixedContent": true,
|
||||
"backgroundColor": "#2563eb",
|
||||
"webContentsDebuggingEnabled": true,
|
||||
"appendUserAgent": "PedScribe-Android"
|
||||
"appendUserAgent": "PedScribe-Android",
|
||||
"androidScheme": "https"
|
||||
},
|
||||
"ios": {
|
||||
"backgroundColor": "#2563eb",
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@
|
|||
"allowMixedContent": true,
|
||||
"backgroundColor": "#2563eb",
|
||||
"webContentsDebuggingEnabled": true,
|
||||
"appendUserAgent": "PedScribe-Android"
|
||||
"appendUserAgent": "PedScribe-Android",
|
||||
"androidScheme": "https"
|
||||
},
|
||||
"ios": {
|
||||
"backgroundColor": "#2563eb",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue