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:
@@ -372,6 +372,21 @@ body {
|
|||||||
color: var(--primary);
|
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 {
|
.stat-label {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
|
|||||||
+11
-2
@@ -2227,7 +2227,14 @@ function showPage(pageId, param = null) {
|
|||||||
|
|
||||||
// Page-specific init
|
// Page-specific init
|
||||||
switch(pageId) {
|
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':
|
case 'inventory':
|
||||||
if (param !== null) {
|
if (param !== null) {
|
||||||
currentLocation = param;
|
currentLocation = param;
|
||||||
@@ -2643,7 +2650,9 @@ async function loadDashboard() {
|
|||||||
['dispensa', 'frigo', 'freezer'].forEach(loc => {
|
['dispensa', 'frigo', 'freezer'].forEach(loc => {
|
||||||
const s = summary.find(x => x.location === loc);
|
const s = summary.find(x => x.location === loc);
|
||||||
const count = s ? s.product_count : 0;
|
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;
|
total += count;
|
||||||
});
|
});
|
||||||
// Add non-standard locations
|
// Add non-standard locations
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.drawable.GradientDrawable
|
import android.graphics.drawable.GradientDrawable
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -828,24 +830,91 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun installApk(file: java.io.File) {
|
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 {
|
try {
|
||||||
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
val pi = packageManager.packageInstaller
|
||||||
androidx.core.content.FileProvider.getUriForFile(
|
val params = android.content.pm.PackageInstaller.SessionParams(
|
||||||
this, "$packageName.provider", file
|
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 {
|
session.commit(pi2.intentSender)
|
||||||
Uri.fromFile(file)
|
|
||||||
}
|
}
|
||||||
val install = Intent(Intent.ACTION_VIEW).apply {
|
Toast.makeText(this, "Installazione in corso…", Toast.LENGTH_SHORT).show()
|
||||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
startActivity(install)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
runOnUiThread {
|
runOnUiThread { Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show() }
|
||||||
Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+67
-11
@@ -8,6 +8,8 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -517,21 +519,75 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun installApk(file: java.io.File) {
|
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 {
|
try {
|
||||||
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
val pi = packageManager.packageInstaller
|
||||||
androidx.core.content.FileProvider.getUriForFile(
|
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||||
this, "$packageName.provider", file
|
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) }
|
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 or Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
}
|
||||||
startActivity(install)
|
Toast.makeText(this, "Installazione in corso…", Toast.LENGTH_SHORT).show()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
runOnUiThread {
|
runOnUiThread { Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show() }
|
||||||
Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -82,17 +82,17 @@
|
|||||||
<div class="dashboard-stats" id="dashboard-stats">
|
<div class="dashboard-stats" id="dashboard-stats">
|
||||||
<div class="stat-card" onclick="showPage('inventory', 'dispensa')">
|
<div class="stat-card" onclick="showPage('inventory', 'dispensa')">
|
||||||
<span class="stat-icon">🗄️</span>
|
<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>
|
<span class="stat-label" data-i18n="locations.dispensa">Dispensa</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card" onclick="showPage('inventory', 'frigo')">
|
<div class="stat-card" onclick="showPage('inventory', 'frigo')">
|
||||||
<span class="stat-icon">🧊</span>
|
<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>
|
<span class="stat-label" data-i18n="locations.frigo">Frigo</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card" onclick="showPage('inventory', 'freezer')">
|
<div class="stat-card" onclick="showPage('inventory', 'freezer')">
|
||||||
<span class="stat-icon">❄️</span>
|
<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>
|
<span class="stat-label" data-i18n="locations.freezer">Freezer</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card" onclick="showPage('shopping')">
|
<div class="stat-card" onclick="showPage('shopping')">
|
||||||
|
|||||||
Reference in New Issue
Block a user