Compare commits
2 commits
a814d2a2c2
...
4613a27879
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4613a27879 | ||
|
|
f31afcdbf4 |
6 changed files with 142 additions and 26 deletions
|
|
@ -9,8 +9,8 @@ android {
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
// Version values below are overwritten by scripts/release.sh from
|
// Version values below are overwritten by scripts/release.sh from
|
||||||
// the root package.json. versionCode auto-increments per release.
|
// the root package.json. versionCode auto-increments per release.
|
||||||
versionCode 714015
|
versionCode 714016
|
||||||
versionName "7.14.15"
|
versionName "7.14.16"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import android.print.PrintDocumentAdapter;
|
||||||
import android.print.PrintManager;
|
import android.print.PrintManager;
|
||||||
import android.provider.MediaStore;
|
import android.provider.MediaStore;
|
||||||
import android.util.Base64;
|
import android.util.Base64;
|
||||||
|
import android.view.WindowManager;
|
||||||
import android.webkit.CookieManager;
|
import android.webkit.CookieManager;
|
||||||
import android.webkit.PermissionRequest;
|
import android.webkit.PermissionRequest;
|
||||||
import android.webkit.WebChromeClient;
|
import android.webkit.WebChromeClient;
|
||||||
|
|
@ -37,6 +38,11 @@ public class MainActivity extends BridgeActivity {
|
||||||
private PermissionRequest pendingPermissionRequest;
|
private PermissionRequest pendingPermissionRequest;
|
||||||
private WebView printWebView;
|
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
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
|
@ -64,6 +70,59 @@ public class MainActivity extends BridgeActivity {
|
||||||
setupFileBridge();
|
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 ────────────────────────────────────
|
// ── Third-Party Cookies ────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Android WebView blocks third-party cookies by default (unlike Chrome,
|
// Android WebView blocks third-party cookies by default (unlike Chrome,
|
||||||
|
|
@ -148,6 +207,7 @@ public class MainActivity extends BridgeActivity {
|
||||||
public void startForegroundService() {
|
public void startForegroundService() {
|
||||||
Intent intent = new Intent(activity, AudioRecordingService.class);
|
Intent intent = new Intent(activity, AudioRecordingService.class);
|
||||||
ContextCompat.startForegroundService(activity, intent);
|
ContextCompat.startForegroundService(activity, intent);
|
||||||
|
activity.setRecordingActive(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@android.webkit.JavascriptInterface
|
@android.webkit.JavascriptInterface
|
||||||
|
|
@ -155,6 +215,17 @@ public class MainActivity extends BridgeActivity {
|
||||||
Intent intent = new Intent(activity, AudioRecordingService.class);
|
Intent intent = new Intent(activity, AudioRecordingService.class);
|
||||||
intent.setAction(AudioRecordingService.ACTION_STOP);
|
intent.setAction(AudioRecordingService.ACTION_STOP);
|
||||||
activity.startService(intent);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "pedscribe-mobile",
|
"name": "pedscribe-mobile",
|
||||||
"version": "7.14.15",
|
"version": "7.14.16",
|
||||||
"description": "PedScribe native mobile app — Capacitor wrapper for Pediatric AI Scribe",
|
"description": "PedScribe native mobile app — Capacitor wrapper for Pediatric AI Scribe",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "pediatric-ai-scribe",
|
"name": "pediatric-ai-scribe",
|
||||||
"version": "7.14.15",
|
"version": "7.14.16",
|
||||||
"description": "AI-powered pediatric clinical documentation platform",
|
"description": "AI-powered pediatric clinical documentation platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -600,9 +600,19 @@ window.nativeStopRecordingService = function() {
|
||||||
try { if (window.NativeRecording) window.NativeRecording.stopForegroundService(); } catch(e) {}
|
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) {
|
window.nativeKeepAwake = function(on) {
|
||||||
try {
|
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 (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.KeepAwake) {
|
||||||
if (on) window.Capacitor.Plugins.KeepAwake.keepAwake();
|
if (on) window.Capacitor.Plugins.KeepAwake.keepAwake();
|
||||||
else window.Capacitor.Plugins.KeepAwake.allowSleep();
|
else window.Capacitor.Plugins.KeepAwake.allowSleep();
|
||||||
|
|
|
||||||
|
|
@ -47,50 +47,86 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
// 2FA still applies on top: biometric replaces the password step but
|
// 2FA still applies on top: biometric replaces the password step but
|
||||||
// a 2FA-enabled account still gets the TOTP prompt afterwards. That
|
// a 2FA-enabled account still gets the TOTP prompt afterwards. That
|
||||||
// is the intended defense-in-depth.
|
// 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
|
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() {
|
function bioPlugin() {
|
||||||
try {
|
try {
|
||||||
if (!isNativeApp()) return null;
|
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;
|
return p || null;
|
||||||
} catch (e) { return null; }
|
} catch (e) { return null; }
|
||||||
}
|
}
|
||||||
function bioAvailable() {
|
function bioAvailable() {
|
||||||
var p = bioPlugin();
|
var p = bioPlugin();
|
||||||
if (!p) return Promise.resolve({ ok: false });
|
if (!p) return Promise.resolve({ ok: false });
|
||||||
return p.isAvailable()
|
return p.checkBiometry()
|
||||||
.then(function (r) { return { ok: !!(r && r.isAvailable), type: r && r.biometryType }; })
|
.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 }; });
|
.catch(function () { return { ok: false }; });
|
||||||
}
|
}
|
||||||
function bioStored() {
|
function bioStored() {
|
||||||
// Cheap check first — was biometric ever enrolled? If not, skip the
|
// 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; }
|
try { return localStorage.getItem(BIO_ENABLED_KEY) === '1'; } catch (e) { return false; }
|
||||||
}
|
}
|
||||||
function bioEnroll(email, password) {
|
function bioEnroll(email, password) {
|
||||||
var p = bioPlugin();
|
if (!bioPlugin()) return Promise.reject(new Error('Biometric plugin unavailable'));
|
||||||
if (!p) return Promise.reject(new Error('Biometric plugin unavailable'));
|
if (!window.SecureStorage) return Promise.reject(new Error('Secure storage unavailable'));
|
||||||
return p.setCredentials({ username: email, password: password, server: BIO_SERVER })
|
return Promise.resolve(
|
||||||
.then(function () { try { localStorage.setItem(BIO_ENABLED_KEY, '1'); } catch (e) {} });
|
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() {
|
function bioRetrieve() {
|
||||||
var p = bioPlugin();
|
var p = bioPlugin();
|
||||||
if (!p) return Promise.reject(new Error('Biometric plugin unavailable'));
|
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',
|
reason: 'Sign in to PedScribe',
|
||||||
title: 'PedScribe',
|
androidTitle: 'PedScribe',
|
||||||
subtitle: 'Use biometric to sign in',
|
androidSubtitle: 'Use biometric to sign in',
|
||||||
description: 'Confirm your identity to continue.'
|
cancelTitle: 'Use password',
|
||||||
|
allowDeviceCredential: false
|
||||||
}).then(function () {
|
}).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() {
|
function bioForget() {
|
||||||
var p = bioPlugin();
|
|
||||||
try { localStorage.removeItem(BIO_ENABLED_KEY); } catch (e) {}
|
try { localStorage.removeItem(BIO_ENABLED_KEY); } catch (e) {}
|
||||||
if (!p) return Promise.resolve();
|
if (!window.SecureStorage) return Promise.resolve();
|
||||||
return p.deleteCredentials({ server: BIO_SERVER }).catch(function () { /* fine if missing */ });
|
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.
|
// Expose a small surface so settings/logout/etc can call into it.
|
||||||
window.PedBio = { available: bioAvailable, stored: bioStored, enroll: bioEnroll, retrieve: bioRetrieve, forget: bioForget };
|
window.PedBio = { available: bioAvailable, stored: bioStored, enroll: bioEnroll, retrieve: bioRetrieve, forget: bioForget };
|
||||||
|
|
@ -113,9 +149,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (!s.ok) return;
|
if (!s.ok) return;
|
||||||
// Tweak the label to the actual biometry type when known.
|
// Tweak the label to the actual biometry type when known.
|
||||||
var label = document.getElementById('bio-login-label');
|
var label = document.getElementById('bio-login-label');
|
||||||
if (label && s.type) {
|
if (label && s.typeName) {
|
||||||
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 = 'Sign in with ' + s.typeName;
|
||||||
label.textContent = typeMap[s.type] || 'Sign in with biometric';
|
|
||||||
}
|
}
|
||||||
btn.classList.remove('hidden'); btn.style.display = '';
|
btn.classList.remove('hidden'); btn.style.display = '';
|
||||||
if (div) { div.classList.remove('hidden'); div.style.display = ''; }
|
if (div) { div.classList.remove('hidden'); div.style.display = ''; }
|
||||||
|
|
@ -681,7 +716,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (isNativeApp() && !bioStored()) {
|
if (isNativeApp() && !bioStored()) {
|
||||||
bioAvailable().then(function (s) {
|
bioAvailable().then(function (s) {
|
||||||
if (!s.ok) return;
|
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') {
|
if (typeof showConfirm === 'function') {
|
||||||
showConfirm('Enable ' + typeName + ' for faster sign-in next time?', function () {
|
showConfirm('Enable ' + typeName + ' for faster sign-in next time?', function () {
|
||||||
bioEnroll(email, password)
|
bioEnroll(email, password)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue