Compare commits

...

8 Commits

4 changed files with 213 additions and 23 deletions
+28 -19
View File
@@ -303,14 +303,15 @@ function _scaleOnMessage(msg) {
const rawValue = parseFloat(msg.value); const rawValue = parseFloat(msg.value);
if (rawValue < 0) return; if (rawValue < 0) return;
// Ignore sub-gram jitter for stability decisions: only integer-gram changes matter. // Ignore sub-2g jitter for stability decisions: changes below 2g are considered noise.
const SCALE_NOISE_G = 2;
let effectiveStable = !!msg.stable; let effectiveStable = !!msg.stable;
const grams = _scaleToGrams(rawValue, msg.unit); const grams = _scaleToGrams(rawValue, msg.unit);
if (grams !== null) { if (grams !== null) {
if (effectiveStable) { if (effectiveStable) {
_scaleLastStableGrams = grams; _scaleLastStableGrams = grams;
} else if (_scaleLastStableGrams !== null) { } else if (_scaleLastStableGrams !== null) {
if (Math.round(grams) === Math.round(_scaleLastStableGrams)) { if (Math.abs(grams - _scaleLastStableGrams) < SCALE_NOISE_G) {
effectiveStable = true; effectiveStable = true;
} }
} }
@@ -402,7 +403,7 @@ function _scaleUpdateLiveBox(msg) {
const raw = parseFloat(msg.value); const raw = parseFloat(msg.value);
const rawUnit = (msg.unit || 'kg').toLowerCase(); const rawUnit = (msg.unit || 'kg').toLowerCase();
// Convert to grams for the < 10 g threshold check // Convert to grams for the < 2 g threshold check
let gForCheck = isFinite(raw) ? raw : 0; let gForCheck = isFinite(raw) ? raw : 0;
if (rawUnit === 'kg') gForCheck = raw * 1000; if (rawUnit === 'kg') gForCheck = raw * 1000;
if (rawUnit === 'lbs' || rawUnit === 'lb') gForCheck = raw * 453.592; if (rawUnit === 'lbs' || rawUnit === 'lb') gForCheck = raw * 453.592;
@@ -410,7 +411,7 @@ function _scaleUpdateLiveBox(msg) {
const valEl = document.getElementById('scale-live-val'); const valEl = document.getElementById('scale-live-val');
const lblEl = document.getElementById('scale-live-label'); const lblEl = document.getElementById('scale-live-label');
if (isFinite(raw) && gForCheck < 10 && gForCheck > 0) { if (isFinite(raw) && gForCheck < 2 && gForCheck > 0) {
// Weight too low — show red flashing warning // Weight too low — show red flashing warning
box.classList.add('scale-low-weight'); box.classList.add('scale-low-weight');
if (valEl) valEl.textContent = `${raw} ${msg.unit || 'kg'}`; if (valEl) valEl.textContent = `${raw} ${msg.unit || 'kg'}`;
@@ -475,14 +476,14 @@ function _scaleAutoFillUse(msg) {
else if (srcUnit === 'ml') { grams = rawVal; scaleAlreadyMl = true; } else if (srcUnit === 'ml') { grams = rawVal; scaleAlreadyMl = true; }
else grams = rawVal; else grams = rawVal;
// Reject if raw grams < 10 (piatto vuoto / tara / rumore) // Reject if raw grams < 2 (tara / rumore)
if (grams < 10) { if (grams < 2) {
_cancelScaleStabilityWait(); // stop bar only; keep sentinel & userDismissed _cancelScaleStabilityWait(); // stop bar only; keep sentinel & userDismissed
return; return;
} }
// Reject if weight hasn't changed enough from last confirmed reading (same product still on scale) // Reject if weight hasn't changed enough from last confirmed reading (same product still on scale)
if (_scaleLastConfirmedGrams !== null && Math.abs(grams - _scaleLastConfirmedGrams) < 10) { if (_scaleLastConfirmedGrams !== null && Math.abs(grams - _scaleLastConfirmedGrams) < 2) {
return; return;
} }
@@ -507,15 +508,8 @@ function _scaleAutoFillUse(msg) {
} }
} }
// Reject if converted value < 10 (density edge case) // Reject if converted value < 2 (density edge case)
if (val < 10) { if (val < 2) {
_cancelScaleStabilityWait();
return;
}
if (val !== _scaleStabilityVal) {
// New (different) weight → clear dismissal, restart stability wait
_scaleStabilityVal = val;
_scaleUserDismissed = false; _scaleUserDismissed = false;
_cancelScaleTimersOnly(); _cancelScaleTimersOnly();
_startScaleStabilityWait(() => { _startScaleStabilityWait(() => {
@@ -618,15 +612,15 @@ function _scaleAutoFillRecipeUse(msg) {
hint.style.display = ''; hint.style.display = '';
} }
if (val < 10) { if (val < 2) {
_cancelScaleStabilityWait(); // stop bar only; keep sentinel _cancelScaleStabilityWait(); // stop bar only; keep sentinel
if (livLabel) livLabel.textContent = t('scale.weight_too_low'); if (livLabel) livLabel.textContent = t('scale.weight_too_low');
return; return;
} }
// Reject if weight hasn't changed enough from last confirmed reading. // Reject if weight hasn't changed enough from last confirmed reading.
// Threshold: 5g — gives enough time to tare after opening the modal. // Threshold: 2g — noise filter.
if (_scaleLastConfirmedGrams !== null && Math.abs(grams - _scaleLastConfirmedGrams) < 5) { if (_scaleLastConfirmedGrams !== null && Math.abs(grams - _scaleLastConfirmedGrams) < 2) {
return; return;
} }
@@ -4820,6 +4814,9 @@ function showItemDetail(inventoryId, productId) {
function closeModal() { function closeModal() {
document.getElementById('modal-overlay').style.display = 'none'; document.getElementById('modal-overlay').style.display = 'none';
clearMoveModalTimer();
// Restore native kiosk settings button visibility
try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(true); } catch (_) {}
_cancelScaleAutoConfirm(false); _cancelScaleAutoConfirm(false);
_scaleRecipeAutoFillPaused = false; _scaleRecipeAutoFillPaused = false;
_scaleUserDismissed = false; _scaleUserDismissed = false;
@@ -8048,14 +8045,22 @@ function closeLowStockPrompt() {
let _moveModalTimer = null; let _moveModalTimer = null;
let _moveModalRAF = null; let _moveModalRAF = null;
let _moveModalTouchHandler = null;
function clearMoveModalTimer() { function clearMoveModalTimer() {
if (_moveModalTimer) { clearTimeout(_moveModalTimer); _moveModalTimer = null; } if (_moveModalTimer) { clearTimeout(_moveModalTimer); _moveModalTimer = null; }
if (_moveModalRAF) { cancelAnimationFrame(_moveModalRAF); _moveModalRAF = null; } if (_moveModalRAF) { cancelAnimationFrame(_moveModalRAF); _moveModalRAF = null; }
if (_moveModalTouchHandler) {
document.getElementById('modal-content')?.removeEventListener('pointerdown', _moveModalTouchHandler, true);
_moveModalTouchHandler = null;
}
} }
function startMoveModalCountdown(btnId, onExpire) { function startMoveModalCountdown(btnId, onExpire) {
clearMoveModalTimer(); clearMoveModalTimer();
// Any touch inside the modal cancels the auto-close countdown
_moveModalTouchHandler = () => clearMoveModalTimer();
document.getElementById('modal-content')?.addEventListener('pointerdown', _moveModalTouchHandler, { capture: true, once: true });
const duration = 15000; const duration = 15000;
const start = performance.now(); const start = performance.now();
const btn = document.getElementById(btnId); const btn = document.getElementById(btnId);
@@ -8102,6 +8107,8 @@ function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacu
</div> </div>
`; `;
document.getElementById('modal-overlay').style.display = 'flex'; document.getElementById('modal-overlay').style.display = 'flex';
// Hide the native kiosk settings button while the modal is open (prevents touch bleed-through)
try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(false); } catch (_) {}
startMoveModalCountdown('btn-move-stay', () => { _saveVacuumAndStay(openedId || 0); }); startMoveModalCountdown('btn-move-stay', () => { _saveVacuumAndStay(openedId || 0); });
} }
@@ -11729,6 +11736,8 @@ function showRecipeMoveModal(productId, fromLoc, remaining, openedId, wasVacuum)
</div> </div>
`; `;
document.getElementById('modal-overlay').style.display = 'flex'; document.getElementById('modal-overlay').style.display = 'flex';
// Hide the native kiosk settings button while the modal is open (prevents touch bleed-through)
try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(false); } catch (_) {}
startMoveModalCountdown('btn-move-stay', () => { closeModal(); }); startMoveModalCountdown('btn-move-stay', () => { closeModal(); });
} }
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "it.dadaloop.evershelf.kiosk" applicationId = "it.dadaloop.evershelf.kiosk"
minSdk = 24 minSdk = 24
targetSdk = 34 targetSdk = 34
versionCode = 11 versionCode = 13
versionName = "1.7.0" versionName = "1.7.2"
} }
signingConfigs { signingConfigs {
@@ -1,8 +1,11 @@
package it.dadaloop.evershelf.kiosk package it.dadaloop.evershelf.kiosk
import android.app.ActivityManager
import android.app.ApplicationExitInfo
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi
import org.json.JSONObject import org.json.JSONObject
import java.io.OutputStreamWriter import java.io.OutputStreamWriter
import java.net.HttpURLConnection import java.net.HttpURLConnection
@@ -40,7 +43,9 @@ object ErrorReporter {
// SharedPreferences for crash persistence // SharedPreferences for crash persistence
private const val PREFS_NAME = "evershelf_kiosk_errors" private const val PREFS_NAME = "evershelf_kiosk_errors"
private const val KEY_PENDING = "pending_crash_json" private const val KEY_PENDING = "pending_crash_json"
private const val KEY_WAS_RUNNING = "was_running_dirty"
private const val KEY_LAST_EXIT_TS = "last_reported_exit_ts"
private val executor = Executors.newSingleThreadExecutor() private val executor = Executors.newSingleThreadExecutor()
@@ -76,6 +81,9 @@ object ErrorReporter {
// Send any crash that was saved to prefs during a previous session // Send any crash that was saved to prefs during a previous session
sendPendingCrash() sendPendingCrash()
// Detect ANR / OOM / native crashes from the previous run
detectPreviousCrash()
// Install a global UncaughtExceptionHandler so ANY unhandled crash is reported // Install a global UncaughtExceptionHandler so ANY unhandled crash is reported
val previousHandler = Thread.getDefaultUncaughtExceptionHandler() val previousHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
@@ -96,6 +104,17 @@ object ErrorReporter {
} }
} }
/**
* Call from Activity.onDestroy() on a *clean* exit (back-pressed, settings, shutdown).
* Clears the dirty-launch sentinel so the next start does not report a false positive.
*/
fun markCleanStop() {
if (::appContext.isInitialized) {
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(KEY_WAS_RUNNING, false).apply()
}
}
/** /**
* Report a caught [Throwable] asynchronously (does not block UI thread). * Report a caught [Throwable] asynchronously (does not block UI thread).
*/ */
@@ -132,6 +151,96 @@ object ErrorReporter {
// ── Internal ───────────────────────────────────────────────────────────── // ── Internal ─────────────────────────────────────────────────────────────
/**
* Detects whether the *previous* run of the app ended with a crash, ANR or OOM kill.
*
* On Android 11+ (API 30) we use [ActivityManager.getHistoricalProcessExitReasons] which
* gives the exact reason and (for Java crashes) a stack trace.
*
* On Android 710 we use a "dirty-launch sentinel": a boolean in SharedPreferences that is
* set to `true` on every start and `false` only when the activity is destroyed cleanly via
* [markCleanStop]. If it is still `true` on the next start, the previous run was not clean.
*/
private fun detectPreviousCrash() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
detectExitReasonApi30()
} else {
// API 2429: dirty-launch sentinel
val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
if (prefs.getBoolean(KEY_WAS_RUNNING, false)) {
reportAsync(
type = "crash-sentinel",
message = "App was not cleanly shut down on previous run (ANR / OOM / native crash suspected).",
stack = "",
context = mapOf(
"device" to deviceInfo,
"note" to "Detected via dirty-launch sentinel (API ${Build.VERSION.SDK_INT})"
)
)
}
}
// Mark this launch as running — will be cleared by markCleanStop() on clean exit
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(KEY_WAS_RUNNING, true).apply()
}
@RequiresApi(Build.VERSION_CODES.R)
private fun detectExitReasonApi30() {
try {
val am = appContext.getSystemService(ActivityManager::class.java) ?: return
// Check the last 5 exits; stop at the first we already reported
val exits = am.getHistoricalProcessExitReasons(null, 0, 5)
if (exits.isEmpty()) return
val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val lastReportedTs = prefs.getLong(KEY_LAST_EXIT_TS, 0L)
val crashReasons = setOf(
ApplicationExitInfo.REASON_CRASH,
ApplicationExitInfo.REASON_CRASH_NATIVE,
ApplicationExitInfo.REASON_ANR,
ApplicationExitInfo.REASON_LOW_MEMORY
)
var newestTs = lastReportedTs
for (exit in exits) {
if (exit.timestamp <= lastReportedTs) continue // already reported
if (exit.reason !in crashReasons) continue
val reasonName = when (exit.reason) {
ApplicationExitInfo.REASON_CRASH -> "crash-java"
ApplicationExitInfo.REASON_CRASH_NATIVE -> "crash-native"
ApplicationExitInfo.REASON_ANR -> "anr"
ApplicationExitInfo.REASON_LOW_MEMORY -> "oom-kill"
else -> "exit-${exit.reason}"
}
val msg = exit.description?.takeIf { it.isNotEmpty() }
?: "${exit.processName ?: "app"} terminated (reason ${exit.reason})"
// Java crashes include a tombstone trace — read up to 4KB
var stack = ""
try {
exit.traceInputStream?.bufferedReader()?.use { stack = it.readText().take(4000) }
} catch (_: Exception) {}
val ctx = mutableMapOf<String, Any?>(
"device" to deviceInfo,
"reason" to exit.reason,
"process" to (exit.processName ?: ""),
"crash_ts" to SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(exit.timestamp)),
"note" to "Detected via ApplicationExitInfo on restart (API ${Build.VERSION.SDK_INT})"
)
reportAsync(type = reasonName, message = msg, stack = stack, context = ctx)
if (exit.timestamp > newestTs) newestTs = exit.timestamp
}
if (newestTs > lastReportedTs) {
prefs.edit().putLong(KEY_LAST_EXIT_TS, newestTs).apply()
}
} catch (_: Exception) {}
}
private fun fingerprint(type: String, message: String): String { private fun fingerprint(type: String, message: String): String {
val key = "$type:${message.take(120)}" val key = "$type:${message.take(120)}"
return key.hashCode().toString(16) return key.hashCode().toString(16)
@@ -83,6 +83,16 @@ class KioskActivity : AppCompatActivity() {
private val pollHandler = Handler(Looper.getMainLooper()) private val pollHandler = Handler(Looper.getMainLooper())
private var activeDownloadId: Long = -1 private var activeDownloadId: Long = -1
// Periodic update-check handler (fires every 30 min; internal throttle in checkForUpdates limits real API calls to every 6h)
private val updateCheckHandler = Handler(Looper.getMainLooper())
private val updateCheckRunnable = Runnable { schedulePeriodicUpdateCheck() }
private val UPDATE_CHECK_INTERVAL_MS = 30L * 60 * 1000 // 30 minutes
private fun schedulePeriodicUpdateCheck() {
checkForUpdates(forceCheck = false)
updateCheckHandler.postDelayed(updateCheckRunnable, UPDATE_CHECK_INTERVAL_MS)
}
// File chooser // File chooser
private var fileChooserCallback: ValueCallback<Array<Uri>>? = null private var fileChooserCallback: ValueCallback<Array<Uri>>? = null
@@ -104,6 +114,9 @@ class KioskActivity : AppCompatActivity() {
private const val KIOSK_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/download/kiosk-latest/evershelf-kiosk.apk" private const val KIOSK_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/download/kiosk-latest/evershelf-kiosk.apk"
private const val SPLASH_DURATION = 1500L private const val SPLASH_DURATION = 1500L
private const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/latest" private const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/latest"
// Keys for persisting a pending update across restarts
private const val KEY_PENDING_UPDATE_VERSION = "pending_update_version"
private const val KEY_PENDING_UPDATE_URL = "pending_update_url"
} }
override fun attachBaseContext(newBase: Context) { override fun attachBaseContext(newBase: Context) {
@@ -492,6 +505,16 @@ class KioskActivity : AppCompatActivity() {
if (apkUrl.isBlank()) return if (apkUrl.isBlank()) return
runOnUiThread { triggerApkDownload(apkUrl) } runOnUiThread { triggerApkDownload(apkUrl) }
} }
/**
* Called by the webapp when a modal is shown / hidden so the native settings
* button does not intercept touches that belong to the HTML modal content.
*/
@JavascriptInterface
fun setNativeSettingsVisible(visible: Boolean) {
runOnUiThread {
btnSettings.visibility = if (visible) View.VISIBLE else View.GONE
}
}
}, "_kioskBridge") }, "_kioskBridge")
val url = prefs.getString(KEY_URL, "http://evershelf.local") ?: "http://evershelf.local" val url = prefs.getString(KEY_URL, "http://evershelf.local") ?: "http://evershelf.local"
@@ -638,7 +661,17 @@ class KioskActivity : AppCompatActivity() {
notifyJs(result) notifyJs(result)
if (!kioskNeedsUpdate) return@Thread if (!kioskNeedsUpdate) {
// Clear any stale pending update if the current version is now up to date
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
return@Thread
}
// Persist the pending update so the banner reappears after a crash/restart
prefs.edit()
.putString(KEY_PENDING_UPDATE_VERSION, latestTag)
.putString(KEY_PENDING_UPDATE_URL, kioskApkUrl)
.apply()
val label = if (isSemver) "$currentKiosk$latestTag" else latestTag val label = if (isSemver) "$currentKiosk$latestTag" else latestTag
runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $label", kioskApkUrl) } runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $label", kioskApkUrl) }
@@ -648,6 +681,33 @@ class KioskActivity : AppCompatActivity() {
}.start() }.start()
} }
/**
* On resume: if a previous session detected an available update and saved it to prefs,
* restore the update banner immediately without a network round-trip.
*/
private fun restorePendingUpdateBanner() {
val savedVersion = prefs.getString(KEY_PENDING_UPDATE_VERSION, null) ?: return
val savedUrl = prefs.getString(KEY_PENDING_UPDATE_URL, null) ?: return
val currentKiosk = try { packageManager.getPackageInfo(packageName, 0).versionName ?: "" } catch (_: Exception) { "" }
// Normalise: strip non-numeric prefix for comparison
val norm = { v: String -> v.replace(Regex("^[^0-9]*"), "") }
fun semverNewer(remote: String, local: String): Boolean {
val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
for (i in 0 until maxOf(r.size, l.size)) {
val rv = r.getOrElse(i) { 0 }; val lv = l.getOrElse(i) { 0 }
if (rv != lv) return rv > lv
}
return false
}
if (currentKiosk.isNotEmpty() && semverNewer(norm(savedVersion), norm(currentKiosk))) {
showNativeUpdateBanner("🔄 Kiosk $currentKiosk$savedVersion", savedUrl)
} else {
// Update was installed or is no longer applicable — clear the saved entry
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
}
}
private fun showNativeUpdateBanner(message: String, apkDownloadUrl: String) { private fun showNativeUpdateBanner(message: String, apkDownloadUrl: String) {
pendingApkDownloadUrl = apkDownloadUrl pendingApkDownloadUrl = apkDownloadUrl
tvUpdateMessage.text = "⬆️ Aggiornamento disponibile: $message" tvUpdateMessage.text = "⬆️ Aggiornamento disponibile: $message"
@@ -952,6 +1012,16 @@ class KioskActivity : AppCompatActivity() {
// Re-apply screensaver flag in case the user changed it in Settings // Re-apply screensaver flag in case the user changed it in Settings
applyScreensaverFlag() applyScreensaverFlag()
} }
// Show banner immediately if there is a pending update detected in a previous session
restorePendingUpdateBanner()
// Start (or restart) the periodic update check
updateCheckHandler.removeCallbacks(updateCheckRunnable)
updateCheckHandler.postDelayed(updateCheckRunnable, UPDATE_CHECK_INTERVAL_MS)
}
override fun onPause() {
super.onPause()
updateCheckHandler.removeCallbacks(updateCheckRunnable)
} }
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
@@ -1040,6 +1110,8 @@ class KioskActivity : AppCompatActivity() {
} }
override fun onDestroy() { override fun onDestroy() {
ErrorReporter.markCleanStop()
updateCheckHandler.removeCallbacks(updateCheckRunnable)
tts?.stop() tts?.stop()
tts?.shutdown() tts?.shutdown()
tts = null tts = null