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 2f3a725..3539daa 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 @@ -15,6 +15,7 @@ import android.print.PrintDocumentAdapter; import android.print.PrintManager; import android.provider.MediaStore; import android.util.Base64; +import android.view.WindowManager; import android.webkit.CookieManager; import android.webkit.PermissionRequest; import android.webkit.WebChromeClient; @@ -37,6 +38,11 @@ public class MainActivity extends BridgeActivity { private PermissionRequest pendingPermissionRequest; private WebView printWebView; + // True between startForegroundService() and stopForegroundService(), i.e. + // while the web app has an active MediaRecorder. Drives the keep-screen-on + // flag and the timer-throttling workaround below. + private volatile boolean recordingActive = false; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -64,6 +70,59 @@ public class MainActivity extends BridgeActivity { setupFileBridge(); } + // ── Recording Lifecycle ──────────────────────────────────── + // + // Recording happens in the WebView (MediaRecorder), not in native code, + // so keeping the foreground service alive is necessary but not sufficient + // — the WebView also has to keep executing JS. Two things protect that: + // + // 1. FLAG_KEEP_SCREEN_ON while recording, so the device does not + // auto-lock mid-encounter. This is the case that actually bites + // clinicians: a long pause in conversation and the screen times out. + // + // 2. resumeTimers() if the activity is paused anyway (user presses the + // power button, or a call comes in). Chromium throttles timers hard + // for hidden WebViews, which starves MediaRecorder's chunk delivery. + // Capacitor never calls webView.onPause(), so the WebView itself is + // still live — it is only the timers that need rescuing. + // + // Note resumeTimers()/pauseTimers() are process-global in WebView, not + // per-instance; calling resume here is safe because this app has no other + // WebView that wants throttling (printWebView is transient). + + void setKeepScreenOn(final boolean on) { + runOnUiThread(() -> { + if (on) { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + }); + } + + void setRecordingActive(boolean active) { + recordingActive = active; + setKeepScreenOn(active); + } + + // NB: BridgeActivity declares these public — narrowing to protected would + // not compile. + @Override + public void onPause() { + super.onPause(); + if (recordingActive && this.bridge != null && this.bridge.getWebView() != null) { + this.bridge.getWebView().resumeTimers(); + } + } + + @Override + public void onResume() { + super.onResume(); + if (this.bridge != null && this.bridge.getWebView() != null) { + this.bridge.getWebView().resumeTimers(); + } + } + // ── Third-Party Cookies ──────────────────────────────────── // // Android WebView blocks third-party cookies by default (unlike Chrome, @@ -148,6 +207,7 @@ public class MainActivity extends BridgeActivity { public void startForegroundService() { Intent intent = new Intent(activity, AudioRecordingService.class); ContextCompat.startForegroundService(activity, intent); + activity.setRecordingActive(true); } @android.webkit.JavascriptInterface @@ -155,6 +215,17 @@ public class MainActivity extends BridgeActivity { Intent intent = new Intent(activity, AudioRecordingService.class); intent.setAction(AudioRecordingService.ACTION_STOP); activity.startService(intent); + activity.setRecordingActive(false); + } + + // Standalone keep-awake, exposed so the web app can hold the screen on + // for non-recording work too. window.nativeKeepAwake() previously + // called Capacitor's KeepAwake plugin, which is not installed in this + // project — so it silently did nothing and the screen slept during + // recordings. + @android.webkit.JavascriptInterface + public void keepAwake(boolean on) { + activity.setKeepScreenOn(on); } } diff --git a/public/js/app.js b/public/js/app.js index f16f2e4..60f8f8f 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -600,9 +600,19 @@ window.nativeStopRecordingService = function() { try { if (window.NativeRecording) window.NativeRecording.stopForegroundService(); } catch(e) {} }; -// Keep screen awake during recording (Capacitor KeepAwake or InsomniaCap) +// Keep screen awake during recording. +// +// Prefer the NativeRecording bridge (addJavascriptInterface, so it is present +// on the remote origin the launcher navigates to). The Capacitor KeepAwake +// plugin is kept as a fallback but is NOT installed in this project — relying +// on it alone meant this function silently did nothing and the screen slept +// mid-recording, killing the MediaRecorder. window.nativeKeepAwake = function(on) { try { + if (window.NativeRecording && typeof window.NativeRecording.keepAwake === 'function') { + window.NativeRecording.keepAwake(!!on); + return; + } if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.KeepAwake) { if (on) window.Capacitor.Plugins.KeepAwake.keepAwake(); else window.Capacitor.Plugins.KeepAwake.allowSleep(); diff --git a/public/js/auth.js b/public/js/auth.js index 29daa98..f240f79 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -47,50 +47,86 @@ document.addEventListener('DOMContentLoaded', function() { // 2FA still applies on top: biometric replaces the password step but // a 2FA-enabled account still gets the TOTP prompt afterwards. That // is the intended defense-in-depth. - var BIO_SERVER = 'pedscribe-bio'; // namespace for the keychain/keystore item + // Two plugins cooperate here, because the one that does the biometric + // prompt does not store anything: + // - BiometricAuthNative (@aparajita/capacitor-biometric-auth) — presents + // the Face ID / fingerprint prompt. checkBiometry() + authenticate(). + // - SecureStoragePlugin (capacitor-secure-storage-plugin), via the + // window.SecureStorage wrapper — holds the credentials in the iOS + // Keychain / Android EncryptedSharedPreferences. + // + // This previously called window.Capacitor.Plugins.NativeBiometric, the API + // of capacitor-native-biometric — a package that is not a dependency of this + // project. The plugin object was always undefined, so bioAvailable() always + // resolved {ok:false} and the biometric button was never revealed. The + // feature has been dead since it was written. + var BIO_CREDS_KEY = 'ped_bio_creds'; // SecureStorage key holding {username,password} var BIO_ENABLED_KEY = 'ped_bio_enabled'; // localStorage flag — used to decide whether to even probe + + // BiometryType enum from the plugin (numeric) → human label. + var BIO_TYPE_NAMES = { + 1: 'Touch ID', + 2: 'Face ID', + 3: 'fingerprint', + 4: 'face recognition', + 5: 'iris recognition' + }; + function bioPlugin() { try { if (!isNativeApp()) return null; - var p = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.NativeBiometric; + var p = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.BiometricAuthNative; return p || null; } catch (e) { return null; } } function bioAvailable() { var p = bioPlugin(); if (!p) return Promise.resolve({ ok: false }); - return p.isAvailable() - .then(function (r) { return { ok: !!(r && r.isAvailable), type: r && r.biometryType }; }) + return p.checkBiometry() + .then(function (r) { + return { + ok: !!(r && r.isAvailable), + type: r && r.biometryType, + typeName: (r && BIO_TYPE_NAMES[r.biometryType]) || 'biometric' + }; + }) .catch(function () { return { ok: false }; }); } function bioStored() { // Cheap check first — was biometric ever enrolled? If not, skip the - // verifyIdentity prompt path entirely so we don't rattle the user. + // prompt path entirely so we don't rattle the user. try { return localStorage.getItem(BIO_ENABLED_KEY) === '1'; } catch (e) { return false; } } function bioEnroll(email, password) { - var p = bioPlugin(); - if (!p) return Promise.reject(new Error('Biometric plugin unavailable')); - return p.setCredentials({ username: email, password: password, server: BIO_SERVER }) - .then(function () { try { localStorage.setItem(BIO_ENABLED_KEY, '1'); } catch (e) {} }); + if (!bioPlugin()) return Promise.reject(new Error('Biometric plugin unavailable')); + if (!window.SecureStorage) return Promise.reject(new Error('Secure storage unavailable')); + return Promise.resolve( + window.SecureStorage.set(BIO_CREDS_KEY, JSON.stringify({ username: email, password: password })) + ).then(function () { try { localStorage.setItem(BIO_ENABLED_KEY, '1'); } catch (e) {} }); } function bioRetrieve() { var p = bioPlugin(); if (!p) return Promise.reject(new Error('Biometric plugin unavailable')); - return p.verifyIdentity({ + if (!window.SecureStorage) return Promise.reject(new Error('Secure storage unavailable')); + // authenticate() resolves on success and rejects on cancel/failure, so the + // credentials are only read after the OS has verified the user. + return p.authenticate({ reason: 'Sign in to PedScribe', - title: 'PedScribe', - subtitle: 'Use biometric to sign in', - description: 'Confirm your identity to continue.' + androidTitle: 'PedScribe', + androidSubtitle: 'Use biometric to sign in', + cancelTitle: 'Use password', + allowDeviceCredential: false }).then(function () { - return p.getCredentials({ server: BIO_SERVER }); + return window.SecureStorage.get(BIO_CREDS_KEY); + }).then(function (raw) { + if (!raw) throw new Error('No stored credentials'); + return JSON.parse(raw); }); } function bioForget() { - var p = bioPlugin(); try { localStorage.removeItem(BIO_ENABLED_KEY); } catch (e) {} - if (!p) return Promise.resolve(); - return p.deleteCredentials({ server: BIO_SERVER }).catch(function () { /* fine if missing */ }); + if (!window.SecureStorage) return Promise.resolve(); + return Promise.resolve(window.SecureStorage.remove(BIO_CREDS_KEY)).catch(function () { /* fine if missing */ }); } // Expose a small surface so settings/logout/etc can call into it. window.PedBio = { available: bioAvailable, stored: bioStored, enroll: bioEnroll, retrieve: bioRetrieve, forget: bioForget }; @@ -113,9 +149,8 @@ document.addEventListener('DOMContentLoaded', function() { if (!s.ok) return; // Tweak the label to the actual biometry type when known. var label = document.getElementById('bio-login-label'); - if (label && s.type) { - var typeMap = { 'FACE_ID': 'Sign in with Face ID', 'TOUCH_ID': 'Sign in with Touch ID', 'FACE_AUTHENTICATION': 'Sign in with face recognition', 'FINGERPRINT': 'Sign in with fingerprint' }; - label.textContent = typeMap[s.type] || 'Sign in with biometric'; + if (label && s.typeName) { + label.textContent = 'Sign in with ' + s.typeName; } btn.classList.remove('hidden'); btn.style.display = ''; if (div) { div.classList.remove('hidden'); div.style.display = ''; } @@ -681,7 +716,7 @@ document.addEventListener('DOMContentLoaded', function() { if (isNativeApp() && !bioStored()) { bioAvailable().then(function (s) { if (!s.ok) return; - var typeName = ({ 'FACE_ID': 'Face ID', 'TOUCH_ID': 'Touch ID', 'FACE_AUTHENTICATION': 'face recognition', 'FINGERPRINT': 'fingerprint' })[s.type] || 'biometric'; + var typeName = s.typeName || 'biometric'; if (typeof showConfirm === 'function') { showConfirm('Enable ' + typeName + ' for faster sign-in next time?', function () { bioEnroll(email, password)