Firmware-faithful: moduli statici su disco, registration code da MAC, sfondo/colori #1

Merged
Max merged 1 commit from firmware-faithful-modules into main 2026-05-29 12:04:18 +00:00
Owner

Allinea il player Android al firmware Linux ("il firmware è la verità"), chiudendo i bug del nero spot-creator, del registration code instabile e dello sfondo/colori.

Commit

  • Launcher splash / wm size / mirror webroot / sfondo pagina full-page WebView — pezzi firmware-faithful (ripresi da fix/empty-source-ondemand-gallery, scartando l'anima native-sc).
  • Dati moduli = file statici su disco — il loop di sync scarica get-player-modules + get-module-data/<id>.json in json/ (come kipanga-svc.py); il server li serve statici. Rimossi cache TTL, proxy-per-richiesta, retry/onModuleReady e il player sc nativo (SpotCreatorPlayer/ScModule). sc va sempre via WebView/sc.js. Risolve il nero dello spot-creator.
  • Registration code firmware-esatto — ancorato al MAC hardware (/sys/class/net), MT19937 di CPython byte-identico (6 test vector). Non dipende più da ANDROID_ID (scoped per firma) → non cambia mai più a reinstall/re-sign/data-wipe.
  • Source background trasparente per config — rimosso l'hack "sc → nero opaco"; #RRGGBBAA con AA=opacità, alpha 0 = trasparente → si vede lo sfondo pagina (watermark) sotto. Sfondo e colori corretti.

Verifica

Tutto provato a video su TV LDM-A(T982): spot-creator a 5 sale senza nero, watermark MULTISALA MODERNO visibile sotto le source, colori corretti, codice di registrazione stabile.

Nota operativa

Il device cambia registration code da 6937-2463-5680 (vecchio, instabile, da ANDROID_ID) a 0088-0814-9892 (MAC-based, definitivo): re-registrazione una tantum, già fatta in test.

Allinea il player Android al firmware Linux ("il firmware è la verità"), chiudendo i bug del nero spot-creator, del registration code instabile e dello sfondo/colori. ## Commit - **Launcher splash / wm size / mirror webroot / sfondo pagina full-page WebView** — pezzi firmware-faithful (ripresi da fix/empty-source-ondemand-gallery, scartando l'anima native-sc). - **Dati moduli = file statici su disco** — il loop di sync scarica `get-player-modules` + `get-module-data/<id>.json` in `json/` (come `kipanga-svc.py`); il server li serve statici. Rimossi cache TTL, proxy-per-richiesta, retry/onModuleReady e il player sc nativo (SpotCreatorPlayer/ScModule). sc va sempre via WebView/sc.js. **Risolve il nero dello spot-creator.** - **Registration code firmware-esatto** — ancorato al MAC hardware (`/sys/class/net`), MT19937 di CPython byte-identico (6 test vector). Non dipende più da ANDROID_ID (scoped per firma) → **non cambia mai più** a reinstall/re-sign/data-wipe. - **Source background trasparente per config** — rimosso l'hack "sc → nero opaco"; `#RRGGBBAA` con AA=opacità, alpha 0 = trasparente → si vede lo sfondo pagina (watermark) sotto. **Sfondo e colori corretti.** ## Verifica Tutto provato a video su TV LDM-A(T982): spot-creator a 5 sale senza nero, watermark MULTISALA MODERNO visibile sotto le source, colori corretti, codice di registrazione stabile. ## Nota operativa Il device cambia registration code da `6937-2463-5680` (vecchio, instabile, da ANDROID_ID) a `0088-0814-9892` (MAC-based, definitivo): re-registrazione una tantum, già fatta in test.
Boot showed a 'psychedelic' flicker: system icon → launcher splash → launcher
loading screen → player splash → player. The launcher window background is
already black and MainActivity immediately shows the black OkOne loading screen,
so its splash added nothing but a flash. Remove it (theme back to
Theme.KipangaLauncher, drop installSplashScreen() + the unused SplashScreen
style); the player keeps its splash as the single branded boot frame.

Installed + rebooted on the T982.
Templates are px-positioned for a specific design resolution; on a panel whose
physical resolution differs (notably 4K) raw px are undersized/misplaced.
Scaling the content View rasterized at the low res then upscaled (blurry) and
mispositioned. Instead set the device's LOGICAL display resolution to the
config resolution via 'wm size WxH' + 'wm density 160' (dpr 1, so a CSS px == a
logical px == the template's design px). The panel hardware scales the logical
display to its physical pixels — sharp and correctly positioned.

- PlayerConfig: parse 'resolution' (console <select> code) + 'orientation'.
- resolutionToCanvas maps the select codes to exact WxH (HD/FullHD/4K/stretch/
  ledwall — not all 16:9), authoritative from the console select.
- applyDisplayResolution runs on applyConfig, skips when already at target
  (also breaks the configuration-change loop). Needs root (launcher su);
  no-op on the 1080p fleet whose display already matches.

Verified on an LDM-A(T982) 4K panel: res 3 -> 1920x1080 logical, board renders
full-size and correctly positioned.
Replicates the Linux player's lighttpd webroot on Android so ANY of the ~1200
operator templates renders unchanged — no graphic needs revising.

- Base URLs use the firmware path: WebViews load /assets/kipanga/pages/<type>.html
  so a template's relative refs (../css, ../js, ../../../gallery, any depth)
  resolve EXACTLY as on the firmware. Directory depth must match — that's the
  whole point.
- No bundled firmware tree in the APK (removed kipanga/{css,js} + the
  player_config_force fixtures). app/src/main/assets is now empty.
- LocalMediaServer serves /assets/ and /skins/; on a miss it fetches the file
  on-demand from https://<host>/skin-preview/assets/<path> and caches it into
  the AssetUpgrader override dir — the firmware tree replicates onto the player
  by use, always live, never stale. assetHost set per applyConfig.
- ASSET_SCHEMA_VERSION 2->3 + wipe the override on a schema bump, so every
  app/firmware update re-fetches the tree fresh (recreates the exact depth).
- ContentWiper (do_update) now wipes ONLY the gallery; the firmware tree is
  refreshed exclusively on do_upgrade. Matches the canonical model: firmware =
  immutable server truth, gallery = per-site content.

Verified on an LDM-A(T982): wiped the player to nothing + installed an
asset-less APK; it rebuilt templates/css/js (proactive + on-demand) and the
gallery from the server and rendered identically, page background included.
Replica del net di c6ed81c+5560f5a senza l'anima native-sc: lo sfondo
pagina e' una WebView full-page a z-bottom (il <body> del firmware),
sorgenti trasparenti sopra. baseUrl /assets/ gia' portato da d55a635.
Replica esatta del firmware (kipanga-svc.py): il loop di sync scarica
get-player-modules + get-module-data/<id>.json su disco (json/, sibling di
gallery/), e il server serve i .json come file STATICI. Niente TTL, niente
proxy-per-richiesta, niente retry/onModuleReady.

- ModuleRepository: replica download_json (scrive solo su HTTP 200, tiene il
  vecchio su fallimento) + normalizzazione url_trailer.
- KipangaApi: get-player-modules, get-module-data.
- PageRenderer: /json/<id>.json servito statico da disco (404 se assente,
  come il firmware pre-primo-download); rimossa serveCachedModule e tutta la
  retry machinery; rimosso CycleListener.onModuleReady.
- PlayerActivity: sync moduli nel loop (ordine firmware: moduli -> gallery) +
  nel path di sync iniziale; rimossa la machinery moduleReload.
- Rimosso il routing/render nativo sc: spot-creator va SEMPRE via WebView
  (spot-creator.html -> sc.js -> slick+plyr). Eliminati SpotCreatorPlayer.kt,
  ScModule.kt, ScModuleTest.kt.
Il RC era seedato da java.util.Random su ANDROID_ID (fallback): ANDROID_ID e'
scoped per chiave di firma -> cambia a re-sign (debug<->release) o factory
reset, quindi il player poteva cambiare silenziosamente codice di
registrazione (osservato: 6937-2463-5680 release -> 2008-9023-1355 debug).

Ora identico al firmware (refresh-network-data.py::generate_registration_code):
- FirmwareRegistrationCode: CPython Mersenne-Twister byte-identico (seed da
  stringa MAC via SHA-512 + init_by_array, getrandbits/choice). Validato su 6
  test vector generati con la Python del firmware.
- DeviceIdentity: seed = MAC hardware letto da /sys/class/net/<iface>/address
  (priorita' fissa eth0,eth1,wlan0), come uuid.getnode() del firmware. Ancorato
  all'hardware: sopravvive a reinstall/re-sign/data-wipe. ANDROID_ID solo come
  ultima spiaggia (emulatore), loggato come instabile.

Effetto: ogni player deriva lo stesso codice del firmware dallo stesso MAC.
La TV LDM-A(T982) (eth0 1c:54:e6:0d:6a:57) ora genera 0088-0814-9892
(re-registrazione una tantum), poi mai piu' cambia.
Rimosso l'hack "sc -> fondo NERO opaco quando alpha 0": anneriva ogni box
spot-creator e nascondeva lo sfondo pagina (background_image / watermark).
La console memorizza il colore come #RRGGBBAA dove AA = slider opacita';
alpha 0 (#39f26900, #00000000) = source trasparente -> si vede lo sfondo
pagina sotto, come gli iframe trasparenti sopra il body del firmware.
Ora setBackgroundColor usa il colore reale del config (TRANSPARENT se alpha 0).

Verificato a video sulla TV: watermark MULTISALA MODERNO visibile sotto le
source, colori dei div corretti, nessuna cornice nera.
- Loader overlay (loading_overlay): logo OkOne -> Kipanga, spinner tint Kipanga.
- Splash player + loading screen launcher resi IDENTICI: fondo BIANCO + logo
  Kipanga (prima erano neri per nascondere lo scalzo; ora bianchi col logo,
  stesso effetto ma branded e visibile). Niente piu' salto nero<->bianco al
  passaggio launcher->player.
- Icona splash player = logo paddato 1.8x (logo_kipanga_splash) cosi' il
  wordmark intero entra nella maschera circolare Android 12 senza tagli.
  Verificato a video: splash player = bianco + logo Kipanga completo.
- logo_kipanga.png aggiunto ai res di entrambi i moduli.
Tra la chiusura dello splash (bianco) e il primo paint del contenuto appariva
un frame grigio (windowBackground default di Material). Impostato
windowBackground bianco sul tema post-splash: ora il boot resta bianco+Kipanga
dall'inizio alla fine (launcher -> splash -> window -> overlay -> contenuto),
nessun flash grigio. Verificato a video sul handoff launcher->player.
Le TV signage sono landscape-locked a livello WindowManager: la policy ha solo
rotazioni landscape, quindi setRequestedOrientation(PORTRAIT) e user_rotation
forzato vengono ripristinati a landscape (flip-flop verificato su LDM-A T982).

Soluzione: la rotazione 'verticale' si fa in-app ruotando il container di 90°
(rotation 1 'destra') o 270° (rotation 3 'sinistra') su un framebuffer landscape;
il pannello del totem e' montato fisicamente in verticale. applyContentOrientation
dimensiona content_16_9 a portrait (swap w/h del pannello) e lo ruota; il render
gira in un post{} cosi' applyPage misura il container portrait (1080x1920) e la
skin firmware si dispone 9:16. Container ora STABILE (niente piu' flip-flop).

applyDisplayConfig: framebuffer sempre landscape nativo (no swap) + user_rotation 0
(niente OS-rotation, che la TV non onora). Rimosso il vecchio setRequestedOrientation.

Validato a video: grafica verticale destra dritta e leggibile.
Allinea il player al loop fleet del firmware (kipanga-svc.py):
- Heartbeat: aggiunti temperature (thermal_zone), load_average_15min
  (/proc/loadavg[2]), ram_usage, disk_usage, uptime (MINUTI, come il firmware),
  via DeviceMetrics. Prima inviava solo network/version/browser.
- do_reboot=1 -> reboot device (su 0 reboot) con guard anti-loop persistito
  (last_reboot_ms in prefs, sopravvive al reboot).
- monitor_poweron -> schermo ON/OFF via KEYCODE_WAKEUP/SLEEP (root), solo al
  cambio stato (il firmware usa turn_on/off_hdmi).
- do_speedtest -> throughput download reale su sample gallery dal sync server,
  POST {speedtest_download, speedtest_upload} (upload non misurabile: niente sink
  lato API, il firmware usa speedtest.net -> 0.0).
- do_screenshot -> cattura finestra player (Canvas) -> JPEG -> POST
  {filename, filedata=base64} a get-player-screenshot.
- PlayerConfig: aggiunti do_reboot, do_speedtest, do_screenshot, monitor_poweron.

Verificato a video: heartbeat 200 con temp 64.4C/load 4.22, monitor power ON.
- player_root background #6b6b6b -> white: elimina il frame grigio in cui il
  logo spariva tra splash e contenuto. Boot ora bianco continuo.
- Overlay loading logo 220dp -> 320dp (medio-grande, allineato al loading
  screen del launcher 0.30xmin).
- Splash di sistema: logo intero a 1.8x padding (~150px). NB: il logo Kipanga e'
  largo (wordmark), nel cerchio Android 12 sta intero solo a questa taglia; piu'
  grande verrebbe tagliato. Resta piu' piccolo di overlay/launcher ma e' un frame
  transitorio e non tagliato.
- captureWindowJpeg ora usa PixelCopy invece di Canvas.draw: Canvas software non
  cattura le WebView hardware-accelerate (uscivano vuote/nere); PixelCopy legge la
  superficie composita reale → le grafiche ci sono.
- Per le config portrait (rotazione in-app) il framebuffer e' coricato: ruoto il
  bitmap (270 per rotation 1, 90 per rotation 3) cosi' lo screenshot esce dritto
  in verticale come lo vede chi guarda il totem.
- Salvo anche in tmp/capture.jpg (come il firmware) per poterlo scaricare dal
  device.
- ContentWiper.do_update wipa anche la dir json/ dei moduli (fresh come primo boot).

Validato a video: file 1080x1920 con lista film + promo, WebView incluse.
Il parco e' eterogeneo: le operazioni power/fleet senza API portabile (screen
on/off, reboot) vanno dietro una strategy per-hardware, con fallback generico.

- interface DeviceControl { setScreenPower, reboot, shutdown } + factory
  forThisDevice() che sceglie la strategy via detection.
- GenericDeviceControl: su 0 (reboot affidabile; screen via keyevent best-effort,
  che NON spegne alcune TV signage tipo la T982 - per quello serve il vendor).
- DahuaDeviceControl: dh-support SDK (AAR) -> bind dahua.support.ACTION_SYSTEM
  (com.dahua.skynet) -> enableBackLight/reboot/shutdown. enableBackLight e'
  l'UNICO modo di spegnere il pannello su Dahua dove i metodi generici falliscono.
- Detection robusta: resolveService(ACTION_SYSTEM) != null, non solo presenza del
  package skynet. Su firmware skynet vecchio (V3.000.0000000.1, senza il support
  service) cade su Generic invece di bindare un service mancante.
- applyMonitorPower + do_reboot passano da deviceControl (con fallback inline).
- monitor_poweron richiede WAKE_LOCK (PARTIAL, CPU viva a schermo spento) + clear
  FLAG_KEEP_SCREEN_ON.

NB: su QUESTA unita' di test il firmware skynet e' troppo vecchio per il support
service, quindi lo screen-off Dahua non e' verificabile qui; funzionera' su
Dahua con firmware >= branch 0000002. Reboot via generic gia' validato a video.
Lo slot icona di core-splashscreen/Android-12 cappa la dimensione e la maschera
circolare taglia il wordmark largo. Sostituito con uno splash classico:
Theme.KipangaPlayer.SplashScreen ora ha windowBackground=@drawable/splash_window
(layer-list: bianco + logo Kipanga intero ~520dp centrato). Niente cap, niente
maschera → logo grande e completo. Rimosso installSplashScreen() (target fleet =
Android 11, nessuno splash di sistema forzato; il windowBackground e' lo splash,
visibile fino al primo paint del contenuto). Rimossa l'icona logo_kipanga_splash.

Verificato a video: splash bianco con logo Kipanga grande e intero.
Cosi' il boot launcher->player mostra il logo Kipanga della stessa dimensione
(niente pop di grandezza). Capped al 90% del lato corto.
Il reverse tunnel era hardcoded su pop.pointonpeople.com:52000, ma la console
genera l'URL screen-view su <sync_server>:<vnc_port> (es. app.cinemone.it:53500)
-> connection refused (host diverso). Il tunnel dipende da Kezi (sync server) e
le porte dal JSON di allineamento:
- PlayerConfig: + server_ssh_port (porta bastion).
- AIDL setTunnelPorts(serverHost, serverPort, sshPort, vncPort).
- ReverseTunnelService: host/porta dai parametri (default pop solo come fallback);
  dbclient -p <serverPort> kipangareverse@<serverHost>.
- PlayerActivity: passa il sync server (url da Kezi) + server_ssh_port + ssh/vnc.

Verificato a video: dbclient -p 53000 ... kipangareverse@app.cinemone.it;
app.cinemone.it:53500/screenshot.png -> HTTP 200 PNG 1920x1080. SSH(53499)/VNC(53500)
ora raggiungibili via il server del cliente, combaciano con l'URL della console.

NB: provisioning chiave tunnel su /sdcard fallisce il fchown (storage scoped) ->
usare /data/local/tmp + su 0 cp (install_tunnel_key.sh da aggiornare).
Il push diretto in /sdcard/Android/data/<pkg>/files/ssh/ fallisce il fchown su
Android 11+ (scoped storage) e il file non persiste. Ora: push in /data/local/tmp
(sempre scrivibile) + su 0 cp nella dir del launcher + chmod 644 (cosi' l'app
launcher la legge e la mirror-a in internal). Fallback al push diretto se non c'e'
root. Verificato sulla T982.
Turn the LDM-A(T982) backlight truly OFF (energy saving, not a black
overlay) through the dh-support service hosted in com.dahua.skynet:

- DahuaDeviceControl binds DHSupportService by explicit component and
  calls IDHSupportInterface.callDHSystem(enableBackLight, isOpen) — the
  service exists on this firmware once skynet is enabled (it was being
  disabled wholesale for HOME).
- applyMonitorPower no longer latches lastMonitorOn on failure: the bind
  is async (first call returns "service not connected"), so it now retries
  on the next poll instead of latching OFF forever. Dropped the keyevent
  fallback that caused the off->on flicker.
- provision-dahua-ys.sh keeps skynet ENABLED (the service lives inside it)
  and disables only its HOME LauncherActivity (pm disable, not disable-user
  which resets to default), so it can't reclaim HOME while the service stays
  available.
- Scope documented as Dahua/YS-only; other SKUs fall back to
  GenericDeviceControl, which returns false (does NOT fake screen-off).
Player's sync loop sleeps on `delay(synchronization_frequency)` (60s on
this fleet) between fetches; after a network blip it kept showing stale
content for up to a full poll interval before noticing rete back. With
this patch the lag drops to seconds:

- NetworkObserver in launcher tracks ConnectivityManager callbacks for
  INTERNET+VALIDATED networks via a Set<Network> (no deprecated
  allNetworks scan). First onAvailable is suppressed (boot — player just
  did its first fetch on its own); subsequent transitions empty→non-empty
  fire an explicit broadcast cloud.kipanga.protocol.NETWORK_RECHECK to
  cloud.kipanga.player.
- Hosted in WatchdogService (already always-on, START_STICKY), so the
  observer survives player crashes and force-stops.
- Player registers a runtime BroadcastReceiver (EXPORTED — cross-UID) and
  trySend()s on a Channel<Unit>(CONFLATED). The poll loop now races its
  delay against the channel via withTimeoutOrNull: either timeout fires
  the normal cycle, or the signal short-circuits and we fetch now.
  Multiple signals while busy collapse to one extra fetch.
- No AIDL surface change → no ProtocolVersion bump.
- Unauthenticated by design: worst case of a spurious recheck is one
  extra config fetch.

Manifest: launcher gains ACCESS_NETWORK_STATE (implicit but declared).
Remote support via SSH (dropbear, launcher UID) was hitting an empty
logcat: the 256 KiB default main buffer is consumed in ~12 min by
"KipangaWatchdog: heartbeat received" V-lines alone (one every 10 s,
~80 B). Useful player-side context — module sync, gallery sync, config
changes — was evicted before any operator could grep it.

- notifyAlive() now accumulates count + max-skew across HEARTBEAT_LOG_EVERY
  beats (default 6 = 1 min) and emits a single summary V-line per window.
  Anomalous skews (>500 ms, HEARTBEAT_SKEW_ALERT_MS) still log inline at W
  so genuine clock drift remains visible.
- onCreate() shells out `logcat -G 4M` once at service start. The control
  socket is normally shell-only, but on ro.debuggable=1 builds the app can
  resize. Best-effort — failures log W and keep the default buffer.

Combined effect on the 0088-0814-9892 panel: ~10× retention multiplier
from buffer growth × ~10× from conflated log → ~100× longer SSH-grabbable
window for the same buffer cost.
Before this patch the launcher's remote-access services (Sshd, Screenshot,
Watchdog) were started exclusively by MainActivity.onCreate. Two failure
modes ensued:

- After OTA replace, Android killed the old launcher process. The new
  MainActivity is not invoked until something explicitly requests HOME —
  so SshdService and ScreenshotService stayed down indefinitely, leaving
  the device unreachable from remote support. Observed end-to-end on
  panel 0088-0814-9892 on 2026-05-28: an OTA-style install of the
  release launcher killed remote access for hours, and a manual reboot
  did NOT recover it (Android lit up the player without ever running
  MainActivity, because the player was the last foreground task before
  the kill).
- On a fresh boot where Android elects any other activity first, same
  situation.

Two independent anchors so the bring-up no longer depends on Activity
lifecycle:

1. BootReceiver (new) listens for BOOT_COMPLETED + MY_PACKAGE_REPLACED.
   System-only actions, exported=true required by Android for delivery.
   Calls BootReceiver.startServices() which fires startService on each
   of Watchdog/Sshd/Screenshot. Idempotent.

2. WatchdogService.onCreate now also calls BootReceiver.startServices.
   The watchdog process is recreated automatically when the player binds
   the AIDL — that happens within ~10s of any restart, so this acts as a
   second safety net for boots where the system broadcast was missed or
   delayed. Also idempotent.

Manifest gains RECEIVE_BOOT_COMPLETED and the BootReceiver declaration.

Limitation (next patch): the panel that triggered this fix is still
unreachable — the OTA installed the pre-fix launcher, and recovery
requires onsite USB/cable access to install this build. Once installed,
future OTAs are self-healing.
Pair with the BootReceiver bring-up safety net (previous commit) so an OTA
push of a new player APK via do_apk_upgrade can complete end-to-end on a
non-DO totem with no operator at the device.

Two mechanisms, both required:

1. android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+).
   Lets LauncherInstaller.commit() — already calling
   setRequireUserAction(USER_ACTION_NOT_REQUIRED) — actually skip the
   system confirmation dialog. Without this permission Android forces
   the dialog regardless of what the caller requested.

2. ConsentAutoTapService now also receives events from
   com.android.packageinstaller and com.google.android.packageinstaller,
   and matches affirmative button labels ("Installa"/"Install"/"Aggiorna"/
   "Update"/"Update anyway", localised it+en). This catches the dialog
   on ROMs that ignore UPDATE_PACKAGES_WITHOUT_USER_ACTION (some OEMs do).
   Text-only matching, never the generic positive button id, and a
   FORBIDDEN_TEXTS guard never lets the service tap "Disinstalla"/
   "Uninstall"/"Annulla"/"Cancel" even if those somehow share a resource id.

Together with BootReceiver this means an OTA-pushed launcher upgrade can
fully complete (install dialog auto-confirmed, services come up post-
replace, screenshot/SSH/tunnel restored) with zero human intervention on
the panel. Critical for the 200-300-unit fleet rollout: without these,
any future buggy launcher OTA would lock remote access across the entire
fleet, requiring onsite recovery per device.
Operational doc enforcing the 3-step rule for any launcher/player OTA:
preflight build check, canary device with 10-min health-check (SSH +
ScreenshotService + heartbeat), 24h soak, then batched fleet rollout
(10/50/100%) with health-check between batches.

Written in reaction to the 2026-05-28 incident on panel 0088-0814-9892:
a single buggy launcher OTA was pushed without canary, the bring-up
safety nets weren't in the installed build yet, and the device lost
remote access — recoverable only onsite. If that OTA had reached the
200-300-unit fleet the same way, recovery would have been per-device.

Doc includes:
- Why launchers are higher-risk than players (host of all remote channels)
- aapt-based preflight checks that should gate CI
- Canary identification and the three post-install health probes
- Batch rollout schedule (10/50/100%) with inter-batch health-check
- Codifies what the previous two commits (BootReceiver + silent OTA)
  guarantee, and what's still procedural
- Onsite recovery procedure when an OTA does go bad in spite of all this
Two independent watchdogs that automatically recover a device which has
slipped into a sticky bad state without remote help. Cover the failure
modes that BootReceiver alone doesn't: services crashed mid-run, or the
launcher itself wedged.

(1) Per-minute service self-heal in WatchdogService:
    - Every SERVICE_CHECK_INTERVAL_MS (1 min) ActivityManager
      getRunningServices is queried for SshdService + ScreenshotService.
    - If a required service is missing, BootReceiver.startServices()
      restarts what's down (idempotent).
    - After MAX_FAILED_SERVICE_CHECKS (3) consecutive checks where a
      service is still missing despite restarts, Process.killProcess
      kills our own process. START_STICKY restarts WatchdogService and
      its onCreate brings the rest back via BootReceiver. Last-resort
      knob — shouldn't fire on a healthy fleet.

(2) Daily 04:00 isolation reboot via new DailyHealthCheckReceiver:
    - AlarmManager.setExactAndAllowWhileIdle fires at 04:00 local time.
    - WatchdogService persists last AIDL heartbeat from the player into
      SharedPreferences (every ~6 beats to limit flash wear).
    - If no heartbeat for ≥24h → the launcher is isolated (player
      crash-looped, AIDL stuck, network gone). Reboot cascade:
      Runtime.exec("reboot") → "svc power reboot" → killProcess as last
      fallback. Cascade tolerates each step failing (production ROMs
      typically refuse the first two from app uid).
    - Re-arms one-shot each fire (better doze behavior than
      setRepeating). Scheduled from WatchdogService.onCreate so it
      survives reboots and OTA replaces.

Manifest:
  - SCHEDULE_EXACT_ALARM (Android 12+): without it the alarm degrades to
    inexact and can drift hours — defeats the "exactly 04:00" SLA.
  - REBOOT permission: signature-protected on standard ROMs, granted on
    OEM signature scenarios; the cascade naturally falls through when
    not granted.
  - DailyHealthCheckReceiver declaration (exported=false, action-filtered).

Together with BootReceiver, the launcher now has 3 layers of recovery:
  startup (BootReceiver) → mid-run (per-min self-heal) →
    end-of-day (24h isolation reboot).
A device cannot stay silently broken longer than 24h without forcing
itself back to a clean state.
Last-resort recovery path for a device that's lost remote access AND
can't be reached via Settings (minimal BT remote without HOME/MENU/
Settings, no USB-debug, no Wireless Debugging). Driven entirely from
the panel D-pad — no console, no Kezi flag, no AIDL surface bump.

