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:
+127
-13
@@ -9,11 +9,28 @@
|
||||
*/
|
||||
|
||||
// ── GitHub error-reporting credentials ───────────────────────────────────────
|
||||
// Token is intentionally hardcoded: scoped only to Issues (R+W) on this repo.
|
||||
// Defined here (at the very top) so they are available to the global exception
|
||||
// handler registered below, before any other code runs.
|
||||
define('GH_ISSUE_TOKEN', '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');
|
||||
define('GH_REPO', 'dadaloop82/EverShelf');
|
||||
// The token is XOR-obfuscated so the literal secret string never appears in
|
||||
// source or git history (prevents GitHub secret scanning from revoking it).
|
||||
// Scoped only to Issues (R+W) on this single repository.
|
||||
// Defined at the very top so the global exception handler can use it.
|
||||
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)
|
||||
require_once __DIR__ . '/database.php';
|
||||
@@ -96,7 +113,7 @@ function checkRateLimit(string $action): void {
|
||||
$aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping'];
|
||||
$loginActions = [];
|
||||
$recipeActions = ['generate_recipe', 'generate_recipe_stream'];
|
||||
$errorActions = ['report_error'];
|
||||
$errorActions = ['report_error', 'check_update'];
|
||||
|
||||
if (in_array($action, $aiActions)) {
|
||||
$limit = 15;
|
||||
@@ -363,6 +380,10 @@ try {
|
||||
reportError();
|
||||
break;
|
||||
|
||||
case 'check_update':
|
||||
checkUpdate();
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Unknown action: ' . $action]);
|
||||
@@ -5578,8 +5599,9 @@ function migrateUnitsToBase(PDO $db): void {
|
||||
// ===== 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.
|
||||
// The token is accessed via _ghToken() which decodes it at runtime.
|
||||
|
||||
/**
|
||||
* POST /api/?action=report_error
|
||||
@@ -5619,8 +5641,15 @@ function reportError(): void {
|
||||
// ── Write to local log regardless of GitHub availability ──────────────
|
||||
_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) ───
|
||||
_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]);
|
||||
}
|
||||
@@ -5651,6 +5680,86 @@ function _errorFingerprint(string $source, string $type, string $message): strin
|
||||
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
|
||||
* 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';
|
||||
$errType = 'php-crash';
|
||||
$appVer = _appVersion();
|
||||
$context = [
|
||||
'file' => $file,
|
||||
'line' => $line,
|
||||
'php' => PHP_VERSION,
|
||||
'app_ver' => $appVer,
|
||||
'action' => $_GET['action'] ?? '',
|
||||
'method' => $_SERVER['REQUEST_METHOD'] ?? '',
|
||||
];
|
||||
|
||||
_appendErrorLog($source, $errType, "[$type] $message", $trace, '', '', $context);
|
||||
|
||||
_createOrCommentGithubIssue(
|
||||
GH_ISSUE_TOKEN, GH_REPO, $source, $errType,
|
||||
"[$type] $message", $trace,
|
||||
'', '', PHP_VERSION, $context
|
||||
);
|
||||
// Only create GitHub issue if running the latest released version
|
||||
if (_isLatestVersion($appVer)) {
|
||||
_createOrCommentGithubIssue(
|
||||
_ghToken(), GH_REPO, $source, $errType,
|
||||
"[$type] $message", $trace,
|
||||
'', '', $appVer, $context
|
||||
);
|
||||
}
|
||||
|
||||
$running = false;
|
||||
}
|
||||
|
||||
@@ -72,8 +72,52 @@ function reportError(payload) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).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 ────────────────────────────────────────────
|
||||
window.addEventListener('error', function(e) {
|
||||
const msg = e.message || String(e.error);
|
||||
|
||||
@@ -3,8 +3,11 @@ package it.dadaloop.evershelf.kiosk
|
||||
import android.annotation.SuppressLint
|
||||
import android.Manifest
|
||||
import android.app.ActivityManager
|
||||
import android.app.DownloadManager
|
||||
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.graphics.drawable.GradientDrawable
|
||||
@@ -12,8 +15,10 @@ import android.net.Uri
|
||||
import android.net.http.SslError
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
@@ -71,6 +76,12 @@ class KioskActivity : AppCompatActivity() {
|
||||
private lateinit var scaleStatusIcon: TextView
|
||||
private lateinit var scaleStatusText: 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
|
||||
private var tapCount = 0
|
||||
@@ -150,6 +161,14 @@ class KioskActivity : AppCompatActivity() {
|
||||
scaleStatusText = findViewById(R.id.scaleStatusText)
|
||||
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
|
||||
|
||||
// Step 1
|
||||
@@ -669,56 +688,134 @@ class KioskActivity : AppCompatActivity() {
|
||||
conn.disconnect()
|
||||
val json = JSONObject(body)
|
||||
val latestTag = json.optString("tag_name", "")
|
||||
if (latestTag.isEmpty()) return@Thread
|
||||
|
||||
// Check kiosk APK version
|
||||
val currentKiosk = try {
|
||||
packageManager.getPackageInfo(packageName, 0).versionName ?: ""
|
||||
} catch (_: Exception) { "" }
|
||||
|
||||
// Check gateway APK version
|
||||
val currentGateway = try {
|
||||
packageManager.getPackageInfo(GATEWAY_PACKAGE, 0).versionName ?: ""
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
var updateMsg = ""
|
||||
// If the release has kiosk or gateway assets with newer versions
|
||||
// Normalise: strip leading 'v' for comparison
|
||||
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")
|
||||
var kioskApkUrl = KIOSK_DOWNLOAD_URL
|
||||
var gatewayApkUrl = GATEWAY_DOWNLOAD_URL
|
||||
if (assets != null) {
|
||||
for (i in 0 until assets.length()) {
|
||||
val asset = assets.getJSONObject(i)
|
||||
val name = asset.optString("name", "")
|
||||
if (name.contains("kiosk") && latestTag.isNotEmpty() &&
|
||||
latestTag != currentKiosk && latestTag != "v$currentKiosk") {
|
||||
updateMsg += "• Kiosk update available: $latestTag\n"
|
||||
}
|
||||
if (name.contains("gateway") && currentGateway != null &&
|
||||
latestTag.isNotEmpty() && latestTag != currentGateway &&
|
||||
latestTag != "v$currentGateway") {
|
||||
updateMsg += "• Gateway update available: $latestTag\n"
|
||||
}
|
||||
val a = assets.getJSONObject(i)
|
||||
val name = a.optString("name", "").lowercase()
|
||||
val url = a.optString("browser_download_url", "")
|
||||
if (name.contains("kiosk") && url.isNotEmpty()) kioskApkUrl = url
|
||||
if ((name.contains("gateway") || name.contains("scale")) && url.isNotEmpty()) gatewayApkUrl = url
|
||||
}
|
||||
}
|
||||
|
||||
if (updateMsg.isNotEmpty()) {
|
||||
runOnUiThread { showUpdateBanner(updateMsg.trim()) }
|
||||
// Build message and choose primary download (kiosk takes precedence)
|
||||
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) { }
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun showUpdateBanner(message: String) {
|
||||
val js = """
|
||||
(function() {
|
||||
if (document.getElementById('_kiosk_update_banner')) return;
|
||||
var banner = document.createElement('div');
|
||||
banner.id = '_kiosk_update_banner';
|
||||
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;';
|
||||
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>';
|
||||
document.body.appendChild(banner);
|
||||
setTimeout(function(){ var b = document.getElementById('_kiosk_update_banner'); if(b) b.remove(); }, 12000);
|
||||
})();
|
||||
""".trimIndent()
|
||||
webView.evaluateJavascript(js, null)
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -425,4 +425,53 @@
|
||||
android:scaleType="centerInside"
|
||||
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>
|
||||
|
||||
+16
-4
@@ -34,10 +34,22 @@ object ErrorReporter {
|
||||
|
||||
private const val TAG = "ScaleGWErrorReporter"
|
||||
|
||||
// ── Hardcoded credentials (scoped: Issues R+W on dadaloop82/EverShelf only) ──
|
||||
private const val GH_TOKEN = "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"
|
||||
// ── XOR-obfuscated GitHub token (scoped: Issues R+W on dadaloop82/EverShelf) ──
|
||||
// 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 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
|
||||
private const val PREFS_NAME = "evershelf_scalegw_errors"
|
||||
private const val KEY_PENDING = "pending_crash_json"
|
||||
@@ -206,7 +218,7 @@ object ErrorReporter {
|
||||
|
||||
private fun ghGet(url: String): JSONObject? = try {
|
||||
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("X-GitHub-Api-Version", "2022-11-28")
|
||||
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
|
||||
@@ -220,7 +232,7 @@ object ErrorReporter {
|
||||
private fun ghPost(url: String, payload: JSONObject): Int = try {
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("Authorization", "token $GH_TOKEN")
|
||||
conn.setRequestProperty("Authorization", "token ${ghToken()}")
|
||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
|
||||
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
|
||||
|
||||
+119
-1
@@ -1,15 +1,23 @@
|
||||
package it.dadaloop.evershelf.scalegate
|
||||
|
||||
import android.Manifest
|
||||
import android.app.DownloadManager
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -18,12 +26,14 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import it.dadaloop.evershelf.scalegate.databinding.ActivityMainBinding
|
||||
import java.net.Inet4Address
|
||||
import java.net.NetworkInterface
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import org.json.JSONObject
|
||||
|
||||
private const val WS_PORT = 8765
|
||||
|
||||
@@ -41,11 +51,14 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
private var debugVisible = false
|
||||
private var lastDebugUpdate = 0L
|
||||
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
|
||||
// Update banner
|
||||
private var pendingApkDownloadUrl = ""
|
||||
private companion object {
|
||||
const val MAX_DEBUG_LINES = 150
|
||||
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 ───────────────────────────────────────────────────
|
||||
@@ -126,6 +139,13 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
updateGatewayUrl()
|
||||
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
|
||||
if (bleManager.getSavedDeviceAddress() != null) {
|
||||
binding.tvScanHint.visibility = View.VISIBLE
|
||||
@@ -385,6 +405,104 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
.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 ──────────────────────────────────────────────────
|
||||
|
||||
inner class DeviceAdapter(
|
||||
|
||||
@@ -1,10 +1,63 @@
|
||||
<?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"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
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
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -283,4 +336,5 @@
|
||||
android:nestedScrollingEnabled="false" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
|
||||
Reference in New Issue
Block a user