fix: APK self-update download+install in kiosk and scale gateway

Root causes fixed:
- REQUEST_INSTALL_PACKAGES permission missing from both manifests
- FileProvider not declared in either manifest (FileProvider.getUriForFile() crashed)
- res/xml/file_paths.xml missing (required by FileProvider)
- setDestinationInExternalPublicDir() used public Downloads dir (needs storage
  permission + FileProvider can't serve it); replaced with getExternalFilesDir()
  which is app-private, needs no permission, and IS accessible by FileProvider
- canRequestPackageInstalls() check returned early with startActivity (fire-and-
  forget); user could never retry. Now uses startActivityForResult/installPermLauncher
  so the download auto-retries when user returns from the Settings screen
- Added download status check (COLUMN_STATUS == STATUS_SUCCESSFUL) before installing
This commit is contained in:
dadaloop82
2026-05-03 17:37:45 +00:00
parent 9ef2a53aeb
commit f9718fee6d
6 changed files with 121 additions and 29 deletions
@@ -23,6 +23,9 @@
<!-- Move task to front (bring kiosk back after gateway launch) -->
<uses-permission android:name="android.permission.REORDER_TASKS" />
<!-- Self-update: install APK downloaded at runtime -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Query gateway app visibility (required Android 11+) -->
<queries>
<package android:name="it.dadaloop.evershelf.scalegate" />
@@ -54,6 +57,17 @@
android:exported="false"
android:theme="@style/Theme.MaterialComponents.Light.NoActionBar" />
<!-- FileProvider for serving the downloaded APK to the installer -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -15,7 +15,6 @@ import android.net.Uri
import android.net.http.SslError
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.provider.Settings
@@ -95,8 +94,9 @@ class KioskActivity : AppCompatActivity() {
private var pendingWebPermission: PermissionRequest? = null
companion object {
private const val FILE_CHOOSER_REQUEST = 1002
private const val FILE_CHOOSER_REQUEST = 1002
private const val PERMISSION_REQUEST_CODE = 1003
private const val INSTALL_PERM_REQUEST = 1004 // ACTION_MANAGE_UNKNOWN_APP_SOURCES
private const val PREFS_NAME = "evershelf_kiosk"
private const val KEY_URL = "evershelf_url"
private const val KEY_SETUP_COMPLETE = "setup_complete"
@@ -758,33 +758,52 @@ class KioskActivity : AppCompatActivity() {
private fun triggerApkDownload(apkUrl: String) {
if (apkUrl.isEmpty()) return
try {
// On Android 8+ we need to check "install unknown apps" permission
// On Android 8+ check the "install unknown apps" source permission
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
!packageManager.canRequestPackageInstalls()) {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:$packageName"))
startActivity(intent)
Toast.makeText(this, "Abilita 'Installa app sconosciute', poi ripremi Scarica", Toast.LENGTH_LONG).show()
pendingApkDownloadUrl = apkUrl // remember URL for the retry
@Suppress("DEPRECATION")
startActivityForResult(
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:$packageName")),
INSTALL_PERM_REQUEST
)
Toast.makeText(this, "Abilita 'Installa app sconosciute', poi torna qui", Toast.LENGTH_LONG).show()
return
}
val dm = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
// Download to app-private external dir — no storage permission needed
val destDir = getExternalFilesDir(null) ?: filesDir
val destFile = java.io.File(destDir, "evershelf-update.apk")
val dm = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
val req = DownloadManager.Request(Uri.parse(apkUrl)).apply {
setTitle("EverShelf — Aggiornamento")
setDescription("Scaricamento aggiornamento in corso…")
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "evershelf-update.apk")
setDestinationUri(Uri.fromFile(destFile))
setMimeType("application/vnd.android.package-archive")
}
val downloadId = dm.enqueue(req)
Toast.makeText(this, "Download avviato…", Toast.LENGTH_SHORT).show()
// Listen for completion
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
if (intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) == downloadId) {
unregisterReceiver(this)
installApk()
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
if (id != downloadId) return
unregisterReceiver(this)
// Verify the download succeeded before trying to install
val q = DownloadManager.Query().setFilterById(downloadId)
val c = (getSystemService(DOWNLOAD_SERVICE) as DownloadManager).query(q)
var ok = false
if (c.moveToFirst()) {
val status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
ok = (status == DownloadManager.STATUS_SUCCESSFUL)
}
c.close()
if (ok) installApk(destFile)
else runOnUiThread {
Toast.makeText(this@KioskActivity, "Download fallito, riprova", Toast.LENGTH_LONG).show()
}
}
}
@@ -799,11 +818,12 @@ class KioskActivity : AppCompatActivity() {
}
}
private fun installApk() {
private fun installApk(file: java.io.File) {
try {
val file = java.io.File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "evershelf-update.apk")
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
androidx.core.content.FileProvider.getUriForFile(this, "$packageName.provider", file)
androidx.core.content.FileProvider.getUriForFile(
this, "$packageName.provider", file
)
} else {
Uri.fromFile(file)
}
@@ -814,7 +834,9 @@ class KioskActivity : AppCompatActivity() {
}
startActivity(install)
} catch (e: Exception) {
Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show()
runOnUiThread {
Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}
@@ -896,6 +918,12 @@ class KioskActivity : AppCompatActivity() {
fileChooserCallback?.onReceiveValue(result)
fileChooserCallback = null
}
// Returned from ACTION_MANAGE_UNKNOWN_APP_SOURCES — retry the download
// regardless of resultCode (the system always returns RESULT_CANCELED here).
if (requestCode == INSTALL_PERM_REQUEST) {
val url = pendingApkDownloadUrl
if (url.isNotEmpty()) triggerApkDownload(url)
}
}
override fun onDestroy() {
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- App-private external dir: no storage permission needed -->
<external-files-path name="apk_downloads" path="." />
</paths>
@@ -24,6 +24,9 @@
<!-- Keep screen on while gateway is active -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Self-update: install APK downloaded at runtime -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<application
@@ -45,6 +48,17 @@
</intent-filter>
</activity>
<!-- FileProvider for serving the downloaded APK to the installer -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -12,7 +12,6 @@ import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.Settings
import android.view.LayoutInflater
import android.view.View
@@ -81,6 +80,14 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
else showDialog("Bluetooth required", "Please enable Bluetooth to use the gateway.")
}
/** Returns from ACTION_MANAGE_UNKNOWN_APP_SOURCES — retry the download. */
private val installPermLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { _ ->
val url = pendingApkDownloadUrl
if (url.isNotEmpty()) triggerApkDownload(url)
}
// ─── Lifecycle ─────────────────────────────────────────────────────────────
override fun onCreate(savedInstanceState: Bundle?) {
@@ -453,26 +460,42 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
!packageManager.canRequestPackageInstalls()) {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName"))
startActivity(intent)
Toast.makeText(this, "Abilita 'Installa app sconosciute', poi ripremi Scarica", Toast.LENGTH_LONG).show()
pendingApkDownloadUrl = apkUrl // remember for retry
installPermLauncher.launch(
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName"))
)
Toast.makeText(this, "Abilita 'Installa app sconosciute', poi torna qui", Toast.LENGTH_LONG).show()
return
}
// Download to app-private external dir — no storage permission needed
val destDir = getExternalFilesDir(null) ?: filesDir
val destFile = java.io.File(destDir, "evershelf-scale-update.apk")
val dm = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
val req = DownloadManager.Request(Uri.parse(apkUrl)).apply {
setTitle("EverShelf Scale Gateway — Aggiornamento")
setDescription("Scaricamento aggiornamento…")
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "evershelf-scale-update.apk")
setDestinationUri(Uri.fromFile(destFile))
setMimeType("application/vnd.android.package-archive")
}
val downloadId = dm.enqueue(req)
Toast.makeText(this, "Download avviato…", Toast.LENGTH_SHORT).show()
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
if (intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) == downloadId) {
unregisterReceiver(this)
installApk("evershelf-scale-update.apk")
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
if (id != downloadId) return
unregisterReceiver(this)
val q = DownloadManager.Query().setFilterById(downloadId)
val c = (getSystemService(DOWNLOAD_SERVICE) as DownloadManager).query(q)
var ok = false
if (c.moveToFirst()) {
val status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
ok = (status == DownloadManager.STATUS_SUCCESSFUL)
}
c.close()
if (ok) installApk(destFile)
else runOnUiThread {
Toast.makeText(this@MainActivity, "Download fallito, riprova", Toast.LENGTH_LONG).show()
}
}
}
@@ -487,11 +510,12 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
}
}
private fun installApk(fileName: String) {
private fun installApk(file: java.io.File) {
try {
val file = java.io.File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), fileName)
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
androidx.core.content.FileProvider.getUriForFile(this, "$packageName.provider", file)
androidx.core.content.FileProvider.getUriForFile(
this, "$packageName.provider", file
)
} else { Uri.fromFile(file) }
val install = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
@@ -499,7 +523,9 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
}
startActivity(install)
} catch (e: Exception) {
Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show()
runOnUiThread {
Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- App-private external dir: no storage permission needed -->
<external-files-path name="apk_downloads" path="." />
</paths>