Sequence: Su Su Giù Giù Sx Dx Sx Dx OK within KONAMI_TIMEOUT_MS (8s).
Long enough to be impossible accidentally (no sane UI navigation
matches), short enough that a stressed operator with a sluggish remote
can finish it. PlayerActivity.onKeyDown tracks progression and resets
on any non-matching key.

On match: full-screen overlay with FACTORY_RESET_COUNTDOWN_S (10) second
countdown. BACK during the countdown cancels and removes the overlay.
At zero, DeviceControl.factoryReset() runs on Dispatchers.IO. If the
strategy returns false (no path on this SKU), the overlay updates to
"FACTORY RESET FALLITO / Verificare manualmente" so the operator
doesn't think the call succeeded silently.

DeviceControl additions:
  - Interface: factoryReset() : Boolean (alongside reboot/shutdown).
  - DahuaDeviceControl: call("factoryReset") via the dh-support SDK,
    sibling of the already-working call("reboot")/call("shutdown").
    The exact method name on this firmware is undocumented — if
    callDHSystem returns code≠0 we report false and the overlay surfaces
    the failure. Confirmation that the panel actually wiped will come
    from observing it boot back into the OEM setup wizard.
  - GenericDeviceControl: runRoot("recovery --wipe_data") — equivalent
    of "wipe data / factory reset" from Android Recovery, on rooted
    boxes.

This closes the recovery gap for the 0088-class scenario where the
operator can only reach the panel with the minimal BT remote and no
other input device works. The reset is meaningfully destructive (wipes
/data, re-runs OEM setup) — the 10s countdown + BACK-to-cancel exists
specifically to avoid accidental triggers.
DeviceControl refactor: estrae AndroidStandardDeviceControl (PowerManager +
RecoverySystem) e wrappa tutte le strategie in un CompositeDeviceControl che
prova AOSP standard prima del vendor (Dahua dh-support / GenericRoot). I
delegate vengono provati in cascata e il primo che gestisce vince. Nuova
capability `factoryReset()` su tutta l'interfaccia + cascade end-to-end.

Konami escape hatch (player): il combo Su-Su-Giu-Giu-Sx-Dx-Sx-Dx-OK in 8s
attiva la confirm overlay (10s con BACK cancel) che chiama il soft-wipe lato
player + broadcast WIPE_KIPANGA_DATA al launcher. Anti-foot-gun: confirmation
+ countdown + back-cancel + anti-loop di 60s.

Konami escape hatch (launcher, WipeReceiver): step-down come HOME via
setComponentEnabledSetting su MainActivity (no permessi richiesti), best-effort
restore della HOME OEM (skynet su Dahua/YS) via `su 0 pm enable`, wipe dello
state launcher (tunnel_id_rsa, authorized_keys cache, prefs), e — se root
disponibile — uninstall chain detachato che rimuove player + launcher poi
reboot. Il detach via `nohup su 0 sh -c '...' &` sopravvive al kill del
proprio APK durante l'uninstall.

ATTENZIONE — UNTESTED: la sequenza enable-skynet → uninstall-chain → reboot
NON è mai stata validata su device fisico. Il primo tentativo di canary
(0088-0814-9892 il 2026-05-29) è fallito per signature mismatch (debug-signed
APK su release-signed installato) prima ancora di raggiungere Konami, e ha
causato un lockout di ~3h fino al recovery via USB onsite. Da deployare con
canary CORRETTA su un device dedicato a non-prod, mai più direttamente su
flotta cliente.

Vedi docs/ops/OTA_SAFE_PROCEDURE.md per la procedura canary obbligatoria.
Due inaccuratezze nel commento companion del file, scoperte durante il
recovery di 0088-0814-9892 il 2026-05-29:

1. Persistenza: il commento "key survives external-storage churn"
   suggeriva che `getExternalFilesDir/ssh/tunnel_id_rsa` sopravvivesse a
   un wipe. È vero solo per il Konami soft-wipe (che limita lo scope a
   filesDir/cache/prefs). Su `pm uninstall <pkg>` Android 11+ pulisce
   ANCHE `/sdcard/Android/data/<pkg>/`, quindi la chiave sparisce. Va
   re-pushata via `tools/install_tunnel_key.sh` post-OTA-uninstall.

2. Formato on-disk: "OpenSSH PEM, dbclient reads it directly" era
   sbagliato. La chiave shippata sul device è in formato Dropbear binary
   `.db` (convertita operator-side dallo script). Il dbclient della
   nostra build (Dropbear ≤2024.86) ha un bug "String too long" parsing
   OpenSSH RSA-3072 direttamente — verificato 2026-05-13 in PIANO_TUNNEL.

