Compare commits

..

No commits in common. "main" and "v7.14.15" have entirely different histories.

8 changed files with 59 additions and 170 deletions

View file

@ -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 714016 versionCode 714015
versionName "7.14.16" versionName "7.14.15"
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.

View file

@ -15,7 +15,6 @@ 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;
@ -38,11 +37,6 @@ 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);
@ -70,59 +64,6 @@ 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,
@ -207,7 +148,6 @@ 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
@ -215,17 +155,6 @@ 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);
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "pedscribe-mobile", "name": "pedscribe-mobile",
"version": "7.14.16", "version": "7.14.15",
"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": {

View file

@ -1,6 +1,6 @@
{ {
"name": "pediatric-ai-scribe", "name": "pediatric-ai-scribe",
"version": "7.14.16", "version": "7.14.15",
"description": "AI-powered pediatric clinical documentation platform", "description": "AI-powered pediatric clinical documentation platform",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {

View file

@ -134,7 +134,7 @@
</div> </div>
<div id="apk-download-link" style="text-align:center;margin:14px 0 0;font-size:13px;"> <div id="apk-download-link" style="text-align:center;margin:14px 0 0;font-size:13px;">
<a href="https://git.danvics.com/danvics/pediatric-ai-scribe-v3/releases/latest" target="_blank" rel="noopener" style="color:#2563eb;text-decoration:none;font-weight:500;"> <a href="https://github.com/ifedan-ed/pediatric-ai-scribe-v3/releases/latest" target="_blank" rel="noopener" style="color:#2563eb;text-decoration:none;font-weight:500;">
<i class="fas fa-mobile-screen"></i> Download Android app (APK) <i class="fas fa-mobile-screen"></i> Download Android app (APK)
</a> </a>
</div> </div>

View file

@ -600,19 +600,9 @@ 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. // Keep screen awake during recording (Capacitor KeepAwake or InsomniaCap)
//
// 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();

View file

@ -47,86 +47,50 @@ 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.
// Two plugins cooperate here, because the one that does the biometric var BIO_SERVER = 'pedscribe-bio'; // namespace for the keychain/keystore item
// 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.BiometricAuthNative; var p = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.NativeBiometric;
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.checkBiometry() return p.isAvailable()
.then(function (r) { .then(function (r) { return { ok: !!(r && r.isAvailable), type: r && r.biometryType }; })
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
// prompt path entirely so we don't rattle the user. // verifyIdentity 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) {
if (!bioPlugin()) return Promise.reject(new Error('Biometric plugin unavailable')); var p = bioPlugin();
if (!window.SecureStorage) return Promise.reject(new Error('Secure storage unavailable')); if (!p) return Promise.reject(new Error('Biometric plugin unavailable'));
return Promise.resolve( return p.setCredentials({ username: email, password: password, server: BIO_SERVER })
window.SecureStorage.set(BIO_CREDS_KEY, JSON.stringify({ username: email, password: password })) .then(function () { try { localStorage.setItem(BIO_ENABLED_KEY, '1'); } catch (e) {} });
).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'));
if (!window.SecureStorage) return Promise.reject(new Error('Secure storage unavailable')); return p.verifyIdentity({
// 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',
androidTitle: 'PedScribe', title: 'PedScribe',
androidSubtitle: 'Use biometric to sign in', subtitle: 'Use biometric to sign in',
cancelTitle: 'Use password', description: 'Confirm your identity to continue.'
allowDeviceCredential: false
}).then(function () { }).then(function () {
return window.SecureStorage.get(BIO_CREDS_KEY); return p.getCredentials({ server: BIO_SERVER });
}).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 (!window.SecureStorage) return Promise.resolve(); if (!p) return Promise.resolve();
return Promise.resolve(window.SecureStorage.remove(BIO_CREDS_KEY)).catch(function () { /* fine if missing */ }); return p.deleteCredentials({ server: BIO_SERVER }).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 };
@ -149,8 +113,9 @@ 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.typeName) { if (label && s.type) {
label.textContent = 'Sign in with ' + 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 = 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 = ''; }
@ -716,7 +681,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 = s.typeName || 'biometric'; var typeName = ({ 'FACE_ID': 'Face ID', 'TOUCH_ID': 'Touch ID', 'FACE_AUTHENTICATION': 'face recognition', 'FINGERPRINT': 'fingerprint' })[s.type] || '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)

View file

@ -5,6 +5,7 @@
# Usage: # Usage:
# scripts/release.sh 6.1.1 # bump to 6.1.1 # scripts/release.sh 6.1.1 # bump to 6.1.1
# scripts/release.sh 6.1.1 --push # also git push + tag push # scripts/release.sh 6.1.1 --push # also git push + tag push
# scripts/release.sh 6.2.0 --push --gh # also create GitHub release
# #
# What it does: # What it does:
# 1. Updates version in root package.json # 1. Updates version in root package.json
@ -12,32 +13,31 @@
# 3. Updates versionName + bumps versionCode in Android build.gradle # 3. Updates versionName + bumps versionCode in Android build.gradle
# 4. Commits the version bump # 4. Commits the version bump
# 5. (optional) git push + push the new tag # 5. (optional) git push + push the new tag
# 6. (optional) create a GitHub release via gh CLI
# #
# It does NOT: # It does NOT:
# - Build the Docker image (run `docker compose build`/`up -d` yourself # - Build the Docker image (run `docker compose build`/`up -d` yourself
# or wire it to a deploy script / CI hook) # or wire it to a deploy script / CI hook)
# - Build the Android APK itself. Forgejo CI does that # - Build the Android APK (run `cd mobile/android && ./gradlew ...`
# (.forgejo/workflows/android-apk.yml): any branch push builds a signed # yourself). This script just marks the version so the build tags
# APK as a 30-day workflow artifact, and a v* tag push additionally # correctly.
# attaches it to a Forgejo release, which is what Obtainium tracks for
# updates. So --push is all you need to ship a build.
# - Publish the release itself. CI does that on the tag push; there is no
# manual publish step to run.
# ============================================================ # ============================================================
set -euo pipefail set -euo pipefail
VERSION="${1:-}" VERSION="${1:-}"
PUSH=false PUSH=false
DO_RELEASE=false
for arg in "${@:2}"; do for arg in "${@:2}"; do
case "$arg" in case "$arg" in
--push) PUSH=true ;; --push) PUSH=true ;;
--gh) DO_RELEASE=true ;;
esac esac
done done
if [[ -z "$VERSION" ]]; then if [[ -z "$VERSION" ]]; then
echo "usage: $0 <version> [--push]" echo "usage: $0 <version> [--push] [--gh]"
echo " example: $0 6.1.1 --push" echo " example: $0 6.1.1 --push --gh"
exit 1 exit 1
fi fi
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
@ -90,25 +90,30 @@ git tag -a "v${VERSION}" -m "Release v${VERSION}"
echo " tagged v${VERSION}" echo " tagged v${VERSION}"
if $PUSH; then if $PUSH; then
# This repo's remote is "forgejo", not "origin". Prefer forgejo, fall back git push origin HEAD
# to origin, otherwise use the only remote there is — so this keeps working git push origin "v${VERSION}"
# if the remote is ever renamed. Pushing the tag is what makes CI attach the echo " pushed to origin"
# signed APK to a Forgejo release for Obtainium; the branch push alone only fi
# builds it as a 30-day workflow artifact.
REMOTE="" if $DO_RELEASE; then
for candidate in forgejo origin; do if ! command -v gh >/dev/null; then
if git remote get-url "$candidate" >/dev/null 2>&1; then REMOTE="$candidate"; break; fi echo "WARN: gh CLI not installed, skipping GitHub release creation" >&2
done else
if [[ -z "$REMOTE" ]]; then APK="mobile/android/app/build/outputs/apk/release/app-release.apk"
REMOTE=$(git remote | head -1) if [[ -f "$APK" ]]; then
gh release create "v${VERSION}" "$APK" \
--title "PedScribe ${VERSION}" \
--notes "Release ${VERSION}" \
--latest
echo " created GitHub release with APK"
else
gh release create "v${VERSION}" \
--title "PedScribe ${VERSION}" \
--notes "Release ${VERSION}" \
--latest
echo " created GitHub release (no APK attached — run gradle first then gh release upload)"
fi
fi fi
if [[ -z "$REMOTE" ]]; then
echo "ERROR: no git remote configured — cannot push. Commit and tag are still local." >&2
exit 1
fi
git push "$REMOTE" HEAD
git push "$REMOTE" "v${VERSION}"
echo " pushed to $REMOTE"
fi fi
echo "==> Done. v$VERSION." echo "==> Done. v$VERSION."