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.