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
// Version values below are overwritten by scripts/release.sh from
// the root package.json. versionCode auto-increments per release.
versionCode 714016
versionName "7.14.16"
versionCode 714015
versionName "7.14.15"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// 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.provider.MediaStore;
import android.util.Base64;
import android.view.WindowManager;
import android.webkit.CookieManager;
import android.webkit.PermissionRequest;
import android.webkit.WebChromeClient;
@ -38,11 +37,6 @@ 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);
@ -70,59 +64,6 @@ 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,
@ -207,7 +148,6 @@ 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
@ -215,17 +155,6 @@ 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

@ -1,6 +1,6 @@
{
"name": "pedscribe-mobile",
"version": "7.14.16",
"version": "7.14.15",
"description": "PedScribe native mobile app — Capacitor wrapper for Pediatric AI Scribe",
"private": true,
"scripts": {

View file

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

View file

@ -134,7 +134,7 @@
</div>
<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)
</a>
</div>

View file

@ -600,19 +600,9 @@ window.nativeStopRecordingService = function() {
try { if (window.NativeRecording) window.NativeRecording.stopForegroundService(); } catch(e) {}
};
// 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.
// Keep screen awake during recording (Capacitor KeepAwake or InsomniaCap)
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,86 +47,50 @@ 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.
// 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_SERVER = 'pedscribe-bio'; // namespace for the keychain/keystore item
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.BiometricAuthNative;
var p = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.NativeBiometric;
return p || null;
} catch (e) { return null; }
}
function bioAvailable() {
var p = bioPlugin();
if (!p) return Promise.resolve({ ok: false });
return p.checkBiometry()
.then(function (r) {
return {
ok: !!(r && r.isAvailable),
type: r && r.biometryType,
typeName: (r && BIO_TYPE_NAMES[r.biometryType]) || 'biometric'
};
})
return p.isAvailable()
.then(function (r) { return { ok: !!(r && r.isAvailable), type: r && r.biometryType }; })
.catch(function () { return { ok: false }; });
}
function bioStored() {
// 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; }
}
function bioEnroll(email, password) {
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) {} });
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) {} });
}
function bioRetrieve() {
var p = bioPlugin();
if (!p) return Promise.reject(new Error('Biometric plugin unavailable'));
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({
return p.verifyIdentity({
reason: 'Sign in to PedScribe',
androidTitle: 'PedScribe',
androidSubtitle: 'Use biometric to sign in',
cancelTitle: 'Use password',
allowDeviceCredential: false
title: 'PedScribe',
subtitle: 'Use biometric to sign in',
description: 'Confirm your identity to continue.'
}).then(function () {
return window.SecureStorage.get(BIO_CREDS_KEY);
}).then(function (raw) {
if (!raw) throw new Error('No stored credentials');
return JSON.parse(raw);
return p.getCredentials({ server: BIO_SERVER });
});
}
function bioForget() {
var p = bioPlugin();
try { localStorage.removeItem(BIO_ENABLED_KEY); } catch (e) {}
if (!window.SecureStorage) return Promise.resolve();
return Promise.resolve(window.SecureStorage.remove(BIO_CREDS_KEY)).catch(function () { /* fine if missing */ });
if (!p) return Promise.resolve();
return p.deleteCredentials({ server: BIO_SERVER }).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 };
@ -149,8 +113,9 @@ 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.typeName) {
label.textContent = 'Sign in with ' + s.typeName;
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';
}
btn.classList.remove('hidden'); btn.style.display = '';
if (div) { div.classList.remove('hidden'); div.style.display = ''; }
@ -716,7 +681,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (isNativeApp() && !bioStored()) {
bioAvailable().then(function (s) {
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') {
showConfirm('Enable ' + typeName + ' for faster sign-in next time?', function () {
bioEnroll(email, password)

View file

@ -5,6 +5,7 @@
# Usage:
# 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.2.0 --push --gh # also create GitHub release
#
# What it does:
# 1. Updates version in root package.json
@ -12,32 +13,31 @@
# 3. Updates versionName + bumps versionCode in Android build.gradle
# 4. Commits the version bump
# 5. (optional) git push + push the new tag
# 6. (optional) create a GitHub release via gh CLI
#
# It does NOT:
# - Build the Docker image (run `docker compose build`/`up -d` yourself
# or wire it to a deploy script / CI hook)
# - Build the Android APK itself. Forgejo CI does that
# (.forgejo/workflows/android-apk.yml): any branch push builds a signed
# APK as a 30-day workflow artifact, and a v* tag push additionally
# 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.
# - Build the Android APK (run `cd mobile/android && ./gradlew ...`
# yourself). This script just marks the version so the build tags
# correctly.
# ============================================================
set -euo pipefail
VERSION="${1:-}"
PUSH=false
DO_RELEASE=false
for arg in "${@:2}"; do
case "$arg" in
--push) PUSH=true ;;
--gh) DO_RELEASE=true ;;
esac
done
if [[ -z "$VERSION" ]]; then
echo "usage: $0 <version> [--push]"
echo " example: $0 6.1.1 --push"
echo "usage: $0 <version> [--push] [--gh]"
echo " example: $0 6.1.1 --push --gh"
exit 1
fi
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}"
if $PUSH; then
# This repo's remote is "forgejo", not "origin". Prefer forgejo, fall back
# to origin, otherwise use the only remote there is — so this keeps working
# if the remote is ever renamed. Pushing the tag is what makes CI attach the
# signed APK to a Forgejo release for Obtainium; the branch push alone only
# builds it as a 30-day workflow artifact.
REMOTE=""
for candidate in forgejo origin; do
if git remote get-url "$candidate" >/dev/null 2>&1; then REMOTE="$candidate"; break; fi
done
if [[ -z "$REMOTE" ]]; then
REMOTE=$(git remote | head -1)
git push origin HEAD
git push origin "v${VERSION}"
echo " pushed to origin"
fi
if $DO_RELEASE; then
if ! command -v gh >/dev/null; then
echo "WARN: gh CLI not installed, skipping GitHub release creation" >&2
else
APK="mobile/android/app/build/outputs/apk/release/app-release.apk"
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
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
echo "==> Done. v$VERSION."