diff --git a/evershelf-kiosk/app/src/main/AndroidManifest.xml b/evershelf-kiosk/app/src/main/AndroidManifest.xml
index 997c2b8..3ec694a 100644
--- a/evershelf-kiosk/app/src/main/AndroidManifest.xml
+++ b/evershelf-kiosk/app/src/main/AndroidManifest.xml
@@ -52,6 +52,13 @@
+
+
>? = null
- // Pending WebView permission request (waiting for runtime grant)
+ // Pending WebView permission request
private var pendingWebPermission: PermissionRequest? = null
companion object {
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 INSTALL_CONFIRM_REQUEST = 1005 // system installer confirm dialog
- private const val UNINSTALL_REQUEST = 1006 // ACTION_DELETE → auto-retry install
+ private const val INSTALL_PERM_REQUEST = 1004
+ private const val INSTALL_CONFIRM_REQUEST = 1005
+ private const val UNINSTALL_REQUEST = 1006
+ private const val SETUP_REQUEST = 1007
private const val PREFS_NAME = "evershelf_kiosk"
private const val KEY_URL = "evershelf_url"
private const val KEY_SETUP_COMPLETE = "setup_complete"
@@ -137,67 +110,47 @@ class KioskActivity : AppCompatActivity() {
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
bindViews()
enterImmersiveMode()
- enableKioskLock()
- requestAllPermissions()
- // Initialise centralised error reporter as early as possible so the
- // UncaughtExceptionHandler is installed before any background work starts.
val savedUrl = prefs.getString(KEY_URL, "") ?: ""
ErrorReporter.init(this, savedUrl)
- // Initialise native TTS engine so the JS bridge works even when
- // Web Speech API voices are unavailable in the Android WebView.
tts = TextToSpeech(this) { status ->
if (status == TextToSpeech.SUCCESS) {
- val it = tts?.setLanguage(Locale.ITALIAN)
- if (it == TextToSpeech.LANG_MISSING_DATA || it == TextToSpeech.LANG_NOT_SUPPORTED) {
- // Italian data missing — fall back to device default
+ val res = tts?.setLanguage(Locale.ITALIAN)
+ if (res == TextToSpeech.LANG_MISSING_DATA || res == TextToSpeech.LANG_NOT_SUPPORTED) {
tts?.language = Locale.getDefault()
}
ttsReady = true
}
}
- // Show splash then proceed
- Handler(Looper.getMainLooper()).postDelayed({
+ if (!prefs.getBoolean(KEY_SETUP_COMPLETE, false)) {
+ // Skip splash — SetupActivity has its own welcome screen
splashContainer.visibility = View.GONE
- if (prefs.getBoolean(KEY_SETUP_COMPLETE, false)) {
+ @Suppress("DEPRECATION")
+ startActivityForResult(Intent(this, SetupActivity::class.java), SETUP_REQUEST)
+ } else {
+ enableKioskLock()
+ Handler(Looper.getMainLooper()).postDelayed({
+ splashContainer.visibility = View.GONE
launchWebView()
- } else {
- showWizard()
- }
- }, SPLASH_DURATION)
+ }, SPLASH_DURATION)
+ }
}
private fun bindViews() {
splashContainer = findViewById(R.id.splashContainer)
- wizardContainer = findViewById(R.id.wizardContainer)
- webView = findViewById(R.id.webView)
- btnSettings = findViewById(R.id.btnSettings)
- step1 = findViewById(R.id.step1)
- step2 = findViewById(R.id.step2)
- step3 = findViewById(R.id.step3)
- stepIndicator = findViewById(R.id.stepIndicator)
- wizardUrl = findViewById(R.id.wizardUrl)
- urlStatus = findViewById(R.id.urlStatus)
- scaleStatusIcon = findViewById(R.id.scaleStatusIcon)
- scaleStatusText = findViewById(R.id.scaleStatusText)
- scaleStatusDetail = findViewById(R.id.scaleStatusDetail)
- scaleQuestionLayout = findViewById(R.id.scaleQuestionLayout)
- step3BottomButtons = findViewById(R.id.step3BottomButtons)
+ webView = findViewById(R.id.webView)
+ btnSettings = findViewById(R.id.btnSettings)
- // Update banner
- updateBanner = findViewById(R.id.updateBanner)
- tvUpdateMessage = findViewById(R.id.tvUpdateMessage)
- btnInstallUpdate = findViewById(R.id.btnInstallUpdate)
- btnDismissUpdate = findViewById(R.id.btnDismissUpdate)
+ updateBanner = findViewById(R.id.updateBanner)
+ tvUpdateMessage = findViewById(R.id.tvUpdateMessage)
+ btnInstallUpdate = findViewById(R.id.btnInstallUpdate)
+ btnDismissUpdate = findViewById(R.id.btnDismissUpdate)
downloadProgressBar = findViewById(R.id.downloadProgressBar)
downloadProgressText = findViewById(R.id.downloadProgressText)
bannerProgressBar = findViewById(R.id.bannerProgressBar)
- serverStatusCard = findViewById(R.id.serverStatusCard)
- serverCheckIcon = findViewById(R.id.serverCheckIcon)
- serverCheckText = findViewById(R.id.serverCheckText)
- serverCheckDetail = findViewById(R.id.serverCheckDetail)
+
btnDismissUpdate.setOnClickListener {
updateBanner.visibility = View.GONE
bannerProgressBar.visibility = View.GONE
@@ -208,56 +161,6 @@ class KioskActivity : AppCompatActivity() {
triggerApkDownload(pendingApkDownloadUrl)
}
- // Triple-tap on wizard title is disabled — exit only via the X button in the overlay
-
- // Step 1
- findViewById(R.id.btnGetStarted).setOnClickListener {
- goToStep(2)
- }
-
- // Step 2
- findViewById(R.id.btnTestUrl).setOnClickListener {
- testConnection()
- }
- findViewById(R.id.btnStep2Back).setOnClickListener {
- goToStep(1)
- }
- findViewById(R.id.btnStep2Next).setOnClickListener {
- val url = wizardUrl.text.toString().trim()
- if (url.isEmpty()) {
- showUrlStatus("Please enter a URL", false)
- return@setOnClickListener
- }
- prefs.edit().putString(KEY_URL, url).apply()
- // Re-init ErrorReporter immediately so install errors in step 3 reach GitHub Issues.
- ErrorReporter.init(this, url)
- goToStep(3)
- }
-
- // Step 3
- findViewById(R.id.btnStep3Back).setOnClickListener {
- goToStep(2)
- }
- findViewById(R.id.btnFinish).setOnClickListener {
- prefs.edit().putBoolean(KEY_HAS_SCALE, true).apply()
- launchGatewayInBackground()
- finishWizard()
- }
- // "Yes" → reveal gateway status and proceed flow
- findViewById(R.id.btnScaleYes).setOnClickListener {
- scaleQuestionLayout.visibility = View.GONE
- val statusCard = findViewById(R.id.scaleStatusCard)
- statusCard.visibility = View.VISIBLE
- step3BottomButtons.visibility = View.VISIBLE
- checkGatewayStatus()
- }
- // "No" → save pref and skip to web view
- findViewById(R.id.btnScaleNo).setOnClickListener {
- prefs.edit().putBoolean(KEY_HAS_SCALE, false).apply()
- finishWizard()
- }
-
- // Settings gear — short press opens settings, no kiosk exit via tap
btnSettings.setOnClickListener {
startActivity(Intent(this, SettingsActivity::class.java))
}
@@ -265,32 +168,22 @@ class KioskActivity : AppCompatActivity() {
startActivity(Intent(this, SettingsActivity::class.java))
true
}
-
- // Pre-fill URL
- val savedUrl = prefs.getString(KEY_URL, "") ?: ""
- if (savedUrl.isNotEmpty()) {
- wizardUrl.setText(savedUrl)
- }
}
- // ── Runtime Permissions ─────────────────────────────────────────────
+ // ── Runtime Permissions (for WebView camera/mic) ─────────────────────
private fun requestAllPermissions() {
val needed = mutableListOf()
- if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED)
needed.add(Manifest.permission.CAMERA)
- }
- if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED)
needed.add(Manifest.permission.RECORD_AUDIO)
- }
if (Build.VERSION.SDK_INT >= 33) {
- if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_MEDIA_IMAGES) != PackageManager.PERMISSION_GRANTED) {
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_MEDIA_IMAGES) != PackageManager.PERMISSION_GRANTED)
needed.add(Manifest.permission.READ_MEDIA_IMAGES)
- }
- } else if (Build.VERSION.SDK_INT <= 32) {
- if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
+ } else {
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED)
needed.add(Manifest.permission.READ_EXTERNAL_STORAGE)
- }
}
if (needed.isNotEmpty()) {
ActivityCompat.requestPermissions(this, needed.toTypedArray(), PERMISSION_REQUEST_CODE)
@@ -300,42 +193,17 @@ class KioskActivity : AppCompatActivity() {
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSION_REQUEST_CODE) {
- // Grant pending WebView permission if camera/mic were just granted
pendingWebPermission?.let { req ->
- val allGranted = grantResults.all { it == PackageManager.PERMISSION_GRANTED }
- if (allGranted) {
- req.grant(req.resources)
- } else {
- req.deny()
- }
+ if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) req.grant(req.resources)
+ else req.deny()
pendingWebPermission = null
}
}
}
- // ── Triple-tap to exit ────────────────────────────────────────────────
-
- private fun handleTripleTap() {
- tapCount++
- tapHandler.removeCallbacks(tapResetRunnable)
- tapHandler.postDelayed(tapResetRunnable, 800)
-
- when (tapCount) {
- 1 -> {} // silent
- 2 -> Toast.makeText(this, "Tap once more to exit kiosk", Toast.LENGTH_SHORT).show()
- 3 -> {
- tapCount = 0
- disableKioskLock()
- Toast.makeText(this, "Exiting kiosk mode...", Toast.LENGTH_SHORT).show()
- finishAffinity()
- }
- }
- }
-
- // ── Kiosk Lock (pin app) ──────────────────────────────────────────────
+ // ── Kiosk Lock ────────────────────────────────────────────────────────
private fun enableKioskLock() {
- // Screen pinning (task lock) — prevents home/recent buttons
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
startLockTask()
}
@@ -343,87 +211,17 @@ class KioskActivity : AppCompatActivity() {
private fun disableKioskLock() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- try {
- stopLockTask()
- } catch (_: Exception) {}
+ try { stopLockTask() } catch (_: Exception) {}
}
}
- // ── Wizard Flow ───────────────────────────────────────────────────────
-
- private fun showWizard() {
- wizardContainer.visibility = View.VISIBLE
- webView.visibility = View.GONE
- btnSettings.visibility = View.GONE
- goToStep(1)
- }
-
- private fun goToStep(step: Int) {
- currentStep = step
- step1.visibility = if (step == 1) View.VISIBLE else View.GONE
- step2.visibility = if (step == 2) View.VISIBLE else View.GONE
- step3.visibility = if (step == 3) View.VISIBLE else View.GONE
- updateStepIndicator()
-
- if (step == 3) {
- // Reset to question state every time step 3 is entered
- scaleQuestionLayout.visibility = View.VISIBLE
- val statusCard = findViewById(R.id.scaleStatusCard)
- statusCard.visibility = View.GONE
- step3BottomButtons.visibility = View.GONE
- findViewById(R.id.btnSkipScale).visibility = View.GONE
- }
- }
-
- private fun updateStepIndicator() {
- stepIndicator.removeAllViews()
- for (i in 1..3) {
- val dot = View(this)
- val size = if (i == currentStep) 10 else 8
- val dp = (size * resources.displayMetrics.density).toInt()
- val params = LinearLayout.LayoutParams(dp, dp)
- params.marginStart = (4 * resources.displayMetrics.density).toInt()
- params.marginEnd = (4 * resources.displayMetrics.density).toInt()
- dot.layoutParams = params
-
- val bg = GradientDrawable()
- bg.shape = GradientDrawable.OVAL
- when {
- i == currentStep -> bg.setColor(0xFF7c3aed.toInt())
- i < currentStep -> bg.setColor(0xFF34d399.toInt())
- else -> bg.setColor(0xFF334155.toInt())
- }
- dot.background = bg
- stepIndicator.addView(dot)
- }
- }
-
- private fun finishWizard() {
- prefs.edit().putBoolean(KEY_SETUP_COMPLETE, true).apply()
- wizardContainer.visibility = View.GONE
- // Re-init ErrorReporter with the confirmed URL so future errors are reported
- val confirmedUrl = prefs.getString(KEY_URL, "") ?: ""
- ErrorReporter.init(this, confirmedUrl)
- launchWebView()
- }
-
- fun resetWizard() {
- prefs.edit().putBoolean(KEY_SETUP_COMPLETE, false).apply()
- wizardContainer.visibility = View.VISIBLE
- webView.visibility = View.GONE
- btnSettings.visibility = View.GONE
- goToStep(1)
- }
-
- // ── Gateway Detection & Launch ────────────────────────────────────────
+ // ── Gateway ────────────────────────────────────────────────────────────
private fun isGatewayInstalled(): Boolean {
return try {
packageManager.getPackageInfo(GATEWAY_PACKAGE, 0)
true
- } catch (e: PackageManager.NameNotFoundException) {
- false
- }
+ } catch (e: PackageManager.NameNotFoundException) { false }
}
private fun launchGatewayInBackground() {
@@ -432,208 +230,21 @@ class KioskActivity : AppCompatActivity() {
val launchIntent = packageManager.getLaunchIntentForPackage(GATEWAY_PACKAGE) ?: return
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(launchIntent)
- // Bring kiosk back to foreground after gateway launches
Handler(Looper.getMainLooper()).postDelayed({
val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
am.moveTaskToFront(taskId, ActivityManager.MOVE_TASK_WITH_HOME)
}, 1500)
}
- private fun checkGatewayStatus() {
- if (isGatewayInstalled()) {
- scaleStatusIcon.text = "\u2705"
- scaleStatusText.text = getString(R.string.wizard_gateway_installed)
- scaleStatusDetail.text = getString(R.string.wizard_gateway_checking)
- scaleStatusDetail.setTextColor(0xFF94a3b8.toInt())
- findViewById(R.id.btnSkipScale).visibility = View.GONE
- findViewById(R.id.btnFinish).text = getString(R.string.btn_launch)
- // Check async if a newer version is available
- checkGatewayUpdate()
- } else {
- scaleStatusIcon.text = "\uD83D\uDCE5"
- scaleStatusText.text = getString(R.string.wizard_gateway_not_installed)
- scaleStatusDetail.text = getString(R.string.wizard_gateway_not_installed_detail)
- scaleStatusDetail.setTextColor(0xFFfbbf24.toInt())
- findViewById(R.id.btnFinish).text = getString(R.string.btn_launch_no_scale)
- findViewById(R.id.btnSkipScale).apply {
- text = getString(R.string.btn_download_gateway)
- setTextColor(0xFFa78bfa.toInt())
- visibility = View.VISIBLE
- setOnClickListener {
- activeInstallBtn = this
- triggerApkDownload(GATEWAY_DOWNLOAD_URL)
- }
- }
- }
- }
+ // ── Install UI ────────────────────────────────────────────────────────
- /** Fetches the latest GitHub release and, if the gateway has an available update,
- * shows the update button in the wizard status card. */
- private fun checkGatewayUpdate() {
- val currentVersion = try {
- packageManager.getPackageInfo(GATEWAY_PACKAGE, 0).versionName ?: return
- } catch (_: Exception) { return }
-
- Thread {
- try {
- val conn = URL(GITHUB_RELEASES_API).openConnection() as java.net.HttpURLConnection
- conn.setRequestProperty("Accept", "application/vnd.github+json")
- conn.connectTimeout = 5000
- conn.readTimeout = 5000
- val json = JSONObject(conn.inputStream.bufferedReader().readText())
- conn.disconnect()
-
- val latestTag = json.optString("tag_name", "")
- if (latestTag.isEmpty()) { showGatewayUpToDate(); return@Thread }
-
- val isSemver = latestTag.trimStart('v').matches(Regex("\\d+\\.\\d+.*"))
- val norm = { v: String -> v.trimStart('v') }
- val needsUpdate = !isSemver || norm(latestTag) != norm(currentVersion)
-
- if (!needsUpdate) { showGatewayUpToDate(); return@Thread }
-
- // Locate the gateway APK among release assets
- var apkUrl = GATEWAY_DOWNLOAD_URL
- val assets = json.optJSONArray("assets")
- if (assets != null) {
- for (i in 0 until assets.length()) {
- val a = assets.getJSONObject(i)
- val name = a.optString("name", "").lowercase()
- val url = a.optString("browser_download_url", "")
- if ((name.contains("gateway") || name.contains("scale")) && url.isNotEmpty()) {
- apkUrl = url; break
- }
- }
- }
- val finalUrl = apkUrl
- runOnUiThread {
- scaleStatusIcon.text = "\uD83D\uDD04"
- scaleStatusText.text = getString(R.string.wizard_gateway_update_available)
- scaleStatusDetail.text = getString(R.string.wizard_gateway_update_detail)
- scaleStatusDetail.setTextColor(0xFFfbbf24.toInt())
- pendingInstallPkg = GATEWAY_PACKAGE
- pendingApkDownloadUrl = finalUrl
- findViewById(R.id.btnSkipScale).apply {
- text = getString(R.string.btn_update_gateway)
- setTextColor(0xFFfbbf24.toInt())
- visibility = View.VISIBLE
- setOnClickListener {
- activeInstallBtn = this
- triggerApkDownload(finalUrl)
- }
- }
- }
- } catch (_: Exception) {
- showGatewayUpToDate()
- }
- }.start()
- }
-
- private fun showGatewayUpToDate() = runOnUiThread {
- scaleStatusDetail.text = getString(R.string.wizard_gateway_installed_detail)
- scaleStatusDetail.setTextColor(0xFF34d399.toInt())
- }
-
- /**
- * Pings the configured EverShelf server to verify it is reachable and that the
- * error-reporting API endpoint responds. Called every time step 3 is entered so
- * the user knows whether install failures will be automatically sent to GitHub Issues.
- */
- private fun checkServerReachability() {
- val url = prefs.getString(KEY_URL, "") ?: ""
- serverCheckIcon.text = "⏳"
- serverCheckText.text = getString(R.string.wizard_server_checking)
- serverCheckText.setTextColor(0xFF94a3b8.toInt())
- serverCheckDetail.visibility = View.GONE
-
- if (url.isEmpty()) {
- serverCheckIcon.text = "⚠️"
- serverCheckText.text = getString(R.string.wizard_server_error)
- serverCheckText.setTextColor(0xFFfbbf24.toInt())
- serverCheckDetail.text = getString(R.string.wizard_server_error_detail)
- serverCheckDetail.visibility = View.VISIBLE
- return
- }
-
- Thread {
- var reachable = false
- try {
- val base = url.trimEnd('/')
- val conn = java.net.URL("$base/api/?action=check_update")
- .openConnection() as java.net.HttpURLConnection
- conn.requestMethod = "GET"
- conn.connectTimeout = 5000
- conn.readTimeout = 5000
- val code = conn.responseCode
- conn.disconnect()
- reachable = code in 200..499 // any HTTP response = server is up
- } catch (_: Exception) {}
- runOnUiThread {
- if (reachable) {
- serverCheckIcon.text = "✅"
- serverCheckText.text = getString(R.string.wizard_server_ok)
- serverCheckText.setTextColor(0xFF34d399.toInt())
- serverCheckDetail.text = getString(R.string.wizard_server_ok_detail)
- serverCheckDetail.visibility = View.VISIBLE
- } else {
- serverCheckIcon.text = "⚠️"
- serverCheckText.text = getString(R.string.wizard_server_error)
- serverCheckText.setTextColor(0xFFfbbf24.toInt())
- serverCheckDetail.text = getString(R.string.wizard_server_error_detail)
- serverCheckDetail.visibility = View.VISIBLE
- }
- }
- }.start()
- }
-
- /**
- * Central UI updater for the download/install progress.
- * - Updates the wizard status card if it is currently visible (step 3).
- * - Updates the update banner message if it is visible (kiosk self-update).
- * - Always updates the active install button text and enabled state.
- *
- * @param icon Emoji icon shown in the status card and button
- * @param title One-line status title (also used as button label)
- * @param detail Secondary detail line (status card only)
- * @param color ARGB color for the detail text
- * @param btnEnabled Whether to re-enable the active button after this state
- * @param progress 0-100 to show determinate bar; -1 = indeterminate; -2 = hide bar
- * @param progressText optional text shown under the bar (e.g. "18.2 MB / 40.5 MB")
- */
private fun setInstallUI(
icon: String, title: String, detail: String, color: Int,
btnEnabled: Boolean = false,
progress: Int = -2,
progressText: String = ""
) = runOnUiThread {
- // Wizard status card (step 3)
- val statusCard = try { findViewById(R.id.scaleStatusCard) } catch (_: Exception) { null }
- if (statusCard?.visibility == View.VISIBLE) {
- scaleStatusIcon.text = icon
- scaleStatusText.text = title
- scaleStatusDetail.text = detail
- scaleStatusDetail.setTextColor(color)
- when {
- progress == -2 -> {
- downloadProgressBar.visibility = View.GONE
- downloadProgressText.visibility = View.GONE
- }
- progress == -1 -> {
- downloadProgressBar.isIndeterminate = true
- downloadProgressBar.visibility = View.VISIBLE
- downloadProgressText.text = progressText
- downloadProgressText.visibility = if (progressText.isEmpty()) View.GONE else View.VISIBLE
- }
- else -> {
- downloadProgressBar.isIndeterminate = false
- downloadProgressBar.progress = progress
- downloadProgressBar.visibility = View.VISIBLE
- downloadProgressText.text = progressText
- downloadProgressText.visibility = if (progressText.isEmpty()) View.GONE else View.VISIBLE
- }
- }
- }
- // Update banner (kiosk / gateway auto-update outside wizard)
+ // Update banner
if (updateBanner.visibility == View.VISIBLE) {
tvUpdateMessage.text = "$icon $title"
if (detail.isNotEmpty()) tvUpdateMessage.text = "${tvUpdateMessage.text}\n$detail"
@@ -651,29 +262,24 @@ class KioskActivity : AppCompatActivity() {
}
}
// Button state
- val btn = activeInstallBtn
- if (btn != null) {
+ activeInstallBtn?.let { btn ->
btn.isEnabled = btnEnabled
btn.text = "$icon $title"
}
}
- /**
- * Polls DownloadManager every 500 ms to report actual byte-level progress
- * in the status card and banner. Stops automatically when download is no
- * longer RUNNING or PENDING.
- */
+ // ── Download Progress Poll ────────────────────────────────────────────
+
private fun startDownloadProgressPoll(downloadId: Long) {
activeDownloadId = downloadId
pollHandler.removeCallbacksAndMessages(null)
fun tick() {
- if (activeDownloadId != downloadId) return // superseded download
+ if (activeDownloadId != downloadId) return
val dm = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
val c = dm.query(DownloadManager.Query().setFilterById(downloadId))
if (!c.moveToFirst()) { c.close(); return }
val status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
- if (status == DownloadManager.STATUS_RUNNING ||
- status == DownloadManager.STATUS_PENDING) {
+ if (status == DownloadManager.STATUS_RUNNING || status == DownloadManager.STATUS_PENDING) {
val dl = c.getLong(c.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
val tot = c.getLong(c.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
c.close()
@@ -684,106 +290,26 @@ class KioskActivity : AppCompatActivity() {
setInstallUI(
"\u23F3",
getString(R.string.install_downloading) + if (tot > 0) " ($pct%)" else "",
- txt,
- 0xFF94a3b8.toInt(),
- btnEnabled = false,
- progress = pct,
- progressText = txt
+ txt, 0xFF94a3b8.toInt(),
+ btnEnabled = false, progress = pct, progressText = txt
)
pollHandler.postDelayed({ tick() }, 500)
} else {
- c.close() // terminal state — BroadcastReceiver will handle success/failure
+ c.close()
}
}
pollHandler.post { tick() }
}
- // ── Connection Test ───────────────────────────────────────────────────
-
- private fun testConnection() {
- val url = wizardUrl.text.toString().trim()
- if (url.isEmpty()) {
- showUrlStatus("Please enter a URL first", false)
- return
- }
- showUrlStatus("Testing connection...", null)
-
- Thread {
- try {
- val conn = URL(url).openConnection()
-
- if (conn is HttpsURLConnection) {
- val trustAll = arrayOf(object : X509TrustManager {
- override fun checkClientTrusted(chain: Array?, authType: String?) {}
- override fun checkServerTrusted(chain: Array?, authType: String?) {}
- override fun getAcceptedIssuers(): Array = arrayOf()
- })
- val sc = SSLContext.getInstance("TLS")
- sc.init(null, trustAll, java.security.SecureRandom())
- conn.sslSocketFactory = sc.socketFactory
- conn.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
- }
-
- conn.connectTimeout = 5000
- conn.readTimeout = 5000
- if (conn is java.net.HttpURLConnection) {
- conn.requestMethod = "GET"
- val code = conn.responseCode
- conn.disconnect()
- if (code !in 200..399) {
- runOnUiThread { showUrlStatus("⚠ Server responded with code $code", false) }
- return@Thread
- }
- // Second check: verify the EverShelf PHP API is actually present.
- var apiOk = false
- var apiCode = -1
- try {
- val base = url.trimEnd('/')
- val apiConn = java.net.URL("$base/api/?action=check_update")
- .openConnection() as java.net.HttpURLConnection
- apiConn.requestMethod = "GET"
- apiConn.connectTimeout = 5000
- apiConn.readTimeout = 5000
- apiCode = apiConn.responseCode
- val body = apiConn.inputStream.bufferedReader().readText()
- apiConn.disconnect()
- apiOk = apiCode in 200..399 &&
- (body.contains("latest_tag") || body.contains("webapp_version") || body.contains("ok"))
- } catch (_: Exception) {}
- runOnUiThread {
- if (apiOk) {
- showUrlStatus("✅ Server EverShelf trovato e API attiva!", true)
- } else {
- showUrlStatus("⚠ Server raggiungibile (HTTP $code) ma API PHP non trovata (codice $apiCode). " +
- "Verifica che il server EverShelf sia installato correttamente.", false)
- }
- }
- }
- } catch (e: Exception) {
- runOnUiThread {
- showUrlStatus("✗ Cannot reach server: ${e.message}", false)
- }
- }
- }.start()
- }
-
- private fun showUrlStatus(text: String, success: Boolean?) {
- urlStatus.visibility = View.VISIBLE
- urlStatus.text = text
- urlStatus.setTextColor(
- when (success) {
- true -> 0xFF34d399.toInt()
- false -> 0xFFf87171.toInt()
- null -> 0xFF94a3b8.toInt()
- }
- )
- }
-
- // ── WebView ───────────────────────────────────────────────────────────
+ // ── WebView ────────────────────────────────────────────────────────────
@SuppressLint("SetJavaScriptEnabled")
private fun launchWebView() {
- webView.visibility = View.VISIBLE
+ // Ensure kiosk lock and permissions are active
+ enableKioskLock()
+ requestAllPermissions()
+
+ webView.visibility = View.VISIBLE
btnSettings.visibility = View.VISIBLE
val settings = webView.settings
@@ -795,34 +321,25 @@ class KioskActivity : AppCompatActivity() {
settings.cacheMode = android.webkit.WebSettings.LOAD_NO_CACHE
webView.webViewClient = object : WebViewClient() {
- override fun onReceivedSslError(
- view: WebView?, handler: SslErrorHandler?, error: SslError?
- ) {
+ override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
handler?.proceed()
}
-
- override fun onReceivedError(
- view: WebView?, request: WebResourceRequest?,
- error: WebResourceError?
- ) {
+ override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
val errorDesc = error?.description?.toString() ?: "unknown"
val errorCode = error?.errorCode ?: -1
val url = request?.url?.toString() ?: ""
if (request?.isForMainFrame == true) {
ErrorReporter.reportMessage(
- type = "webview-load-error",
+ type = "webview-load-error",
message = "WebView failed to load main frame: $errorDesc (code $errorCode)",
- extra = mapOf("url" to url, "errorCode" to errorCode)
+ extra = mapOf("url" to url, "errorCode" to errorCode)
)
view?.loadData(errorPageHtml(), "text/html", "UTF-8")
}
}
-
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
- // Inject X (exit) and ↻ (refresh) buttons into the page header
injectKioskOverlay()
- // Check for updates periodically
checkForUpdates()
}
}
@@ -835,14 +352,12 @@ class KioskActivity : AppCompatActivity() {
for (res in request.resources) {
when (res) {
PermissionRequest.RESOURCE_VIDEO_CAPTURE -> {
- if (ContextCompat.checkSelfPermission(this@KioskActivity, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
+ if (ContextCompat.checkSelfPermission(this@KioskActivity, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED)
needed.add(Manifest.permission.CAMERA)
- }
}
PermissionRequest.RESOURCE_AUDIO_CAPTURE -> {
- if (ContextCompat.checkSelfPermission(this@KioskActivity, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
+ if (ContextCompat.checkSelfPermission(this@KioskActivity, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED)
needed.add(Manifest.permission.RECORD_AUDIO)
- }
}
}
}
@@ -855,35 +370,30 @@ class KioskActivity : AppCompatActivity() {
}
}
override fun onConsoleMessage(msg: ConsoleMessage?): Boolean {
- // Forward JS errors and warnings to the error reporter
if (msg != null && msg.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
ErrorReporter.reportMessage(
- type = "webview-js-error",
+ type = "webview-js-error",
message = msg.message(),
- extra = mapOf(
- "source_id" to msg.sourceId(),
- "line" to msg.lineNumber()
- )
+ extra = mapOf("source_id" to msg.sourceId(), "line" to msg.lineNumber())
)
}
return true
}
override fun onShowFileChooser(
- wv: WebView?,
- callback: ValueCallback>?,
+ wv: WebView?, callback: ValueCallback>?,
params: FileChooserParams?
): Boolean {
fileChooserCallback?.onReceiveValue(null)
fileChooserCallback = callback
val intent = params?.createIntent()
if (intent != null) {
+ @Suppress("DEPRECATION")
startActivityForResult(intent, FILE_CHOOSER_REQUEST)
}
return true
}
}
- // Add JS interface ONCE before loading
webView.addJavascriptInterface(object {
@JavascriptInterface
fun exit() {
@@ -900,26 +410,16 @@ class KioskActivity : AppCompatActivity() {
webView.reload()
}
}
- /**
- * Speak [text] via Android native TTS.
- * Called by app.js when running inside the kiosk WebView so that
- * speech synthesis works even without Web Speech API offline voices.
- * [rate] and [pitch] are floats (default 1.0).
- */
@JavascriptInterface
fun speak(text: String, rate: Float, pitch: Float) {
val engine = tts ?: return
if (!ttsReady) return
engine.setSpeechRate(rate.coerceIn(0.1f, 4f))
engine.setPitch(pitch.coerceIn(0.1f, 4f))
- engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, "kiosk_tts")
+ engine.speak(text, android.speech.tts.TextToSpeech.QUEUE_FLUSH, null, "kiosk_tts")
}
- /** Cancel any ongoing speech. */
@JavascriptInterface
- fun stopSpeech() {
- tts?.stop()
- }
- /** Returns "true" when the TTS engine is ready. */
+ fun stopSpeech() { tts?.stop() }
@JavascriptInterface
fun isTtsReady(): String = if (ttsReady) "true" else "false"
}, "_kioskBridge")
@@ -927,26 +427,19 @@ class KioskActivity : AppCompatActivity() {
val url = prefs.getString(KEY_URL, "http://evershelf.local") ?: "http://evershelf.local"
webView.loadUrl(url)
- // Launch gateway in background
launchGatewayInBackground()
-
- // Keep screen on
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
- // ── Inject kiosk buttons in header (left of title) ──────────────────
+ // ── Inject kiosk overlay (exit + refresh buttons) ────────────────────
private fun injectKioskOverlay() {
- // Use a position:fixed overlay so injection never depends on SPA DOM readiness.
val js = """
(function() {
if (document.getElementById('_kiosk_overlay')) return;
-
var wrap = document.createElement('div');
wrap.id = '_kiosk_overlay';
wrap.style.cssText = 'position:fixed;top:8px;left:8px;z-index:2147483647;display:flex;gap:6px;align-items:center;pointer-events:auto;';
-
- // Exit button
var exitBtn = document.createElement('button');
exitBtn.id = '_kiosk_exit_btn';
exitBtn.textContent = '\u2715';
@@ -958,8 +451,6 @@ class KioskActivity : AppCompatActivity() {
if (typeof _kioskBridge !== 'undefined') _kioskBridge.exit();
}
});
-
- // Refresh button
var refBtn = document.createElement('button');
refBtn.id = '_kiosk_refresh_btn';
refBtn.textContent = '\u21bb';
@@ -970,7 +461,6 @@ class KioskActivity : AppCompatActivity() {
if (typeof _kioskBridge !== 'undefined') _kioskBridge.hardReload();
else location.reload(true);
});
-
wrap.appendChild(exitBtn);
wrap.appendChild(refBtn);
document.documentElement.appendChild(wrap);
@@ -984,7 +474,6 @@ class KioskActivity : AppCompatActivity() {
private fun checkForUpdates() {
val lastCheck = prefs.getLong("last_update_check", 0)
val now = System.currentTimeMillis()
- // Check at most once every 6 hours
if (now - lastCheck < 6 * 60 * 60 * 1000) return
prefs.edit().putLong("last_update_check", now).apply()
@@ -993,7 +482,7 @@ class KioskActivity : AppCompatActivity() {
val conn = URL(GITHUB_RELEASES_API).openConnection() as java.net.HttpURLConnection
conn.setRequestProperty("Accept", "application/vnd.github+json")
conn.connectTimeout = 5000
- conn.readTimeout = 5000
+ conn.readTimeout = 5000
val body = conn.inputStream.bufferedReader().readText()
conn.disconnect()
val json = JSONObject(body)
@@ -1007,14 +496,11 @@ class KioskActivity : AppCompatActivity() {
packageManager.getPackageInfo(GATEWAY_PACKAGE, 0).versionName ?: ""
} catch (_: Exception) { null }
- // Normalise: strip leading 'v' for comparison
val norm = { v: String -> v.trimStart('v') }
- // If tag is not semver-like (e.g. "latest") we can't compare — treat as "needs update"
val isSemver = latestTag.trimStart('v').matches(Regex("\\d+\\.\\d+.*"))
- // Find APK download URLs in release assets
val assets = json.optJSONArray("assets")
- var kioskApkUrl = "" // only set if the release actually contains the APK
+ var kioskApkUrl = ""
var gatewayApkUrl = ""
if (assets != null) {
for (i in 0 until assets.length()) {
@@ -1026,205 +512,111 @@ class KioskActivity : AppCompatActivity() {
}
}
- // Kiosk needs update: APK is in release AND (non-semver tag OR version mismatch)
- val kioskHasApk = kioskApkUrl.isNotEmpty()
- val kioskNeedsUpdate = kioskHasApk && currentKiosk.isNotEmpty() &&
+ val kioskNeedsUpdate = kioskApkUrl.isNotEmpty() && currentKiosk.isNotEmpty() &&
(!isSemver || norm(latestTag) != norm(currentKiosk))
-
- // Gateway needs update: installed AND APK in release AND (non-semver OR mismatch)
- val gatewayHasApk = gatewayApkUrl.isNotEmpty()
- val gatewayNeedsUpdate = currentGateway != null && gatewayHasApk &&
+ val gatewayNeedsUpdate = currentGateway != null && gatewayApkUrl.isNotEmpty() &&
(!isSemver || norm(latestTag) != norm(currentGateway))
if (!kioskNeedsUpdate && !gatewayNeedsUpdate) return@Thread
- // Build message and choose primary download (kiosk takes precedence)
val lines = mutableListOf()
var primaryApkUrl = ""
if (kioskNeedsUpdate) {
val label = if (isSemver) "$currentKiosk → $latestTag" else latestTag
- lines += "🔄 Kiosk $label"
+ lines += "\uD83D\uDD04 Kiosk $label"
primaryApkUrl = kioskApkUrl
}
if (gatewayNeedsUpdate) {
val label = if (isSemver) "$currentGateway → $latestTag" else latestTag
- lines += "🔄 Scale Gateway $label"
+ lines += "\uD83D\uDD04 Scale Gateway $label"
if (primaryApkUrl.isEmpty()) primaryApkUrl = gatewayApkUrl
}
val message = lines.joinToString(" • ")
-
runOnUiThread { showNativeUpdateBanner(message, primaryApkUrl) }
} catch (_: Exception) { }
}.start()
}
- /**
- * Shows a native Android banner at the TOP of the screen (above the WebView).
- * Includes a prominent "Scarica" button that downloads and installs the APK.
- */
private fun showNativeUpdateBanner(message: String, apkDownloadUrl: String) {
pendingApkDownloadUrl = apkDownloadUrl
tvUpdateMessage.text = "⬆️ Aggiornamento disponibile: $message"
updateBanner.visibility = View.VISIBLE
- // Auto-hide after 30 s (user can dismiss manually)
updateBanner.postDelayed({ updateBanner.visibility = View.GONE }, 30_000)
}
- /**
- * Downloads the APK via DownloadManager and opens the installer when done.
- * Requires INTERNET + REQUEST_INSTALL_PACKAGES permissions.
- * All progress is reflected in the active UI (status card or banner) — no Toasts.
- */
+ // ── APK Download + Install ─────────────────────────────────────────────
+
private fun triggerApkDownload(apkUrl: String) {
if (apkUrl.isEmpty()) return
- // Always keep this up-to-date so installApk() can derive the target package from the URL.
pendingApkDownloadUrl = apkUrl
- try {
- // On Android 8+ check the "install unknown apps" source permission
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
- !packageManager.canRequestPackageInstalls()) {
- // pendingApkDownloadUrl already set above
- setInstallUI(
- "\uD83D\uDD12",
- getString(R.string.install_perm_detail),
- getString(R.string.install_perm_detail),
- 0xFFfbbf24.toInt(),
- btnEnabled = false
- )
- @Suppress("DEPRECATION")
- startActivityForResult(
- Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
- Uri.parse("package:$packageName")),
- INSTALL_PERM_REQUEST
- )
- return
- }
-
- // Show "downloading" state immediately
- setInstallUI(
- "\u23F3",
- getString(R.string.install_downloading),
- getString(R.string.install_downloading_detail),
- 0xFF94a3b8.toInt(),
- btnEnabled = false
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !packageManager.canRequestPackageInstalls()) {
+ setInstallUI("\uD83D\uDD12", getString(R.string.install_perm_detail), getString(R.string.install_perm_detail), 0xFFfbbf24.toInt(), btnEnabled = false)
+ @Suppress("DEPRECATION")
+ startActivityForResult(
+ Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName")),
+ INSTALL_PERM_REQUEST
)
+ return
+ }
+ setInstallUI("\u23F3", getString(R.string.install_downloading), getString(R.string.install_downloading_detail), 0xFF94a3b8.toInt(), btnEnabled = false)
+ 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(getString(R.string.install_downloading))
+ setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
+ setDestinationUri(Uri.fromFile(destFile))
+ setMimeType("application/vnd.android.package-archive")
+ }
+ val downloadId = dm.enqueue(req)
+ startDownloadProgressPoll(downloadId)
- // 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(getString(R.string.install_downloading))
- setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
- setDestinationUri(Uri.fromFile(destFile))
- setMimeType("application/vnd.android.package-archive")
- }
- val downloadId = dm.enqueue(req)
- startDownloadProgressPoll(downloadId)
-
- val receiver = object : BroadcastReceiver() {
- override fun onReceive(ctx: Context?, intent: Intent?) {
- 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) {
- pollHandler.removeCallbacksAndMessages(null)
- activeDownloadId = -1
- setInstallUI(
- "\u23F3",
- getString(R.string.install_installing),
- getString(R.string.install_installing),
- 0xFF94a3b8.toInt(),
- btnEnabled = false,
- progress = -1
- )
- installApk(destFile)
- } else {
- pollHandler.removeCallbacksAndMessages(null)
- activeDownloadId = -1
- setInstallUI(
- "\u274C",
- getString(R.string.install_error_download),
- getString(R.string.install_error_download_detail),
- 0xFFf87171.toInt(),
- btnEnabled = true,
- progress = -2
- )
- runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
- ErrorReporter.reportMessage("install_download_failed",
- "DownloadManager returned failure for URL: $apkUrl")
- }
+ val receiver = object : BroadcastReceiver() {
+ override fun onReceive(ctx: Context?, intent: Intent?) {
+ 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()) ok = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) == DownloadManager.STATUS_SUCCESSFUL
+ c.close()
+ if (ok) {
+ pollHandler.removeCallbacksAndMessages(null); activeDownloadId = -1
+ setInstallUI("\u23F3", getString(R.string.install_installing), getString(R.string.install_installing), 0xFF94a3b8.toInt(), btnEnabled = false, progress = -1)
+ installApk(destFile)
+ } else {
+ pollHandler.removeCallbacksAndMessages(null); activeDownloadId = -1
+ setInstallUI("\u274C", getString(R.string.install_error_download), getString(R.string.install_error_download_detail), 0xFFf87171.toInt(), btnEnabled = true, progress = -2)
+ runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
+ ErrorReporter.reportMessage("install_download_failed", "DownloadManager returned failure for URL: $apkUrl")
}
}
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- // RECEIVER_EXPORTED required: ACTION_DOWNLOAD_COMPLETE is sent by the system DownloadManager
- // (an external process), so NOT_EXPORTED would silently block the broadcast on API 33+.
- registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), RECEIVER_EXPORTED)
- } else {
- @Suppress("UnspecifiedRegisterReceiverFlag")
- registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
- }
- } catch (e: Exception) {
- setInstallUI(
- "\u274C",
- getString(R.string.install_error_download),
- e.message ?: "",
- 0xFFf87171.toInt(),
- btnEnabled = true
- )
- runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), RECEIVER_EXPORTED)
+ } else {
+ @Suppress("UnspecifiedRegisterReceiverFlag")
+ registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
}
}
private fun installApk(file: java.io.File) {
if (!file.exists() || file.length() == 0L) {
- setInstallUI(
- "\u274C",
- getString(R.string.install_error_download),
- "File APK non trovato sul dispositivo.",
- 0xFFf87171.toInt(),
- btnEnabled = true
- )
+ setInstallUI("\u274C", getString(R.string.install_error_download), "File APK non trovato sul dispositivo.", 0xFFf87171.toInt(), btnEnabled = true)
runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
return
}
- // Validate APK magic bytes (ZIP local file header = 'PK' = 0x50 0x4B).
- // If GitHub returned a 404 HTML page, DownloadManager still reports SUCCESS
- // but the file starts with '<' not 'PK' — catch that before calling PackageInstaller.
- val magic: ByteArray? = try {
- file.inputStream().use { s -> val b = ByteArray(4); s.read(b); b }
- } catch (_: Exception) { null }
+ val magic: ByteArray? = try { file.inputStream().use { s -> val b = ByteArray(4); s.read(b); b } } catch (_: Exception) { null }
val isApk = magic != null && magic[0] == 0x50.toByte() && magic[1] == 0x4B.toByte()
if (!isApk) {
- setInstallUI(
- "\u274C",
- getString(R.string.install_error_download),
- "Il file scaricato non è un APK valido (possibile 404 sulla release). " +
- "Verifica che la release GitHub sia pubblicata.",
- 0xFFf87171.toInt(),
- btnEnabled = true,
- progress = -2
- )
+ setInstallUI("\u274C", getString(R.string.install_error_download), "Il file scaricato non è un APK valido.", 0xFFf87171.toInt(), btnEnabled = true, progress = -2)
runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
- ErrorReporter.reportMessage("install_invalid_apk",
- "Downloaded file is not a valid APK (bad magic bytes). URL=$pendingApkDownloadUrl size=${file.length()}")
- file.delete() // remove corrupt file so next attempt re-downloads
+ ErrorReporter.reportMessage("install_invalid_apk", "Downloaded file is not a valid APK. URL=$pendingApkDownloadUrl size=${file.length()}")
+ file.delete()
return
}
- // Derive the target package from the download URL (not the filename, which is always
- // 'evershelf-update.apk'). The URL contains 'gateway' or 'scale' when installing the
- // scale gateway; anything else is a kiosk self-update.
val targetPkg = when {
pendingApkDownloadUrl.contains("gateway", ignoreCase = true) ||
pendingApkDownloadUrl.contains("scale", ignoreCase = true) -> GATEWAY_PACKAGE
@@ -1233,13 +625,10 @@ class KioskActivity : AppCompatActivity() {
installWithPackageInstaller(file, targetPkg)
}
- /** Use PackageInstaller (API 21+) for reliable install-over-existing support. */
private fun installWithPackageInstaller(file: java.io.File, targetPkg: String) {
try {
- val pi = packageManager.packageInstaller
- val params = android.content.pm.PackageInstaller.SessionParams(
- android.content.pm.PackageInstaller.SessionParams.MODE_FULL_INSTALL
- )
+ val pi = packageManager.packageInstaller
+ val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
params.setAppPackageName(targetPkg)
val sessionId = pi.createSession(params)
pi.openSession(sessionId).use { session ->
@@ -1249,19 +638,13 @@ class KioskActivity : AppCompatActivity() {
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
+ val status = intent?.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) ?: PackageInstaller.STATUS_FAILURE
when (status) {
- android.content.pm.PackageInstaller.STATUS_PENDING_USER_ACTION -> {
- // Android needs user confirmation — use startActivityForResult so we
- // get notified if the system installer fails (e.g. signature conflict)
+ 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)
@@ -1269,36 +652,20 @@ class KioskActivity : AppCompatActivity() {
if (confirmIntent != null) {
pendingInstallFile = file
pendingInstallPkg = targetPkg
- setInstallUI(
- "\u23F3",
- getString(R.string.install_installing),
- getString(R.string.install_confirm_detail),
- 0xFF94a3b8.toInt(),
- btnEnabled = false
- )
+ setInstallUI("\u23F3", getString(R.string.install_installing), getString(R.string.install_confirm_detail), 0xFF94a3b8.toInt(), btnEnabled = false)
+ @Suppress("DEPRECATION")
startActivityForResult(confirmIntent, INSTALL_CONFIRM_REQUEST)
}
}
- android.content.pm.PackageInstaller.STATUS_SUCCESS -> {
- setInstallUI(
- "\u2705",
- getString(R.string.install_success),
- getString(R.string.install_success_detail),
- 0xFF34d399.toInt(),
- btnEnabled = false,
- progress = -2
- )
- // Re-check gateway status after 3 s so the wizard reflects reality
+ PackageInstaller.STATUS_SUCCESS -> {
+ setInstallUI("\u2705", getString(R.string.install_success), getString(R.string.install_success_detail), 0xFF34d399.toInt(), btnEnabled = false, progress = -2)
Handler(Looper.getMainLooper()).postDelayed({
- val card = try { findViewById(R.id.scaleStatusCard) } catch (_: Exception) { null }
- if (card?.visibility == View.VISIBLE) checkGatewayStatus()
updateBanner.visibility = View.GONE
bannerProgressBar.visibility = View.GONE
}, 3000)
}
- android.content.pm.PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
- android.content.pm.PackageInstaller.STATUS_FAILURE_CONFLICT -> {
- // Signature mismatch: offer to uninstall; on return auto-retry install
+ PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
+ PackageInstaller.STATUS_FAILURE_CONFLICT -> {
runOnUiThread {
pendingInstallFile = file
pendingInstallPkg = targetPkg
@@ -1306,63 +673,39 @@ class KioskActivity : AppCompatActivity() {
.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() // release screen pin so uninstall UI can open
- startActivityForResult(
- Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$targetPkg")),
- UNINSTALL_REQUEST
- )
+ disableKioskLock()
+ @Suppress("DEPRECATION")
+ startActivityForResult(Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$targetPkg")), UNINSTALL_REQUEST)
}
- .setNegativeButton("Annulla", null)
- .show()
+ .setNegativeButton("Annulla", null).show()
}
}
else -> {
- val msg = intent?.getStringExtra(
- android.content.pm.PackageInstaller.EXTRA_STATUS_MESSAGE
- ) ?: "status=$status"
- setInstallUI(
- "\u274C",
- getString(R.string.install_error_install),
- msg,
- 0xFFf87171.toInt(),
- btnEnabled = true,
- progress = -2
- )
+ 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")
- // Generic failure on an already-installed package: offer uninstall as last resort.
- val pkgInstalled = try {
- packageManager.getPackageInfo(targetPkg, 0); true
- } catch (_: Exception) { false }
+ 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\n" +
- "Se la versione precedente usa una firma diversa " +
- "bisogna prima disinstallarla.\n\n" +
- "Disinstalla ora e riprova automaticamente?")
+ .setMessage("Installazione fallita (status=$status).\n\nDisinstalla la versione precedente e riprova?")
.setPositiveButton("Disinstalla e riprova") { _, _ ->
- disableKioskLock() // release screen pin so uninstall UI can open
- startActivityForResult(
- Intent(Intent.ACTION_DELETE,
- android.net.Uri.parse("package:$targetPkg")),
- UNINSTALL_REQUEST
- )
+ disableKioskLock()
+ @Suppress("DEPRECATION")
+ startActivityForResult(Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$targetPkg")), UNINSTALL_REQUEST)
}
- .setNegativeButton("Annulla", null)
- .show()
+ .setNegativeButton("Annulla", null).show()
}
}
}
}
}
}
- val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
- RECEIVER_NOT_EXPORTED else 0
+ 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,
@@ -1371,28 +714,11 @@ class KioskActivity : AppCompatActivity() {
)
session.commit(pi2.intentSender)
}
- // "Installazione in corso…" is already set by the download-complete handler.
- // If called from onActivityResult (retry after uninstall), set it now.
- 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) {
- setInstallUI(
- "\u274C",
- getString(R.string.install_error_download),
- e.message ?: "",
- 0xFFf87171.toInt(),
- btnEnabled = true,
- progress = -2
- )
+ setInstallUI("\u274C", getString(R.string.install_error_download), e.message ?: "", 0xFFf87171.toInt(), btnEnabled = true, progress = -2)
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}")
}
}
@@ -1451,86 +777,62 @@ class KioskActivity : AppCompatActivity() {
enterImmersiveMode()
if (prefs.getBoolean(KEY_SETUP_COMPLETE, false) && webView.visibility == View.VISIBLE) {
val url = prefs.getString(KEY_URL, "") ?: ""
- if (url.isNotEmpty() && webView.url != url) {
- webView.loadUrl(url)
- }
- }
- if (!prefs.getBoolean(KEY_SETUP_COMPLETE, false) &&
- wizardContainer.visibility != View.VISIBLE &&
- splashContainer.visibility != View.VISIBLE) {
- showWizard()
- }
- if (currentStep == 3 && wizardContainer.visibility == View.VISIBLE) {
- val statusCard = findViewById(R.id.scaleStatusCard)
- // Only re-check if the user has already answered "Yes" (status card visible)
- if (statusCard.visibility == View.VISIBLE) checkGatewayStatus()
+ if (url.isNotEmpty() && webView.url != url) webView.loadUrl(url)
}
}
+ @Suppress("DEPRECATION")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
+
+ // Setup wizard completed
+ if (requestCode == SETUP_REQUEST) {
+ if (resultCode == RESULT_OK) {
+ val newUrl = prefs.getString(KEY_URL, "") ?: ""
+ ErrorReporter.init(this, newUrl)
+ enableKioskLock()
+ launchWebView()
+ } else {
+ // User exited setup without completing — close app
+ finishAffinity()
+ }
+ return
+ }
+
if (requestCode == FILE_CHOOSER_REQUEST) {
- val result = if (resultCode == RESULT_OK && data != null) {
- WebChromeClient.FileChooserParams.parseResult(resultCode, data)
- } else null
+ val result = if (resultCode == RESULT_OK && data != null)
+ WebChromeClient.FileChooserParams.parseResult(resultCode, data) else null
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)
}
- // System installer returned: OK = install succeeded.
if (requestCode == INSTALL_CONFIRM_REQUEST && resultCode == RESULT_OK) {
- setInstallUI(
- "\u2705",
- getString(R.string.install_success),
- getString(R.string.install_success_detail),
- 0xFF34d399.toInt(),
- btnEnabled = false,
- progress = -2
- )
- Handler(Looper.getMainLooper()).postDelayed({
- val card = try { findViewById(R.id.scaleStatusCard) } catch (_: Exception) { null }
- if (card?.visibility == View.VISIBLE) checkGatewayStatus()
- updateBanner.visibility = View.GONE
- bannerProgressBar.visibility = View.GONE
- }, 3000)
+ setInstallUI("\u2705", getString(R.string.install_success), getString(R.string.install_success_detail), 0xFF34d399.toInt(), btnEnabled = false, progress = -2)
+ Handler(Looper.getMainLooper()).postDelayed({ updateBanner.visibility = View.GONE; bannerProgressBar.visibility = View.GONE }, 3000)
}
- // Not OK = install failed (possibly signature conflict).
- // Show a dialog offering to uninstall the old version so the user can retry.
if (requestCode == INSTALL_CONFIRM_REQUEST && resultCode != RESULT_OK) {
- val f = pendingInstallFile
- val pkg = pendingInstallPkg
+ val f = pendingInstallFile; val pkg = pendingInstallPkg
if (f != null && f.exists() && pkg.isNotEmpty()) {
runOnUiThread {
androidx.appcompat.app.AlertDialog.Builder(this)
.setTitle("⚠️ Installazione non riuscita")
- .setMessage("Se hai visto un errore di conflitto firma, devi disinstallare la versione precedente.\n\nDisinstalla ora? L'installazione ripartirà automaticamente.")
+ .setMessage("Se hai visto un errore di conflitto firma, devi disinstallare la versione precedente.\n\nDisinstalla ora?")
.setPositiveButton("Disinstalla") { _, _ ->
- disableKioskLock() // release screen pin so uninstall UI can open
- startActivityForResult(
- Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$pkg")),
- UNINSTALL_REQUEST
- )
+ disableKioskLock()
+ startActivityForResult(Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$pkg")), UNINSTALL_REQUEST)
}
- .setNegativeButton("Annulla", null)
- .show()
+ .setNegativeButton("Annulla", null).show()
}
}
}
- // Returned from uninstall screen — re-enable kiosk lock, then auto-retry install.
if (requestCode == UNINSTALL_REQUEST) {
enableKioskLock()
- val f = pendingInstallFile
- val pkg = pendingInstallPkg
+ val f = pendingInstallFile; val pkg = pendingInstallPkg
if (f != null && f.exists() && pkg.isNotEmpty()) {
- // Small delay: give PackageManager time to finish processing the removal.
- Handler(Looper.getMainLooper()).postDelayed({
- installWithPackageInstaller(f, pkg)
- }, 600)
+ Handler(Looper.getMainLooper()).postDelayed({ installWithPackageInstaller(f, pkg) }, 600)
}
}
}
@@ -1546,6 +848,6 @@ class KioskActivity : AppCompatActivity() {
if (webView.visibility == View.VISIBLE && webView.canGoBack()) {
webView.goBack()
}
- // Block back button in kiosk mode
+ // Back button blocked in kiosk mode
}
}
diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/SetupActivity.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/SetupActivity.kt
new file mode 100644
index 0000000..1a0cfcc
--- /dev/null
+++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/SetupActivity.kt
@@ -0,0 +1,813 @@
+package it.dadaloop.evershelf.kiosk
+
+import android.Manifest
+import android.app.AlertDialog
+import android.app.DownloadManager
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.SharedPreferences
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.net.wifi.WifiManager
+import android.os.Build
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.provider.Settings
+import android.view.View
+import android.widget.EditText
+import android.widget.LinearLayout
+import android.widget.ProgressBar
+import android.widget.ScrollView
+import android.widget.TextView
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import com.google.android.material.button.MaterialButton
+import java.net.HttpURLConnection
+import java.net.URL
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.net.ssl.HttpsURLConnection
+import javax.net.ssl.SSLContext
+import javax.net.ssl.TrustManager
+import javax.net.ssl.X509TrustManager
+
+/**
+ * Full setup wizard — runs BEFORE KioskActivity locks the screen.
+ * The user can always exit (finishAffinity) via the ✕ button.
+ *
+ * Steps:
+ * 0 — Welcome / intro / privacy
+ * 1 — Permissions rationale + grant
+ * 2 — Server URL + auto-discovery + connection test
+ * 3 — Smart scale question → gateway info + install
+ * 4 — Done
+ */
+class SetupActivity : AppCompatActivity() {
+
+ private lateinit var prefs: SharedPreferences
+ private var currentStep = 0
+
+ // Step containers
+ private lateinit var stepWelcome: LinearLayout
+ private lateinit var stepPermissions: LinearLayout
+ private lateinit var stepServer: LinearLayout
+ private lateinit var stepScale: LinearLayout
+ private lateinit var stepDone: LinearLayout
+
+ // Progress dots
+ private lateinit var progressDots: LinearLayout
+
+ // Server step
+ private lateinit var urlEdit: EditText
+ private lateinit var urlStatus: TextView
+ private lateinit var btnTestUrl: MaterialButton
+ private lateinit var btnDiscover: MaterialButton
+ private lateinit var discoverStatus: TextView
+
+ // Scale step
+ private lateinit var scaleQuestionCard: LinearLayout
+ private lateinit var gatewayInfoCard: LinearLayout
+ private lateinit var gatewayInstallCard: LinearLayout
+ private lateinit var gatewayStatusIcon: TextView
+ private lateinit var gatewayStatusText: TextView
+ private lateinit var gatewayStatusDetail: TextView
+ private lateinit var btnInstallGateway: MaterialButton
+ private lateinit var gatewayProgressBar: ProgressBar
+ private lateinit var gatewayProgressText: TextView
+ private lateinit var step3NextButtons: LinearLayout
+
+ // Done step
+ private lateinit var summaryText: TextView
+
+ // Permissions step
+ private lateinit var permsGrantedCard: LinearLayout
+
+ // APK install state (for gateway)
+ private var pendingApkDownloadUrl = ""
+ private var pendingInstallFile: java.io.File? = null
+ private var pendingInstallPkg = ""
+ private val pollHandler = Handler(Looper.getMainLooper())
+ private var activeDownloadId: Long = -1
+
+ // Auto-discover cancellation flag
+ private val discoverCancelled = AtomicBoolean(false)
+
+ companion object {
+ private const val PREFS_NAME = "evershelf_kiosk"
+ private const val KEY_URL = "evershelf_url"
+ private const val KEY_SETUP_COMPLETE = "setup_complete"
+ private const val KEY_HAS_SCALE = "has_scale"
+ private const val GATEWAY_PACKAGE = "it.dadaloop.evershelf.scalegate"
+ private const val GATEWAY_DOWNLOAD_URL =
+ "https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk"
+ private const val INSTALL_PERM_REQUEST = 2001
+ private const val INSTALL_CONFIRM_REQUEST = 2002
+ private const val UNINSTALL_REQUEST = 2003
+ private const val PERMISSION_REQUEST_CODE = 2004
+ }
+
+ // ── Lifecycle ─────────────────────────────────────────────────────────
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_setup)
+ prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ bindViews()
+ showStep(0)
+ }
+
+ override fun onBackPressed() {
+ when (currentStep) {
+ 0 -> confirmExit()
+ else -> showStep(currentStep - 1)
+ }
+ }
+
+ override fun onDestroy() {
+ pollHandler.removeCallbacksAndMessages(null)
+ discoverCancelled.set(true)
+ super.onDestroy()
+ }
+
+ // ── Binding ────────────────────────────────────────────────────────────
+
+ private fun bindViews() {
+ progressDots = findViewById(R.id.setupProgressDots)
+ stepWelcome = findViewById(R.id.stepWelcome)
+ stepPermissions = findViewById(R.id.stepPermissions)
+ stepServer = findViewById(R.id.stepServer)
+ stepScale = findViewById(R.id.stepScale)
+ stepDone = findViewById(R.id.stepDone)
+
+ // Server step
+ urlEdit = findViewById(R.id.setupUrlEdit)
+ urlStatus = findViewById(R.id.setupUrlStatus)
+ btnTestUrl = findViewById(R.id.btnSetupTestUrl)
+ btnDiscover = findViewById(R.id.btnDiscover)
+ discoverStatus = findViewById(R.id.discoverStatus)
+
+ // Scale step
+ scaleQuestionCard = findViewById(R.id.scaleQuestionCard)
+ gatewayInfoCard = findViewById(R.id.gatewayInfoCard)
+ gatewayInstallCard = findViewById(R.id.gatewayInstallCard)
+ gatewayStatusIcon = findViewById(R.id.gatewayStatusIcon)
+ gatewayStatusText = findViewById(R.id.gatewayStatusText)
+ gatewayStatusDetail = findViewById(R.id.gatewayStatusDetail)
+ btnInstallGateway = findViewById(R.id.btnInstallGateway)
+ gatewayProgressBar = findViewById(R.id.gatewayProgressBar)
+ gatewayProgressText = findViewById(R.id.gatewayProgressText)
+ step3NextButtons = findViewById(R.id.step3NextButtons)
+
+ // Done step
+ summaryText = findViewById(R.id.setupSummaryText)
+
+ // Permissions step
+ permsGrantedCard = findViewById(R.id.permsGrantedCard)
+
+ // Pre-fill saved URL
+ val savedUrl = prefs.getString(KEY_URL, "") ?: ""
+ if (savedUrl.isNotEmpty()) urlEdit.setText(savedUrl)
+
+ // ── Welcome ──────────────────────────────────────────────────────
+ findViewById(R.id.btnSetupExit).setOnClickListener { confirmExit() }
+ findViewById(R.id.btnWelcomeStart).setOnClickListener { showStep(1) }
+
+ // ── Permissions ──────────────────────────────────────────────────
+ findViewById(R.id.btnGrantPerms).setOnClickListener { requestPermissions() }
+ findViewById(R.id.btnPermsBack).setOnClickListener { showStep(0) }
+ findViewById(R.id.btnPermsNext).setOnClickListener { showStep(2) }
+
+ // ── Server ───────────────────────────────────────────────────────
+ btnDiscover.setOnClickListener { autoDiscover() }
+ btnTestUrl.setOnClickListener { testConnection() }
+ findViewById(R.id.btnServerBack).setOnClickListener { showStep(1) }
+ findViewById(R.id.btnServerNext).setOnClickListener {
+ val url = urlEdit.text.toString().trim()
+ if (url.isEmpty()) {
+ showUrlStatus(getString(R.string.setup_enter_url), false)
+ return@setOnClickListener
+ }
+ prefs.edit().putString(KEY_URL, url).apply()
+ ErrorReporter.init(this, url)
+ showStep(3)
+ }
+
+ // ── Scale ─────────────────────────────────────────────────────────
+ findViewById(R.id.btnScaleYes).setOnClickListener {
+ scaleQuestionCard.visibility = View.GONE
+ gatewayInfoCard.visibility = View.VISIBLE
+ gatewayInstallCard.visibility = View.VISIBLE
+ step3NextButtons.visibility = View.VISIBLE
+ checkGatewayStatus()
+ }
+ findViewById(R.id.btnScaleNo).setOnClickListener {
+ prefs.edit().putBoolean(KEY_HAS_SCALE, false).apply()
+ showStep(4)
+ }
+ btnInstallGateway.setOnClickListener {
+ pendingApkDownloadUrl = GATEWAY_DOWNLOAD_URL
+ triggerApkDownload(GATEWAY_DOWNLOAD_URL)
+ }
+ findViewById(R.id.btnScaleBack).setOnClickListener { showStep(2) }
+ findViewById(R.id.btnScaleNext).setOnClickListener {
+ prefs.edit().putBoolean(KEY_HAS_SCALE, true).apply()
+ showStep(4)
+ }
+
+ // ── Done ──────────────────────────────────────────────────────────
+ findViewById(R.id.btnLaunch).setOnClickListener { finishSetup() }
+ }
+
+ // ── Step navigation ───────────────────────────────────────────────────
+
+ private fun showStep(step: Int) {
+ currentStep = step
+ stepWelcome.visibility = if (step == 0) View.VISIBLE else View.GONE
+ stepPermissions.visibility = if (step == 1) View.VISIBLE else View.GONE
+ stepServer.visibility = if (step == 2) View.VISIBLE else View.GONE
+ stepScale.visibility = if (step == 3) View.VISIBLE else View.GONE
+ stepDone.visibility = if (step == 4) View.VISIBLE else View.GONE
+
+ updateProgressDots()
+
+ // Reset scale step when entering it
+ if (step == 3) {
+ scaleQuestionCard.visibility = View.VISIBLE
+ gatewayInfoCard.visibility = View.GONE
+ gatewayInstallCard.visibility = View.GONE
+ step3NextButtons.visibility = View.GONE
+ }
+
+ // Build summary when entering done step
+ if (step == 4) buildSummary()
+
+ // Cancel auto-discover when leaving server step
+ if (step != 2) discoverCancelled.set(true)
+
+ // Scroll to top
+ try { findViewById(R.id.setupScrollView).scrollTo(0, 0) } catch (_: Exception) {}
+ }
+
+ private fun updateProgressDots() {
+ progressDots.removeAllViews()
+ // 4 dots (steps 1–4); step 0 welcome uses no dots
+ val active = maxOf(currentStep, 1)
+ val density = resources.displayMetrics.density
+ for (i in 1..4) {
+ val dot = View(this)
+ val sizeDp = if (i == active) 10 else 7
+ val px = (sizeDp * density).toInt()
+ val lp = LinearLayout.LayoutParams(px, px)
+ lp.marginStart = (5 * density).toInt()
+ lp.marginEnd = (5 * density).toInt()
+ dot.layoutParams = lp
+ val bg = android.graphics.drawable.GradientDrawable()
+ bg.shape = android.graphics.drawable.GradientDrawable.OVAL
+ bg.setColor(when {
+ i < active -> 0xFF34d399.toInt() // completed
+ i == active -> 0xFF7c3aed.toInt() // current
+ else -> 0xFF334155.toInt() // future
+ })
+ dot.background = bg
+ progressDots.addView(dot)
+ }
+ }
+
+ // ── Exit ──────────────────────────────────────────────────────────────
+
+ private fun confirmExit() {
+ AlertDialog.Builder(this)
+ .setTitle(getString(R.string.setup_exit_title))
+ .setMessage(getString(R.string.setup_exit_message))
+ .setPositiveButton(getString(R.string.setup_exit_confirm)) { _, _ ->
+ pollHandler.removeCallbacksAndMessages(null)
+ discoverCancelled.set(true)
+ finishAffinity()
+ }
+ .setNegativeButton(getString(R.string.setup_exit_cancel), null)
+ .show()
+ }
+
+ // ── Permissions ───────────────────────────────────────────────────────
+
+ private fun allPermissionsGranted(): Boolean {
+ val cam = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
+ val mic = ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
+ return cam && mic
+ }
+
+ private fun requestPermissions() {
+ val needed = mutableListOf()
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED)
+ needed.add(Manifest.permission.CAMERA)
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED)
+ needed.add(Manifest.permission.RECORD_AUDIO)
+ if (Build.VERSION.SDK_INT >= 33) {
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_MEDIA_IMAGES) != PackageManager.PERMISSION_GRANTED)
+ needed.add(Manifest.permission.READ_MEDIA_IMAGES)
+ } else {
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED)
+ needed.add(Manifest.permission.READ_EXTERNAL_STORAGE)
+ }
+ if (needed.isEmpty()) {
+ // Already granted — show confirmation and allow next
+ permsGrantedCard.visibility = View.VISIBLE
+ } else {
+ ActivityCompat.requestPermissions(this, needed.toTypedArray(), PERMISSION_REQUEST_CODE)
+ }
+ }
+
+ override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+ if (requestCode == PERMISSION_REQUEST_CODE) {
+ permsGrantedCard.visibility = View.VISIBLE
+ // Proceed to next step regardless — user can always grant later
+ Handler(Looper.getMainLooper()).postDelayed({ showStep(2) }, 600)
+ }
+ }
+
+ // ── Connection Test ───────────────────────────────────────────────────
+
+ private fun testConnection() {
+ val url = urlEdit.text.toString().trim()
+ if (url.isEmpty()) { showUrlStatus(getString(R.string.setup_enter_url), false); return }
+ showUrlStatus(getString(R.string.setup_testing), null)
+
+ Thread {
+ val base = url.trimEnd('/')
+ // Try both API path variants
+ val candidates = listOf(
+ "$base/api/index.php?action=get_settings",
+ "$base/api/?action=get_settings"
+ )
+ var found = false
+ for (apiUrl in candidates) {
+ val conn = openConn(apiUrl) ?: continue
+ try {
+ val code = conn.responseCode
+ if (code !in 200..399) { conn.disconnect(); continue }
+ val body = conn.inputStream.bufferedReader().readText()
+ conn.disconnect()
+ if (body.contains("gemini_key_set") || body.contains("\"success\"")) {
+ found = true; break
+ }
+ } catch (_: Exception) { try { conn.disconnect() } catch (_: Exception) {} }
+ }
+ // If API not found, try plain base URL to distinguish unreachable vs wrong path
+ if (!found) {
+ var baseReachable = false
+ try {
+ val conn = openConn(base) ?: openConn("$base/")
+ val code = conn?.responseCode ?: -1
+ conn?.disconnect()
+ baseReachable = code in 200..499
+ } catch (_: Exception) {}
+ runOnUiThread {
+ if (baseReachable) {
+ showUrlStatus("⚠ ${getString(R.string.setup_api_not_found)}", false)
+ } else {
+ showUrlStatus("✗ ${getString(R.string.setup_unreachable)}", false)
+ }
+ }
+ } else {
+ runOnUiThread { showUrlStatus("✅ ${getString(R.string.setup_server_found)}", true) }
+ }
+ }.start()
+ }
+
+ private fun showUrlStatus(text: String, success: Boolean?) {
+ urlStatus.visibility = View.VISIBLE
+ urlStatus.text = text
+ urlStatus.setTextColor(when (success) {
+ true -> 0xFF34d399.toInt()
+ false -> 0xFFf87171.toInt()
+ null -> 0xFF94a3b8.toInt()
+ })
+ }
+
+ private fun openConn(urlStr: String): HttpURLConnection? {
+ return try {
+ val conn = URL(urlStr).openConnection()
+ if (conn is HttpsURLConnection) {
+ val trustAll = arrayOf(object : X509TrustManager {
+ override fun checkClientTrusted(c: Array?, t: String?) {}
+ override fun checkServerTrusted(c: Array?, t: String?) {}
+ override fun getAcceptedIssuers(): Array = arrayOf()
+ })
+ val sc = SSLContext.getInstance("TLS")
+ sc.init(null, trustAll, java.security.SecureRandom())
+ conn.sslSocketFactory = sc.socketFactory
+ conn.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
+ }
+ (conn as HttpURLConnection).apply {
+ requestMethod = "GET"
+ connectTimeout = 3000
+ readTimeout = 3000
+ }
+ } catch (_: Exception) { null }
+ }
+
+ // ── Auto-Discover ─────────────────────────────────────────────────────
+
+ @Suppress("DEPRECATION")
+ private fun autoDiscover() {
+ discoverCancelled.set(false)
+ btnDiscover.isEnabled = false
+ btnDiscover.text = getString(R.string.setup_discovering)
+ discoverStatus.visibility = View.VISIBLE
+ discoverStatus.text = getString(R.string.setup_discovering_detail)
+ discoverStatus.setTextColor(0xFF94a3b8.toInt())
+
+ // Determine local subnet
+ val wifiMgr = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
+ val ipInt = wifiMgr.connectionInfo.ipAddress
+ val subnets = mutableListOf()
+ if (ipInt != 0) {
+ val a = (ipInt shr 0) and 0xFF
+ val b = (ipInt shr 8) and 0xFF
+ val c = (ipInt shr 16) and 0xFF
+ subnets += "$a.$b.$c"
+ }
+ // Always include common subnets as fallback
+ for (s in listOf("192.168.1", "192.168.0", "192.168.2", "10.0.0")) {
+ if (!subnets.contains(s)) subnets += s
+ }
+
+ val ports = listOf(80, 8080)
+ val paths = listOf(
+ "/api/index.php?action=get_settings",
+ "/dispensa/api/index.php?action=get_settings",
+ "/evershelf/api/index.php?action=get_settings"
+ )
+ val executor = Executors.newFixedThreadPool(40)
+ val found = AtomicBoolean(false)
+
+ Thread {
+ val futures = mutableListOf>()
+ outer@ for (subnet in subnets) {
+ for (i in 1..254) {
+ if (discoverCancelled.get() || found.get()) break@outer
+ val ip = "$subnet.$i"
+ for (port in ports) {
+ if (discoverCancelled.get() || found.get()) break@outer
+ futures += executor.submit submit@{
+ if (discoverCancelled.get() || found.get()) return@submit null
+ val scheme = if (port == 443 || port == 8443) "https" else "http"
+ for (path in paths) {
+ val urlStr = "$scheme://$ip:$port$path"
+ try {
+ val conn = openConn(urlStr) ?: continue
+ val code = conn.responseCode
+ if (code in 200..399) {
+ val body = conn.inputStream.bufferedReader().readText()
+ conn.disconnect()
+ if (body.contains("gemini_key_set") || body.contains("\"success\"")) {
+ val base = urlStr.substringBefore("/api/")
+ return@submit "$base/"
+ }
+ } else conn.disconnect()
+ } catch (_: Exception) {}
+ }
+ null
+ }
+ }
+ }
+ }
+
+ // Collect results
+ for (f in futures) {
+ if (discoverCancelled.get()) break
+ val result = try { f.get(4, TimeUnit.SECONDS) } catch (_: Exception) { null }
+ if (result != null && found.compareAndSet(false, true)) {
+ runOnUiThread {
+ urlEdit.setText(result)
+ discoverStatus.text = "✅ ${getString(R.string.setup_server_found)}: $result"
+ discoverStatus.setTextColor(0xFF34d399.toInt())
+ showUrlStatus("✅ ${getString(R.string.setup_server_found)}", true)
+ btnDiscover.isEnabled = true
+ btnDiscover.text = getString(R.string.setup_discover_btn)
+ }
+ break
+ }
+ }
+ executor.shutdown()
+
+ if (!found.get() && !discoverCancelled.get()) {
+ runOnUiThread {
+ discoverStatus.text = getString(R.string.setup_discover_not_found)
+ discoverStatus.setTextColor(0xFFf87171.toInt())
+ btnDiscover.isEnabled = true
+ btnDiscover.text = getString(R.string.setup_discover_btn)
+ }
+ } else if (!found.get()) {
+ runOnUiThread {
+ btnDiscover.isEnabled = true
+ btnDiscover.text = getString(R.string.setup_discover_btn)
+ }
+ }
+ }.start()
+ }
+
+ // ── Gateway ────────────────────────────────────────────────────────────
+
+ private fun isGatewayInstalled() = try {
+ packageManager.getPackageInfo(GATEWAY_PACKAGE, 0); true
+ } catch (_: PackageManager.NameNotFoundException) { false }
+
+ private fun checkGatewayStatus() {
+ if (isGatewayInstalled()) {
+ gatewayStatusIcon.text = "✅"
+ gatewayStatusText.text = getString(R.string.wizard_gateway_installed)
+ gatewayStatusDetail.text = getString(R.string.wizard_gateway_installed_detail)
+ gatewayStatusDetail.setTextColor(0xFF34d399.toInt())
+ btnInstallGateway.visibility = View.GONE
+ gatewayProgressBar.visibility = View.GONE
+ gatewayProgressText.visibility = View.GONE
+ } else {
+ gatewayStatusIcon.text = "📲"
+ gatewayStatusText.text = getString(R.string.wizard_gateway_not_installed)
+ gatewayStatusDetail.text = getString(R.string.wizard_gateway_not_installed_detail)
+ gatewayStatusDetail.setTextColor(0xFFfbbf24.toInt())
+ btnInstallGateway.visibility = View.VISIBLE
+ }
+ }
+
+ private fun setGatewayUI(icon: String, text: String, detail: String, color: Int,
+ btnEnabled: Boolean = true, progress: Int = -2) {
+ runOnUiThread {
+ gatewayStatusIcon.text = icon
+ gatewayStatusText.text = text
+ gatewayStatusDetail.text = detail
+ gatewayStatusDetail.setTextColor(color)
+ btnInstallGateway.isEnabled = btnEnabled
+ when {
+ progress == -2 -> {
+ gatewayProgressBar.visibility = View.GONE
+ gatewayProgressText.visibility = View.GONE
+ }
+ progress == -1 -> {
+ gatewayProgressBar.isIndeterminate = true
+ gatewayProgressBar.visibility = View.VISIBLE
+ gatewayProgressText.visibility = View.GONE
+ }
+ else -> {
+ gatewayProgressBar.isIndeterminate = false
+ gatewayProgressBar.progress = progress
+ gatewayProgressBar.visibility = View.VISIBLE
+ gatewayProgressText.visibility = View.VISIBLE
+ }
+ }
+ }
+ }
+
+ private fun startProgressPoll(downloadId: Long) {
+ activeDownloadId = downloadId
+ pollHandler.removeCallbacksAndMessages(null)
+ fun tick() {
+ if (activeDownloadId != downloadId) return
+ val dm = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
+ val c = dm.query(DownloadManager.Query().setFilterById(downloadId))
+ if (!c.moveToFirst()) { c.close(); return }
+ val status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
+ if (status == DownloadManager.STATUS_RUNNING || status == DownloadManager.STATUS_PENDING) {
+ val dl = c.getLong(c.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
+ val tot = c.getLong(c.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
+ c.close()
+ val pct = if (tot > 0) (dl * 100 / tot).toInt() else 0
+ val txt = if (tot > 0) "%.1f / %.1f MB".format(dl / 1_048_576f, tot / 1_048_576f) else ""
+ setGatewayUI(
+ "⏳",
+ getString(R.string.install_downloading) + if (tot > 0) " ($pct%)" else "",
+ txt, 0xFF94a3b8.toInt(), btnEnabled = false, progress = pct
+ )
+ runOnUiThread { gatewayProgressText.text = txt }
+ pollHandler.postDelayed({ tick() }, 500)
+ } else {
+ c.close()
+ }
+ }
+ pollHandler.post { tick() }
+ }
+
+ private fun triggerApkDownload(apkUrl: String) {
+ pendingApkDownloadUrl = apkUrl
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !packageManager.canRequestPackageInstalls()) {
+ @Suppress("DEPRECATION")
+ startActivityForResult(
+ Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName")),
+ INSTALL_PERM_REQUEST
+ )
+ return
+ }
+ setGatewayUI("⏳", getString(R.string.install_downloading), "", 0xFF94a3b8.toInt(), btnEnabled = false, progress = -1)
+ val destDir = getExternalFilesDir(null) ?: filesDir
+ val destFile = java.io.File(destDir, "evershelf-gateway-setup.apk")
+ val dm = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
+ val req = DownloadManager.Request(Uri.parse(apkUrl)).apply {
+ setTitle("EverShelf Scale Gateway")
+ setDescription(getString(R.string.install_downloading))
+ setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
+ setDestinationUri(Uri.fromFile(destFile))
+ setMimeType("application/vnd.android.package-archive")
+ }
+ val downloadId = dm.enqueue(req)
+ startProgressPoll(downloadId)
+
+ val receiver = object : BroadcastReceiver() {
+ override fun onReceive(ctx: Context?, intent: Intent?) {
+ 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()) {
+ ok = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) ==
+ DownloadManager.STATUS_SUCCESSFUL
+ }
+ c.close()
+ pollHandler.removeCallbacksAndMessages(null)
+ activeDownloadId = -1
+ if (ok) {
+ setGatewayUI("⏳", getString(R.string.install_installing), "", 0xFF94a3b8.toInt(), btnEnabled = false, progress = -1)
+ installApk(destFile)
+ } else {
+ setGatewayUI("❌", getString(R.string.install_error_download), getString(R.string.install_error_download_detail), 0xFFf87171.toInt())
+ }
+ }
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), RECEIVER_EXPORTED)
+ } else {
+ @Suppress("UnspecifiedRegisterReceiverFlag")
+ registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
+ }
+ }
+
+ private fun installApk(file: java.io.File) {
+ if (!file.exists() || file.length() == 0L) {
+ setGatewayUI("❌", getString(R.string.install_error_download), "File APK non trovato sul dispositivo.", 0xFFf87171.toInt())
+ return
+ }
+ // Validate APK magic bytes (ZIP header)
+ val magic = try { file.inputStream().use { s -> val b = ByteArray(4); s.read(b); b } } catch (_: Exception) { null }
+ if (magic == null || magic[0] != 0x50.toByte() || magic[1] != 0x4B.toByte()) {
+ setGatewayUI("❌", getString(R.string.install_error_download), "Il file scaricato non è un APK valido.", 0xFFf87171.toInt())
+ file.delete()
+ return
+ }
+ installWithPackageInstaller(file, GATEWAY_PACKAGE)
+ }
+
+ private fun installWithPackageInstaller(file: java.io.File, targetPkg: String) {
+ try {
+ 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)
+ }
+ }
+ val action = "it.dadaloop.evershelf.kiosk.SETUP_INSTALL_$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 -> {
+ @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
+ @Suppress("DEPRECATION")
+ startActivityForResult(confirmIntent, INSTALL_CONFIRM_REQUEST)
+ }
+ }
+ android.content.pm.PackageInstaller.STATUS_SUCCESS -> {
+ setGatewayUI("✅", getString(R.string.install_success), getString(R.string.install_success_detail), 0xFF34d399.toInt(), btnEnabled = false)
+ Handler(Looper.getMainLooper()).postDelayed({ checkGatewayStatus() }, 1500)
+ }
+ android.content.pm.PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
+ android.content.pm.PackageInstaller.STATUS_FAILURE_CONFLICT -> {
+ runOnUiThread { offerUninstallAndRetry(file, targetPkg) }
+ }
+ else -> {
+ val msg = intent?.getStringExtra(android.content.pm.PackageInstaller.EXTRA_STATUS_MESSAGE) ?: "status=$status"
+ setGatewayUI("❌", getString(R.string.install_error_install), msg, 0xFFf87171.toInt())
+ val pkgInstalled = try { packageManager.getPackageInfo(targetPkg, 0); true } catch (_: Exception) { false }
+ if (pkgInstalled) runOnUiThread { offerUninstallAndRetry(file, targetPkg) }
+ }
+ }
+ }
+ }
+ 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)
+ }
+ setGatewayUI("⏳", getString(R.string.install_installing), "", 0xFF94a3b8.toInt(), btnEnabled = false, progress = -1)
+ } catch (e: Exception) {
+ setGatewayUI("❌", getString(R.string.install_error_install), e.message ?: "", 0xFFf87171.toInt())
+ }
+ }
+
+ private fun offerUninstallAndRetry(file: java.io.File, pkg: String) {
+ pendingInstallFile = file
+ pendingInstallPkg = pkg
+ AlertDialog.Builder(this)
+ .setTitle("⚠️ Conflitto firma APK")
+ .setMessage("L'app installata usa una firma diversa. Devi prima disinstallare la versione precedente.\n\nDisinstalla ora? L'installazione riprenderà automaticamente.")
+ .setPositiveButton("Disinstalla") { _, _ ->
+ @Suppress("DEPRECATION")
+ startActivityForResult(
+ Intent(Intent.ACTION_DELETE, Uri.parse("package:$pkg")),
+ UNINSTALL_REQUEST
+ )
+ }
+ .setNegativeButton("Annulla", null)
+ .show()
+ }
+
+ // ── Summary / Finish ─────────────────────────────────────────────────
+
+ private fun buildSummary() {
+ val url = prefs.getString(KEY_URL, "") ?: ""
+ val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false)
+ val gwOk = hasScale && isGatewayInstalled()
+ val sb = StringBuilder()
+ if (url.isNotEmpty()) sb.appendLine("🌐 Server: $url")
+ sb.appendLine(when {
+ gwOk -> "✅ Scale Gateway: installato"
+ hasScale -> "⚠️ Scale Gateway: non ancora installato"
+ else -> "⏭ Bilancia: non configurata"
+ })
+ summaryText.text = sb.toString().trimEnd()
+ }
+
+ private fun finishSetup() {
+ prefs.edit().putBoolean(KEY_SETUP_COMPLETE, true).apply()
+ setResult(RESULT_OK)
+ finish()
+ }
+
+ // ── Activity Results ─────────────────────────────────────────────────
+
+ @Suppress("DEPRECATION")
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+ when (requestCode) {
+ INSTALL_PERM_REQUEST -> {
+ if (pendingApkDownloadUrl.isNotEmpty()) triggerApkDownload(pendingApkDownloadUrl)
+ }
+ INSTALL_CONFIRM_REQUEST -> {
+ if (resultCode == RESULT_OK) {
+ setGatewayUI("✅", getString(R.string.install_success), getString(R.string.install_success_detail), 0xFF34d399.toInt(), btnEnabled = false)
+ Handler(Looper.getMainLooper()).postDelayed({ checkGatewayStatus() }, 1500)
+ } else {
+ val f = pendingInstallFile
+ val pkg = pendingInstallPkg
+ if (f != null && f.exists() && pkg.isNotEmpty()) {
+ runOnUiThread {
+ AlertDialog.Builder(this)
+ .setTitle("⚠️ Installazione non riuscita")
+ .setMessage("Se c'è un conflitto di firma, devi disinstallare la versione precedente.\n\nDisinstalla ora?")
+ .setPositiveButton("Disinstalla") { _, _ ->
+ startActivityForResult(Intent(Intent.ACTION_DELETE, Uri.parse("package:$pkg")), UNINSTALL_REQUEST)
+ }
+ .setNegativeButton("Annulla", null).show()
+ }
+ }
+ }
+ }
+ UNINSTALL_REQUEST -> {
+ val f = pendingInstallFile
+ val pkg = pendingInstallPkg
+ if (f != null && f.exists() && pkg.isNotEmpty()) {
+ Handler(Looper.getMainLooper()).postDelayed({ installWithPackageInstaller(f, pkg) }, 600)
+ }
+ }
+ }
+ }
+}
diff --git a/evershelf-kiosk/app/src/main/res/layout/activity_kiosk.xml b/evershelf-kiosk/app/src/main/res/layout/activity_kiosk.xml
index da984be..d8ccf4b 100644
--- a/evershelf-kiosk/app/src/main/res/layout/activity_kiosk.xml
+++ b/evershelf-kiosk/app/src/main/res/layout/activity_kiosk.xml
@@ -4,7 +4,7 @@
android:layout_height="match_parent"
android:background="#0f172a">
-
+
@@ -43,486 +43,6 @@
android:indeterminateTint="#7c3aed" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ android:text="" />
-
+
+
+
+
+
+
diff --git a/evershelf-kiosk/app/src/main/res/layout/activity_setup.xml b/evershelf-kiosk/app/src/main/res/layout/activity_setup.xml
new file mode 100644
index 0000000..9408388
--- /dev/null
+++ b/evershelf-kiosk/app/src/main/res/layout/activity_setup.xml
@@ -0,0 +1,961 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/evershelf-kiosk/app/src/main/res/values-de/strings.xml b/evershelf-kiosk/app/src/main/res/values-de/strings.xml
index 075e91e..99d2c4c 100644
--- a/evershelf-kiosk/app/src/main/res/values-de/strings.xml
+++ b/evershelf-kiosk/app/src/main/res/values-de/strings.xml
@@ -2,6 +2,21 @@
EverShelf Kiosk
+
+ Bitte zuerst eine URL eingeben
+ Verbindung wird getestet…
+ EverShelf-Server gefunden und API aktiv!
+ Server erreichbar, aber EverShelf-API nicht gefunden. Pfad prüfen.
+ Server nicht erreichbar
+ 🔍 Lokales Netzwerk durchsuchen
+ Suche läuft…
+ Suche nach EverShelf-Servern im lokalen Netzwerk (kann bis zu 30 s dauern)…
+ Kein EverShelf-Server automatisch gefunden. URL manuell eingeben.
+ Setup beenden?
+ Die Einrichtung kann später beim erneuten Öffnen der App abgeschlossen werden.
+ Beenden
+ Weiter
+
Smart-Waage (Optional)
Um eine Bluetooth-Küchenwaage zu verwenden, musst du die EverShelf Scale Gateway App separat installieren.
diff --git a/evershelf-kiosk/app/src/main/res/values-it/strings.xml b/evershelf-kiosk/app/src/main/res/values-it/strings.xml
index 8ed3ada..f9cc293 100644
--- a/evershelf-kiosk/app/src/main/res/values-it/strings.xml
+++ b/evershelf-kiosk/app/src/main/res/values-it/strings.xml
@@ -2,6 +2,21 @@
EverShelf Kiosk
+
+ Inserisci prima un URL
+ Verifica connessione…
+ Server EverShelf trovato e API attiva!
+ Server raggiungibile ma API EverShelf non trovata. Verifica il percorso.
+ Impossibile raggiungere il server
+ 🔍 Cerca nella rete locale
+ Scansione in corso…
+ Ricerca server EverShelf nella rete locale (potrebbe richiedere fino a 30 s)…
+ Nessun server EverShelf trovato automaticamente. Inserisci l\'URL manualmente.
+ Uscire dalla configurazione?
+ Puoi completare la configurazione più tardi riaprendo l\'app.
+ Esci
+ Continua
+
Bilancia Smart (Opzionale)
Per usare una bilancia da cucina Bluetooth, devi installare l\'app EverShelf Scale Gateway separatamente.
diff --git a/evershelf-kiosk/app/src/main/res/values/strings.xml b/evershelf-kiosk/app/src/main/res/values/strings.xml
index 3384770..f8d6734 100644
--- a/evershelf-kiosk/app/src/main/res/values/strings.xml
+++ b/evershelf-kiosk/app/src/main/res/values/strings.xml
@@ -1,6 +1,21 @@
EverShelf Kiosk
+
+ Please enter a URL first
+ Testing connection…
+ EverShelf server found and API active!
+ Server reachable but EverShelf API not found. Check the path.
+ Cannot reach server
+ 🔍 Search local network
+ Scanning…
+ Scanning local network for EverShelf servers (this may take up to 30 s)…
+ No EverShelf server found automatically. Enter the URL manually.
+ Exit setup?
+ You can complete setup later when you reopen the app.
+ Exit
+ Continue
+
Smart Scale (Optional)
To use a Bluetooth kitchen scale, you need the EverShelf Scale Gateway app installed separately.