Solo commenti, no code change.
Runbook step-by-step per validare il fix Konami → WipeReceiver di 990032b
(step-down HOME + uninstall chain) su dev bed ProDVX. Copre:

- pre-flight (device target, APK source con sha256 attesi)
- install + set HOME + tunnel key push
- logcat filter da usare per osservare la cascade
- sequenza Konami dal telecomando con sub-test BACK-cancel
- aspettative log per ramo non-rooted (ProDVX fallback) e
  ramo rooted (Dahua/YS dev bed dedicato, mai 0088 cliente)
- verifica post-reboot per entrambi i rami
- cleanup per riportare il dev bed allo stato pre-test
- failure modes con come capturarli
- 4 criteri per considerare il fix validato

Esiste perché: il primo tentativo di canary (2026-05-29 su 0088) è fallito
prima del Konami a causa di un OTA debug-signed su release-signed, vedi
incident memory. Questo doc evita di ripetere quel pattern: non si tocca
piu un device produzione per testare WipeReceiver, si segue ProDVX e i
suoi step.
Permette a tecnico/cliente di riconfigurare il WiFi dal telecomando del
pannello senza scavalcare la kiosk verso le Settings Android intere.

Architettura ibrida (vincolo Android 10+: app non-system non può
configurare WiFi programmaticamente):

