fix: APK install conflict (PackageInstaller) + dashboard stat skeleton

APK install conflict:
- Replace ACTION_VIEW-based install with PackageInstaller.Session API (API 21+)
- PackageInstaller gives us the actual install status via BroadcastReceiver:
  STATUS_PENDING_USER_ACTION → launch system confirmation dialog automatically
  STATUS_SUCCESS → success toast
  STATUS_FAILURE_CONFLICT/INCOMPATIBLE → show AlertDialog offering to
    uninstall the old version (ACTION_DELETE) so user can re-download and install
- FileProvider no longer needed for install (still kept for other uses)
- Kiosk: derive target package from filename (gateway vs kiosk self-update)

Dashboard 0-flash:
- Replace hardcoded 0 in HTML stat-value spans with ... placeholder
- Add .stat-loading CSS class: shimmer skeleton animation (gradient sweep)
- showPage(dashboard): set ... + stat-loading before API call
- loadDashboard: remove stat-loading class and set real count after data arrives
This commit is contained in:
dadaloop82
2026-05-03 17:51:18 +00:00
parent 58e69625bd
commit 73fbb73974
5 changed files with 179 additions and 30 deletions
+15
View File
@@ -372,6 +372,21 @@ body {
color: var(--primary);
}
/* Skeleton pulse while stat is loading */
.stat-value.stat-loading {
color: transparent;
background: linear-gradient(90deg, var(--border) 25%, var(--bg-dark, #e2e8f0) 50%, var(--border) 75%);
background-size: 200% 100%;
animation: stat-shimmer 1.2s infinite;
border-radius: 6px;
min-width: 2rem;
display: inline-block;
}
@keyframes stat-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.stat-label {
font-size: 0.85rem;
color: var(--text-light);
+11 -2
View File
@@ -2227,7 +2227,14 @@ function showPage(pageId, param = null) {
// Page-specific init
switch(pageId) {
case 'dashboard': loadDashboard(); break;
case 'dashboard':
// Show skeleton on stat-cards while data loads
['dispensa', 'frigo', 'freezer'].forEach(loc => {
const el = document.getElementById(`stat-${loc}`);
if (el) { el.textContent = '…'; el.classList.add('stat-loading'); }
});
loadDashboard();
break;
case 'inventory':
if (param !== null) {
currentLocation = param;
@@ -2643,7 +2650,9 @@ async function loadDashboard() {
['dispensa', 'frigo', 'freezer'].forEach(loc => {
const s = summary.find(x => x.location === loc);
const count = s ? s.product_count : 0;
document.getElementById(`stat-${loc}`).textContent = count;
const el = document.getElementById(`stat-${loc}`);
el.textContent = count;
el.classList.remove('stat-loading');
total += count;
});
// Add non-standard locations
@@ -9,6 +9,8 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.app.PendingIntent
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.graphics.drawable.GradientDrawable
import android.net.Uri
@@ -828,24 +830,91 @@ class KioskActivity : AppCompatActivity() {
}
private fun installApk(file: java.io.File) {
if (!file.exists() || file.length() == 0L) {
runOnUiThread { Toast.makeText(this, "File APK non trovato", Toast.LENGTH_LONG).show() }
return
}
// Derive the package name we are installing from the filename
val targetPkg = when {
file.name.contains("gateway") || file.name.contains("scale") -> GATEWAY_PACKAGE
else -> packageName // kiosk self-update
}
installWithPackageInstaller(file, targetPkg)
}
/** Use PackageInstaller (API 21+) for reliable install-over-existing support. */
private fun installWithPackageInstaller(file: java.io.File, targetPkg: String) {
try {
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
androidx.core.content.FileProvider.getUriForFile(
this, "$packageName.provider", file
val pi = packageManager.packageInstaller
val params = android.content.pm.PackageInstaller.SessionParams(
android.content.pm.PackageInstaller.SessionParams.MODE_FULL_INSTALL
)
params.setAppPackageName(targetPkg)
val sessionId = pi.createSession(params)
pi.openSession(sessionId).use { session ->
file.inputStream().use { input ->
session.openWrite("package", 0, file.length()).use { out ->
input.copyTo(out)
session.fsync(out)
}
}
// Register a BroadcastReceiver for the install result
val action = "it.dadaloop.evershelf.kiosk.INSTALL_RESULT_$sessionId"
val resultReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
unregisterReceiver(this)
val status = intent?.getIntExtra(
android.content.pm.PackageInstaller.EXTRA_STATUS,
android.content.pm.PackageInstaller.STATUS_FAILURE
) ?: android.content.pm.PackageInstaller.STATUS_FAILURE
when (status) {
android.content.pm.PackageInstaller.STATUS_PENDING_USER_ACTION -> {
// Android needs user confirmation — launch the system dialog
@Suppress("DEPRECATION")
val confirmIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
intent?.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
else intent?.getParcelableExtra(Intent.EXTRA_INTENT)
if (confirmIntent != null) startActivity(confirmIntent)
}
android.content.pm.PackageInstaller.STATUS_SUCCESS ->
runOnUiThread { Toast.makeText(this@KioskActivity, "✅ Aggiornamento installato", Toast.LENGTH_SHORT).show() }
android.content.pm.PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
android.content.pm.PackageInstaller.STATUS_FAILURE_CONFLICT -> {
// Signature mismatch: offer to uninstall the old version first
runOnUiThread {
androidx.appcompat.app.AlertDialog.Builder(this@KioskActivity)
.setTitle("⚠️ Conflitto firma APK")
.setMessage("L'app installata usa una firma diversa.\n\nDevi disinstallare la versione precedente e poi ripremere Scarica.")
.setPositiveButton("Disinstalla") { _, _ ->
startActivity(Intent(Intent.ACTION_DELETE,
android.net.Uri.parse("package:$targetPkg")))
}
.setNegativeButton("Annulla", null)
.show()
}
}
else -> {
val msg = intent?.getStringExtra(
android.content.pm.PackageInstaller.EXTRA_STATUS_MESSAGE
) ?: "status=$status"
runOnUiThread { Toast.makeText(this@KioskActivity, "Installazione: $msg", Toast.LENGTH_LONG).show() }
}
}
}
}
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
RECEIVER_NOT_EXPORTED else 0
registerReceiver(resultReceiver, IntentFilter(action), flags)
val pi2 = PendingIntent.getBroadcast(
this, sessionId,
Intent(action).setPackage(packageName),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
} else {
Uri.fromFile(file)
session.commit(pi2.intentSender)
}
val install = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(install)
Toast.makeText(this, "Installazione in corso…", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
runOnUiThread {
Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show()
}
runOnUiThread { Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show() }
}
}
@@ -8,6 +8,8 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.app.PendingIntent
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
@@ -517,21 +519,75 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
}
private fun installApk(file: java.io.File) {
if (!file.exists() || file.length() == 0L) {
runOnUiThread { Toast.makeText(this, "File APK non trovato", Toast.LENGTH_LONG).show() }
return
}
try {
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
androidx.core.content.FileProvider.getUriForFile(
this, "$packageName.provider", file
val pi = packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
params.setAppPackageName(packageName)
val sessionId = pi.createSession(params)
pi.openSession(sessionId).use { session ->
file.inputStream().use { input ->
session.openWrite("package", 0, file.length()).use { out ->
input.copyTo(out)
session.fsync(out)
}
}
val action = "it.dadaloop.evershelf.scalegate.INSTALL_RESULT_$sessionId"
val resultReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
unregisterReceiver(this)
val status = intent?.getIntExtra(
PackageInstaller.EXTRA_STATUS,
PackageInstaller.STATUS_FAILURE
) ?: PackageInstaller.STATUS_FAILURE
when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
@Suppress("DEPRECATION")
val confirmIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
intent?.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
else intent?.getParcelableExtra(Intent.EXTRA_INTENT)
if (confirmIntent != null) startActivity(confirmIntent)
}
PackageInstaller.STATUS_SUCCESS ->
runOnUiThread { Toast.makeText(this@MainActivity, "✅ Aggiornamento installato", Toast.LENGTH_SHORT).show() }
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
PackageInstaller.STATUS_FAILURE_CONFLICT -> {
runOnUiThread {
AlertDialog.Builder(this@MainActivity)
.setTitle("⚠️ Conflitto firma APK")
.setMessage("L'app installata usa una firma diversa.\n\nDevi disinstallare la versione precedente e poi ripremere Scarica.")
.setPositiveButton("Disinstalla") { _, _ ->
startActivity(Intent(Intent.ACTION_DELETE,
android.net.Uri.parse("package:$packageName")))
}
.setNegativeButton("Annulla", null)
.show()
}
}
else -> {
val msg = intent?.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
?: "status=$status"
runOnUiThread { Toast.makeText(this@MainActivity, "Installazione: $msg", Toast.LENGTH_LONG).show() }
}
}
}
}
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
RECEIVER_NOT_EXPORTED else 0
registerReceiver(resultReceiver, IntentFilter(action), flags)
val pi2 = PendingIntent.getBroadcast(
this, sessionId,
Intent(action).setPackage(packageName),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
} else { Uri.fromFile(file) }
val install = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
session.commit(pi2.intentSender)
}
startActivity(install)
Toast.makeText(this, "Installazione in corso…", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
runOnUiThread {
Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show()
}
runOnUiThread { Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show() }
}
}
+3 -3
View File
@@ -82,17 +82,17 @@
<div class="dashboard-stats" id="dashboard-stats">
<div class="stat-card" onclick="showPage('inventory', 'dispensa')">
<span class="stat-icon">🗄️</span>
<span class="stat-value" id="stat-dispensa">0</span>
<span class="stat-value stat-loading" id="stat-dispensa"></span>
<span class="stat-label" data-i18n="locations.dispensa">Dispensa</span>
</div>
<div class="stat-card" onclick="showPage('inventory', 'frigo')">
<span class="stat-icon">🧊</span>
<span class="stat-value" id="stat-frigo">0</span>
<span class="stat-value stat-loading" id="stat-frigo"></span>
<span class="stat-label" data-i18n="locations.frigo">Frigo</span>
</div>
<div class="stat-card" onclick="showPage('inventory', 'freezer')">
<span class="stat-icon">❄️</span>
<span class="stat-value" id="stat-freezer">0</span>
<span class="stat-value stat-loading" id="stat-freezer"></span>
<span class="stat-label" data-i18n="locations.freezer">Freezer</span>
</div>
<div class="stat-card" onclick="showPage('shopping')">