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)