Commit graph

20 commits

Author SHA1 Message Date
ScreenTinker 57cdaf7e4e feat(apk): v4 liveness contract + caching-cluster fix + reconnect-safe downloads
APK v4 — client-only (Android player). Brings the reference client up to the locked
v4 liveness contract and fixes the "stuck downloading / offline in CMS" caching bug:
- v4 liveness watchdog (LivenessWatchdog): half-open detection via server-silence,
  arm-ONLY-after device:heartbeat-ack (degrade-safe), 45s±10s jittered threshold,
  exp backoff 1/2/4/8/16→30s ±20%, no-poll; reconnect delegates to the #148
  ConnectionGuard (teardown-before-reopen, single socket).
- Caching two-root fix: callTimeout + .part+Content-Length integrity + atomic swap
  (CacheValidation), onPlayerError advance, re-ack cached content + reconnect re-ack.
- Screen resilience (PlaylistSelection): a pending/failed download never blanks the
  screen — keep-current, swap only fully-valid content.
- Reconnect-safe background downloads (DownloadCoordinator): single-flight per
  contentId + bounded pool + failure backoff + cancellation; a reconnect mid-fetch
  can't orphan/duplicate/storm. Refuse 206 partials.
- v4 client identity block on register (client_type/version/platform/contract_version).
Tests: 52 JVM unit tests (watchdog, cache validation, reproduce-then-prove download
stall/truncation/reconnect-mid-download, screen selection, assembly soak).
Depends on the server device:heartbeat-ack (core pass) — degrade-safe until then.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 09:57:53 -05:00
ScreenTinker 1a5c468537 fix(#148) android root cause: single-socket-per-device invariant (no duplicate connections)
Some checks failed
CI / Unit tests (node --test) (push) Has been cancelled
CI / OpenAPI spec lint (push) Has been cancelled
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Has been cancelled
CI / Boot smoke + version check (push) Has been cancelled
The player opened duplicate/rapid WebSocket connections for the same device_id: connect() was
unconditional (disconnect + forceNew socket) and reachable from every lifecycle entry point
(boot, service start, MainActivity/ProvisioningActivity bind, foreground re-bind, START_STICKY).
A ROM that re-binds on foreground (MAXHUB PROC_STATE_TOP, isBindService:true) therefore
re-invoked connect() repeatedly -> a burst of sockets, each evicted by the next (the 8-in-9s
storm). Fire TV never re-binds like that, so it never reproduced.

- ConnectionGuard (new, pure/testable — service is the shell, per the OtaThrottle pattern):
  shouldOpenNewSocket(hasSocket, sameUrl, socketActive) — reuse a live/self-healing socket to
  the same url; open a new one only when none is usable.
- WebSocketService: connect() is now idempotent (@Synchronized + ConnectionGuard) — every entry
  point reuses the one socket, never opens a duplicate; body split into openSocket(). socketActive
  / currentUrl track the single socket.
- Single owner: onStartCommand now calls connect() so the SERVICE owns the one connection
  (idempotent across START_STICKY restarts), not whichever activity binds.
- Reconnect discipline: on io server/client disconnect (which Socket.IO does NOT auto-reconnect)
  mark the socket inert and schedule exactly ONE backed-off re-open — never a blind re-open loop;
  a transport drop keeps socketActive=true so Socket.IO's own reconnect is reused.

Test: ConnectionGuardTest (5, incl. 8-rapid-binds-all-reuse). :app:testDebugUnitTest green
(ConnectionGuard 5, OtaThrottle 7, ScheduleEval 1). NOT bumped/signed/released — Dan builds+signs
with the BMG keystore; 1.9.2-patch2 (server net) covers un-updated devices.
2026-07-02 19:29:50 -05:00
ScreenTinker 289d6b6f95 fix(#144): OTA update-check circuit-breaker + phantom guard + per-device keying
/api/update/check offered the update whenever client !== latest (raw string
inequality, not semver) with no backoff. A device that can't APPLY the update
(broken OTA client 1.7.12, signing/Fire OS) keeps reporting the same version and is
told update_available=true on every poll; a fast poll loop saturates the event loop
(prod loop-lag 49s). All requests share one NAT IP, so IP-keying is useless.

server-only breaker (lib/ota-breaker.js), two independent axes:
- RATE breaker (primary, immediate): a key checking >THRESHOLD (3) times within
  WINDOW (60s) is looping -> throttle update_available with exponential backoff
  (30s->2m->8m->cap 30m). Healthy devices poll ~12 min and never approach this, so
  rollout/stragglers are inherently safe -- NO grace-for-flood timer; slow == safe.
- PHANTOM guard (immediate): unrecognized version, or a prerelease of an OLDER core
  (superseded old-minor beta e.g. 1.9.1-beta4), gets no-offer on the first check. A
  RECENT real older version (beta3 vs latest beta4; stable 1.7.12) stays offerable.
- Never offers a downgrade (client >= latest -> no offer).

KEYING (#144 option 3): keyed on device_id when present, else reported version.
- server.js:581 accepts + logs ?device_id=, passes it to the breaker.
- UpdateChecker.kt:122 appends &device_id=<config.deviceId> (existing registered id;
  omitted until provisioned). One-line client change.
beta4+ clients get precise per-device throttling; stuck legacy clients sending only
?version= are caught by the version-keyed + rate + phantom logic. Response gains
additive `reason` + `retry_after_seconds` (old clients ignore).

BOUNDED STATE: a periodic sweep (startSweep, wired in server.js) evicts buckets idle
> IDLE_RESET_MS so the keyed Map can't grow unbounded (churned device_ids); not
reset-on-access only.

SCOPE (deliberate): this targets the FAST flood + phantoms. The slow #144 drip
(stable 1.7.12 polling ~every 12 min, ~20/hr) stays below >3/60s and is NOT
throttled -- catching it needs #144 option-3 "skip-this-version after N cycles",
which is intentionally NOT in this build.

NOTE: carries a CLIENT/APK change -> versionCode must increment at the beta4 bump and
the release keystore is required for the APK. The device_id path only helps devices
that can install beta4+; the stuck legacy fleet is covered by the version-keyed path.

Tests: unit (lib/ota-breaker, injected time) a-f + comparator + escalation + sweep +
slow-drip-scope; HTTP integration (real endpoint, device_id passthrough). Full suite
green serial AND parallel (234). OTA-only delta -- reconnect/reclaim/shed/content-ack/
block untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:36:52 -05:00
ScreenTinker 0c0a8dd68a fix(ota): surface stuck OTA on dashboard + read APK signer correctly on API 28/29 (#139)
Follow-up to the cache/backoff loop fix (aa23cf0): make a device that can't
self-install visible to operators, and fix the signature-verify bug that kept the
whole #139 fix from engaging on the actual Fire OS target.

Dashboard surface (Phase 2):
- devices gains ota_status / ota_target_version / ota_attempts / ota_updated_at
  via the idempotent ALTER TABLE ADD COLUMN migration (non-destructive,
  default-backfilled, idempotent on re-run).
- The device reports ota_status (OtaThrottle.statusFor -> none | pending |
  manual_update_required) in device_info; the server persists it on register
  (the reconnect backstop). devices d.* already surfaces it to the dashboard.
- Dashboard shows a non-blocking amber badge when manual_update_required
  ("Update available (vX) - install failed N times, manual update required");
  i18n key in en.js (non-en inherits via the en fallback). Server suite +1 test.

Event-driven status (Option B):
- New device:ota-status WS message, emitted on STATE TRANSITIONS only
  (enter-backoff -> manual_update_required, clear -> none), so the badge updates
  promptly without waiting for a reconnect and without per-poll/heartbeat chatter.
  Server handler persists the same fields; an unknown/forged device_id is a safe
  no-op. The register-path persist stays as the reconnect backstop.

Signature-verify fix (the critical piece):
verifyApkSignature read the downloaded APK's signer via
getPackageArchiveInfo(GET_SIGNING_CERTIFICATES).signingInfo, but that field is
null for ARCHIVE files on API 28/29 (populated only from API 30). On Fire OS 8
(Android 9 / API 28) - the actual deployment target - this returned 0 certs from
a correctly-signed APK, so every OTA was refused as "tampered," the cache was
deleted, and the full APK re-downloaded every check cycle. This was the real
cause of the #139 re-download loop, NOT a silent-install failure: the cache and
backoff added in this branch sit behind this verify gate and never engaged on
the target.

Fix: below API 30, read the archive's signer via the legacy GET_SIGNATURES +
.signatures (its v1/JAR cert, which IS populated on 28/29). Keep
GET_SIGNING_CERTIFICATES + signingInfo for API >= 30 and for the installed-app
read (which works on 28+). The archive's signer is still extracted and compared
to the installed app's signer; a mismatch or zero-cert APK is still rejected.
This reads the cert correctly on old APIs - it does not weaken verification.

Verified on emulators:
- API 28: verify now passes for a legit APK (was: 0 certs, refused). Full backoff
  then engages - 8.5MB pulled once, cache-hit on retries, backoff after 3,
  manual_update_required emitted once; clears on successful update.
- API 28 negative: a re-signed (different-key) APK is still refused on cert
  MISMATCH - no hole opened.
- API 30: unchanged path still passes (no regression).
- server suite 173/173, OtaThrottleTest 7/7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 22:49:01 -05:00
ScreenTinker aa23cf02dd fix(ota): stop OTA re-download loop on devices that cannot silently install (#139)
Devices that download an OTA APK but cannot silently install it (Fire TV: no
device-owner path) re-downloaded the full APK every check cycle indefinitely -
install never completes, version never advances, next check re-triggers.

Client (UpdateChecker.kt, ServerConfig.kt, OtaThrottle.kt):
- Reuse a cached, signature-verified APK instead of re-downloading every cycle;
  delete leftover invalid files; keep the verified APK on disk as the
  manual-install artifact.
- Persisted per-version attempt budget (EncryptedSharedPreferences) so it
  survives the Fire OS app restarts that drive the loop. An attempt is counted
  only when an install is launched - a download/verify failure does not consume
  the budget, so a transient network problem cannot park a healthy device in
  backoff. After 3 failed installs, back off to one retry per 24h.
- Clear OTA state and caches when a check returns update_available=false while
  state is pending (app relaunched as the new version).
- Report OTA status to the dashboard via device:log (tag ota) on state
  transitions only (enter-backoff, clear) to avoid flooding the channel.
- Extract throttle decision logic into a pure OtaThrottle object (no Android
  deps) with JUnit coverage (OtaThrottleTest) for the state transitions.

Server (server.js):
- Reword /download/apk log from "OTA update in progress" to "APK served" and
  rate-limit to once per IP / 10 min so a looping device cannot flood the log.

Note: client-cooperative fix - prevents the loop in cohorts running this APK.
Currently-stuck beta4 devices still require a one-time manual update.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:53:55 -05:00
screentinker 6f0e4a07f6
Fix per-item mute (#129): persist, ship to device, and toggle in real time (#130)
* fix(server): persist + ship + real-time per-item mute (#129)

The dashboard mute toggle was a no-op end to end. The active model is playlist_items
(the device payload is its published_snapshot); the legacy `assignments` table the bug
report cited is unused for devices. Three breaks:

- PUT /api/assignments/:id silently dropped `muted` (only read sort_order/duration_sec/
  zone_id). It now accepts muted (coerced 0/1) and ITEM_SELECT returns it, so the toggle
  persists and its on/off state sticks.
- playlist_items had no `muted` column — added (schema + idempotent migration).
- buildSnapshotItems didn't select muted, so it never reached the published_snapshot /
  device payload — now included.

Real-time: on a mute change, emit device:mute-changed { content_id, widget_id, muted } to
every device on that playlist so the player toggles the matching item's volume live,
decoupled from publish (the value is also in the next snapshot, so it persists). Adds a
[mute] log line (the report noted zero mute log entries).

Test: test/mute.test.js — PUT persists + returns muted, it reaches the published
snapshot, and a non-mute update doesn't reset it. Server suite 164/164.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(player): apply per-item mute live on Android + web (#129)

Honor the new per-item mute from the server, both in real time and on reload.

Android:
- WebSocketService: onMuteChanged callback + main-thread device:mute-changed handler.
- MediaPlayerManager.setVideoMuted(): flips the live ExoPlayer volume on the current
  video (YouTube autoplays muted; images/widgets are silent).
- MainActivity: on device:mute-changed, apply immediately if the toggled item is the
  one playing now.
- PlaylistController.sig(): include muted so a published mute change re-renders/persists
  instead of being de-duped.

Web player (server/player/index.html):
- device:mute-changed handler toggles the current <video>; the video mount now also
  honors item.muted so a published mute sticks across reloads.

Tizen intentionally not included: its player mutes ALL video for autoplay, so per-item
unmute isn't achievable there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:54:23 -05:00
screentinker 965920cd17
PiP overlay MVP: push image/web overlays to a device or group (#109) (#127)
* PiP overlay MVP: push image/web overlays to a device or group (#109)

Implements the #109 MVP from docs proposal: a floating overlay PUSHED to a device or
group in real time, rendered above the playlist without disturbing it. Scope is the
MVP only — video/RTSP, MQTT, offline-queue, and the priority/stacking system are
deferred to follow-up PRs as the proposal specifies.

Protocol (/device socket, player-agnostic):
- device:pip-show { pip_id, type:image|web, uri, position, width, height, duration,
  title?, title_color?, background_color?, opacity?, border_radius?, close_button? }
- device:pip-clear { pip_id? }
The player fetches uri itself (same trust model as remote_url content; server never
proxies). type:web is full-trust by design, hence the 'full' token scope.

Server (server/routes/pip.js, new; mounted in config/api-surface.js PUBLIC_ROUTERS):
- POST /api/pip and POST /api/pip/clear + DELETE /api/pip, all requireScope('full').
- Resolves device_id to a device OR a group, expands a group to members, and emits
  per-device — reusing the group command route's room-size online check and
  {device_id, name, status: sent|offline} result shape. Generates pip_id.
- Validates type/position allowlists, uri http(s), numeric bounds on
  width/height/duration/opacity/border_radius, colors via the existing VALID_COLOR
  (#RRGGBB; transparency is the separate opacity field).
- Workspace-isolated: every target query is scoped to req.workspaceId, so a token
  bound to workspace A can't address workspace B (404). Offline devices are reported,
  never queued (PiP is ephemeral).

Player overlay layer (Tizen; tizen/js/pip-overlay.js, new):
- A #pip sibling ABOVE #stage that PlaylistPlayer/ZoneRenderer never touch.
- applyOrientation now applies the SAME transform to #pip as #stage, so corner
  positions track the visible CONTENT in all four orientations.
- image -> <img>, web -> <iframe> (muted by default: empty allow= denies autoplay),
  sized/positioned/styled per payload, optional title bar.
- Single overlay slot, last-show-wins; duration timer (0 = until cleared); pip-clear
  (id-aware) or timer tears down; teardown wrapped so a malformed payload can't wedge
  the layer. Reports show/clear over device:log (tag 'pip').

Dashboard: a minimal "Send overlay" / "Clear overlay" tester on the device-detail
controls (device/group via the open device, type, uri, position, duration), calling
POST /api/pip through the api helper.

Tests (server suite green, 161/161):
- api.test.js: PiP tier — authz (read/write 403, full passes), workspace isolation
  (wsA token -> wsB device 404), payload validation, device + group targeting, clear;
  plus the PUBLIC_ROUTERS snapshot-firewall updated for /api/pip.
- pip-overlay.test.js: loads the real player.js + pip-overlay.js in a vm with a DOM
  shim; proves the overlay shows, auto-dismisses on the duration timer, and never
  changes the playlist signature / touches #stage; web->iframe, last-show-wins,
  id-aware clear, malformed-payload safety.

Not in this PR (intentional):
- Android player overlay — fast-follow. Protocol + server are player-agnostic; the
  Android layer (an overlay View above the player, orientation-matched to MainActivity's
  rootView rotation) is the same shape and lands next.
- OpenAPI docs for POST /api/pip — the contract test's scope heuristic only treats
  'command' paths as full-scope, so documenting a full-scope non-command route there
  needs that heuristic extended first; deferred with the docs item (proposal §8.6).
- video/rtsp types, MQTT, offline queue-on-reconnect, priority/stacking, arbitrary
  (x,y)/selector positioning (proposal §6).

Refs #109

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PiP overlay: add Android + web players (#109)

Extends the #109 PiP MVP to the other two players so the protocol (device:pip-show /
device:pip-clear) is honored fleet-wide, not just on Tizen. No server/protocol changes —
the route and socket messages are player-agnostic; these are the two missing surfaces.

Web player (server/player/index.html):
- New #pipContainer layer above #playerContainer, pointer-transparent, that the playlist
  render never touches. The same orientation transform is applied to it as to
  #playerContainer (extended to also reset width/height on landscape so a
  portrait->landscape switch realigns), so corner positions track the visible content.
- Inline PiP logic mirroring tizen/js/pip-overlay.js: image -> <img>, web -> <iframe>
  (muted by default via empty allow=), position/size/bg/opacity/radius/title, single slot
  last-show-wins, duration timer (0 = until cleared), id-aware clear, wrapped teardown.
- device:pip-show/clear handlers; reports show/clear over device:log (tag "pip").

Android player:
- activity_main.xml: a pipLayout FrameLayout as the LAST child of rootLayout — it draws
  above the content AND inherits rootView's orientation rotation/translation, so corner
  positioning is orientation-matched for free.
- PipOverlay.kt (new): builds the overlay box into pipLayout. image -> ImageView (decoded
  off-thread via ImageLoader, dropped if torn down mid-decode); web -> WebView with
  mediaPlaybackRequiresUserGesture=true (mute-by-default). Gravity-based corner/center
  placement with a 4% inset, GradientDrawable bg + corner radius, alpha=opacity, optional
  title bar. Single slot last-show-wins; duration timer; id-aware clear; teardown wrapped
  and also run on activity destroy (WebView cleanup).
- WebSocketService: onPipShow/onPipClear callbacks + safeOn handlers posted to the main
  thread (they build Views) + a sendLog(tag, level, message) emitter for device:log.
- MainActivity: instantiate PipOverlay (log -> wsService.sendLog("pip", ...)), wire the
  callbacks, tear down on destroy.

Verified: Android assembleDebug builds clean; web player inline JS parses; server suite
still 161/161 (no server changes this commit). Not yet validated on real hardware —
four-orientation corner positioning mirrors the player container/rootView transform but
should be eyeballed on a panel.

Refs #109

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:54:44 -05:00
ScreenTinker 0cd2a904e5 Android player: video-wall (wall:sync) support
Ports the wall:sync protocol the web and Tizen players already ship to native
Kotlin/ExoPlayer, so the Android player can join a video wall.

- WallController (new): 4Hz leader broadcast; follower latency-compensated drift
  controller (hard-seek past 0.3s, gentle +/-3% playbackRate nudge past 0.05s);
  role handling with immediate align on entry and on wall:sync-request. Per-tile
  rotation intentionally not applied (web/Tizen parity; left as a TODO).
- MediaPlayerManager: expose position/duration/seekExact/setSpeed for the drift
  controller; RESIZE_MODE_FILL / ImageView FIT_XY in wall mode (object-fit:fill
  parity), restored to fit/fitCenter on exit. Follower mute (setWallMute) persists
  across leader-driven item switches, and followers loop (REPEAT_MODE_ONE) so they
  never freeze on the last frame if the leader's next index is late.
- PlaylistController: wallFollower flag suppresses auto-advance (leader drives the
  index); getIndex/gotoIndex for follower tracking; itemStartedAtMs for non-video
  sync position.
- WebSocketService: onWallSync/onWallSyncRequest handlers (posted to the main
  thread since they drive ExoPlayer) + emitWallSync/emitWallSyncRequest senders
  guarded on socket.connected() like sendPlaybackState.
- MainActivity: parse wall_config in onPlaylistUpdate and branch before the
  orientation + multi-zone paths; size/translate rootView to this screen's slice;
  exit() restores full screen.

Compiles clean (./gradlew :app:assembleDebug). NOT yet validated on a device or a
real wall — the ExoPlayer seek/speed sync and the slice transform need on-device
tuning before this is trusted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:16:29 -05:00
ScreenTinker 6add29bf6a fix(player): auto-relaunch after OTA self-update (#96)
After the OTA installs, PACKAGE_REPLACED kills the old process and nothing brought
MainActivity back, so updating screens dropped to the launcher (the 1.9.0 fleet bug). Add a
MY_PACKAGE_REPLACED receiver that relaunches via a shared Relauncher cascade (extracted from
BootReceiver so boot + post-update share one path):
  1. overlay-direct startActivity (SYSTEM_ALERT_WINDOW) - legal on all versions when granted
  2. full-screen-intent notification - auto-launches <14; on 14+ (USE_FULL_SCREEN_INTENT
     revoked) degrades to a VISIBLE, tappable "tap to resume" prompt - fail loud, never a
     silent dark screen

Emulator-proven on Android 16: MY_PACKAGE_REPLACED -> Relauncher[update] -> overlay-direct
(BAL_ALLOW_SAW_PERMISSION) -> MainActivity on the new version. Accessibility re-binds across
the package-replace (Service connected fires post-relaunch), so sequential OTAs keep their
auto-confirm.

Unattended OTA requires accessibility (auto-confirm the install) + overlay (relaunch); the
setup wizard grants both. A device where they're skipped degrades to the visible prompt.

Closes #96.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:34:32 -05:00
ScreenTinker 5bcaca7c51 fix(player): OTA install silently fails on Android 14+ (explicit PendingIntent)
UpdateChecker.tryPackageInstaller built the INSTALL_COMPLETE status PendingIntent with
FLAG_MUTABLE and an implicit intent. On Android 14+ (target SDK 34) that combination is
disallowed - getBroadcast() throws, the inner catch swallows it, and the PackageInstaller
session is never committed. Result: every OTA silently fails to install on a 14+ device
(download succeeds, version never changes). Make the intent explicit via setPackage(),
keeping FLAG_MUTABLE so PackageInstaller can still write EXTRA_STATUS back.

Emulator-proven on Android 16 (API 36): "Package installer session committed" and the
update applies. Distinct from the relaunch bug - this is install-on-14+.

Part of #96.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:34:32 -05:00
ScreenTinker d9d7a8ae0f feat(android): reliable boot-launch incl. Android TV (1.7.11)
The player has a launcher (category.HOME) + a boot receiver, but auto-start was
unreliable where you can't set a home launcher (Android TV) and on Android 14+,
where USE_FULL_SCREEN_INTENT is auto-revoked for non-calling apps so the boot
full-screen launcher silently no-ops.

Boot launch:
- BootReceiver now does a direct background startActivity when 'display over other
  apps' (SYSTEM_ALERT_WINDOW) is granted — a real exception to the bg-activity-launch
  restriction, and the one path that works on Android TV. Full-screen-intent
  notification kept as a fallback (locked screen / no overlay).
- Boot notification moved to a dedicated HIGH-importance channel (full-screen
  intents are only honored from one), and it auto-dismisses once the UI is up.

Setup screen — new permission rows so operators can grant what boot-launch needs:
- Launch on Boot (USE_FULL_SCREEN_INTENT, shown on Android 14+)
- Background Activity (battery-optimization exemption)
- Display Over Apps (SYSTEM_ALERT_WINDOW)
Made the screen scrollable and ~50% smaller text/buttons so all rows + Continue
fit on one screen (incl. landscape signage). Install-Unknown-Apps subtitle now
states updates are signature-verified, so it doesn't read as 'install anything'.

Verified end-to-end on an Android 16 emulator: after reboot the app auto-launched
(Direct launch via overlay) and the boot notice cleared itself; all rows toggle.
2026-06-09 17:44:49 -05:00
ScreenTinker 5e3408be9a fix(android): OTA install never completed; auto-confirm for kiosks (1.7.10)
The OTA downloaded + verified the new APK and committed a PackageInstaller
session, but never handled STATUS_PENDING_USER_ACTION (which Android 13+ returns
for non-device-owner installers) — so the session stalled and the update never
installed. Reproduced on an Android 13 emulator: device stayed on the old version.

- UpdateChecker: register a receiver for the session's INSTALL_COMPLETE broadcast;
  on PENDING_USER_ACTION launch the system confirm dialog (and log SUCCESS).
- PowerAccessibilityService: when the package-installer dialog appears, auto-click
  the confirm button (by id, then label) so unattended kiosk screens update
  without a human tap. Scoped strictly to the package installer.

Verified end-to-end on Android 13: device auto-updated 1.7.10 -> 1.7.11 with no
interaction (receiver launched the dialog, accessibility confirmed it). Ships as
1.7.10 (also carries the Android 14+ crash + YouTube 152 fixes).

NOTE: existing 1.7.7 devices still need a one-time manual reinstall to reach a
build that has this fix; from 1.7.10 onward OTA is fully automatic.
2026-06-09 16:14:08 -05:00
ScreenTinker 5c0721b77f Merge branch 'main' into fix/fullscreen-widgets 2026-06-08 22:42:59 -05:00
ScreenTinker 73912d5f58 feat(debug): live per-device debug logging toggle on the device screen
Checkbox on the device-detail page streams the Android player's player/zone logs
live (no adb). Transient (off on reconnect), not persisted.

- Android: DebugLog util (logcat + optional socket emit); 'set_debug' command wires
  the sink + flag; key player/zone decisions (layout mode, playItem, per-zone
  render) emit through it.
- Server: relay device:log -> dashboard workspace room as dashboard:device-log.
- Dashboard: 'Debug logging' checkbox sends set_debug; live log panel streams lines
  (rendered via textContent; capped at 500).
2026-06-08 21:49:03 -05:00
ScreenTinker d41bd1f27d fix(android): verify OTA APK signature before install + disable backup (Critical)
The updater fetched download_url from the server JSON and installed it via
PackageInstaller with NO verification, over cleartext (usesCleartextTraffic,
no pinning). A network MITM or compromised server could return a malicious APK
and have it silently installed (REQUEST_INSTALL_PACKAGES) → full device RCE.

Fix: before install, verify the downloaded APK (a) is our own package and
(b) shares a current signing certificate with the installed app
(GET_SIGNING_CERTIFICATES on P+, GET_SIGNATURES below). An attacker can't forge
our signing key, so this holds even over an untrusted/cleartext transport.
Fail-closed on any parse/verify error; the APK is deleted on mismatch. Gates
both the session-install and intent-fallback paths.

Also set android:allowBackup="false" so adb backup can't exfiltrate the
device token / config.

Compile-checked + signed debug APK builds. NOT verified on-device - needs a
real update cycle on a device (valid update installs; a wrong-signed APK is
rejected) before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:19:55 -05:00
ScreenTinker 66ef47239f fix(android): Android 14+ MediaProjection / foreground-service compliance (#5)
On Android 14+ (targetSdk 34) the app could fail to run at all on newer devices
(Pixel 10, onn HD stick). Root cause: the always-on WebSocketService called the
2-arg startForeground(), which claims EVERY foreground-service type declared in
the manifest - including mediaProjection. Android 14 rejects starting a
mediaProjection-typed FGS without a MediaProjection consent token, so the core
service threw on launch and the player never came up. Matches the reporter's
"screen recording policy" hunch - via the FGS type, not the capture trigger.

Fixes:
- WebSocketService now claims ONLY mediaPlayback (explicit
  startForeground(..., FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK), API>=29 guarded;
  2-arg on older). Manifest type narrowed to mediaPlayback.
- New MediaProjectionService (manifest type mediaProjection), started only AFTER
  the user grants consent. It enters the foreground with the mediaProjection type
  BEFORE getMediaProjection() (required on 14+), then drives ScreenCaptureService.
  The consent Activity now hands the result to this service instead of calling
  getMediaProjection() directly (an Activity can't hold that FGS type).
- ScreenCaptureService: register the MediaProjection.Callback BEFORE
  createVirtualDisplay() (Android 14 throws IllegalStateException otherwise).

Verified: Kotlin compiles, manifest merges (WebSocketService=mediaPlayback,
MediaProjectionService=mediaProjection), signed debug APK builds. NOT yet
verified on-device - needs a Pixel 10 / onn-stick run + logcat to confirm the
exact crash is resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:19:56 -05:00
ScreenTinker 66a137cffe Android: bump to 1.7.8 + fix safeOn return type
Released APK 1.7.8 includes the OOM/crash-loop fix, WebSocket crash
hardening, and the http(s)-only ImageLoader scheme guard. Bumped
versionCode 10 -> 11 and versionName 1.7.7 -> 1.7.8 so existing
1.7.7 installs auto-update on the next UpdateChecker poll.

Also fixed the safeOn extension function: Socket.on() returns Emitter,
not Socket, so the original `return on(...)` failed compile with a
type mismatch. Switched to `on(...); return this` for proper chaining.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:45:18 -05:00
ScreenTinker 8866e305f0 Fix Android app crash on WebSocket connection loss
Every Socket.IO listener now goes through a safeOn helper that wraps
the body in try/catch(Throwable). Unsafe args[0] as JSONObject and
data.getString() patterns replaced with firstOrNull as? JSONObject
and optString — a malformed payload from the server, or a transient
state error during disconnect, no longer surfaces as an unhandled
exception on the IO thread.

Reconnection now uses explicit exponential backoff with jitter
(1s → 60s, randomizationFactor 0.5) so a fleet doesn't reconnect in
lockstep after a server blip. EVENT_DISCONNECT stops the heartbeat
while disconnected; the player keeps showing cached content. register,
sendHeartbeat, requestPlaylistRefresh, sendScreenshot, sendContentAck,
sendPlaybackState, and disconnect are all wrapped — telemetry / WiFi
service calls can throw on some devices.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:13:26 -05:00
ScreenTinker 1d253c4cae Android + web player: handle device_token authentication
Follows up on the security audit remediation (afbe113) which added
device_token auth to the WebSocket /device namespace.

Android player (ServerConfig.kt, WebSocketService.kt):
- Persist device_token in EncryptedSharedPreferences alongside device_id
- Send device_token in device:register on reconnect and playlist refresh
- Save/overwrite token from device:registered response (handles legacy
  devices getting their first token)
- Handle device:auth-error by clearing credentials and showing pairing screen
- clearDeviceCredentials() method wipes device_id, device_token, is_paired

Web player (player/index.html):
- Save deviceToken in localStorage config from device:registered response
- Send device_token in register() payload on reconnect
- Handle device:auth-error and device:unpaired events — clear config and
  show re-pair UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 22:52:52 -05:00
ScreenTinker 1594a9d4a4 Initial open source release
ScreenTinker - open source digital signage management software.
MIT License, all features included, no license gates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 12:14:53 -05:00