Commit graph

714 commits

Author SHA1 Message Date
Claude d978a5d2a6 Make per-item scheduling work on Android 7 instead of blanking the screen
ScheduleEval uses java.time — Instant, LocalDate, ZoneId — which is API 26. minSdk is 24, and core
library desugaring was never enabled, so on Android 7.0/7.1 the first evaluation threw
NoClassDefFoundError. Those API levels are still common on cheap signage sticks and older TV boxes.

The damage was much worse than a failed check, because NoClassDefFoundError is an Error, not an
Exception. The evaluator's deliberate fail-open guard — written so that "a blank screen is worse
than an over-running promo" — did not catch it. The Error propagated out of scheduleAllows, through
firstActiveIndex and updatePlaylist, past another catch(Exception), and was only swallowed at the
service boundary. Because updatePlaylist aborted before the download block, no content was fetched
either; and on a cold start from cache the same Error reached a handler that clears the playlist
cache. So the moment anyone used dayparting or expiry, those panels sat on "waiting for content"
with nothing downloaded and nothing cached, and a reboot did not help. The stated contract was
inverted on exactly the hardware it was meant to protect.

Two changes. Desugaring is the real fix: java.time now exists on API 24/25, so the code runs as
written. The guard is widened to Throwable as well, so this class of failure can never again slip
past a catch that was written to be total — that is belt and braces, not the fix.

Release build assembles cleanly with desugaring on; 134 Android JVM tests green. Still to confirm on
a real API 24/25 image before release — the unit tests run on the JVM, where java.time always
exists, which is precisely why this was invisible to them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:20:41 -05:00
Claude f4d309a0d4 Apply a playlist change even when the outgoing item never advances
Replacing the single item of a one-item playlist did nothing. The old promo, board or clip kept
playing while the dashboard showed the new playlist published and the device perfectly healthy —
only a reboot or a manual refresh cleared it.

#157 defers a rotation so a live item is not yanked mid-play, and applies it "on the next natural
advance". For a one-item playlist there is no such thing, by design: single-item rendering
deliberately never advances. A video gets `loop = (playlist.length === 1)` and so never fires
`ended`; a YouTube embed loops for the same reason and skips its safety net; a solo widget is "held"
on a self-re-arming refresh that never calls nextItem, because reloading it would reset a directory
board's scroll. Tizen is worse still — `single` makes every renderer skip its timer, so images
freeze too.

Two guards, the same pair already applied to the Android controller:

- A one-item playlist is never deferred. There is nothing to protect from being cut off, since
  nothing was going to advance anyway.
- Any deferral that does happen gets a 60-second deadline. The deferral is a bet that an advance is
  coming; if the bet loses, the change must still land rather than strand the screen on content the
  operator has already replaced.

Verified in headless Chrome: a one-item playlist holding a solo widget (the "held" case that never
advances), its only item replaced with a different widget — the screen followed, with no reload and
no restart. Before the change it stayed on the replaced item indefinitely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:17:44 -05:00
Claude d3f6af831b Recover a zone whose video fails, instead of leaving that region black
In a multi-zone layout a zone's video advanced only on `ended`. On the web there was no error
handler and — alone among the zone branches, which all arm a timer — no timer either. On Android the
zone player listened for STATE_ENDED with no error listener and no fallback.

A playback error lands in STATE_IDLE, never STATE_ENDED, so nothing advanced. A 404, an unreachable
remote_url, a clip the device cannot decode, or content not yet cached while the device is offline
(the zone then falls back to the server URL, which fails with no network) all had the same result:
that region of the screen went black and stayed black for days, while every other zone kept rotating
normally. It reads as a rendering bug rather than a bad file, and nothing self-heals — the layout has
to change or the app has to restart.

