Fix biometric login, and keep recording alive across screen lock

Biometric sign-in has never worked. auth.js drove
window.Capacitor.Plugins.NativeBiometric — the API of
capacitor-native-biometric, which is not a dependency of this project. The
installed plugin is @aparajita/capacitor-biometric-auth, registered as
BiometricAuthNative with an entirely different API and no credential
storage at all. bioPlugin() therefore always returned null, bioAvailable()
always resolved {ok:false}, and the button was never revealed.

Rewritten against what is actually installed, with no new dependency:
BiometricAuthNative (checkBiometry/authenticate) presents the prompt, and
the already-working SecureStoragePlugin — via the window.SecureStorage
wrapper — holds the credentials. Credentials are only read after
authenticate() resolves, so the OS still gates access. biometryType is a
numeric enum in this plugin, so the old FACE_ID/TOUCH_ID string maps are
replaced with a single lookup exposed as typeName.

Secure storage itself was fine and is unchanged: SecureStoragePlugin
matches the name the wrapper looks up and is registered in
capacitor.settings.gradle.

Recording across screen lock: window.nativeKeepAwake() called Capacitor's
KeepAwake plugin, which is also not installed here, so it silently did
nothing and the device slept mid-encounter — taking the WebView's
MediaRecorder with it. keepAwake() is now a method on the existing
NativeRecording JavascriptInterface, which sets FLAG_KEEP_SCREEN_ON, and
is bound to the WebView so it survives the launcher's navigation to the
remote origin. The Capacitor plugin remains a fallback.

If the screen is locked anyway (power button, incoming call), the activity
pauses and Chromium throttles timers for hidden WebViews, starving
MediaRecorder's chunk delivery. MainActivity now calls resumeTimers() on
pause while recording. The foreground service was already correct — it
holds a partial wake lock and declares FOREGROUND_SERVICE_TYPE_MICROPHONE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-07-31 01:12:47 +02:00
parent a814d2a2c2
commit f31afcdbf4
3 changed files with 138 additions and 22 deletions

View file

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

View file

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

View file

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