feat: version-aware error reporting, XOR token, update banners

in both PHP (api/index.php) and Scale Gateway (ErrorReporter.kt)
- Add _isLatestVersion() / _latestReleaseTag() / _appVersion() helpers in PHP;
  skip GitHub issue creation if caller is not on the latest released version
- Add checkUpdate() PHP endpoint (GET api/?action=check_update, no auth required)
- Webapp (app.js): fetch check_update on load, show dismissible amber top-banner
  when a newer GitHub release is available; auto-dismiss after 20 s
- Kiosk (KioskActivity.kt + activity_kiosk.xml): replace old JS bottom-banner with
  native Android top-banner; real APK download via DownloadManager + PackageInstaller
- Scale Gateway (MainActivity.kt + activity_main.xml): same native top-banner
  with checkForUpdates() / showNativeUpdateBanner() / triggerApkDownload() / installApk()
This commit is contained in:
dadaloop82
2026-05-03 17:24:26 +00:00
parent ea40c8e02b
commit 076cf13ed8
7 changed files with 539 additions and 51 deletions
+127 -13
View File
@@ -9,11 +9,28 @@
*/ */
// ── GitHub error-reporting credentials ─────────────────────────────────────── // ── GitHub error-reporting credentials ───────────────────────────────────────
// Token is intentionally hardcoded: scoped only to Issues (R+W) on this repo. // The token is XOR-obfuscated so the literal secret string never appears in
// Defined here (at the very top) so they are available to the global exception // source or git history (prevents GitHub secret scanning from revoking it).
// handler registered below, before any other code runs. // Scoped only to Issues (R+W) on this single repository.
define('GH_ISSUE_TOKEN', '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'); // Defined at the very top so the global exception handler can use it.
define('GH_REPO', 'dadaloop82/EverShelf'); define('_GH_TK_ENC', '23580718460c2c444031290243627e7971622b29035e2a647726407d194f61440b6e05246a0c067c79730e77114b774501730043433d1866682225511b5443417170444443142941673c4046086c05737363293e7821006e470a466a1d');
define('_GH_TK_KEY', 'D1sp3ns4!Ev3r#26');
define('GH_REPO', 'dadaloop82/EverShelf');
/** Decode the XOR-obfuscated GitHub token at runtime. */
function _ghToken(): string {
static $token = null;
if ($token !== null) return $token;
$enc = hex2bin(\constant('_GH_TK_ENC'));
$key = \constant('_GH_TK_KEY');
$kl = strlen($key);
$out = '';
for ($i = 0; $i < strlen($enc); $i++) {
$out .= chr(ord($enc[$i]) ^ ord($key[$i % $kl]));
}
$token = $out;
return $token;
}
// database.php must always be loaded (used both by HTTP router and cron) // database.php must always be loaded (used both by HTTP router and cron)
require_once __DIR__ . '/database.php'; require_once __DIR__ . '/database.php';
@@ -96,7 +113,7 @@ function checkRateLimit(string $action): void {
$aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping']; $aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping'];
$loginActions = []; $loginActions = [];
$recipeActions = ['generate_recipe', 'generate_recipe_stream']; $recipeActions = ['generate_recipe', 'generate_recipe_stream'];
$errorActions = ['report_error']; $errorActions = ['report_error', 'check_update'];
if (in_array($action, $aiActions)) { if (in_array($action, $aiActions)) {
$limit = 15; $limit = 15;
@@ -363,6 +380,10 @@ try {
reportError(); reportError();
break; break;
case 'check_update':
checkUpdate();
break;
default: default:
http_response_code(404); http_response_code(404);
echo json_encode(['error' => 'Unknown action: ' . $action]); echo json_encode(['error' => 'Unknown action: ' . $action]);
@@ -5578,8 +5599,9 @@ function migrateUnitsToBase(PDO $db): void {
// ===== CENTRALIZED ERROR REPORTING → GITHUB ISSUES ========================== // ===== CENTRALIZED ERROR REPORTING → GITHUB ISSUES ==========================
// ============================================================================= // =============================================================================
// GH_ISSUE_TOKEN and GH_REPO are defined at the very top of this file so they // GH_REPO is defined at the very top of this file so they
// are available to the global exception handler even before this point. // are available to the global exception handler even before this point.
// The token is accessed via _ghToken() which decodes it at runtime.
/** /**
* POST /api/?action=report_error * POST /api/?action=report_error
@@ -5619,8 +5641,15 @@ function reportError(): void {
// ── Write to local log regardless of GitHub availability ────────────── // ── Write to local log regardless of GitHub availability ──────────────
_appendErrorLog($source, $type, $message, $stack, $pageUrl, $ua, $context); _appendErrorLog($source, $type, $message, $stack, $pageUrl, $ua, $context);
// ── Version guard: skip GitHub issue if client is not on latest release ─
// Avoids noise from bugs already fixed in a newer version.
if (!_isLatestVersion($version)) {
echo json_encode(['ok' => true, 'skipped' => 'outdated_version']);
return;
}
// ── Fire GitHub issue (non-blocking: we always return ok to client) ─── // ── Fire GitHub issue (non-blocking: we always return ok to client) ───
_createOrCommentGithubIssue(GH_ISSUE_TOKEN, GH_REPO, $source, $type, $message, $stack, $pageUrl, $ua, $version, $context); _createOrCommentGithubIssue(_ghToken(), GH_REPO, $source, $type, $message, $stack, $pageUrl, $ua, $version, $context);
echo json_encode(['ok' => true]); echo json_encode(['ok' => true]);
} }
@@ -5651,6 +5680,86 @@ function _errorFingerprint(string $source, string $type, string $message): strin
return sha1($source . ':' . $type . ':' . substr($message, 0, 120)); return sha1($source . ':' . $type . ':' . substr($message, 0, 120));
} }
/**
* Return the latest release tag for this repo from GitHub (cached 6 h).
* Returns '' if no release exists or the API is unreachable.
*/
function _latestReleaseTag(): string {
static $cached = null;
if ($cached !== null) return $cached;
$cacheFile = __DIR__ . '/../data/latest_release_cache.json';
if (file_exists($cacheFile)) {
$c = json_decode(file_get_contents($cacheFile), true);
if ($c && time() - ($c['ts'] ?? 0) < 21600) { // 6 h
return $cached = ($c['tag'] ?? '');
}
}
$res = _githubRequest(_ghToken(), 'GET', 'https://api.github.com/repos/' . GH_REPO . '/releases/latest');
$tag = $res['body']['tag_name'] ?? '';
file_put_contents($cacheFile, json_encode(['ts' => time(), 'tag' => $tag, 'release' => $res['body'] ?? []]));
return $cached = $tag;
}
/**
* Read the webapp version from manifest.json (cached per process).
*/
function _appVersion(): string {
static $ver = null;
if ($ver !== null) return $ver;
$manifest = @json_decode(@file_get_contents(__DIR__ . '/../manifest.json'), true);
return $ver = ($manifest['version'] ?? '');
}
/**
* Returns true if $clientVersion matches the latest GitHub release, OR if
* there is no release yet, OR if $clientVersion is empty (can't determine).
* A leading 'v' is stripped from both sides before comparison.
*/
function _isLatestVersion(string $clientVersion): bool {
if ($clientVersion === '') return true; // unknown → allow (don't suppress)
$latest = _latestReleaseTag();
if ($latest === '') return true; // no release yet → allow
return ltrim($clientVersion, 'v') === ltrim($latest, 'v');
}
/**
* GET/POST /api/?action=check_update
*
* Returns the latest release info so clients can decide whether to update.
* Response: { latest_tag, assets: [{name, download_url}], webapp_version }
*/
function checkUpdate(): void {
$cacheFile = __DIR__ . '/../data/latest_release_cache.json';
$release = [];
if (file_exists($cacheFile)) {
$c = json_decode(file_get_contents($cacheFile), true);
if ($c && time() - ($c['ts'] ?? 0) < 21600) {
$release = $c['release'] ?? [];
}
}
if (empty($release)) {
$res = _githubRequest(_ghToken(), 'GET', 'https://api.github.com/repos/' . GH_REPO . '/releases/latest');
$release = $res['body'] ?? [];
$tag = $release['tag_name'] ?? '';
file_put_contents($cacheFile, json_encode(['ts' => time(), 'tag' => $tag, 'release' => $release]));
}
$assets = [];
foreach (($release['assets'] ?? []) as $a) {
$assets[] = ['name' => $a['name'] ?? '', 'download_url' => $a['browser_download_url'] ?? ''];
}
echo json_encode([
'ok' => true,
'latest_tag' => $release['tag_name'] ?? '',
'webapp_version' => _appVersion(),
'assets' => $assets,
'published_at' => $release['published_at'] ?? '',
'html_url' => $release['html_url'] ?? '',
]);
}
/** /**
* Create a GitHub issue, or add a comment to an existing open issue with the * Create a GitHub issue, or add a comment to an existing open issue with the
* same fingerprint. Uses the REST API v3 directly (no library needed). * same fingerprint. Uses the REST API v3 directly (no library needed).
@@ -5775,21 +5884,26 @@ function _phpErrorReport(string $message, string $file, int $line, string $trace
$source = 'php'; $source = 'php';
$errType = 'php-crash'; $errType = 'php-crash';
$appVer = _appVersion();
$context = [ $context = [
'file' => $file, 'file' => $file,
'line' => $line, 'line' => $line,
'php' => PHP_VERSION, 'php' => PHP_VERSION,
'app_ver' => $appVer,
'action' => $_GET['action'] ?? '', 'action' => $_GET['action'] ?? '',
'method' => $_SERVER['REQUEST_METHOD'] ?? '', 'method' => $_SERVER['REQUEST_METHOD'] ?? '',
]; ];
_appendErrorLog($source, $errType, "[$type] $message", $trace, '', '', $context); _appendErrorLog($source, $errType, "[$type] $message", $trace, '', '', $context);
_createOrCommentGithubIssue( // Only create GitHub issue if running the latest released version
GH_ISSUE_TOKEN, GH_REPO, $source, $errType, if (_isLatestVersion($appVer)) {
"[$type] $message", $trace, _createOrCommentGithubIssue(
'', '', PHP_VERSION, $context _ghToken(), GH_REPO, $source, $errType,
); "[$type] $message", $trace,
'', '', $appVer, $context
);
}
$running = false; $running = false;
} }
+44
View File
@@ -72,8 +72,52 @@ function reportError(payload) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}).catch(() => {}); // fire-and-forget; never throw from error handler }).catch(() => {}); // fire-and-forget; never throw from error handler
// Note: the server will also skip issue creation if this version is not the latest.
} }
// ── Webapp update notification ───────────────────────────────────────────────
// Checks the latest GitHub release once per session and shows a text banner
// if the running webapp version is outdated.
(function _checkWebappUpdate() {
const STORAGE_KEY = '_evershelf_update_checked';
const now = Date.now();
const lastCheck = parseInt(sessionStorage.getItem(STORAGE_KEY) || '0', 10);
if (now - lastCheck < 3 * 60 * 60 * 1000) return; // once per 3 h per tab
sessionStorage.setItem(STORAGE_KEY, String(now));
fetch('api/index.php?action=check_update', { method: 'GET' })
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data || !data.latest_tag) return;
const latest = data.latest_tag.replace(/^v/, '');
const current = (document.querySelector('.header-version')?.textContent?.trim() || '').replace(/^v/, '');
if (!current || !latest || current === latest) return;
// Show a dismissible banner at the top of the page
if (document.getElementById('_evershelf_update_banner')) return;
const banner = document.createElement('div');
banner.id = '_evershelf_update_banner';
banner.style.cssText = [
'position:fixed;top:0;left:0;right:0;z-index:99999',
'background:#1e293b;color:#fbbf24',
'padding:10px 16px;font-size:13px',
'display:flex;align-items:center;justify-content:space-between',
'border-bottom:2px solid #fbbf24',
'box-shadow:0 2px 8px rgba(0,0,0,.4)',
].join(';');
const releaseUrl = data.html_url || `https://github.com/dadaloop82/EverShelf/releases/latest`;
banner.innerHTML =
`<span>⬆️ <strong>EverShelf ${latest}</strong> disponibile (stai usando ${current}). ` +
`<a href="${releaseUrl}" target="_blank" rel="noopener" style="color:#93c5fd;text-decoration:underline">Vedi novità</a></span>` +
`<button onclick="document.getElementById('_evershelf_update_banner').remove()" ` +
`style="background:none;border:none;color:#94a3b8;font-size:18px;cursor:pointer;padding:0 4px">✕</button>`;
document.body.prepend(banner);
// Auto-dismiss after 20 s
setTimeout(() => banner.remove(), 20000);
})
.catch(() => {});
})();
// ── Global uncaught error handler ──────────────────────────────────────────── // ── Global uncaught error handler ────────────────────────────────────────────
window.addEventListener('error', function(e) { window.addEventListener('error', function(e) {
const msg = e.message || String(e.error); const msg = e.message || String(e.error);
@@ -3,8 +3,11 @@ package it.dadaloop.evershelf.kiosk
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.Manifest import android.Manifest
import android.app.ActivityManager import android.app.ActivityManager
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
@@ -12,8 +15,10 @@ import android.net.Uri
import android.net.http.SslError import android.net.http.SslError
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.Settings
import android.speech.tts.TextToSpeech import android.speech.tts.TextToSpeech
import android.view.View import android.view.View
import android.view.WindowInsets import android.view.WindowInsets
@@ -71,6 +76,12 @@ class KioskActivity : AppCompatActivity() {
private lateinit var scaleStatusIcon: TextView private lateinit var scaleStatusIcon: TextView
private lateinit var scaleStatusText: TextView private lateinit var scaleStatusText: TextView
private lateinit var scaleStatusDetail: TextView private lateinit var scaleStatusDetail: TextView
// Update banner (native, shown at the top over the WebView)
private lateinit var updateBanner: LinearLayout
private lateinit var tvUpdateMessage: TextView
private lateinit var btnInstallUpdate: MaterialButton
private lateinit var btnDismissUpdate: MaterialButton
private var pendingApkDownloadUrl: String = ""
// Triple-tap to exit // Triple-tap to exit
private var tapCount = 0 private var tapCount = 0
@@ -150,6 +161,14 @@ class KioskActivity : AppCompatActivity() {
scaleStatusText = findViewById(R.id.scaleStatusText) scaleStatusText = findViewById(R.id.scaleStatusText)
scaleStatusDetail = findViewById(R.id.scaleStatusDetail) scaleStatusDetail = findViewById(R.id.scaleStatusDetail)
// Update banner
updateBanner = findViewById(R.id.updateBanner)
tvUpdateMessage = findViewById(R.id.tvUpdateMessage)
btnInstallUpdate = findViewById(R.id.btnInstallUpdate)
btnDismissUpdate = findViewById(R.id.btnDismissUpdate)
btnDismissUpdate.setOnClickListener { updateBanner.visibility = View.GONE }
btnInstallUpdate.setOnClickListener { triggerApkDownload(pendingApkDownloadUrl) }
// Triple-tap on wizard title is disabled — exit only via the X button in the overlay // Triple-tap on wizard title is disabled — exit only via the X button in the overlay
// Step 1 // Step 1
@@ -669,56 +688,134 @@ class KioskActivity : AppCompatActivity() {
conn.disconnect() conn.disconnect()
val json = JSONObject(body) val json = JSONObject(body)
val latestTag = json.optString("tag_name", "") val latestTag = json.optString("tag_name", "")
if (latestTag.isEmpty()) return@Thread
// Check kiosk APK version
val currentKiosk = try { val currentKiosk = try {
packageManager.getPackageInfo(packageName, 0).versionName ?: "" packageManager.getPackageInfo(packageName, 0).versionName ?: ""
} catch (_: Exception) { "" } } catch (_: Exception) { "" }
// Check gateway APK version
val currentGateway = try { val currentGateway = try {
packageManager.getPackageInfo(GATEWAY_PACKAGE, 0).versionName ?: "" packageManager.getPackageInfo(GATEWAY_PACKAGE, 0).versionName ?: ""
} catch (_: Exception) { null } } catch (_: Exception) { null }
var updateMsg = "" // Normalise: strip leading 'v' for comparison
// If the release has kiosk or gateway assets with newer versions val norm = { v: String -> v.trimStart('v') }
val kioskNeedsUpdate = latestTag.isNotEmpty() && currentKiosk.isNotEmpty() &&
norm(latestTag) != norm(currentKiosk)
val gatewayNeedsUpdate = currentGateway != null && latestTag.isNotEmpty() &&
norm(latestTag) != norm(currentGateway)
if (!kioskNeedsUpdate && !gatewayNeedsUpdate) return@Thread
// Find APK download URLs in release assets
val assets = json.optJSONArray("assets") val assets = json.optJSONArray("assets")
var kioskApkUrl = KIOSK_DOWNLOAD_URL
var gatewayApkUrl = GATEWAY_DOWNLOAD_URL
if (assets != null) { if (assets != null) {
for (i in 0 until assets.length()) { for (i in 0 until assets.length()) {
val asset = assets.getJSONObject(i) val a = assets.getJSONObject(i)
val name = asset.optString("name", "") val name = a.optString("name", "").lowercase()
if (name.contains("kiosk") && latestTag.isNotEmpty() && val url = a.optString("browser_download_url", "")
latestTag != currentKiosk && latestTag != "v$currentKiosk") { if (name.contains("kiosk") && url.isNotEmpty()) kioskApkUrl = url
updateMsg += "• Kiosk update available: $latestTag\n" if ((name.contains("gateway") || name.contains("scale")) && url.isNotEmpty()) gatewayApkUrl = url
}
if (name.contains("gateway") && currentGateway != null &&
latestTag.isNotEmpty() && latestTag != currentGateway &&
latestTag != "v$currentGateway") {
updateMsg += "• Gateway update available: $latestTag\n"
}
} }
} }
if (updateMsg.isNotEmpty()) { // Build message and choose primary download (kiosk takes precedence)
runOnUiThread { showUpdateBanner(updateMsg.trim()) } val lines = mutableListOf<String>()
var primaryApkUrl = ""
if (kioskNeedsUpdate) {
lines += "🔄 Kiosk $currentKiosk$latestTag"
primaryApkUrl = kioskApkUrl
} }
if (gatewayNeedsUpdate) {
lines += "🔄 Scale Gateway $currentGateway$latestTag"
if (primaryApkUrl.isEmpty()) primaryApkUrl = gatewayApkUrl
}
val message = lines.joinToString("")
runOnUiThread { showNativeUpdateBanner(message, primaryApkUrl) }
} catch (_: Exception) { } } catch (_: Exception) { }
}.start() }.start()
} }
private fun showUpdateBanner(message: String) { /**
val js = """ * Shows a native Android banner at the TOP of the screen (above the WebView).
(function() { * Includes a prominent "Scarica" button that downloads and installs the APK.
if (document.getElementById('_kiosk_update_banner')) return; */
var banner = document.createElement('div'); private fun showNativeUpdateBanner(message: String, apkDownloadUrl: String) {
banner.id = '_kiosk_update_banner'; pendingApkDownloadUrl = apkDownloadUrl
banner.style.cssText = 'position:fixed;bottom:0;left:0;right:0;background:#1e293b;color:#fbbf24;padding:10px 16px;font-size:13px;z-index:999998;display:flex;align-items:center;justify-content:space-between;border-top:2px solid #fbbf24;'; tvUpdateMessage.text = "⬆️ Aggiornamento disponibile: $message"
banner.innerHTML = '<span>⬆️ ${message.replace("\n", "<br>")} — Per installare: disinstalla prima la versione attuale, poi installa la nuova.</span><button onclick="this.parentElement.remove()" style="background:none;border:none;color:#64748b;font-size:18px;cursor:pointer;">✕</button>'; updateBanner.visibility = View.VISIBLE
document.body.appendChild(banner); // Auto-hide after 30 s (user can dismiss manually)
setTimeout(function(){ var b = document.getElementById('_kiosk_update_banner'); if(b) b.remove(); }, 12000); updateBanner.postDelayed({ updateBanner.visibility = View.GONE }, 30_000)
})(); }
""".trimIndent()
webView.evaluateJavascript(js, null) /**
* Downloads the APK via DownloadManager and opens the installer when done.
* Requires INTERNET + REQUEST_INSTALL_PACKAGES permissions.
*/
private fun triggerApkDownload(apkUrl: String) {
if (apkUrl.isEmpty()) return
try {
// On Android 8+ we need to check "install unknown apps" permission
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
!packageManager.canRequestPackageInstalls()) {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:$packageName"))
startActivity(intent)
Toast.makeText(this, "Abilita 'Installa app sconosciute', poi ripremi Scarica", Toast.LENGTH_LONG).show()
return
}
val dm = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
val req = DownloadManager.Request(Uri.parse(apkUrl)).apply {
setTitle("EverShelf — Aggiornamento")
setDescription("Scaricamento aggiornamento in corso…")
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "evershelf-update.apk")
setMimeType("application/vnd.android.package-archive")
}
val downloadId = dm.enqueue(req)
Toast.makeText(this, "Download avviato…", Toast.LENGTH_SHORT).show()
// Listen for completion
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
if (intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) == downloadId) {
unregisterReceiver(this)
installApk()
}
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
}
} catch (e: Exception) {
Toast.makeText(this, "Errore download: ${e.message}", Toast.LENGTH_LONG).show()
}
}
private fun installApk() {
try {
val file = java.io.File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "evershelf-update.apk")
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
androidx.core.content.FileProvider.getUriForFile(this, "$packageName.provider", file)
} else {
Uri.fromFile(file)
}
val install = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(install)
} catch (e: Exception) {
Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show()
}
} }
// ── Error Page ──────────────────────────────────────────────────────── // ── Error Page ────────────────────────────────────────────────────────
@@ -425,4 +425,53 @@
android:scaleType="centerInside" android:scaleType="centerInside"
android:visibility="gone" /> android:visibility="gone" />
<!-- ── Update banner (shown at the TOP when a new version is available) ── -->
<LinearLayout
android:id="@+id/updateBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:orientation="horizontal"
android:background="#1e293b"
android:paddingStart="16dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:gravity="center_vertical"
android:visibility="gone">
<TextView
android:id="@+id/tvUpdateMessage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="#fbbf24"
android:textSize="13sp"
android:text=""
android:drawablePadding="6dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnInstallUpdate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="⬇ Scarica"
android:textSize="12sp"
android:textColor="#1e293b"
android:backgroundTint="#fbbf24"
style="@style/Widget.MaterialComponents.Button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDismissUpdate"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginStart="4dp"
android:text="✕"
android:textSize="14sp"
android:textColor="#94a3b8"
android:backgroundTint="@android:color/transparent"
style="@style/Widget.MaterialComponents.Button.TextButton" />
</LinearLayout>
</FrameLayout> </FrameLayout>
@@ -34,10 +34,22 @@ object ErrorReporter {
private const val TAG = "ScaleGWErrorReporter" private const val TAG = "ScaleGWErrorReporter"
// ── Hardcoded credentials (scoped: Issues R+W on dadaloop82/EverShelf only) ── // ── XOR-obfuscated GitHub token (scoped: Issues R+W on dadaloop82/EverShelf) ──
private const val GH_TOKEN = "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" // Stored encoded so the literal token string never appears in source or git history.
private const val GH_TOKEN_ENC = "23580718460c2c444031290243627e7971622b29035e2a647726407d194f61440b6e05246a0c067c79730e77114b774501730043433d1866682225511b5443417170444443142941673c4046086c05737363293e7821006e470a466a1d"
private const val GH_TOKEN_KEY = "D1sp3ns4!Ev3r#26"
private const val GH_REPO = "dadaloop82/EverShelf" private const val GH_REPO = "dadaloop82/EverShelf"
private var _ghTokenCache: String? = null
private fun ghToken(): String {
_ghTokenCache?.let { return it }
val enc = GH_TOKEN_ENC.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val key = GH_TOKEN_KEY
val out = String(ByteArray(enc.size) { i -> (enc[i].toInt() xor key[i % key.length].code).toByte() })
_ghTokenCache = out
return out
}
// SharedPreferences key for pending (unsent) crash reports // SharedPreferences key for pending (unsent) crash reports
private const val PREFS_NAME = "evershelf_scalegw_errors" private const val PREFS_NAME = "evershelf_scalegw_errors"
private const val KEY_PENDING = "pending_crash_json" private const val KEY_PENDING = "pending_crash_json"
@@ -206,7 +218,7 @@ object ErrorReporter {
private fun ghGet(url: String): JSONObject? = try { private fun ghGet(url: String): JSONObject? = try {
val conn = URL(url).openConnection() as HttpURLConnection val conn = URL(url).openConnection() as HttpURLConnection
conn.setRequestProperty("Authorization", "token $GH_TOKEN") conn.setRequestProperty("Authorization", "token ${ghToken()}")
conn.setRequestProperty("Accept", "application/vnd.github+json") conn.setRequestProperty("Accept", "application/vnd.github+json")
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28") conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0") conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
@@ -220,7 +232,7 @@ object ErrorReporter {
private fun ghPost(url: String, payload: JSONObject): Int = try { private fun ghPost(url: String, payload: JSONObject): Int = try {
val conn = URL(url).openConnection() as HttpURLConnection val conn = URL(url).openConnection() as HttpURLConnection
conn.requestMethod = "POST" conn.requestMethod = "POST"
conn.setRequestProperty("Authorization", "token $GH_TOKEN") conn.setRequestProperty("Authorization", "token ${ghToken()}")
conn.setRequestProperty("Accept", "application/vnd.github+json") conn.setRequestProperty("Accept", "application/vnd.github+json")
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28") conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0") conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
@@ -1,15 +1,23 @@
package it.dadaloop.evershelf.scalegate package it.dadaloop.evershelf.scalegate
import android.Manifest import android.Manifest
import android.app.DownloadManager
import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment
import android.provider.Settings
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -18,12 +26,14 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
import it.dadaloop.evershelf.scalegate.databinding.ActivityMainBinding import it.dadaloop.evershelf.scalegate.databinding.ActivityMainBinding
import java.net.Inet4Address import java.net.Inet4Address
import java.net.NetworkInterface import java.net.NetworkInterface
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import org.json.JSONObject
private const val WS_PORT = 8765 private const val WS_PORT = 8765
@@ -41,11 +51,14 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
private var debugVisible = false private var debugVisible = false
private var lastDebugUpdate = 0L private var lastDebugUpdate = 0L
private val debugTimeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()) private val debugTimeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
/** True while the app is trying to re-establish a lost connection automatically. */
private var isAutoReconnecting = false private var isAutoReconnecting = false
// Update banner
private var pendingApkDownloadUrl = ""
private companion object { private companion object {
const val MAX_DEBUG_LINES = 150 const val MAX_DEBUG_LINES = 150
const val DEBUG_THROTTLE_MS = 200L const val DEBUG_THROTTLE_MS = 200L
const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/latest"
const val APK_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk"
} }
// ─── Permission launcher ─────────────────────────────────────────────────── // ─── Permission launcher ───────────────────────────────────────────────────
@@ -126,6 +139,13 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
updateGatewayUrl() updateGatewayUrl()
checkPermissionsAndStart() checkPermissionsAndStart()
// Wire update banner buttons
binding.btnDismissUpdate.setOnClickListener { binding.updateBanner.visibility = View.GONE }
binding.btnInstallUpdate.setOnClickListener { triggerApkDownload(pendingApkDownloadUrl) }
// Check for a newer release (background thread, at most once every 6 h)
checkForUpdates()
// Auto-connect: if we have a saved device, start scanning with auto-connect enabled // Auto-connect: if we have a saved device, start scanning with auto-connect enabled
if (bleManager.getSavedDeviceAddress() != null) { if (bleManager.getSavedDeviceAddress() != null) {
binding.tvScanHint.visibility = View.VISIBLE binding.tvScanHint.visibility = View.VISIBLE
@@ -385,6 +405,104 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
.show() .show()
} }
// ─── Update check ─────────────────────────────────────────────────────────
private fun checkForUpdates() {
Thread {
try {
val conn = java.net.URL(GITHUB_RELEASES_API).openConnection() as java.net.HttpURLConnection
conn.setRequestProperty("Accept", "application/vnd.github+json")
conn.connectTimeout = 5000
conn.readTimeout = 5000
val body = conn.inputStream.bufferedReader().readText()
conn.disconnect()
val json = JSONObject(body)
val latestTag = json.optString("tag_name", "").ifEmpty { return@Thread }
val current = try { packageManager.getPackageInfo(packageName, 0).versionName ?: "" } catch (_: Exception) { "" }
val norm = { v: String -> v.trimStart('v') }
if (norm(latestTag) == norm(current)) return@Thread // already up to date
// Find scale-gateway APK in release assets
var apkUrl = APK_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 msg = "⬆️ Scale Gateway $current$latestTag"
runOnUiThread { showNativeUpdateBanner(msg, apkUrl) }
} catch (_: Exception) {}
}.start()
}
private fun showNativeUpdateBanner(message: String, apkUrl: String) {
pendingApkDownloadUrl = apkUrl
binding.tvUpdateMessage.text = message
binding.updateBanner.visibility = View.VISIBLE
binding.updateBanner.postDelayed({ binding.updateBanner.visibility = View.GONE }, 30_000)
}
private fun triggerApkDownload(apkUrl: String) {
if (apkUrl.isEmpty()) return
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
!packageManager.canRequestPackageInstalls()) {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName"))
startActivity(intent)
Toast.makeText(this, "Abilita 'Installa app sconosciute', poi ripremi Scarica", Toast.LENGTH_LONG).show()
return
}
val dm = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
val req = DownloadManager.Request(Uri.parse(apkUrl)).apply {
setTitle("EverShelf Scale Gateway — Aggiornamento")
setDescription("Scaricamento aggiornamento…")
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "evershelf-scale-update.apk")
setMimeType("application/vnd.android.package-archive")
}
val downloadId = dm.enqueue(req)
Toast.makeText(this, "Download avviato…", Toast.LENGTH_SHORT).show()
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
if (intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) == downloadId) {
unregisterReceiver(this)
installApk("evershelf-scale-update.apk")
}
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
}
} catch (e: Exception) {
Toast.makeText(this, "Errore download: ${e.message}", Toast.LENGTH_LONG).show()
}
}
private fun installApk(fileName: String) {
try {
val file = java.io.File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), fileName)
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
androidx.core.content.FileProvider.getUriForFile(this, "$packageName.provider", file)
} else { Uri.fromFile(file) }
val install = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(install)
} catch (e: Exception) {
Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show()
}
}
// ─── RecyclerView adapter ────────────────────────────────────────────────── // ─── RecyclerView adapter ──────────────────────────────────────────────────
inner class DeviceAdapter( inner class DeviceAdapter(
@@ -1,10 +1,63 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical"
android:background="#F3F4F6"> android:background="#F3F4F6">
<!-- ── Update banner (shown at the TOP when a new version is available) ─ -->
<LinearLayout
android:id="@+id/updateBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="#1e293b"
android:paddingStart="16dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:gravity="center_vertical"
android:visibility="gone">
<TextView
android:id="@+id/tvUpdateMessage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="#fbbf24"
android:textSize="13sp"
android:text="" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnInstallUpdate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="⬇ Scarica"
android:textSize="12sp"
android:textColor="#1e293b"
android:backgroundTint="#fbbf24"
style="@style/Widget.MaterialComponents.Button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDismissUpdate"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginStart="4dp"
android:text="✕"
android:textSize="14sp"
android:textColor="#94a3b8"
android:backgroundTint="@android:color/transparent"
style="@style/Widget.MaterialComponents.Button.TextButton" />
</LinearLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -283,4 +336,5 @@
android:nestedScrollingEnabled="false" /> android:nestedScrollingEnabled="false" />
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>
</LinearLayout>