Both fixes already existed elsewhere and were simply not carried across. MediaPlayerManager treats a
playback error as a completion for exactly this reason ("Root-2: a corrupt/undecodable video used to
freeze the playlist forever"), the fullscreen web path has both an onerror and a timer, and Tizen's
ZoneRenderer has an onerror plus a duration+5s safety net. The multi-zone paths were the gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:11:59 -05:00
Claude f66c941c1d Stop the content edit dialog rewriting types it cannot represent
Opening Edit on a YouTube item and pressing Save Changes — with nothing else touched — turned it
into an MP4.

The type dropdown offers six fixed options and is rendered unconditionally. For video/youtube no
option matched, so the browser selected the first one, video/mp4. The save handler then reads the
select's value and sends it because it differs from the stored type:

    const mimeType = overlay.querySelector('#editMimeType').value;   // 'video/mp4'
    if (mimeType !== contentItem.mime_type) updateData.mime_type = mimeType;

and the server stores what it is sent. mime_type is the renderer selector in every player, so the
item became an "MP4" whose source is a YouTube embed page: a dead slide on every screen in the
playlist. It could not be undone from the dialog either, because there is no video/youtube option to
set it back, and the YouTube-specific controls disappear once the type has changed.

The same applies to uploads the sniffer accepts but the list omits — the sniffer allows fifteen
types, the dropdown covers six — so .mov, .svg, .heic, .avif and .bmp were all rewritten the same
way.

The dialog now includes the item's actual type as a selected option whenever the fixed six cannot
express it, so opening and saving is a no-op and the type is never silently changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:09:59 -05:00
Claude a3b668d32f Treat an empty device_info as "nothing new", not as "forget what you know"
Every web and BrightSign player nulled seventeen of its own device columns every five minutes.

The browser player's refresh-register sends `device_info: {}` on a 300-second timer — it has nothing
new to report, it just wants a fresh playlist. But `{}` is truthy, and applyDeviceInfo is a blind
full-row overwrite with no per-field presence check, so it bound undefined for every column.
better-sqlite3 stores undefined as NULL rather than throwing, so the write succeeded and the row was
quietly emptied: android_version, app_version, screen_width/height, render_*, ota_status and
attempts, tier, the four capability flags and the four volume/brightness columns.

Android never hit it, because it always sends the full object. So this degraded exactly the client
family that cannot be inspected any other way — a browser player has no adb, and the dashboard row
is all there is. Fleet view, resolution diagnostics and any version-based logic read blank for them,
which also makes evaluating a browser-based platform look worse than it is.

The surrounding code already anticipates the refresh shape: recordReconnect and persistIdentity are
both gated behind `if (!isPlaylistRefresh)`. This call was the one that was not.

5 tests, including one pinning the driver behaviour the bug depended on — undefined binds as NULL
rather than throwing, which is why this was a silent five-minutely wipe instead of a loud error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:09:11 -05:00
Claude fb8cafc444 Web player: survive the suspended-account card destroying the status element
The suspended branch replaces the whole status overlay with its own markup, and that markup does not
contain #statusText. showStatus then did:

    document.getElementById('statusText').textContent = msg;

so every later call threw a TypeError for the life of the page. The consequences got worse the
further down they went:

- Each refresh beat re-emits device:paired, whose handler calls showStatus('Waiting for content...')
  — so the player raised an uncaught error and sent itself a "crashed" exit beacon every few minutes
  while suspended. This is very likely the "Cannot set properties of null (setting 'textContent')"
  the comment near the exit-signal contract says could never be traced.
- showNothingScheduled() calls showStatus BEFORE arming its 30-second re-check. So once the account
  was restored, a playlist whose dayparts had all closed left the screen on the stale orange
  "Account Suspended / Please upgrade your plan" card with no retry timer at all — it never
  re-checked the schedule and never recovered without a reload.

showStatus now rebuilds the element if it is missing rather than bailing, so the message the caller
asked for is actually displayed and the recovery path continues.

Verified in headless Chrome against the real player: destroy the overlay exactly as the suspended
branch does, then call showStatus — no throw, no uncaught page error, and "Waiting for content..."
on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:08:06 -05:00
Claude 64a6bfd860 Web player: notice when the layout changes, not just when the items do
Editing a layout did nothing on a web screen that was already showing one. Add a zone, move an item
between zones, resize a zone, switch layouts, clear the layout — all silent, for as long as the item
list itself stayed the same.

Two reasons, and both had to be fixed:

- The change fingerprint covered item identity, order, revision, schedules and transition, but not
  zone_id — so moving an item from one zone to another produced a byte-identical fingerprint
  (published_snapshot is ordered by sort_order, so the order did not move either).
- The layout is not part of the item list at all, so a change to it could never appear in an
  item-derived fingerprint. `layout` was assigned and then the function returned "Playlist
  unchanged", and in multi-zone mode nothing else re-renders: each zone runs its own timers and
  renderContent is never called again. The no-change health check does not help either, because the
  old zone divs still hold media so the surface looks attached.

zone_id now sits in the item fingerprint, and the layout gets its own signature covering the layout
id and every zone's geometry, stacking, type and fit. Tizen's ZoneRenderer has always compared a
zone signature — this is the web equivalent, and it is the same defect that was fixed on Android
this week.

Verified in headless Chrome against the real player: a third zone added IN PLACE (same layout id,
same item list, no reload, no restart) re-rendered the screen to three zones. Before the change that
update was discarded as unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:06:23 -05:00
Claude d580994bb3 Carry a widget's revision into the multi-zone path on Android
The widget-refresh work covered the fullscreen path only, so editing a widget placed in a ZONE still
never reached the screen. Two independent gaps, both of which had to close:

- The zone render URL was built from the widget id alone, with no rev, so even a forced re-render
  fetched a URL the WebView had already seen.
- The decision to re-render zones at all keys on an assignment signature of
  content_id:zone_id:widget_id. A widget's identity does not change when it is edited, so the
  signature was byte-identical and the branch fell through to "Multi-zone unchanged, skipping".

A zone holding a single widget never rotates either, so nothing else would have reloaded it. The
customer edited a widget, the dashboard showed the new content, and that region of the screen kept
the old version until the layout geometry changed or the app was force-stopped.

The server has supplied widget_rev on every assignment since the fullscreen fix; both the fullscreen
Android path and the web player's zone path already used it. This is the path that was missed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:04:10 -05:00
Claude acadb4c1f4 Stop the playlist and the OTA checker when the Activity is destroyed
onDestroy already shuts down the wall and group controllers, and its comment says exactly why: those
Handlers are on the main looper, which outlives the Activity, so a surviving tick "would keep
broadcasting sync frames against the released player forever". Three other things on that same looper
were never stopped.

PlaylistController kept advancing after the Activity was gone. Every tick wrote the resume index and
emitted play_start/play_end through the still-live WebSocketService, so after any relaunch — the
"launch" command, Relauncher after OTA or boot, a re-pair, or a config change outside the ones the
manifest handles — two controllers were reporting playback for one screen. That inflates Total Plays
and Hours in Reports for that panel, and races over the resume position #234 depends on. Widget items
also re-entered showWidget on a WebView nobody owned any more.

UpdateChecker was never stopped either, and its install receiver was never unregistered:
installReceiverRegistered is per-instance, so each recreate added another checker polling
/api/update/check and another receiver for INSTALL_COMPLETE. N of those turns one
STATUS_PENDING_USER_ACTION into N confirm dialogs stacked over customer content, and concurrent
checkers race in tryPackageInstaller — which starts by abandoning ALL of the app's installer
sessions, so one can abandon another's staged session mid-flight and the update never completes.
shutdown() now does both, and the receiver is held so it can actually be unregistered.

The Activity's own posted callbacks (the 30s failure-check loop among them) are cleared too.

134 Android JVM tests green. The effect is a leak and a duplicate reporting stream rather than a
wrong value on a screen, so it is verified by reading the lifecycle rather than by a unit test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:03:19 -05:00
Claude 6e9a1f9711 Drop an image decode that finished after the screen moved on
A remote image is decoded on a background thread and mounted on the main thread, and it was mounted
unconditionally — nothing checked it was still wanted.

ImageLoader allows 10s connect plus 30s read, against a slot that is typically 10s, so a slow or
briefly unreachable host finished long after the playlist had advanced and painted itself over
whatever was playing. When that was a video the mount also called exoPlayer.stop(), which lands in
STATE_IDLE — and the advance listener only fires onVideoComplete on STATE_ENDED or a playback error.
Nothing scheduled the next item, so the playlist stopped permanently. The routine refresh could not
rescue it: the playlist signature was unchanged, so the update returned early, and content was still
on screen so nothing looked wrong from the server's side.

The failure branch had the same shape more mildly — onImageError posts next(), cutting short
whatever had since started playing.

Every path that takes the screen now bumps a generation, and a decode applies only if the value it
captured is still current. PipOverlay.loadImageInto has always carried this token; the fullscreen
path was the one place a background result was applied with no staleness check.

4 tests over the guard, kept as pure arithmetic so they need no Android runtime, including that only
the latest of several queued decodes wins and that the error branch is gated too. 134 Android JVM
tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:01:37 -05:00
Claude 88e4a9eb49 Respond to a server rejection once, and stop destroying the cache over a transient one
onUnpaired was assigned twice in setupServiceCallbacks. The later assignment silently replaced the
first, so the handler added earlier this week to surface WHY the server refused a device — the one
whose comment says "Only ProvisioningActivity ever assigned onUnpaired, and it is gone by the time
playback is running" — could never run. Thirty lines below it, something else was assigning exactly
that.

What actually executed cleared the offline playlist cache and jumped to the pairing screen on EVERY
rejection. That is wrong for the case the service is explicitly built to survive: handleServerRejection
parses a settle window, sets awaitingRepair, holds all registration and schedules a single retry, so
a reclaim-settle hold recovers on its own within the window. Tearing the player down over it cost the
panel the cache it would have replayed from and forced a full re-download after re-pairing — the
opposite of what the hold is for.

The two are now one handler. It always surfaces the server's reason, and only navigates to
provisioning when the rejection is terminal and not a block:

  transient  the service recovers by itself; show the reason and stay put
  blocked    a block deliberately survives a re-pair, so the pairing screen cannot resolve it
  terminal   the device really is gone and the operator needs the code

The cache is kept in every case. It is what lets a screen keep showing content while someone walks
over to re-pair it, and re-pairing restores the settings anyway. The service now exposes whether a
rejection carried a settle window, since only it can know.

4 tests over the decision, kept pure so it needs no Activity. 130 Android JVM tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:59:49 -05:00
Claude 9c6b80c411 Apply a saved device snapshot only inside the workspace it was taken in
Per-device settings are saved against the hardware fingerprint so a panel that is deleted and paired
again comes back configured — name, orientation, playlist, blocked flag — without anyone visiting
it. That is deliberate and worth keeping.

A fingerprint is hardware-derived, so the same physical panel presents the same one whoever pairs
it. applyToDevice looked the snapshot up on fingerprint alone with no workspace comparison, and its
per-field guards only check that the referenced row still EXISTS, never who it belongs to:

    if (s.playlist_id && db.prepare('SELECT 1 FROM playlists WHERE id = ?').get(s.playlist_id))

So a screen removed from one workspace and paired into another inherited the first workspace's
playlist and displayed its content, and `blocked` crossed the same way — a device arriving blocked
with nothing the new owner could see to explain it. The manual restore route already compares
workspaces before calling this, so the automatic re-pair path was the only place the check was
missing.

A mismatch is a quiet no-op rather than an error: re-pairing a second-hand panel into a different
workspace is a legitimate thing to do, it just must not carry the previous configuration along. A
snapshot with no workspace recorded still applies, so rows predating the column keep working.

5 tests: neither playlist nor block crosses, a mismatch does not throw, restore still works in full
inside the owning workspace (including a genuine block surviving a re-pair), and legacy rows are
unaffected. 882 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 20:56:59 -05:00
Claude 14367af5f1 Keep a workspace on schedules that outlive their device group
Deleting a device group converts its group schedules into per-device ones so the screens keep their
programming. That INSERT omitted workspace_id, which is nullable with no default, so every converted
row landed with workspace_id = NULL.

A null workspace does not merely look untidy — it makes the row unreachable in three directions at
once, and they compound into the worst possible combination:

  invisible   the schedule list and the all-screens calendar both filter on workspace_id
  undeletable PUT and DELETE refuse a row with no workspace (403)
  still live  services/scheduler.js has no workspace filter, so it keeps firing every 60 seconds

"I deleted the group but the screens still switch content at 9am, and there is nothing in the
calendar to remove." The only way out was direct database access.

The conversion now carries the workspace, preferring the schedule's own and falling back to the
group's so a legacy group schedule that itself predates workspace_id still converts into a reachable
row. A boot migration repairs rows already orphaned in the field by recovering the workspace from
the device each one targets; anything still unresolvable is left alone rather than guessed at.

4 tests: the converted row keeps its workspace, is visible to the query the list and calendar use,
preserves the actual programming rather than just the ownership, and the repair recovers a row
orphaned before this fix existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:53:21 -05:00
Claude 9958c7c7be Save a layout by diffing its zones, not by deleting and re-inserting them
Nudging one zone in the layout editor and pressing Save destroyed unrelated tenant data across the
whole workspace, and returned 200.

The handler deleted every zone and re-inserted the same ids. Its comment claimed that was safe —
"Reuse each zone's id when supplied so device->zone assignments survive an edit (a fresh uuid per
save would orphan them)" — but reusing the id does not help, because SQLite runs the referential
actions on the DELETE and re-inserting the same primary key afterwards resurrects nothing. Two
things point at those rows:

  playlist_items.zone_id  ON DELETE SET NULL  -> every multi-zone playlist item un-assigned, so
                                                 those playlists silently fell back to fullscreen
  schedules.zone_id       ON DELETE CASCADE   -> every zone-bound schedule permanently deleted

No warning, no undo, and nothing in the UI to suggest a geometry tweak had touched schedules at all.

Zones are now updated in place, inserted when new, and deleted only when the editor actually removed
them. An update touches no foreign key, so nothing pointing at a surviving zone is affected. The
cascades are left exactly as they are: on a genuinely removed zone they are the correct behaviour,
and the tests pin that too.

4 tests: a moved zone keeps item assignments and zone-bound schedules, the geometry change is really
applied, adding a zone disturbs nothing, and removing a zone still un-assigns its items and removes
its schedules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:51:28 -05:00
Claude c393cf8ab3 Hold overlay pushes to the same write check as every other fleet action
A PiP overlay renders across a live screen — an arbitrary web page, at full resolution, for as long
as the operator wants. That is a fleet-affecting write, but the three routes that perform it carried
only requireScope('full'), which gates API tokens and is a deliberate pass-through for dashboard
sessions. The file's own comment says so ("No-op for JWT sessions"), on the assumption that
something else covered that case. Nothing did.

Every sibling route pairs the two checks — device-groups.js gates POST /:id/command with
`requireScope('full'), requireGroupWrite`. These had only the half that does nothing for a logged-in
user, so a member who is refused on every other device mutation was accepted here.

requireFleetWrite restores the pairing on POST /, POST /clear and DELETE /, resolving the caller's
context against the workspace the same way the rest of the codebase does.

