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
|
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": {
|
||||||
|
|
|
||||||
|
|
@ -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://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)
|
<i class="fas fa-mobile-screen"></i> Download Android app (APK)
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
# 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
|
||||||
|
|
@ -13,31 +12,32 @@
|
||||||
# 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 (run `cd mobile/android && ./gradlew ...`
|
# - Build the Android APK itself. Forgejo CI does that
|
||||||
# yourself). This script just marks the version so the build tags
|
# (.forgejo/workflows/android-apk.yml): any branch push builds a signed
|
||||||
# correctly.
|
# 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
|
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] [--gh]"
|
echo "usage: $0 <version> [--push]"
|
||||||
echo " example: $0 6.1.1 --push --gh"
|
echo " example: $0 6.1.1 --push"
|
||||||
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,30 +90,25 @@ git tag -a "v${VERSION}" -m "Release v${VERSION}"
|
||||||
echo " tagged v${VERSION}"
|
echo " tagged v${VERSION}"
|
||||||
|
|
||||||
if $PUSH; then
|
if $PUSH; then
|
||||||
git push origin HEAD
|
# This repo's remote is "forgejo", not "origin". Prefer forgejo, fall back
|
||||||
git push origin "v${VERSION}"
|
# to origin, otherwise use the only remote there is — so this keeps working
|
||||||
echo " pushed to origin"
|
# if the remote is ever renamed. Pushing the tag is what makes CI attach the
|
||||||
fi
|
# signed APK to a Forgejo release for Obtainium; the branch push alone only
|
||||||
|
# builds it as a 30-day workflow artifact.
|
||||||
if $DO_RELEASE; then
|
REMOTE=""
|
||||||
if ! command -v gh >/dev/null; then
|
for candidate in forgejo origin; do
|
||||||
echo "WARN: gh CLI not installed, skipping GitHub release creation" >&2
|
if git remote get-url "$candidate" >/dev/null 2>&1; then REMOTE="$candidate"; break; fi
|
||||||
else
|
done
|
||||||
APK="mobile/android/app/build/outputs/apk/release/app-release.apk"
|
if [[ -z "$REMOTE" ]]; then
|
||||||
if [[ -f "$APK" ]]; then
|
REMOTE=$(git remote | head -1)
|
||||||
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."
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue