Commit graph

81 commits

Author SHA1 Message Date
Claude abdb3b434d Silence a backgrounded player, and rebuild zones when a layout is edited in place
Two more from #234, both Android-only.

1. "I closed the app and I can still hear the sound." Nothing in the Android lifecycle pauses a
   WebView, and MainActivity had no onStop at all, so a YouTube embed kept playing with the app in
   the background and the panel kept making noise with the app apparently closed. onStop rather
   than onPause: onPause also fires for a transient dialog or a permission prompt, and pausing
   playback for those would be a visible stutter on a wall. Pauses via the IFrame-API bridge that
   already exists for live mute, so returning to the foreground resumes in place instead of
   restarting the clip.

2. "I added 4 zones and they dont appear on the screen. I had 3 zones before and they appeared."
   The zone rebuild fired only when the layout ID changed. Editing a layout in place keeps its id,
   so setupZones never ran: the geometry stayed at three zones and only the assignments
   re-rendered into the old ones, which is why it took a force-stop to appear. The rebuild now also
   triggers on a signature of the zones themselves (id, position, size, z-index, type, fit).

Compiles clean; NOT yet verified on hardware — both need a device to prove, unlike the audio-on-
item-switch fix which was measured before and after on an emulator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 19:48:31 -05:00
Claude 4cc750ba3a Text widgets: stop losing text off the bottom, and show an edit without an app restart
Two separate faults in the same widget, both reported on #234.

1. Text taller than the screen vanished in silence. renderText set overflow:hidden on the document
   with nothing able to scroll it, so anything past the bottom edge was simply gone: "Text goes to
   bottom and disappears. It dont fit."

   The content now gets a wrapper and an overflow mode:
     fit    (default) shrink until it fits — a NO-OP when the content already fits, so it rescues
            widgets that are currently losing text without changing ones that are fine
     scroll pan through it on a loop with a pause at each end, for content genuinely longer than a
            screen where shrinking would make it unreadable
     clip   the old behaviour, kept because a designer-positioned layout may deliberately run past
            the edge and must not be rescaled underneath its author

   Measuring runs after layout, after web fonts settle, and on resize — a rotation or a resized zone
   changes the answer, and fonts arriving late is the classic cause of a fit computed against the
   wrong height.

2. Editing a widget did not reach the screen until the app was restarted. The render endpoint serves
   live config, but the player deliberately keeps a widget's WebView while its URL is unchanged
   (re-navigating every duration is a visible flash and destroys widget state — a half-typed
   directory search, scroll position). Editing changes the content, not the id, so the URL never
   changed and the reuse check always hit.

   The widget's updated_at now travels to the player as widget_rev and goes into the render URL, so
   the URL differs exactly when the content differs — and only then, so the anti-flash reuse still
   holds for untouched widgets. The rev is refreshed at send time rather than read from the
   published snapshot, because a widget edit does not republish the playlist. Editing a widget also
   now pushes to the displays showing it, instead of notifying nothing at all.

859 server tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 19:44:12 -05:00
Claude 452c286357 Stop a YouTube embed when the playlist moves off it
The video kept playing behind the next item and its audio carried on over the top: "even when the
picture is there the sound from the video continues playing."

Switching away only set the WebView's visibility to GONE, and visibility is not playback state — a
hidden WebView keeps running. The three paths that leave a YouTube item (image mount, local video,
streamed video) all hid it and none stopped it. stop() has always blanked the WebView with
about:blank; the item-switch paths simply never did.

This could not surface before 1.9.26, because a YouTube item never advanced at all, so nothing ever
switched away from one. Fixing the advance is what exposed it.

The reporter narrowed it further without being asked, and their finding names the mechanism exactly:
"picture, video -> the sound continues when the picture comes after the video. picture, video,
html/text -> the sound do not play after the video." A widget loads a new URL into the SAME WebView,
which replaces the YouTube page and stops it; an image only hides it. One case was silent and the
other was not for precisely that reason.

stopYoutubeIfPlaying() is guarded on the OUTGOING type, so it must be called before currentType is
reassigned, and it cannot blank a widget that is being reused. Blanking is safe because playYoutube
reloads the embed from scratch on every play.

Verified on an Android 12 emulator, counting the app's own started audio players against the item on
screen, before and after:
  1.9.27 as released — image on screen, 1 player still started (the reported fault)
  with this fix      — image on screen, 0 players started; 1 only while the video is up

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 19:33:15 -05:00
Claude 9034478c28 Android: a YouTube item must end on its duration, and clearing a playlist must apply at once
A screen kept showing a YouTube video after its playlist was reassigned, and kept showing it after
"no playlist" was selected. Restarting the app showed the new content immediately, which ruled out
the network, the download and the server payload.

Two faults met:

1. Nothing ever ended a YouTube item. playCurrentItem armed an advance only for images and widgets;
   video/youtube is neither, and it is played by loading an embed into a WebView, which reports no
   completion. playYoutube even took the item's durationSec and never read it. So any playlist
   containing a YouTube item stopped rotating at that item permanently — broader than what was
   reported. The web and Tizen players both already time YouTube off its duration; Android was the
   only player that did not, so this brings it back in line.

2. #157 defers a playlist change when the item on screen is dropped from the new list, applying it at
   the next natural advance. With no advance ever coming, the change was stranded. An EMPTY new list
   went down the same path, so "no playlist" — the one action that should always take effect
   immediately — was deferred too.

Fixed all three layers: video/youtube now ends on a timer (ItemTiming), an empty list is never
deferred (PendingSwap), and a deferral gets a 60s deadline so no future item type that ends on a
callback can strand a swap again. Local and remote video stay off the timer path, where STATE_ENDED
drives them, so clips are not cut short.

The deferral rule and the timing rule are pure seams, tested without a device: 126 Android JVM tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-29 22:11:40 -05:00
ScreenTinker 275e1683b8 Report the screen's own IP, and make the Wi-Fi name an honest optional
A customer read the device page's IP as their screen's address and reported it as wrong.
It was not wrong, it was a different thing: devices.ip_address is the PUBLIC address the
server sees the connection arrive from. Both are useful — you want the public one to
recognise a site, and the local one to actually reach the panel — so the page now shows
each, labelled.

The player already computed its own address for the connectivity report; it just never
reported it. Read straight off the interfaces, so Ethernet panels get it too, and it needs
no permission. Stored on device_telemetry beside wifi_ssid/wifi_rssi, where the
per-heartbeat network facts already live, rather than as another devices column.

The same customer saw "Unknown" for the Wi-Fi name and assumed it needed device-owner
access. It needs LOCATION: Android 8.1+ returns the literal "<unknown ssid>" to an app
without it. So "Unknown" was us reporting a permission gap as if the network had no name.

The player now distinguishes not-allowed-to-know from genuinely-no-Wi-Fi, and the page says
"Needs location permission" instead of a blank. The permission is declared but NEVER
requested at startup and nothing else uses it — a signage player demanding location to
display a network name is a bad trade. It is an opt-in row on the setup screen, using the
same Enable/Manage pattern, and refusing it changes that one field and nothing else.

Also caught by the test suite, and worth recording: the first version of this dropped the
comma in the device SELECT list ("t.uptime_seconds t.local_ip"), which 500'd the endpoint
and failed seven tests that never mention telemetry. Verified end to end afterwards —
public and local addresses both returned, distinct, from a real request.
2026-07-29 21:57:59 -05:00
ScreenTinker 3159f94107 Make unblock stick, and say so when a device is refused
A customer blocked a screen once to see what the button did, then spent an evening
unable to get it back. Three separate faults stacked up.

1. Unblock did not stick. applyToDevice() restores `blocked` on re-pair — deliberately,
so a block cannot be shrugged off by deleting the device — which makes the SAVED copy
the real authority. Unblock only ever wrote `devices`, so the saved row stayed 1 and the
next delete + re-pair silently re-blocked. There was no way out from the dashboard at
all: unblock, re-pair, refused, repeat. Block and unblock now both mirror to the saved
copy, so the survives-a-re-pair property is deliberate rather than a leftover.

2. The refusal was invisible. handleServerRejection() clears credentials and calls
onUnpaired, but only ProvisioningActivity ever assigned that callback — and it is long
gone by the time playback is running. So the screen sat on "Connecting to server" and
the player eventually blamed the URL, sending the operator off checking their network
while the server had already said exactly what was wrong. MainActivity now handles it.

(This half was mine: clearing those leaked callbacks to stop the relaunch loop removed
the only thing that surfaced a rejection. It was a broken path — it fired into a
destroyed Activity — but it was the only one, and MainActivity should have owned it.)

3. The reason was thrown away. The server sends device:auth-error {error: "Device
blocked"} and the client discarded it. It is kept now, and a blocked screen says so
instead of implying a network fault. Localised in all six languages, matching the other
on-screen status strings.

Also ran on prod: one stale saved block cleared (fingerprint ef6540376599, the reporter's
tablet), DB backed up first. It was the only such row.

Tests pin both directions, because the two are easy to confuse: unblock must clear the
saved copy, AND a genuine block must still survive a delete + re-pair.
2026-07-29 18:44:11 -05:00
ScreenTinker 8eff6d57d1 Let permissions be turned back off from the setup screen
Every row on the setup screen hid its button once the permission was granted
(visibility = GONE), which made each one a one-way door. None of these can be revoked
by the app — they all live in system Settings — so hiding the only route to that screen
removed the way back entirely. Asked on #234: "if I make the app as Home launcher but
later on want to remove it then how can I do it?"

The button now stays and relabels to "Manage", with the same destination. Two rows
needed more than a relabel, because their existing destination was a dead end once
granted:

  - Battery: ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS only ASKS to add an
    exemption and cannot remove one. An already-exempt user now goes to the system
    list (verified: Settings$HighPowerApplicationsActivity).
  - Notifications: requestPermissions() does nothing once the answer has been given.
    Now opens app notification settings, which toggles either way.

Also fixes the launcher row disagreeing with itself. The status read
resolveActivity(MATCH_DEFAULT_ONLY), which can name us for merely being a HOME
candidate, while the button asked RoleManager. So the row could say ON while the OEM
launcher was still home — and the button would then offer to BECOME home rather than
open the picker. That is the other half of the same report: "in the apk I have granted
the permission ... BUT in the settings of the tablet it still shows the tablet native
launcher as home." Status and action now ask the same authority.

Verified on an Android 12 tablet, both directions: not-home reads OFF/Set; after
becoming home it reads ON/Manage and Manage opens the Home-app picker (DefaultAppActivity)
— a way out, which is what was asked for.

NOTE: this screen's strings are hardcoded English in the layout and in code ("ON",
"OFF", "Enable", "Continue Anyway"), so "Manage" matches what is already there rather
than introducing one translated word among twenty untranslated ones. Localising the
screen is worth doing and is deliberately not mixed into this change.
2026-07-29 18:27:45 -05:00
ScreenTinker 83c9bc5aa6 Clear ProvisioningActivity's service callbacks (the white-flash relaunch loop)
Reported on #234 as a screen that flashes white "over and over", unkillable — "there
is nothing we can do on the tablet". It is a leaked listener.

ProvisioningActivity installs onRegistered/onUnpaired/onPaired on WebSocketService and
then finish()es. The service outlives it and nothing ever clears them: MainActivity
assigns neither of those three, so nothing overwrites them either. onPaired therefore
stays wired to a destroyed Activity for the life of the process — keeping it alive, and
still firing.

And it fires often. The server sends device:paired on EVERY register, not only the
first. So: register -> paired -> the stale callback starts MainActivity with
CLEAR_TASK -> new Activity binds and registers -> paired -> again. Measured on an
Android 12 tablet with a bare paired device and nothing assigned: 240 activity starts
in 180 seconds, about 1.3 a second, indefinitely.

Android 12 is where it becomes intolerable rather than merely wasteful: every launch
draws a splash screen there, so each iteration is a visible white flash. The same loop
on Android 9 has no splash and reads as an occasional glitch — which is why it was
originally dismissed as unreproducible after a clean reinstall. A clean reinstall
starts MainActivity directly and never runs ProvisioningActivity, so the callback is
never installed and the loop never begins. Pairing is what arms it.

onPaired is now one-shot — the hand-off to MainActivity is all it was ever for — and
all three are dropped in onDestroy too, which covers backing out before pairing
completes.

Same device, same pairing flow, 180s: 240 activity starts and 240 splash screens
before, 0 and 0 after, with registrations falling from 240 to 2.

⚠️ No other callback is ever nulled either (there are ~20). MainActivity's are
overwritten by the next MainActivity so they self-heal, but each one leaks the previous
Activity until then. Worth a sweep; this commit fixes only the three that never get
overwritten.
2026-07-29 18:01:34 -05:00
ScreenTinker bc00bc1eb1 Stop re-registering the device once per playlist item
PlaylistController.next() asks for a playlist refresh on every item advance, and
requestPlaylistRefresh() emits a full device:register. The server's register handler
runs 7+ statements plus the identity/fingerprint path and rebuilds the playlist
payload, then pushes the whole playlist back down. So a panel showing a 10-second
image re-registered six times a minute, indefinitely, and each reply fed a fresh
playlist into a controller that had to diff it — which is what kept the #234 restart
loop supplied.

It was buying nothing. The heartbeat already refreshes every 4th beat (60s), so the
periodic pull this duplicated happens either way.

Throttled at the single chokepoint rather than by editing callers, because the callers
have genuinely different intents — network-came-back, service-connected, per-item, and
the heartbeat itself — and ranking them would be guesswork. A shared floor keeps every
caller's meaning: recovery paths still refresh, they just cannot stack. The window sits
just under the heartbeat's own 60s so the two interleave instead of the throttle
systematically eating the pull we are relying on.

Measured on the reproduction over 240s: 9 registrations for 9 item plays before, 3 for
the same 9 plays after, with playback unchanged. The saving scales with how short the
items are — a 10s item goes from six refreshes a minute to about one.

Does NOT change what a refresh does, only how often one may be asked for.
2026-07-29 14:13:42 -05:00
ScreenTinker 66d9dc7fef Resume the playlist where it was after an Activity rebuild (#234)
Reported as "if there are 2 pictures or one picture and one video only one plays",
and the reporter had never once seen the second item.

PlaylistController is constructed with MainActivity, so every rebuild gives it a fresh,
empty instance. The playlist then arrives — from the disk cache or the socket, it does
not matter which — and the controller sees "0 -> N items", treats it as a first load,
and starts at the top. Anything the panel does that recreates the Activity therefore
sends playback back to item 1.

That would be survivable if it happened rarely. On the reproduction it happened at
every item boundary: the device re-registers, the app relaunches itself with
NEW_TASK|CLEAR_TOP, onCreate runs, and playback restarts. The second item was on
screen for 135ms each cycle, which is why it read as "only one plays" rather than as
a glitch. Prod play_logs agree: the second item logging 0-1s durations while the first
accumulated every real second of playtime, on two unrelated customer devices.

Position now lives in ServerConfig, outside the object that keeps being rebuilt, and
start() resumes from it when the save is recent. A cold start, a stale save, a
shrunken playlist, a missing save, or a clock that jumped backwards all fall back to
starting at the top, so genuine first-runs are untouched.

This does NOT address why the panel relaunches itself once per item — that is the
noisier half and wants its own change. It does mean a relaunch costs a restarted item
instead of a playlist that can never advance.

Reproduced first, on an Android 9 emulator with the reporter's exact shape (12MP
portrait JPEG + 40s MP4): image 135ms before, a full 10.05s after, with the video
holding its 40.1s, over four clean cycles.
2026-07-29 09:13:30 -05:00
ScreenTinker f5ce88c93f Keep retrying an install for a working day, and flag a human straight away
Three attempts inside one hour, then a day of silence, was calibrated for the wrong
cost. The ~8.7MB re-download that throttle exists to prevent is already prevented by
the APK cache — downloadAndInstall reuses a previously verified file, so attempts
2..N pull no bytes. What actually blocks these installs is a confirm dialog waiting
for somebody to walk past, and giving up an hour in guarantees nobody has.

The cap is now 40, roughly a working day at the 30-minute cadence, before falling back
to the existing daily retry. Two things had to come with it, because raising the number
alone would have made things worse:

Telling the operator is now a SEPARATE threshold from giving up. It used to fire at
the cap, so a bare bump would have pushed "this panel needs attention" from about an
hour out to about twenty. It fires at ATTEMPTS_BEFORE_FLAGGING (3) instead, and
statusFor keys on the same threshold, so a device reports manual_update_required as
soon as a human is demonstrably needed and KEEPS reporting it while it retries.
Previously the status dropped back to 'pending' once the backoff window elapsed, so a
panel that needed hands looked healthy in between attempts.

PackageInstaller sessions are now abandoned before a new one is opened. Every attempt
stages a full copy of the APK via openWrite, and a session whose dialog is never
accepted holds onto it. At three that was a rounding error; at forty it would be
~350MB of staged installs on hardware without it to spare, and would eventually trip
the per-app session limit.

The warning text no longer promises a 24h backoff it is not about to take, and says
what would actually fix it — accept the prompt, or have the MDM delegate install
permission.

The three tests that broke encoded the old thresholds and were rewritten to the new
intent rather than retuned to pass.
2026-07-28 23:31:47 -05:00
ScreenTinker 56abfa3579 Make "force update" actually forceful, and make it say what happened
The dashboard button sent the same checkForUpdate() the 30-minute timer calls, so it
was subject to every guard the timer is subject to, and every one of those guards
returns silently. The toast fires on ack.delivered — which only means the command
reached the device's socket — so a panel that was capped, or standing down under an
MDM, looked exactly like one that had updated. "You get the toast popup, but nothing
happens" was an accurate description of working code.

A forced run is a different thing from a timer tick: a human aimed it at one device
and is watching that screen. So it now

  - hands the attempt budget back (OtaThrottle.onForcedCheck), un-parking a device
    sitting in backoff instead of making it wait out the window,
  - overrides the MDM stand-down, since a targeted human action is a stronger and
    better-aimed signal than the global OTA_ALLOW_MANAGED_DEVICES switch,
  - and REPORTS the outcome, including the boring ones. "Already on the latest
    version" is the single most valuable line here: silence was indistinguishable
    from failure, and that ambiguity is the whole bug.

It also distinguishes "install launched" from "installed". Off device-owner Android
raises a confirm dialog somebody has to accept, and the gap between those two states
is precisely where the button appears to do nothing — so the report names which one
happened and says the dialog is waiting.

The timer path is unchanged and stays quiet on purpose: reporting every capped tick
would move a Fire-OS-restart flood onto the WS channel, which is what #139 fixed.

Verified on a real panel end to end: dashboard socket emit -> ack {"delivered":true}
-> "Force update check triggered (operator)" -> "Force update: already on the latest
version (1.9.23)". OtaBackoffCadenceTest additionally pins the retry cadence that
prompted this (3 fast attempts, then one per 24h, full budget back on a new release)
so it stops being re-derived from the source each time it comes up.
2026-07-28 23:24:01 -05:00
ScreenTinker c779d62d63 Add an operator override for self-update on MDM-managed panels
A player stands down from self-updating when another device owner manages the panel,
on the assumption that the MDM distributes packages instead. That assumption does not
always hold: an operator may run an MDM for policy alone and still want ScreenTinker's
OTA to own the player. Until now there was no way to say so — the stand-down was a
client-side decision with no operator input.

OTA_ALLOW_MANAGED_DEVICES=1 makes the server advertise `allow_managed: true` in
/api/update/check, and players skip the stand-down. Default off: the safe behaviour
stays the default, and only an explicit opt-in changes it.

Absence is not consent. The client parses the field with a false default, so a newer
player against an older server that has never heard of it still stands down; and the
server always emits the key, so a player can tell "the operator said no" from "this
server has no opinion". Config parsing is strict for the same reason — only 1/true
enable it, and anything else, including a plausible typo like "ture" or "yes", lands
on the safe side rather than riding JavaScript truthiness.

This deliberately does NOT grant silent install. Off device-owner, and without
DELEGATION_PACKAGE_INSTALLATION delegated by the MDM, Android still raises a confirm
dialog somebody has to accept, so the override alone will not fix a fleet whose
installs are failing at that dialog — delegating the scope is the real fix there. The
README says so at the point of use, because reaching for this flag is the natural
mistake.

Only reachable because the stand-down now runs after the version check rather than
before it; it needs the server's answer in hand to consult.
2026-07-28 23:07:30 -05:00
ScreenTinker ed22693a6e Merge branch 'fix/transition-overlay-handoff'
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-07-28 22:35:40 -05:00
ScreenTinker 0b6907704e Merge branch 'fix/resume-self-advance-on-follower-exit' 2026-07-28 22:35:40 -05:00
ScreenTinker d913397559 Hand the frame back cleanly when a wipe ends
Reported as one or two frames of the OUTGOING photo after every transition, before
the incoming one appears. Three things conspired, all at the moment the wipe ends.

The overlay is a translucent SurfaceView with setZOrderOnTop(true) and a clear colour
of (0,0,0,0). onDrawFrame() cleared unconditionally, before testing whether there was
anything to draw. finish() left RENDERMODE_CONTINUOUSLY on and only POSTED the content
swap and the hide to the main thread, so the GL thread got at least one more frame in
first: it cleared to fully transparent while the overlay was still visible, showing
straight through to the ImageView — which still held the previous photo, because the
swap had not run yet. Not a black flash; a see-through one. The same clear ran on the
failed/hard-cut path.

So: clear only when a frame is actually going to be drawn over it, and stop the render
loop in finish() on the GL thread rather than waiting for the main thread to park the
overlay. What stays on screen is then the wipe's final frame, which is the destination
image, and it is correct to leave it there.

That still left the hand-off itself racing. Hiding a Z-ordered SurfaceView is a
SurfaceFlinger transaction that is not synchronised with the app drawing the newly
mounted bitmap, so the hide can land a vsync before the paint and uncover the old photo
anyway. The overlay now lingers briefly before parking. It costs nothing to look at —
both layers are showing the same picture — and it removes the race rather than
narrowing it.

Measured on the panel with 64x36 frame classification over screen recordings: the old
photo reappeared after 1 of 4 wipes before, 0 of 14 after the first two changes. That
sampling runs through a virtual display and cannot see every composited frame, so it
bounds the problem rather than proving absence — hence closing the last gap by
construction instead of by measurement.

The web player never had this: it calls mount() and then hides the canvas synchronously
in one task, so both land in the same paint.
2026-07-28 22:23:23 -05:00
ScreenTinker 8cf395f63b Re-arm self-advance when follower mode is turned off
While follower mode is on — a video wall follower, or a group-sync member —
playCurrentItem() deliberately never calls scheduleAdvance(): the wall/group tick
owns the index instead. Leaving that mode cleared the flag but re-armed nothing, so
the item already on screen had no timer behind it and the playlist stopped dead.
Unchecking "sync" on a group froze every member showing an image, until the app was
restarted. A 30-frame sample of a real panel returned exactly one unique frame.

Video hid the damage: onVideoComplete() -> next() still fires once repeatMode drops
back to OFF, so a video playlist recovers on its own and only images and widgets
strand. Both wall and group exit run through setWallFollower(), so the fix belongs
there rather than in either controller.

The entering edge was wrong in the same way, oppositely: a timer armed by the last
playCurrentItem() stayed live across the transition into follower mode and would fire
a next() that fights the tick for the index. It is now cancelled.

Resume is measured from when the item actually started, so leaving sync 8s into a 10s
image advances in ~2s rather than restarting the full slot; an already-elapsed slot
yields 0 and the existing MIN_ADVANCE_MS backstop keeps that off a busy loop.
FollowerExit is a pure seam so the arithmetic is testable without a Handler.

Verified on the panel that reproduced it: "follower mode off — resuming self-advance
in 9233ms", same pid, 40 frames / 7 unique / 9 advances where it previously froze.
2026-07-28 22:23:02 -05:00
ScreenTinker 5ba60ffa1a Stand down from self-OTA only for a real device owner, and say so when we do
The MDM auto-detect added in #166 asked "is any device admin active outside our
package". On a stock Fire TV stick the answer is yes: com.amazon.tv.parentalcontrols
is registered, holding wipe-data and nothing else. A retail stick with no enrolment
anywhere therefore declared itself MDM-managed and opted out of updates for good —
one sat 12 versions behind (1.9.11 against 1.9.23) while the server offered it every
release in between.

Device admin is not device owner. isDeviceOwnerApp/isProfileOwnerApp are public since
API 21 and accept any package name, so the owner really can be read directly; the
comment claiming otherwise was the root of the over-broad test. Profile owner is not
enough either — on that same stick parental controls owns user 0 — so the check is
now a foreign DEVICE owner, and delegated install scope short-circuits it since an
owner that delegated installs to us wants us installing.

Where doubt remains the asymmetry decides it: standing down wrongly is silent and
permanent, while attempting wrongly is capped at MAX_INSTALL_ATTEMPTS and surfaces
manual_update_required. Better to be the kind of wrong that reaches a dashboard.

That visibility was missing too. The stand-down ran before the version check, so a
managed panel never learned an update existed and kept reporting ota_status 'none' —
indistinguishable from up to date, which is why nothing flagged it. It now checks
first and parks genuinely-managed panels in manual_update_required, announced once
per target version rather than every polling cycle.

ManagedLogic is a pure seam alongside TierLogic; the admin shapes under test are the
ones dumped from the real device.
2026-07-28 21:40:18 -05:00
ScreenTinker 59c536c923 Keep a solo widget mounted, and size its keyboard to the viewport
Two problems on a panel showing one fullscreen widget, both visible as flashing.

The player re-navigated the WebView every duration_sec. PlaylistController.next()
requests a playlist refresh between plays and playCurrentItem() re-issues the item
unconditionally, so a one-item playlist reloaded the same URL forever. The existing
dedupe guard only covers the playlist-update path, so it logged "not restarting"
AFTER the reload had already happened. On an interactive widget that also discarded
whatever the viewer had typed.

showWidget() is now idempotent: same URL with the widget already on screen returns
without re-navigating, and the cached URL is cleared at every media-type transition
so switching away and back still reloads. The refresh itself is untouched — schedule
re-evaluation and dayparting still run on the timer, and widgets keep refreshing
their own data client-side (directory-search polls its board every 30s and preserves
the current query). The web player already behaved this way via reevaluateHeldWidget;
this brings the Android player to parity.

Separately, the directory-search keyboard was laid out in fixed pixels for a
1920-wide viewport. A panel's CSS viewport is its resolution over its density, so a
1080p screen at 240dpi presents 1280x720 — where four rows of 56px keys took ~37% of
the height instead of ~24%, and the lone max-width:700px breakpoint never fired to
correct it. Key metrics are now clamped against vh. The clamp maxima are the previous
fixed values and both vh terms exceed them at 1080 tall, so a 1080 viewport renders
pixel-identically; shorter viewports scale down. The breakpoint no longer re-pins .key,
which would have undone the clamp.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 20:39:59 -05:00
screentinker 11c2890433
fix(android): visible D-pad focus stroke on setup buttons (#218)
The setup screen buttons used a bare <ripple> which only reacts to
state_pressed (touch). D-pad navigation triggers state_focused, which the
ripple ignored, leaving the focused button nearly invisible against the
dark background from TV viewing distance.

Wrap the button shape in a <selector> (kept inside the ripple so touch
still gets the press ripple) that adds a bright 3dp white stroke on
state_focused.

Closes #209

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:34:21 -05:00
ScreenTinker 07419fee1f feat(players): proof-of-play on Android+Tizen; close Tizen parity gaps
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Android and Tizen never emitted device:play-event, so Reports showed Total
Plays / Hours / proof-of-play as all zero for those devices (only the web
player logged plays). Both now emit play_start on show and play_end on
advance/teardown, mirroring the web player's contract (leader-gated for walls,
widget-id fallback so durations close). Server side unchanged — play_logs and
the reports queries were already waiting for the events.

Tizen parity with the web player (from the parity audit):
- audio: landscape <video> honors item.muted (warm-muted for autoplay, then
  applies the real state) instead of force-muting; wall followers stay silent
- device:mute-changed: real-time per-item mute of the on-screen video
- device:remote-key: D-pad/volume/mute/home, BACK=info overlay, POWER=screen-off
- device:remote-touch: normalized-coordinate touch injection
- buffered widget swap: reveal the new iframe on load then clear (no black flash)
- diagnostic info overlay toggled by the dashboard BACK key

Still muted on Tizen: the portrait AVPlay video path and transition-composited
video (would need webapis.avplay volume APIs / renderVideoBuffered work).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:22:18 -05:00
screentinker ba00dd2811
fix(transition-engine): Android supersede wedge/leak + web/Tizen stale-video guard (#205)
Pre-release review follow-up to #204: fixes the Android superseded-wipe playlist wedge + GL leak, and adds the stale-item guard to web/Tizen renderVideoBuffered.
2026-07-20 16:58:28 -05:00
screentinker 96b71a0d56
feat: transition engine — GL wipes across web, Tizen & Android (+ image↔video) (#204)
Client-side GL Transitions v1 across all three players with never-blank degradation: shader lib + generated manifest + real-WebGL CI, transition-as-widget normalization, persistent WebGL/GLES2 compositors (web/Tizen/native Android), image↔video wipes, SSRF-hardened media proxy, decode-gated image preload, dashboard picker. Fixes the playlist change-fingerprint that dropped transition edits. Alpha + on-device soak validated.
2026-07-20 16:45:32 -05:00
screentinker 335681b907
feat(directory-board): panel-ring scroll + in-place refresh + per-device frame diagnostic (#203)
Compositor panel-ring board scroll (smooth on Blink+Gecko, no blank-on-refresh), a per-device frame-rate diagnostic widget + dashboard card, and web/Android/Tizen device-id passthrough to widget render URLs.
2026-07-17 20:17:21 -05:00
screentinker af286826dc
fix(android): stop zero-duration widget self-loop pegging the main thread (#198)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
A solo fullscreen widget (or image) with duration_sec=0 hit an unclamped
scheduleAdvance(item.durationSec * 1000L) in PlaylistController.playCurrentItem,
scheduling a 0ms auto-advance. For a single-item playlist next() re-selects the
same item, so it re-played every looper tick (~20x/sec) — black-screening the TV
and locking the UI (couldn't even reach home). Triggered when a schedule collapses
the playlist to a single always-on duration-0 widget.

Use slotMs() (the max(1, duration||10) contract shared with the web/Tizen players)
so a zero/negative duration floors to 10s. Also floor scheduleAdvance() itself to
MIN_ADVANCE_MS (500ms) as a backstop so no future path can busy-loop the main thread.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:01:19 -05:00
screentinker 77322c631a
fix(android): show soft keyboard for PIN/URL dialogs over immersive fullscreen (#191)
The hidden-settings PIN box (2x back) and the change-server URL dialog couldn't be
typed into on kiosk devices: a plain AlertDialog shown over the player's
SYSTEM_UI_FLAG_IMMERSIVE_STICKY activity never gains window focus, so the soft
keyboard doesn't attach and the EditText gets no cursor. On a panel/Fire TV with
no hardware keyboard that means the PIN can't be entered at all. Pre-existing
(showPinDialog unchanged since it was added; immersive flags since the initial
release) — surfaces on tier-0 kiosk devices where immersive is active.

Fix: shared showImeDialog() applies the standard immersive-dialog IME workaround —
mark the dialog NOT_FOCUSABLE before show() (so it doesn't steal focus and reset
the activity's immersive flags), mirror the immersive systemUiVisibility onto the
dialog, then clear NOT_FOCUSABLE after show() so the IME can attach, force
SOFT_INPUT_STATE_ALWAYS_VISIBLE, and requestFocus the field. Routed both the PIN
and change-server dialogs through it.

Compiles (:app:compileDebugKotlin). IME behavior needs a real tier-0 device to
confirm (can't be exercised headless).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 10:00:26 -05:00
screentinker fa31eb9cc3
fix(android): reset stuck download backoff on content change + network reconnect (#170) (#190)
Symptom 1's 'stuck on first load, fixed by toggling the playlist' is stale
download backoff. On a fresh device the first downloads fail while the link is
settling; DownloadCoordinator escalates an exponential backoff (15s..5min cap),
and ensure() then SKIPS those items. The 60s playlist refresh re-fires
onPlaylistUpdate but ensure() still skips them, and backoff was only ever cleared
by forget() (content-delete) — never by a re-assignment. So the item stays stuck
until the 5-min window happens to lapse; toggling the playlist is just a manual
way to wait it out.

Fix (storm-safe — neither reset fires on the routine same-playlist 60s refresh):
1. DownloadCoordinator.resetBackoff(id) / resetAllBackoff() — clear attempts +
   nextAttemptAt but KEEP inFlight (single-flight preserved, no duplicate .part).
2. onPlaylistUpdate resets backoff for each item ONLY when the content-id
   signature changed (first load, reassignment, toggle-back), then ensures — so a
   genuine (re)assignment retries immediately. Same-signature 60s refresh -> no
   reset -> the retry-storm guard stays intact.
3. Network onAvailable (was onLost-only) -> resetAllBackoff() + requestPlaylistRefresh,
   so content that failed while the link was settling retries the moment real
   connectivity arrives.

Tests: DownloadCoordinatorTest — resetBackoff/resetAllBackoff re-attempt a
backed-off item before the clock advances; existing backoff/single-flight tests
unchanged. :app:testDebugUnitTest green.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:07:43 -05:00
screentinker bc9e72ec0b
fix(content): render YouTube Shorts in 9:16 instead of a landscape frame (#184) (#189)
Vertical Shorts were played in a player forced to 100%x100% on a landscape
frame, so they looked wrong (pillarboxed/small). Option A: detect vertical at
ingest, persist it, and have every player honor it.

- Ingest (routes/content.js): detect a Short from the /shorts/ URL form OR
  portrait oEmbed dims (oEmbed now queried with the ORIGINAL url so /shorts/
  reports its true dimensions), and persist it as st_aspect=vertical on the
  stored embed URL. That's the only signal players get (remote_url), so it must
  be captured at ingest, not re-derived per loop. YouTube ignores the unknown
  param; players read the video id, not the full URL, to build the embed.
- Players read st_aspect=vertical and center a 9:16 box (fills a portrait screen,
  pillarboxes cleanly on landscape) instead of 100%x100%:
  web (player/index.html), Android (WebViewSupport.youtubeEmbedHtml), Tizen
  (player.js single-zone + zone paths). Dashboard library uses a static thumbnail,
  so it's unaffected.

Not doing Option B (yt-dlp): runtime dep + storage/bandwidth + maintenance +
YouTube ToS; embed-disabled Shorts already skip gracefully.

Tests: youtube-shorts.test.js (4) — /shorts/ and portrait-dims tag vertical,
landscape stays untagged, /shorts/ tags even if oEmbed fails. Android compiles;
web player inline JS + Tizen player.js parse.

Note: pre-existing Shorts added before this aren't retagged (would need an oEmbed
backfill) — re-add to fix, or a follow-up migration.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:41:43 -05:00
screentinker a15086540f
feat(widgets): directory-search widget (interactive search of a directory board, live-sync) (#188)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
* feat(widgets): add directory-search widget

An interactive, walk-up search view of an existing directory-board. It
references a source board by id (no data copy), so a venue can run the
scrolling board on a main screen and a search view on a tablet, letting
people find an entry instantly.

Server (routes/widgets.js):
- register 'directory-search' + renderDirectorySearch(): resolves the source
  board, inlines its categories as one \u003c-guarded JSON blob, renders all
  text via textContent (XSS-safe), live case-insensitive filter over
  identifier/name/subtitle (debounced), grouped results, available styling,
  optional touch on-screen QWERTY keyboard that drives the same filter path.
- missing / non-directory-board source -> friendly full-page fallback, not a 500.
- live-sync while open is out of scope; left a // TODO for a poll hook.

Frontend editor (views/widgets.js): type + magnifier icon, source-board
dropdown (from loaded widgets, filtered to directory-board), title, logo
(reuses the board's picker), placeholder text, theme, on-screen-keyboard toggle;
getConfigFromForm case. i18n: widget.dirsearch.* + type keys in en/es/it/de/pt/fr.
docs: openapi widget_type enum. Tests: server/test/directory-search.test.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(widgets): live-sync for directory-search (poll source board, no reload)

Reflect directory-board edits on an open directory-search page without a reload.

- New public GET /api/widgets/:id/data.json returns { categories } for a
  directory-board (404 for missing/wrong-type so the page keeps last-good data
  on a transient miss). CORS-open (ACAO:*) + no-store so a null-origin sandboxed
  widget iframe can read it; exposes only data already public via /render.
  Exempted from CSP + auth in server.js alongside /render.
- directory-search page inlines its source_widget_id and polls the board's
  data.json every 30s via a relative URL (works behind a proxy/base path and
  from a null-origin iframe). Only rebuilds + rerenders when the data actually
  changed, so a mid-search view isn't disturbed; skips while document.hidden;
  keeps last-good data on any fetch error. Flatten logic factored into
  buildFlat() and reused by the poll.

Tests: data.json feed (categories, CORS header, 404s) + poll wiring in
directory-search.test.js (13/13). Verified live in a browser (page.clock
fast-forward): editing the board updates the search page with no reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(android): let player WebViews take touch focus for interactive widgets

directory-search is served through the existing generic widget path
(loadUrl <server>/api/widgets/:id/render), so it already renders on Android
with JS + DOM storage + mixed-content enabled, same-origin (so its live-sync
fetch of the source board's data.json works), and no touch blocking.

Add isFocusable/isFocusableInTouchMode to the shared WebView config so the
search field reliably takes a tap/cursor inside the kiosk lock-task WebView.
Harmless for passive widgets (board/YouTube have no focusable inputs); the
widget's own on-screen keyboard still drives the filter when the system IME
is suppressed. Verified with :app:compileDebugKotlin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:00:22 -05:00
screentinker 9c70fcc790
feat(diagnostics): device incident log — offline cause, network-vs-reboot, display-sleep (#175)
* feat(diagnostics): device incident log — why a screen went offline/black, with device-attested cause

Field request (Bold/s_t_r_o_b_e): "screens go offline randomly — let us see the cause." Answers it
across the fleet with a unified incident log, and — the key insight — lets the DEVICE disambiguate
the cause the server can't: if the app process survived the gap it was a NETWORK problem (not a
reboot), and it can even tell a dropped Wi-Fi/Ethernet link from a link-up-but-server-unreachable
(router/upstream) failure.

Schema:
- device_status_log gains reason + detail (WHY each offline transition happened).
- NEW device_events table (unified incident feed): type (offline/online/display_off/display_on/
  crash/reboot/network/app_error) + reason + detail, indexed, age-pruned + per-device capped.

Server:
- Capture the socket.io disconnect REASON (transport_close/ping_timeout/transport_error) instead of
  discarding it — recorded in the offline-cause log. devices.offline_reason stays on the EXIT-SIGNAL
  contract (crashed/clean_exit/silent) — a separate axis, preserved (violent death = 'silent').
- device:event handler (typed incidents) + device:connectivity-report handler (device-attested).
  lib/incident-classify.js (pure, unit-tested) composes reason+detail: cold_start->reboot;
  link_lost->network "Wi-Fi/Ethernet link lost"; else network "LAN up, server unreachable
  (router/upstream)"; appends SSID / weak-signal (rssi<-75) / IP-changed. On a report it upgrades the
  most-recent offline row from the server's guess to the device's ground truth.
- heartbeat timeout -> 'heartbeat_timeout'; retention/cap for device_events.
- Device-detail API returns statusLog.reason/detail + the last 50 device_events.

Device (Android WebSocketService): ConnectivityManager default-network callback (link-lost during a
gap) + Wi-Fi SSID/RSSI + IP snapshot -> device:connectivity-report on reconnect (app survived =>
network); ACTION_SCREEN_ON/OFF receiver -> device:event display_on/off ("screen went black"). All
guarded/feature-detected; no manifest change; compiles clean.

Web + Tizen players: reconnect connectivity-report (link_lost from navigator.onLine during the gap)
+ visibilitychange -> display_off/on. Best-effort (no wifi detail in a browser). Tizen exit-signal
marker slice untouched.

CMS (device-detail): the offline cause on the uptime-timeline hover + a new "Recent incidents" panel
(merged offline periods + typed events, friendly labels, detail, relative time + down-duration).

Built as a 4-way parallel agent fan-out over disjoint domains against a locked contract, then
integrated. Verified: full server suite 443/443 (incl. the seam fix keeping the exit-signal contract
intact), Android compileDebugKotlin clean, all players + CMS node -c clean.

Refs #170.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(diagnostics): internet-reachability probe — split "our server down" from "no internet" (#170)

Follow-up to the incident log: when a device's link is UP but it's offline, "router/upstream" was a
catch-all. The device now probes a public host (1.1.1.1 / 8.8.8.8 :443) DURING the gap, so the cause
pinpoints blame:
  - link_lost=true                     -> Wi‑Fi/Ethernet link lost (device's own link)
  - link up, internet_ok=true          -> server_down: internet reachable, OUR server was unreachable
  - link up, internet_ok=false         -> no_internet: router/ISP down
  - link up, no probe result           -> generic router/upstream (unchanged fallback)

- Android WebSocketService: fire a short daemon-thread TCP probe (443, either host) at disconnect;
  the result rides the connectivity-report as internet_ok (omitted if the gap ends before it finishes).
- lib/incident-classify.js: 3-way split on internet_ok; new reasons server_down / no_internet.
- Frontend i18n: device.event.server_down / .no_internet labels.
- Tests: +3 classify cases (server_down, no_internet, link_lost wins over internet_ok). 12/12.

Verified: classify 12/12, Android compileDebugKotlin clean, node -c clean. Refs #170.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(diagnostics): log an 'upgrade' incident (old → new app_version) — server-side (#170)

When a device reports an app_version different from the stored one, applyDeviceInfo logs an
'upgrade' device_events row (detail 'old → new'). Server-side, so it covers Android/Tizen/web with
no client change; a fresh pair (no prior version) isn't counted. Adds device.event.upgrade label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:26:04 -05:00
screentinker 837f65e634
fix(content+android): rotation-aware media — portrait upright on dashboard AND player (#170) (#172)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
* fix(content): rotation-aware media dimensions — portrait no longer stored landscape (#170)

Ingest recorded CODED width/height and ignored rotation, so a portrait phone video
(coded 1920x1080 + 90° Display-Matrix) or a portrait photo (EXIF orientation 6) was
stored LANDSCAPE. The player then rendered it wrong-aspect and letterboxed — the
"portrait content degraded + blue bar at the bottom" symptom in #170. The reporter's
workaround (pre-rotate + mark Landscape) is exactly what this bug forces.

- lib/media-orientation.js (new): pure, unit-tested display-dimension helpers = single
  source of truth for ingest AND the backfill. videoDisplayDims() reads the modern
  Display-Matrix side_data rotation (falls back to the legacy tags.rotate, sign-normalized);
  imageDisplayDims() honors EXIF orientation 5..8. Odd quarter-turns swap W/H.
- lib/content-ingest.js: use the helpers for stored dims; add sharp .rotate() so image
  THUMBNAILS are auto-oriented too (video thumbs were already auto-rotated by ffmpeg).
- scripts/backfill-rotation-dims.js (new): idempotent, dry-run-by-default maintenance to
  correct already-uploaded portrait media (re-probe -> fix dims -> regenerate image thumbs).
- test/media-orientation.test.js: 5 bites (tag + Display-Matrix, sign/normalize, EXIF 5..8,
  the blue-bar landscape->portrait case, null-safety).

Scopes #170 to its residual-on-1.9.4 issues; the 1.9.3 "never displays" slice was #162 +
the remote_url-null download fix, already shipped in 1.9.4. The slow low-res/orientation-
cycling first load is tracked separately in #170 pending repro data.

Refs #170.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(android): honor EXIF orientation in ImageLoader so portrait photos render upright (#170)

Completes the rotation-aware media fix on the PLAYER side. The server ingest fix (this
branch) corrects stored dimensions + auto-orients the thumbnail, but the panel draws the
full-res original via BitmapFactory, which ignores EXIF — so a portrait photo (landscape
pixels tagged "rotate 90") still rendered sideways on the screen. QA root-cause pass on
#170 caught this gap: the Android player reads no stored dims and applied no EXIF.

ImageLoader now reads the EXIF orientation (from the file for cached content, from the byte
stream for remote_url images — ExifInterface(stream) is API 24+, minSdk is 24) and rotates/
flips the decoded bitmap via a Matrix (all 8 orientations). NORMAL/UNDEFINED is a no-op (no
extra allocation); a transformed copy recycles the source; OOM falls back to the source
rather than crashing. Videos were already correct (ExoPlayer honors the rotation matrix).

Verified: :app:compileDebugKotlin clean.

Refs #170. Rides with the server rotation-dims fix on this branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:05:11 -05:00
screentinker ef91f644a7
feat(system-control): Tier 0/1 controls with no device-owner dependency (#160) (#169)
* feat(system-control): Tier 0/1 controls with no device-owner dependency [#160]

Track A of the system-control split (Track B = device owner, shipped in #168). Ships the
capabilities that need NO device owner, with graceful per-tier degradation.

Capability reporting (keystone):
- DeviceInfo now reports can_write_settings / accessibility_enabled / overlay_granted
  alongside the existing tier/device_owner flags; server persists them (3 additive device
  columns, older APKs default to 0); dashboard gates controls + shows what's grantable.

Android SystemControl (new, all best-effort / no-op when unsupported):
- Tier 0 (no permission): media volume (AudioManager STREAM_MUSIC), per-window brightness
  (WindowManager.LayoutParams.screenBrightness — dims our window only).
- Tier 1 (WRITE_SETTINGS): system-wide brightness + screen-off timeout (Settings.System).
- Commands set_volume / set_brightness / set_system_brightness / set_screen_timeout wired
  in MainActivity.onCommand; ALLOWED_COMMANDS extended for the group path.
- SetupActivity gains a one-time WRITE_SETTINGS grant row (mirrors the overlay/accessibility
  grants); manifest declares WRITE_SETTINGS.

Dashboard:
- device-detail "System control" section (any Android panel): volume + this-app brightness
  sliders always; system brightness + sleep-timeout only when the panel reports
  can_write_settings, else a "grant on the panel" hint. Sends on release (not drag).

Validated live on a non-owner tier-0 panel: dashboard → set_volume 0.75/0.15 → the panel's
STREAM_MUSIC volume moved to 11/2 (of 15). 423 server tests green.

Closes #160.

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

* fix(system-control): volume slider reflects real volume + device-owner brightness/timeout [#160]

Two fixes from live testing:

1. Volume "doesn't remember" — the slider hardcoded 50 because the panel never reported its
   current volume. Now DeviceInfo reports media_volume (0..1); a new lightweight device:info
   socket event lets the panel re-report right after a set_volume (no full re-register / playlist
   re-push); server stores devices.media_volume; the dashboard inits the slider from it.
   Validated: dashboard set_volume 0.60 -> panel STREAM_MUSIC 2->9 (of 15) -> stored 0.60.

2. System brightness/timeout on a DEVICE OWNER — was gated only on WRITE_SETTINGS, which an
   owner doesn't have. A device owner can set those via DevicePolicyManager.setSystemSetting
   with no grant, so SystemControl now takes that path when isDeviceOwner(), and the dashboard
   enables the Tier-1 controls when can_write_settings OR tier===2. STPolicy.setSystemSetting added.

deviceSocket device_info UPDATE extracted into applyDeviceInfo(), shared by device:register and
device:info. Migration: devices.media_volume REAL (additive). 423 server tests green.

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

* feat(system-control): brightness/timeout remember + move controls into a tab [#160]

Same "remember what it's set to" treatment as volume, now for brightness + sleep timeout, and
the System control section moves off the top into its own "Controls" tab.

Reporting (DeviceInfo -> device:info re-report -> devices columns -> dashboard slider init):
- system_brightness (read from Settings.System, no permission) + screen_off_timeout_ms.
- window_brightness: persisted in ServerConfig (survives relaunch, re-applied on MainActivity
  launch) so the per-window slider reflects it too.
- reportInfoNow() now also fires after set_brightness / set_screen_timeout.

Dashboard: new "Controls" tab (any Android panel) holding the volume/brightness/timeout controls;
every control inits from the reported value; sleep dropdown preselects the current timeout.

Server: +3 additive columns (system_brightness, window_brightness, screen_off_timeout_ms); the
device_info UPDATE stores them. Migrations all additive/re-runnable. 423 server tests green.

Validated live: set_brightness 0.40 -> stored window_brightness 0.40; volume 0.30 -> 0.33.

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-07-12 20:48:47 -05:00
screentinker 501ffb11c1
feat(device-owner): tier foundation + QR provisioning + content-expiry & device enhancements (#168)
Device-owner tier substrate + silent install, end-to-end QR provisioning (Android 12+ compliance, APK-derived checksum, URL pre-seed, zero-touch onboarding, guided a11y screen), content-expiry (#157) with player no-restart deferral, and the device-enhancement batch (#10/#12/#13/#14). Backward-compatible with 1.9.3 clients; all autonomous behaviors opt-in. QA + security review green. Closes #161, #157, #159.
2026-07-12 19:41:07 -05:00
screentinker 938a43a466
Group sync: clock/schedule synchronized playback (offline-native) + double-buffer (#167)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
* feat(group-sync): synchronized playback per group (server + Android) [stage 1]

Play a group's shared playlist in lockstep across its displays — start/end items
together — by reusing the video-wall sync primitive with the spatial transform
removed and keyed on group_id instead of wall_id.

Server:
- device_groups += sync_enabled + leader_device_id (optional pin).
- buildPlaylistPayload emits a group_sync:{group_id, is_leader} block ONLY for a
  member whose playlist matches the group's shared playlist (playlist-match guard —
  a mismatched member is ignored, never synced). Membership via device_group_members.
- Leader auto-election: pinned-if-online -> first online matching member -> stable
  fallback; computed (never persisted, so the operator's pin is preserved).
- group:sync / group:sync-request relay among eligible members (mirrors wall:sync,
  guarded on membership + playlist match).
- Self-heal: re-push payloads to group members on (re)connect so is_leader refreshes.
- PUT /api/device-groups/:id accepts sync_enabled + leader_device_id and re-pushes.

Android:
- WallController is now mode-aware. WALL = transform + object-fit:fill + forced
  follower-mute (UNCHANGED — every wall branch is byte-equivalent when isGroup=false).
  GROUP = same leader/follower timing incl. the full video drift controller, but
  full-screen (no transform), normal fit, and per-item mute honored (no forced mute).
- WebSocketService: emitGroupSync/emitGroupSyncRequest + onGroupSync/onGroupSyncRequest.
- MainActivity: dispatch emit by mode, parseGroupConfig, onPlaylistUpdate group hook.

Wall path is provably unchanged (additive mode, defaults to WALL). Kotlin compiles;
server suite 407/407.

Stage 2 (follow-up): web + Tizen parity, dashboard sync toggle + leader picker,
offline-sweep leader promotion.

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

* feat(group-sync): web + Tizen player parity [stage 2]

Mirror the Android group-sync generalization in the two web-based players so a group
with a shared playlist syncs across mixed player types, not just Android.

Web player (server/player/index.html):
- group:sync / group:sync-request handlers (index jump + latency-compensated video
  drift, same maths as wall:sync) — no audio policy here, per-item mute honored.
- emitGroupSync() + applyGroupSync() (4Hz leader broadcast; follower sync-request).
  NO CSS transform, NO forced mute (unlike applyWallMode).
- isFollower now also true for a group follower (suppresses self-advance).
- handlePlaylistUpdate handles data.group_sync (mutually exclusive with wall).

Tizen (tizen/js/player.js WallController + app.js):
- WallController is mode-aware: WALL styles the slice + forced follower semantics
  (UNCHANGED when mode !== 'group'); GROUP clears any wall styling (full-screen) and
  drives leader/follower timing only. syncId()/clearStageStyle() helpers; emitSync/
  onSync/onSyncRequest key the event + id off the mode.
- app.js: group:sync/request socket handlers; onPlaylist enters group sync on a
  group_sync block, else exits — content renders through the normal single-zone path.

Realigned the v4-exit-signal-phase3 TIZEN slice (682->696) shifted by the app.js edits.
Wall path is provably unchanged in both (additive group mode). Server suite 407/407;
both players' JS parse.

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

* feat(group-sync): dashboard UI — sync toggle + leader picker [stage 3]

On each group's header row:
- "Sync" checkbox -> PUT /api/groups/:id { sync_enabled } enables synchronized
  playback; server re-pushes to members so they enter/exit sync mode. A hint notes
  it needs a group playlist and that a display on a different playlist is ignored.
- When on, a "Leader: Auto / <display>" picker -> { leader_device_id } (null = auto-
  elect, which self-heals; or pin a specific member to always lead when online).

Adds api.updateGroup(id, data) and the en i18n strings (en-only, mirroring the
existing per-group UI keys; the i18n parity test is apitoken-scoped).

Frontend parses (ESM); server suite 407/407.

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

* feat(group-sync): rework to clock/schedule sync + double-buffer + polish

Replace the leader/follower relay model with clock/schedule sync. Every
same-playlist member derives the identical (index, position) locally from a
server-disciplined clock + the deterministic playlist schedule, so sync:
  - needs no server at play-time (offline-native), and
  - has no leader role to double-elect (kills the split-brain class the leaked
    WallController tick produced).

Server
  - heartbeat-ack now carries server_ms + echoes client_ms for NTP-style clock
    discipline; the client caches the offset (survives an outage).
  - POST /groups/:id/resync -> group:resync (manual "Resync now").
  - (kept: group_sync payload; leader machinery is now vestigial/ignored.)

Clients (web / Tizen / Android)
  - Clock offset disciplined over the heartbeat, cached (localStorage / prefs).
  - Schedule engine: pos = (syncedNow mod Sum(duration_sec)) with a CANONICAL
    slot formula identical across platforms so mixed-platform groups can't drift.
  - Snap-on-load: a fresh clip hard-seeks ONCE to the exact position (was ~5s of
    gentle nudge to eat a ~0.3s load offset); steady-state keeps the gentle nudge.
  - Double buffer: warm the next clip a few s before the boundary -> instant
    switch, no black hold. Android pre-decodes on a throwaway surface so the swap
    doesn't flash one wrong-aspect (landscape-stretched) frame.
  - In-place duration edits: duration_sec dropped from the change signature and
    applied in place, so a duration edit re-anchors the schedule WITHOUT a restart.
  - Live-log shows discrete corrections (jump/align/seek) immediately; only the
    steady-state line is throttled.

Android
  - Fix leaked WallController leader tick: onDestroy() now stops it (Handler on the
    main looper outlived the Activity -> zombie broadcaster / split-brain).

Dashboard
  - Group leader picker -> "Resync now" button.

Tests: heartbeat-ack clock fields + resync route; exit-signal .wgt slice realigned.

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-07-11 14:24:31 -05:00
screentinker 1ebdb1f7a9
feat(ota): self-update kill switch — global, per-device, and MDM auto-detect (#166)
Lets an operator (or an MDM) own updates instead of the app self-installing, which
on managed panels shows a self-install confirm dialog over customer content
(#155). Three layered controls:

- GLOBAL (server): config.otaEnabled from OTA_ENABLED (default on). When off,
  /api/update/check returns update_available:false, reason:ota_disabled_global —
  the whole instance stops offering updates.
- PER-DEVICE (server + dashboard): new devices.ota_enabled column (default 1). When
  0, that device is never offered an update (reason:ota_disabled_device). A
  "Self-update (OTA)" toggle in the device settings flips it via PUT /api/devices/:id.
- AUTO-DETECT (Android): UpdateChecker stands down entirely when a foreign device
  owner (an MDM/DPC) manages the panel — detected via getActiveAdmins() + not being
  device owner ourselves. Pure client-side, errs safe, needs no server change.

The two server gates are enforced server-side so they cover EVERY client version,
not just ones with the client-side stand-down. When OTA is off the device still
reports its version (dashboard sees state); the MDM/operator owns the actual update.

For an MDM-managed fleet (e.g. Pivot/MAXHUB), turn OTA off and let the MDM push the
APK — the install-dialog race disappears from every angle.

Tests: +2 (per-device gate + a real OTA_ENABLED=false server for the global gate);
full server suite 393 pass; Android compiles.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:38:58 -05:00
ScreenTinker f60f677cf0 fix(android): player provisioning + playback robustness
Client-side fixes to the Android signage player, all validated end-to-end on a
Pixel-10 emulator (Android 16) against the alpha server.

- content download: a local item with "remote_url": null was mis-tagged as a
  remote stream (org.json optString returns the STRING "null" for a JSON null),
  so it was ack'd "ready" and NEVER downloaded — stranding the screen on
  "waiting for content" and only ever playing 1 of N files. Guard with isNull().
- playback (#162): PlaylistController trusted isRunning+currentIndex as "already
  playing" and never re-called playItem, permanently stranding a panel on
  "waiting for content" after a restart/OTA/content-not-ready-at-first-start.
  Guards now require hasContentOnScreen (a genuine render) before short-circuiting.
- provisioning: revert to the URL-entry screen if a connect attempt hangs >60s
  (wrong/unreachable URL) instead of an endless "Connecting to server…".
- re-pair: a server rejection (device:unpaired / auth-error) left the device
  connected-but-unregistered with no pairing code (stuck); a naive re-register
  then stormed the #150 reclaim guard ~20x/s. Now: re-register once, debounced +
  backed off; honor the reclaim-settle window with a stable "re-pairing available
  in Xs" countdown; show the code only once the server accepts it (isPairingCodeLive).
- status: a fully-online device could sit on a stale "Connecting to server…" when
  MainActivity was relaunched (CLEAR_TASK) after the service already registered —
  it now pulls a fresh playlist on bind so the real state renders.
- setup: add a Default Launcher (HOME role) step so a kiosk can be set as the
  default launcher without adb (prevents ~45s activity-recreate churn).
- debug: new DebugLog.v() streams the deep download/playback trace only while
  live dashboard debug is enabled; silent in production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:34:47 -05:00
BlazzzPlay d474122334 Merge origin/main into feat/android-hidden-settings-menu
Resolved conflict in server/db/database.js: kept both settings_pin
migration (our change) and device_settings table migration (main's #150).
2026-07-09 20:09:58 -04:00
BlazzzPlay 58f27d56e8 fix(android): server-provisioned settings PIN replaces hardcoded 0000
- Remove stray brace that broke compilation (MainActivity line 985)
- Server generates unique 6-digit PIN per device during pairing
- PIN stored in encrypted SharedPreferences (ServerConfig.settingsPin)
- Fallback: generate random PIN locally if server doesn't send one
- Include settings_pin in device:paired on pair + reconnect
- DB migration: settings_pin column on devices table
- Hint changed from hardcoded 0000 to generic 'PIN' string
2026-07-09 19:05:24 -04:00
BlazzzPlay c7f1eed63f feat(android): PIN gate for hidden settings menu 2026-07-09 10:50:34 -04:00
BlazzzPlay 6ca2782ec1 fix(android): remove orphaned duplicate showExitDialog() block 2026-07-09 10:42:54 -04:00
ScreenTinker 8ad2258e7c feat: app-ending signal (exit-signal contract v1) — server + APK + .wgt + /player
Best-effort "last gasp" so Offline is annotated with WHY it went away — completing the liveness story.
Categories: crashed (client uncaught-exception), clean_exit (client confident lifecycle-end, best-effort),
silent (SERVER-inferred by absence — the honest catch-all for violent/external death incl. force-stop/MDM).

SERVER:
- device:exit socket handler + token-authed beacon POST /api/device/exit (reliable-on-unload). Both gated
  by liveness.sanitizeExitReason (honesty: only crashed/clean_exit accepted; 'silent'/unknown rejected).
- offline_reason/offline_reason_at/offline_detail columns (additive migration). Clear-on-online (a reason
  is always THIS session's); offline transition COALESCEs to 'silent'. Pure annotation — offline detection
  and #148/liveness are untouched. Offline dashboard emits carry offline_reason + client_type.
CLIENTS (canonical {reason,detail} shape):
- /player: window error/unhandledrejection + pagehide(persisted=false) -> sendBeacon.
- .wgt: same + BACK-key exit -> socket.emit + sendBeacon.
- APK: global UncaughtExceptionHandler -> crashed (blocking beacon, chains to default); Service.onDestroy
  -> clean_exit (socket + bounded beacon). New ExitSignal.kt. onStop/onPause NOT wired (background != exit).
Proven (Phase 3): per-category classification, nothing misclassified, external kill -> silent (never
clean_exit), backgrounding emits no false exit, #148/reconnect-vs-exit intact. 382/382 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:32:40 -05:00
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
BlazzzPlay 69be6e804e feat(android): hidden settings menu with multi-tap BACK/ESC detection
Add an in-app settings menu reachable via 2× BACK (or ESC) taps,
with a 1.8s window — Android TV and touch devices.

- 2 taps: settings dialog (change server, re-pair, permissions, exit)
- 3 taps: exit dialog directly (skip menu)
- Auto-banner after 10+ consecutive connection failures

Settings options:
- Change server URL (pre-fills ProvisioningActivity)
- Reconfigure device (clear credentials → re-pair)
- Permissions (Accessibility + Notifications status → system settings)
- Device info (ID, APK version, connection status)
- Exit app (finishAffinity)

Also adds EXTRA_SERVER_URL to ProvisioningActivity and a
consecutiveFailures counter to WebSocketService.
2026-07-07 16:57:49 -04: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 a36880b147 fix: per-item mute round-trip + multi-zone orphan-zone fallback & warnings
Two independent multi-zone bugs, plus operator-facing warnings, i18n, and
regression tests guarding the data contracts.

Bug 1 — per-item mute was a no-op end to end:
- GET /api/devices/:id dropped the `muted` column from its assignments SELECT,
  so the dashboard toggle never reflected state (the muted=false case in
  particular). Column restored to the device payload.
- Android player now honours the per-item mute flag for YouTube (initial state
  + live via the IFrame JS API).

Bug 2 — items whose zone_id belongs to a different layout were silently dropped:
- Player fallback (web + Android): an orphaned zone_id is recovered into the
  largest zone instead of vanishing, with telemetry.
- server/lib/zone-validate.js is the single source of truth for the orphan rule
  (zone not in the device's active layout); used by the device payload
  (per-item `orphan` flag + `active_layout_zones`) and the device list
  (`orphan_count`).
- Assign-time hardening: a stale zone_id (not in the device's active layout) is
  cleared to null on POST/PUT rather than persisted as a new orphan.
- scripts/find-orphan-zone-items.js: read-only sweep for existing orphans.

Dashboard warnings (operator-facing, never on the live player):
- Per-item badge + reassign affordance, device-list glance, preview banner.
- Graceful degradation: the zone selector falls back to /api/layouts/:id so it
  can't vanish on a stale payload.

i18n: orphan-zone strings added to en/es/fr/de/pt/it (hi falls back by design;
count strings interpolate through tn()).

Tests: server/test/device-zone-contract.test.js adds 5 regression tests for the
data contracts above (muted true/false round-trip, active_layout_zones, orphan
flag + count, orphan-clears-on-reassign, assign-time clearing). 172/172 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:16:29 -05:00
screentinker 1f2e923005
fix(#134): quiet false "reconnect" log + report HDMI output and UI render resolution (#136)
Two device-REPORTING fixes from the #134 investigation (the PiP rendering itself
was #135).

1) "Device reconnects every ~45s" was a logging artifact, not instability. The
   player re-emits a full device:register on the SAME socket every ~45-60s
   (requestPlaylistRefresh) to pull a fresh playlist; the server logged
   "Device reconnected" for every register of a known device. The attached 4-day
   log showed 1415 "reconnected" vs 30 real socket connects and 0 heartbeat
   timeouts — the socket never dropped, so #134's "PiP lost between reconnects"
   was a misdiagnosis. Fix: only log a genuine reconnect (new socket); a
   same-socket re-register is a refresh (currentDeviceId === device_id) and stays
   quiet. The playlist still refreshes.

2) Device reported 720p while the monitor showed a 1080 signal. DeviceInfo
   reported getRealMetrics() — the UI RENDER SURFACE — but TV boxes render the UI
   at 720p and upscale to a 1080p HDMI signal. Now report BOTH: screen_width/height
   = the output mode (Display.Mode.physicalWidth/Height), render_width/height =
   the render surface (getRealMetrics). Two new nullable devices columns, stored on
   pairing INSERT + reconnect UPDATE, exposed via the device API, shown on the
   dashboard as "1920x1080 (UI 1280x720)" when they differ.

Backward compatible (required + verified on emulator): a device that omits
render_* — or sends no device_info at all — still registers, with render_* = null,
on both the INSERT and UPDATE paths. New columns nullable; stores use
`?? null` / `|| null`. All 167 server tests pass.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 15:32:11 -05:00
screentinker 7660d7433e
fix(#109): render Android PiP overlay above the YouTube WebView video plane (#135)
* fix(#109): render Android PiP overlay above the YouTube WebView video plane

The PiP overlay (#109) returned sent:1 and showed its title in `uiautomator
dump`, but nothing painted on screen while YouTube was playing. By elimination
(YouTube-specific, landscape so no off-screen transform, real on-screen bounds
in the dump) the cause is surface occlusion: pipLayout sat as the last child of
rootLayout — the SAME compositing band as R.id.youtubeWebView — so the playing
video surface drew over it.

Fix (task option 1a): reparent pipLayout out of rootLayout to the window
content (android.R.id.content) as a top-level sibling drawn after rootLayout, so
it composites above the WebView. MainActivity.mirrorTransformToPip() copies
rootView's orientation/wall transform onto it so corner positions still track
the rotated content (web/Tizen parity). show() also bringToFront()+
requestLayout()+invalidate() on attach (covers the cause-3 measure/visibility
path). Remote-view screenshots now capture the content root so the PiP is still
included.

Instrumentation (Phase 1, default OFF): PipOverlay.pipDebug paints a solid
magenta box + border with media on top (box paints even if media never loads)
and logs box/pipLayout/rootView/youtubeWebView geometry over device:log tag
"pip"; loadImageInto also logs on success. Toggled via device:command
{type:"pip_debug"} (routed through MainActivity.onCommand).

Server: POST /api/pip and the clear handler log one concise [pip] dispatch line
(target + sent/offline) so journalctl shows PiP activity.

Validated end-to-end on an emulator (pixel10/API34) paired to an isolated local
server with YouTube playing: no crash, the PiP box composites above the live
video frame (center + top-right), clear removes it, and the portrait transform
mirror rotates the overlay with the stage (no off-screen). The Fire TV
hardware-overlay punch-through still needs real hardware (emulator composites
video inline); pipDebug + docs/109-android-pip-visibility.md cover that.

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

* fix(#109): image PiPs never painted — set slot token before decode

Emulator e2e of an image PiP (a QR PNG) found the image area always blank (box
background + title only). Pre-existing defect, also on main, independent of the
occlusion reparent.

Root cause in PipOverlay.show(): teardown() clears `current` to null, then
loadImageInto() captured `token = current` (null) as its drop-if-replaced guard,
but `current` was set to the new pip_id AFTER the media was built. The image
decode finishes on a background thread and posts back after show() returns, so
`token != current` (null != pip_id) was always true and every decoded bitmap was
dropped. Web PiPs and the box/title were unaffected, which masked it.

Fix: set `current = pip_id` before building media so loadImageInto's token
matches. Verified on emulator — a QR image PiP now renders over both a static
image and live YouTube (hardware screencap + the app's software view.draw
capture both show it).

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

* docs(#109): record web PiP (HTML+JS) verification on emulator

Web PiP type loads its WebView and executes JS (a page stamping JS OK · <time>
rendered over live YouTube). No code change — web PiPs don't use the image path
that had the token bug. Completes the image/web/box content-type verification.

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

* feat(#109): implement PiP close_button on Android (was a documented no-op)

The server forwarded close_button (routes/pip.js) and it's in openapi.yaml, but
no player rendered it — Tizen deferred "close-button focus" as non-MVP, the web
player has none, and Android's PipOverlay never read the flag. So the documented
field did nothing on any device.

Implement it on Android: when close_button:true, a tappable ✕ floats at the box's
top-right in a FrameLayout wrapper that is a SIBLING of the box — so it isn't
clipped by the box outline or dimmed by the overlay opacity. Tapping it clears
THIS overlay (id-matched via the captured token). Only the ✕ is clickable; the
rest of the full-screen pipLayout stays touch-transparent, so taps elsewhere
fall through to the playing content (no input regression).

Verified on the emulator over live YouTube: the ✕ renders at the corner, and
tapping it removes the overlay while the video keeps playing.

Parity note: web/Tizen players still don't implement close_button; D-pad focus
of the ✕ on non-touch TV hardware is intentionally not wired (MVP = touch/pointer,
matching the Tizen focus deferral).

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-19 14:49:20 -05:00