5 tests pin both directions: refused for a read-only member on all three routes and for an
unauthenticated caller, still allowed for a workspace_editor and for an org owner acting into the
workspace (actingAs, whose workspaceRole is null and must not read as a viewer).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:46:03 -05:00
Claude 81f5d4f9f3 Stop shrinking hand-written text widgets into illegibility
A person typing font-size:16px into the Text/HTML widget got 0.15vw — 2.8px on a 1080p screen,
1.9px at 1280 wide, smaller again on anything narrower. Not clipped, not hidden: rendered at a size
nobody can read, in the one widget whose entire purpose is hand-written HTML.

renderText converted every px font size to vw (px/108). That conversion exists to rescue LEGACY
Content Designer output, which used to publish absolute sizes as fontSize*10.8 px — dividing by 108
recovers the author's intended size and lets those widgets scale to any screen. Today's designer
emits cqw and no px at all (frontend/js/views/designer.js), so the conversion only ever needed to
apply to that legacy output. It was applied to everything.

Now it runs only on designer-authored markup, identified by its absolutely-positioned elements —
the same signal the dashboard already uses to decide whether a text widget can be reopened in the
designer. Hand-written markup keeps its px exactly as typed, and legacy designer widgets are
unchanged.

Found by looking at the screen. The rendered HTML and the widget URL both looked correct in every
check I ran; only a screenshot showed the text was microscopic.

5 tests covering both directions, including that a hand-written absolutely-positioned element
without the designer's left-first shape keeps its px. Verified on an Android screen: a 60px heading
and 24px body now render at their authored sizes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:28:28 -05:00
Claude e0bdd3b65c Web player: re-render a widget whose content was edited
The signature fix was necessary but not sufficient, and only a browser showed it. The update arrived
and was applied — the console logged "Playlist changed, updating" and playlist[0].widget_rev held the
NEW revision — but the iframe on screen still carried the old one.

Two guards were swallowing it. Continuity keeps a surviving item playing and deliberately does not
re-render ("Just retarget the index pointer - no re-render, no interrupt"), and identity is
content/widget ID, which does not change when a widget is EDITED. So the edited widget counted as
surviving. And the fallback that would eventually notice does not apply either: a solo widget is
deliberately never re-rendered on a timer, because that would reset a directory board's scroll.

Between them the new revision sat in the playlist, unused, indefinitely.

Now a surviving WIDGET whose rev changed is re-rendered through the buffered swap — which builds the
new iframe hidden and reveals it on load, so it is flash-free by design and this costs nothing
visually. Non-widget items and unedited widgets are untouched, so the continuity behaviour that
guard exists for is intact.

Verified in headless Chrome driving the real player: paired, widget assigned, then edited with no
page reload and no restart. rev 1785460578 -> 1785460589 on the live iframe.

Also caught here: my first attempt called renderItem(), which does not exist — the console.log fired
and the exception ate the rest of the handler, which looked exactly like the fix not working. The
function is renderContent(item).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:17:22 -05:00
Claude 5c6e0325b1 Widget edits reach the web and Tizen players too, and a pinned render can be cached offline
Same fault as Android, in both other players, and my earlier read of them was wrong: I assumed they
rebuilt the iframe each cycle so could not go stale. They do rebuild — but only after the update
survives a change check, and both change checks key on IDENTITY:

  web    content_id|widget_id|remote_url|filepath|filename|schedules|transition
  tizen  [content_id, widget_id, remote_url, mime_type, schedules, transition]

A widget's identity does not change when it is edited, so an edit produced an identical signature,
the update was discarded as "unchanged", and the old render stayed up. widget_rev now sits in both,
alongside schedules and transition, which are there for exactly this reason.

The render URL carries the rev on both players as well. In the zone path the web player was picking
up `item.widget_rev` inside a loop whose variable is `a` — that would have been undefined on every
zone; it now reads the zone assignment's own rev.

Caching, which is the reason this is worth doing properly rather than just busting the URL: a URL
carrying ?rev=<updated_at> is content-addressed, so those bytes cannot change without the URL
changing. The render endpoint now returns immutable caching for a pinned URL and keeps no-store for
a bare one, and the service worker serves pinned renders cache-first (CACHE_NAME v18).

That closes a real gap. no-store meant widgets were the ONE thing the player's offline cache could
never hold, so a display that lost its uplink lost its widgets — while its images and video kept
playing. Offline resilience is the point of that cache. Old players sending no rev are unaffected:
they still get no-store, because without a rev nothing distinguishes one render from the next.

Verified live: bare URL -> no-store; ?rev=123 -> public, max-age=31536000, immutable. 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 20:09:06 -05:00
Claude 6e3be7a95a Include a widget's revision in the playlist change signature
The widget-refresh fix did not work, and only the emulator showed it.

widget_rev reached the device correctly and the render URL was built from it correctly, but the
controller de-duped the update before any of that mattered: sig() keys on content/widget IDENTITY,
and a widget's identity does not change when it is edited. The payload was byte-identical, the
update was discarded, the old items were kept — including the old rev — so the URL never changed and
the WebView reuse held. Measured: the player sat on rev=1785459552 for three full cycles after an
edit, logging "Widget already showing, not reloading" each time.

Adding widgetRev to the signature is the same move already made for muted (#129), schedules
(#74/#75) and transitions — all cases where an edit changes playback without changing identity.

Re-verified on the emulator, app left running:
  edited   -> "Showing widget: ...&rev=1785459720" (reload, new rev, no restart)
  unedited -> 3 x "already showing", 0 reloads over 45s, so the anti-flash reuse is intact

Worth recording: the code read correct on all three previous passes. Only running it exposed this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:03:32 -05:00
Claude cad19abee1 Push layout edits to displays, and let a layout be renamed
Editing a layout notified nothing at all — no push to the displays using it — so a zone change
waited for the next heartbeat refresh at best. Combined with the Android rebuild being keyed on the
layout ID (which does not change when you edit a layout in place), that is why adding a fourth zone
took a force-stop to appear. The player-side fix makes the rebuild happen; this makes it prompt.

Renaming: duplicating a template produces "<template> (Copy)" and there was nowhere to change it.
The server has always accepted a name on PUT /layouts/:id; no UI ever sent one. The only name field
in the editor belongs to the selected ZONE, which is easy to mistake for the layout's own — zones
could always be renamed, layouts never could. The heading is now an input and its value rides along
with the Save the user already presses.

Verified on an Android 12 emulator, app left running throughout:
  3-zone layout assigned      -> "Multi-zone layout with 3 zones (was=null)"
  4th zone added in place     -> "Multi-zone layout with 4 zones (layout=a96c39ab, was=a96c39ab)"
The ids match, so the old id-only condition would have skipped the rebuild entirely. Applied ~1s
after the PUT, with no restart and no force-stop.

Also verified the background-audio fix on the same device: 1 started audio player with the video in
the foreground, 0 once another app was brought to the front. (First attempt was invalid — HOME
re-shows this player because it is the default launcher, so it never backgrounds.)

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:58:21 -05:00
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
ScreenTinker 752f39ea43 chore(release): v1.9.27 2026-07-30 19:16:55 -05:00
Claude f7bf9412e4 docs(changelog): 1.9.27 — beta APK channel with a working switch back 2026-07-30 19:16:48 -05:00
ScreenTinker 6d33d00cd0 Merge feat/ota-two-channel: serve a beta APK alongside stable, with a real switch back 2026-07-30 19:13:51 -05:00
Claude b44f9d4f03 Serve a beta APK alongside the stable one, and let a display move between them
1.9.26's opt-in was passive: it stopped a sideloaded build being reverted, but there was still one
APK slot and latest_version was the server's own VERSION, so a beta had to be installed by hand on
every display. This makes it a real channel.

- apk-cache tracks two slots. ScreenTinker-beta.apk is optional and reaches only displays with
  ota_beta = 1.
- A beta must DECLARE its version in a sidecar ScreenTinker-beta.apk.version. The server cannot
  infer it — stable's version is the server's own constant because the two ship together, and
  reading it from the APK means parsing binary AndroidManifest.xml on the request path. If the
  sidecar is missing or unparseable the channel does not activate at all and opted-in displays keep
  getting stable. Failing closed matters: advertising a version that does not match the bytes served
  is the OTA-loop condition this fleet has been bitten by before.
- The check and the download resolve the channel identically and fall back to stable identically, so
  apk_size always describes the bytes actually delivered. No APK change was needed — the client
  already fetches whatever download_url it is handed, so displays in the field can be moved between
  channels from the dashboard today.

Switching back needed care. Stable is semver-OLDER than the beta it replaces, so the ordinary
"never offer a downgrade" rule stranded the display and unticking the box would have been another
silent no-op. The first attempt returned any non-opted-in display running a pre-release — which
broke a #144 test, correctly: that would have dragged every existing pre-release tester back to
stable the moment their server upgraded, the exact harm the opt-in exists to prevent. So the return
now requires evidence we actually served that display the beta channel (devices.ota_channel_served,
written once on change, not per check). A tester ahead of the server on their own build is left
alone exactly as before.

Documented in the README, including the constraint that makes the switch-back physically possible:
beta builds must carry a versionCode no higher than the stable they branch from, because Android
refuses to install a lower one. Equal numbers install in both directions.

Verified end to end against a live server with two real signed APKs: stable serves 1.9.26, beta
serves 1.9.27-rc1, an unknown channel falls back to stable, removing the version file deactivates
the channel, and the full opt-in -> serve -> switch-back lifecycle produces offer / up-to-date /
channel-return in order. 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:12:46 -05:00
ScreenTinker d70764991e chore(release): v1.9.26 2026-07-30 18:43:35 -05:00
Claude ac68e95b2b docs(changelog): 1.9.26 — YouTube advance, clearable playlists, pre-release opt-in 2026-07-30 18:43:27 -05:00
Claude dd7596a674 Merge docs/readme-catchup: README catch-up and CHANGELOG backfill
# Conflicts:
#	scripts/bump-version.sh
2026-07-30 18:39:56 -05:00
ScreenTinker 234bff795d Merge docs/api-device-network-fields: document device network fields, pin the spec version 2026-07-30 18:38:06 -05:00
ScreenTinker 2bf8b4271f Merge fix/youtube-never-advances: YouTube items advance, playlists can be cleared, per-display beta opt-in 2026-07-30 18:38:01 -05:00
Claude 301c76c3f7 Let a display opt in to pre-release builds, so a test build is not reverted under the tester
Handing someone a test build was a trap. A prerelease sorts BELOW its own release — 1.9.25-fix234d
is semver-older than 1.9.25 — so a sideloaded display asked "anything newer?", was correctly told
yes, and updated itself straight back off the build we had asked someone to test. Same versionCode,
so Android installed it without complaint. Silent, and within minutes.

That is what happened on #234: the reporter installed the fix, tested for an evening, and reported
nothing had changed. They were right. Their tablet was running the old code again by then, and I had
told them it was fixed without ever checking what the device reported.

Adds a per-display opt-in (devices.ota_beta, default 0, checkbox next to the OTA toggle). When set,
the display keeps a prerelease of the CURRENT core instead of being pulled back to its release.

Deliberately narrow in one direction and deliberately wide in the other:

- Narrow: it only holds a prerelease of the core already installed. A plain release, a -patchN
  build, an upgrade to a newer core, and a display ahead of the server all behave exactly as before,
  and the flag defaults off so a fleet that never sets it is unaffected.
- Wide: an opted-in display is exempted from the superseded-prerelease guard. That guard would
  otherwise pin a tester on an old test build permanently — an older-core prerelease is never
  offered anything, so they would have to notice and sideload their way out. Writing the test is
  what surfaced that; opting in must never mean never updating again.

9 tests covering both directions, including that shipping a newer release pulls a beta display back
onto the release line. 845 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 18:35:31 -05:00
Claude 5297f091af Let a display's playlist actually be cleared
"No playlist" was an option you could select that did nothing. The picker offered it, and the change
handler opened with `if (!newPlaylistId) return; // Don't allow deselecting for now` — so choosing it
sent no request, changed nothing, and said nothing. The guard was honest about why: there was no way
to do it. PUT /devices/:id has never read playlist_id (200, ignored), and POST /playlists/:id/assign
can only ever set one.

Reported on #234 as "I also selected No playlist ... it still showed the same video". It did, and my
first explanation blamed the playlist-swap deferral. The deferral would have stranded it too — that
is fixed separately and tested — but on this path nothing was ever sent, so the deferral never got
the chance.

DELETE /api/devices/:id/playlist, device-scoped rather than playlist-scoped because there is no
playlist to authorize against when clearing. Ownership goes through checkDeviceOwnership like every
other device mutation, so a viewer and a stranger are refused. Clearing an already-clear display is
a no-op success, since it lives in a dropdown someone can pick twice. The now-empty playlist is
pushed to the device so the screen stops, rather than leaving the old content up until something
else happens to refresh it.

Validated on an Android 12 emulator against the reporter's shape: cleared while a YouTube item was
on screen, zero plays afterwards, device row cleared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 18:29:18 -05:00
Claude 3b23600d97 docs(changelog): backfill 1.9.3 through 1.9.25
The changelog stopped at 1.9.2-patch2, so 23 shipped releases had no entry — including the whole
transition engine, group sync, the device-owner foundation, the hardening pass and every #234 fix.
Anyone deciding whether to upgrade, or working out which release changed a behaviour, had nothing to
read between 1.9.2 and now.

Written from the actual commit ranges between tags rather than from memory, and pitched at the
question a reader has ("do I need this, and what will change") rather than as a commit dump. Detail
scales with the release: 1.9.5 (group sync, device-owner foundation, agency folders) and 1.9.25 get
real explanation; 1.9.9 and 1.9.19 get two lines, because that is what they were.

The 1.9.16 hardening entry describes each fix in the same neutral terms as its commit — the
invariant restored, not the weakness. This is a public repository, some findings from that review
are still open, and exploitation detail helps nobody deciding whether to upgrade. The advice there
is just "upgrade".

Also adds a CHANGELOG check to bump-version.sh: it warns if the release being cut has no entry.
Deliberately a warning and not generation — a generated changelog is worse than none, since it reads
like documentation while saying nothing. This only stops a release being cut silently without one,
which is how the file fell 23 versions behind.
2026-07-29 22:38:40 -05:00
Claude 0b9d9aff76 docs(readme): catch up on displays, OTA behaviour, plans and the public API
The README had drifted behind several shipped features and, worse, behind a few behaviours that
surprise people in practice. Everything here was verified against the code rather than written from
memory — three claims were wrong on the first pass and are corrected below.

Added:

- **Public REST API.** Scoped tokens, the OpenAPI contract and the browsable reference at /docs were
  not mentioned anywhere in the README despite being a shipped, documented surface.
- **When a display will not update itself.** The three things to check in order, and the retry model
  spelled out because "nothing is happening" is indistinguishable from "it gave up" otherwise:
  flagged for attention after 3 failed installs, still retrying to 40 (cheap — the APK is cached, so
  later attempts pull no bytes), then about one a day indefinitely, cleared by a new version. Plus
  what Force update overrides (back-off, attempt count and the MDM stand-down) and what it cannot
  (invent install permissions).
- **Deleting and re-pairing a display.** Settings are keyed to the hardware, so a re-paired panel
  returns configured — which reads as a bug when the old playlist reappears. Also documents that a
  block deliberately survives re-pair, and that Unblock is the way out (and that before 1.9.25 it
  only cleared half, so a display can still be stuck).
- **Plans and comped accounts.** The platform-admin plan overview, and how an inactive plan runs a
  comped/beta/legacy tier without appearing on the pricing page.
- **Optional location permission** for reporting the Wi-Fi network name, and that permission rows
  stay visible as Manage so grants can be reviewed or revoked.
- **One playlist per display**, and that Scheduling is how you rotate several — the question a
  customer asked this week.
- LAN and WAN addresses in the telemetry feature bullet; BrightSign in Supported Platforms.

Corrected while verifying:

- The API reference is served at /docs, not /api-docs.
- Tizen does NOT self-update; only the Android APK does. The two were wrongly lumped together.
- The admin section is labelled "Subscription Plans".
- The retry description conflated the flag threshold (3) with the attempt cap (40) — different
  numbers doing different jobs.
- BrightSign is listed with the caveat that its HTML widget may not survive the player's reload on
  deploy, rather than as unqualified support.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-29 22:33:48 -05:00
Claude c483ef34dd docs(api): document a device's WAN/LAN addresses and SSID sentinel, and stop the spec version drifting
The published API reference (frontend/api-docs.html renders docs/openapi.yaml through Redoc) said
version 1.9.0 while 1.9.25 was shipping. bump-version.sh updates VERSION, server/package.json,
android versionName/versionCode and tizen/config.xml — the spec was simply never added to it, so it
had been frozen since the public API landed and integrators were reading a version identity that no
longer existed.

Spec changes:

- info.version -> 1.9.25.
- Device gains its two network addresses, which are easy to confuse and are now described so they
  cannot be: ip_address is the PUBLIC/WAN address the server observed on connect (X-Forwarded-For
  aware, normally shared by every device at a site), local_ip is the device's OWN LAN address as
  reported by the player, which is the one that reaches a panel on site. local_ip is new; both were
  returned by GET /devices and neither was documented.
- Device gains its flattened latest-telemetry block (wifi_ssid, wifi_rssi, battery, storage, ram,
  cpu_usage, uptime_seconds) — all returned already, none documented, all nullable because a web
  player does not report what Android does.
- wifi_ssid's "permission" value is called out as a sentinel, not a network name: Android 10+
  withholds the SSID without a location permission ScreenTinker only requests if an operator opts
  in. An integrator who does not know that renders "permission" to an end user as their Wi-Fi name.

Drift prevention, because a wrong version number is silent and nobody re-reads one they trust:

- bump-version.sh now writes the spec version too, anchored to info.version (operation- and
  schema-level version keys are indented deeper and untouched; openapi: 3.1.0 is unaffected).
- Three contract tests: the spec version tracks package.json, the two addresses stay documented
  and distinct, and the SSID sentinel stays explained.

No new endpoints — audited every public router's routes against the spec and all are documented.
830 server tests + the 5 contract tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-29 22:26:47 -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 40035533e5 Merge branch 'feat/report-lan-ip-and-optional-ssid'
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-29 21:57:59 -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 54f1f62762 Merge branch 'fix/group-drag-actually-moves' 2026-07-29 21:33:56 -05:00
ScreenTinker ead452d9b1 Dragging a screen onto a group now moves it instead of adding it
Reported by a customer with two screens and two groups: dragging a screen from one group
to the other showed a confirmation, changed what the screen was playing, but left the
displays page showing the old group — and a second attempt said it was already in group 2.

All three observations were correct. The drop handler borrowed the Manage modal's
"add it to X too?" confirm, then called addDeviceToGroup and nothing else, then reported
"Moved {name} to {group}". So it asked about adding, claimed to move, and added: the
screen ended up in BOTH groups. The page was not stale, it was accurate — and the retry
was right too, because by then it really was in group 2 as well as group 1.

The screen's content DID change because joining a group syncs the device's playlist to
the group's, which is why it looked half-applied rather than broken.

Drag is a move gesture, so it now removes the other memberships after adding the new one
— add first, so a failure leaves the screen in the group it already had rather than
ungrouped by a half-finished move. A removal that fails warns rather than reporting
success it did not achieve.

The Manage modal is deliberately left alone: its checkboxes are add/remove and its "too?"
wording is accurate there. Multi-group membership is a real feature; it just is not what
dragging means.

Not merely cosmetic: deviceSyncGroup() notes it picks "deterministically if it's somehow
in several", so a screen left in two sync-enabled groups gets an arbitrary one. A
half-completed move leaves synchronised playback ambiguous.

Strings added to the six locales that carry the dashboard set; hi.js has none of them and
falls back to English.
2026-07-29 21:33:56 -05:00
ScreenTinker 3f0db335d2 chore(release): v1.9.25 2026-07-29 20:38:47 -05:00
ScreenTinker 2906e559cb Advance the versionCode baseline past the published test builds
Three prereleases were cut for #234 and handed to the reporter, consuming versionCodes
89 through 93 via VERSION_CODE overrides that were never written back to this file. The
committed default was still 88, so bump-version.sh would have produced 89 for 1.9.25 —
an APK that installs over nothing anyone has been testing, since Android refuses a
lower-or-equal code, and silently so from the user's side.

Set to 93 so the next bump lands on 94, above every published build.

Lesson worth keeping: a VERSION_CODE override for a one-off build leaves this file lying
about where the release line actually is.
2026-07-29 20:38:46 -05:00
ScreenTinker bb016b8313 Merge branch 'feat/admin-plans-with-counts' 2026-07-29 19:36:10 -05:00
ScreenTinker a25c6827a7 Show every plan on the admin tab, with who is on each
The admin plan table read /api/subscription/plans, which filters `active = 1` because
that endpoint feeds the public pricing page. So the one screen meant to show the
operator what plans exist could not show a hidden one — a comped or beta tier was
invisible to us as well as to customers, with no way to see it existed or who was on it.
Found immediately after creating exactly such a plan.

GET /api/admin/plans (platform-admin only) returns every plan plus, per plan, the number
of accounts, organisations and screens on it. Visible plans sort first so the list still
reads like the pricing ladder, with hidden ones after and badged.

The public endpoint is deliberately untouched: hiding a plan has to keep working, and
the test pins BOTH directions because they pull against each other — the admin list must
include an inactive plan, and the public list must never leak one.

Counts are the point, not decoration: "how many people are on what plan" is the question
you actually ask of this screen, and it was answerable only by hand in SQLite.

Also carries a warning for accounts whose plan no longer resolves. Both users.plan_id and
organizations.plan_id are FK-enforced to plans.id and there is no delete-plan route, so
this should be unreachable — but migrations here do rebuild tables with foreign keys off
(the tenant-cascade one rebuilt thirteen), and that is exactly how a row would be
orphaned. Six lines for a state that would otherwise be silent.

Strings added to en/de/es/fr/it/pt. Not hi: it has no admin translations at all, lookup
falls back to English, and four Hindi strings among forty English ones would read worse
than consistent English.
2026-07-29 19:36:10 -05:00
ScreenTinker 10f1dccdc8 Merge branch 'fix/unblock-clears-saved-block' 2026-07-29 18:44:11 -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 39c4ec8af8 Merge branch 'fix/setup-permissions-revocable' 2026-07-29 18:27:45 -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