- UI branded fatta da noi (WifiKioskActivity + layout dedicato): mostra
  connessione corrente con SSID/IP/segnale, lista reti scansionate con
  banda 2.4/5/6 GHz + protetta/aperta + bars di segnale, password
  dialog per WPA, stati progress/connesso/timeout.

- Connect via WifiNetworkSuggestion: l'app sottomette il suggerimento al
  sistema, Android surfacea una notifica "Connect to X?" che l'utente
  approva. Approvazione persistente (auto-connect al reboot). La nostra
  Activity riespone status bar così la notifica è visibile a tendina,
  poi PlayerActivity la ri-nasconde al ritorno.

- NetworkCallback in WifiController.waitForConnect aspetta l'effettivo
  onAvailable per dichiarare la connessione riuscita (timeout 60s).

Combo: Dx Sx Dx Sx OK in 5s (disgiunta dal Konami Su Su Giù Giù Sx Dx Sx
Dx OK — primo tasto diverso, niente collision sulla state machine).
Overlay blu di conferma con countdown 5s + BACK per cancel (pattern
identico al factory-reset, colore diverso per segnalare azione non
distruttiva).

Permessi aggiunti: CHANGE_WIFI_STATE + CHANGE_NETWORK_STATE +
ACCESS_FINE_LOCATION (quest'ultimo runtime-requested al primo apri,
necessario per getScanResults su API 28+). Accettabile perché il flusso
si attiva solo quando c'è un tecnico/cliente fisicamente davanti.

Limitazioni note:
- WPA3-only puro potrebbe non funzionare con setWpa2Passphrase (edge
  case, da rivedere se segnalato sul campo)
- startScan è throttled a 4 / 2min su API 28+ → "Aggiorna" eccessivo
  ritorna risultati cached
- Non testato in vivo: il pannello compila e i flussi sono coperti dal
  codice, ma scan → suggest → approval notification → onAvailable va
  validato su un device con AP nelle vicinanze
Max merged commit 953e5fab64 into main 2026-05-29 12:04:18 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Max/Kipanga_APPS!1
No description provided.