fix(kiosk): fix APK install failure — session lifecycle, error details, issue reporting

- KioskActivity: remove .use{} on PackageInstaller session to prevent premature
  session close causing STATUS_FAILURE=1; align with SetupActivity pattern
- SetupActivity: show full diagnostic info (status code + human-readable hint,
  device, Android version) in the UI card instead of just 'status=1'
- SetupActivity: use Build.PRODUCT/BOARD fallback when MANUFACTURER='unknown'
- ErrorReporter: add forceReport param to bypass in-session dedup for retries
- ErrorReporter: include Android SDK version in deviceInfo; fallback for
  'unknown' MANUFACTURER/MODEL using PRODUCT/HARDWARE/BOARD
This commit is contained in:
dadaloop82
2026-05-05 05:32:31 +00:00
parent fc47cd8c27
commit abeb87c536
3 changed files with 170 additions and 78 deletions
@@ -64,7 +64,14 @@ object ErrorReporter {
val pi = context.packageManager.getPackageInfo(context.packageName, 0) val pi = context.packageManager.getPackageInfo(context.packageName, 0)
appVersion = pi.versionName ?: "unknown" appVersion = pi.versionName ?: "unknown"
} catch (_: Exception) {} } catch (_: Exception) {}
deviceInfo = "${Build.MANUFACTURER} ${Build.MODEL} (Android ${Build.VERSION.RELEASE})" deviceInfo = buildString {
val mfr = Build.MANUFACTURER.takeIf { it.isNotBlank() && it != "unknown" }
?: Build.PRODUCT.takeIf { it.isNotBlank() && it != "unknown" }
?: Build.BOARD
val model = Build.MODEL.takeIf { it.isNotBlank() && it != "unknown" }
?: Build.HARDWARE
append("$mfr $model (Android ${Build.VERSION.RELEASE}/${Build.VERSION.SDK_INT})")
}
// 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()
@@ -110,15 +117,17 @@ object ErrorReporter {
/** /**
* Report a non-exception message (e.g. WebView page error, network failure). * Report a non-exception message (e.g. WebView page error, network failure).
* @param forceReport if true, bypasses the in-session dedup so retries are always sent.
*/ */
fun reportMessage( fun reportMessage(
type: String, type: String,
message: String, message: String,
extra: Map<String, Any?> = emptyMap() extra: Map<String, Any?> = emptyMap(),
forceReport: Boolean = false
) { ) {
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo) val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
ctx.putAll(extra) ctx.putAll(extra)
reportAsync(type = type, message = message, stack = "", context = ctx) reportAsync(type = type, message = message, stack = "", context = ctx, force = forceReport)
} }
// ── Internal ───────────────────────────────────────────────────────────── // ── Internal ─────────────────────────────────────────────────────────────
@@ -128,10 +137,14 @@ object ErrorReporter {
return key.hashCode().toString(16) return key.hashCode().toString(16)
} }
private fun reportAsync(type: String, message: String, stack: String, context: Map<String, Any?>) { private fun reportAsync(type: String, message: String, stack: String, context: Map<String, Any?>, force: Boolean = false) {
val fp = fingerprint(type, message) val fp = fingerprint(type, message)
synchronized(sentFingerprints) { if (!force) {
if (!sentFingerprints.add(fp)) return // already reported this session synchronized(sentFingerprints) {
if (!sentFingerprints.add(fp)) return // already reported this session
}
} else {
synchronized(sentFingerprints) { sentFingerprints.add(fp) }
} }
executor.execute { doPost(type, message, stack, context) } executor.execute { doPost(type, message, stack, context) }
} }
@@ -644,50 +644,110 @@ class KioskActivity : AppCompatActivity() {
try { try {
val pi = packageManager.packageInstaller val pi = packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
params.setAppPackageName(targetPkg) // Note: setAppPackageName() is intentionally omitted — it causes STATUS_FAILURE (1)
// on some OEM/Android versions even when the package name is correct.
val sessionId = pi.createSession(params) val sessionId = pi.createSession(params)
pi.openSession(sessionId).use { session -> val session = pi.openSession(sessionId)
try {
file.inputStream().use { input -> file.inputStream().use { input ->
session.openWrite("package", 0, file.length()).use { out -> session.openWrite("package", 0, file.length()).use { out ->
input.copyTo(out) input.copyTo(out)
session.fsync(out) session.fsync(out)
} }
} }
val action = "it.dadaloop.evershelf.kiosk.INSTALL_RESULT_$sessionId" } catch (e: Exception) {
val resultReceiver = object : BroadcastReceiver() { try { session.abandon() } catch (_: Exception) {}
override fun onReceive(ctx: Context?, intent: Intent?) { throw e
unregisterReceiver(this) }
val status = intent?.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) ?: PackageInstaller.STATUS_FAILURE // Do NOT close() the session after commit — it is now owned by the system.
when (status) { val action = "it.dadaloop.evershelf.kiosk.INSTALL_RESULT_$sessionId"
PackageInstaller.STATUS_PENDING_USER_ACTION -> { val resultReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
val status = intent?.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) ?: PackageInstaller.STATUS_FAILURE
when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
// Do NOT unregister here — the final result arrives as a second broadcast
@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) {
pendingInstallFile = file
pendingInstallPkg = targetPkg
setInstallUI("\u23F3", getString(R.string.install_installing), getString(R.string.install_confirm_detail), 0xFF94a3b8.toInt(), btnEnabled = false)
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
val confirmIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) startActivityForResult(confirmIntent, INSTALL_CONFIRM_REQUEST)
intent?.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) } else {
else intent?.getParcelableExtra(Intent.EXTRA_INTENT) unregisterReceiver(this)
if (confirmIntent != null) { setInstallUI("\u274C", getString(R.string.install_error_install), "No confirmation intent", 0xFFf87171.toInt(), btnEnabled = true, progress = -2)
pendingInstallFile = file
pendingInstallPkg = targetPkg
setInstallUI("\u23F3", getString(R.string.install_installing), getString(R.string.install_confirm_detail), 0xFF94a3b8.toInt(), btnEnabled = false)
@Suppress("DEPRECATION")
startActivityForResult(confirmIntent, INSTALL_CONFIRM_REQUEST)
}
} }
PackageInstaller.STATUS_SUCCESS -> { }
setInstallUI("\u2705", getString(R.string.install_success), getString(R.string.install_success_detail), 0xFF34d399.toInt(), btnEnabled = false, progress = -2) PackageInstaller.STATUS_SUCCESS -> {
Handler(Looper.getMainLooper()).postDelayed({ unregisterReceiver(this)
updateBanner.visibility = View.GONE setInstallUI("\u2705", getString(R.string.install_success), getString(R.string.install_success_detail), 0xFF34d399.toInt(), btnEnabled = false, progress = -2)
bannerProgressBar.visibility = View.GONE Handler(Looper.getMainLooper()).postDelayed({
}, 3000) updateBanner.visibility = View.GONE
bannerProgressBar.visibility = View.GONE
}, 3000)
}
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
PackageInstaller.STATUS_FAILURE_CONFLICT -> {
unregisterReceiver(this)
runOnUiThread {
pendingInstallFile = file
pendingInstallPkg = targetPkg
androidx.appcompat.app.AlertDialog.Builder(this@KioskActivity)
.setTitle("⚠️ Conflitto firma APK")
.setMessage("L'app installata usa una firma diversa.\n\nDisinstalla la versione precedente: al termine l'installazione riparte automaticamente.")
.setPositiveButton("Disinstalla") { _, _ ->
disableKioskLock()
@Suppress("DEPRECATION")
startActivityForResult(Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$targetPkg")), UNINSTALL_REQUEST)
}
.setNegativeButton("Annulla", null).show()
} }
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE, }
PackageInstaller.STATUS_FAILURE_CONFLICT -> { -1 /* STATUS_FAILURE_ABORTED */ -> {
unregisterReceiver(this)
runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
}
else -> {
unregisterReceiver(this)
val msg = intent?.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) ?: ""
val deviceLabel = buildDeviceLabel()
val diagInfo = buildString {
appendLine("Status: $status${installStatusHint(status)}")
if (msg.isNotEmpty()) appendLine("Msg: $msg")
appendLine("PKG: $targetPkg")
appendLine("APK: ${file.length() / 1024} KB")
appendLine("Android: ${Build.VERSION.SDK_INT} (${Build.VERSION.RELEASE})")
appendLine("Device: $deviceLabel")
}
setInstallUI("\u274C", getString(R.string.install_error_install),
diagInfo.trim(), 0xFFf87171.toInt(), btnEnabled = true, progress = -2)
runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
ErrorReporter.reportMessage(
"install_failure",
"PackageInstaller status=$status pkg=$targetPkg android=${Build.VERSION.SDK_INT}",
mapOf(
"pkg" to targetPkg,
"status" to status,
"msg" to msg,
"apk_kb" to (file.length() / 1024),
"android" to Build.VERSION.SDK_INT,
"device" to deviceLabel
),
forceReport = true
)
val pkgInstalled = try { packageManager.getPackageInfo(targetPkg, 0); true } catch (_: Exception) { false }
if (pkgInstalled) {
runOnUiThread { runOnUiThread {
pendingInstallFile = file pendingInstallFile = file
pendingInstallPkg = targetPkg pendingInstallPkg = targetPkg
androidx.appcompat.app.AlertDialog.Builder(this@KioskActivity) androidx.appcompat.app.AlertDialog.Builder(this@KioskActivity)
.setTitle("⚠️ Conflitto firma APK") .setTitle("⚠️ Installazione fallita (status=$status)")
.setMessage("L'app installata usa una firma diversa.\n\nDisinstalla la versione precedente: al termine l'installazione riparte automaticamente.") .setMessage(diagInfo.trim())
.setPositiveButton("Disinstalla") { _, _ -> .setPositiveButton("Disinstalla e riprova") { _, _ ->
disableKioskLock() disableKioskLock()
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
startActivityForResult(Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$targetPkg")), UNINSTALL_REQUEST) startActivityForResult(Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$targetPkg")), UNINSTALL_REQUEST)
@@ -695,48 +755,48 @@ class KioskActivity : AppCompatActivity() {
.setNegativeButton("Annulla", null).show() .setNegativeButton("Annulla", null).show()
} }
} }
else -> {
val msg = intent?.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) ?: "status=$status"
setInstallUI("\u274C", getString(R.string.install_error_install), msg, 0xFFf87171.toInt(), btnEnabled = true, progress = -2)
runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
ErrorReporter.reportMessage("install_failure", "PackageInstaller status=$status msg=$msg pkg=$targetPkg")
val pkgInstalled = try { packageManager.getPackageInfo(targetPkg, 0); true } catch (_: Exception) { false }
if (pkgInstalled) {
runOnUiThread {
pendingInstallFile = file
pendingInstallPkg = targetPkg
androidx.appcompat.app.AlertDialog.Builder(this@KioskActivity)
.setTitle("⚠️ Installazione fallita")
.setMessage("Installazione fallita (status=$status).\n\nDisinstalla la versione precedente e riprova?")
.setPositiveButton("Disinstalla e riprova") { _, _ ->
disableKioskLock()
@Suppress("DEPRECATION")
startActivityForResult(Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$targetPkg")), UNINSTALL_REQUEST)
}
.setNegativeButton("Annulla", null).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
)
session.commit(pi2.intentSender)
} }
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
)
session.commit(pi2.intentSender)
setInstallUI("\u23F3", getString(R.string.install_installing), getString(R.string.install_installing), 0xFF94a3b8.toInt(), btnEnabled = false, progress = -1) setInstallUI("\u23F3", getString(R.string.install_installing), getString(R.string.install_installing), 0xFF94a3b8.toInt(), btnEnabled = false, progress = -1)
} catch (e: Exception) { } catch (e: Exception) {
setInstallUI("\u274C", getString(R.string.install_error_download), e.message ?: "", 0xFFf87171.toInt(), btnEnabled = true, progress = -2) setInstallUI("\u274C", getString(R.string.install_error_install), e.message ?: "", 0xFFf87171.toInt(), btnEnabled = true, progress = -2)
runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) } runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
ErrorReporter.reportMessage("install_packager_exception", "installWithPackageInstaller exception for $targetPkg: ${e.message}") ErrorReporter.reportMessage("install_packager_exception",
"installWithPackageInstaller exception for $targetPkg: ${e.message}",
forceReport = true)
} }
} }
private fun buildDeviceLabel(): String {
val mfr = Build.MANUFACTURER.takeIf { it.isNotBlank() && it != "unknown" }
?: Build.PRODUCT.takeIf { it.isNotBlank() && it != "unknown" }
?: Build.BOARD
val model = Build.MODEL.takeIf { it.isNotBlank() && it != "unknown" }
?: Build.HARDWARE
return "$mfr $model"
}
private fun installStatusHint(status: Int): String = when (status) {
1 -> "Errore generico (APK incompatibile con questo dispositivo o versione Android)"
2 -> "Bloccato da policy o da un'altra app in corso"
3 -> "Annullato dall'utente"
4 -> "APK non valido o corrotto"
5 -> "Conflitto: versione precedente con firma diversa"
6 -> "Spazio insufficiente"
7 -> "Incompatibile con questa versione di Android"
else -> "Errore sconosciuto"
}
// ── Error Page ──────────────────────────────────────────────────────── // ── Error Page ────────────────────────────────────────────────────────
private fun errorPageHtml(): String { private fun errorPageHtml(): String {
@@ -913,28 +913,48 @@ class SetupActivity : AppCompatActivity() {
unregisterReceiver(this) unregisterReceiver(this)
val msg = intent?.getStringExtra( val msg = intent?.getStringExtra(
android.content.pm.PackageInstaller.EXTRA_STATUS_MESSAGE android.content.pm.PackageInstaller.EXTRA_STATUS_MESSAGE
) ?: "status=$status" ) ?: ""
val deviceLabel = buildString {
val mfr = Build.MANUFACTURER.takeIf { it.isNotBlank() && it != "unknown" }
?: Build.PRODUCT.takeIf { it.isNotBlank() && it != "unknown" }
?: Build.BOARD
val model = Build.MODEL.takeIf { it.isNotBlank() && it != "unknown" }
?: Build.HARDWARE
append("$mfr $model")
}
val hint = when (status) {
1 -> "APK incompatibile con questo dispositivo o versione Android"
2 -> "Bloccato da policy o da un'altra installazione in corso"
3 -> "Annullato"
4 -> "APK non valido o corrotto"
5 -> "Conflitto: versione precedente con firma diversa"
6 -> "Spazio insufficiente"
7 -> "Incompatibile con questa versione di Android"
else -> "Errore sconosciuto"
}
val diagInfo = buildString { val diagInfo = buildString {
appendLine("Status: $status") appendLine("Status $status: $hint")
appendLine("Msg: $msg") if (msg.isNotEmpty()) appendLine("Dettaglio: $msg")
appendLine("PKG: $targetPkg") appendLine("Pacchetto: $targetPkg")
appendLine("APK: ${file.length() / 1024} KB") appendLine("APK: ${file.length() / 1024} KB")
appendLine("Android: ${Build.VERSION.SDK_INT} (${Build.VERSION.RELEASE})") appendLine("Android: ${Build.VERSION.SDK_INT} (${Build.VERSION.RELEASE})")
appendLine("Device: ${Build.MANUFACTURER} ${Build.MODEL}") appendLine("Dispositivo: $deviceLabel")
} }
setGatewayUI("", getString(R.string.install_error_install), setGatewayUI("", getString(R.string.install_error_install),
msg, 0xFFf87171.toInt()) diagInfo.trim(), 0xFFf87171.toInt())
ErrorReporter.reportMessage( ErrorReporter.reportMessage(
"install_failure", "install_failure",
"PackageInstaller failed: status=$status msg=$msg", "PackageInstaller status=$status pkg=$targetPkg android=${Build.VERSION.SDK_INT}",
mapOf( mapOf(
"pkg" to targetPkg, "pkg" to targetPkg,
"status" to status, "status" to status,
"hint" to hint,
"msg" to msg, "msg" to msg,
"apk_kb" to (file.length() / 1024), "apk_kb" to (file.length() / 1024),
"android" to Build.VERSION.SDK_INT, "android" to Build.VERSION.SDK_INT,
"device" to "${Build.MANUFACTURER} ${Build.MODEL}" "device" to deviceLabel
) ),
forceReport = true
) )
val pkgInstalled = try { val pkgInstalled = try {
packageManager.getPackageInfo(targetPkg, 0); true packageManager.getPackageInfo(targetPkg, 0); true
@@ -943,7 +963,6 @@ class SetupActivity : AppCompatActivity() {
if (pkgInstalled) { if (pkgInstalled) {
offerUninstallAndRetry(file, targetPkg) offerUninstallAndRetry(file, targetPkg)
} else { } else {
// Show diagnostic dialog with Retry button
AlertDialog.Builder(this@SetupActivity) AlertDialog.Builder(this@SetupActivity)
.setTitle("❌ Installazione fallita (status=$status)") .setTitle("❌ Installazione fallita (status=$status)")
.setMessage(diagInfo.trim()) .setMessage(diagInfo.trim())