Compare commits
5 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f556d50a09 | ||
|
|
3fb4c10f2b | ||
|
|
4613a27879 | ||
|
|
f31afcdbf4 | ||
|
|
a814d2a2c2 |
8 changed files with 170 additions and 59 deletions
|
|
@ -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 714015
|
||||
versionName "7.14.15"
|
||||
versionCode 714016
|
||||
versionName "7.14.16"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// 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.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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "pedscribe-mobile",
|
||||
"version": "7.14.15",
|
||||
"version": "7.14.16",
|
||||
"description": "PedScribe native mobile app — Capacitor wrapper for Pediatric AI Scribe",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "pediatric-ai-scribe",
|
||||
"version": "7.14.15",
|
||||
"version": "7.14.16",
|
||||
"description": "AI-powered pediatric clinical documentation platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@
|
|||
</div>
|
||||
|
||||
<div id="apk-download-link" style="text-align:center;margin:14px 0 0;font-size:13px;">
|
||||
<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;">
|
||||
<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;">
|
||||
<i class="fas fa-mobile-screen"></i> Download Android app (APK)
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
# 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
|
||||
|
|
@ -13,31 +12,32 @@
|
|||
# 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 (run `cd mobile/android && ./gradlew ...`
|
||||
# yourself). This script just marks the version so the build tags
|
||||
# correctly.
|
||||
# - 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.
|
||||
# ============================================================
|
||||
|
||||
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] [--gh]"
|
||||
echo " example: $0 6.1.1 --push --gh"
|
||||
echo "usage: $0 <version> [--push]"
|
||||
echo " example: $0 6.1.1 --push"
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
|
|
@ -90,30 +90,25 @@ git tag -a "v${VERSION}" -m "Release v${VERSION}"
|
|||
echo " tagged v${VERSION}"
|
||||
|
||||
if $PUSH; then
|
||||
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
|
||||
# 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)
|
||||
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."
|
||||
|
|
|
|||
Loading…
Reference in a new issue