Commit graph

690 commits

Author SHA1 Message Date
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
ScreenTinker b09bed645d Merge branch 'fix/provisioning-callback-relaunch-loop' 2026-07-29 18:01:42 -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 ce626c7e8e Merge branch 'fix/playlist-refresh-not-once-per-item'
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 14:13:42 -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 3a681abda0 Merge branch 'fix/resume-playlist-position-across-recreate' 2026-07-29 09:13:30 -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 c115ad5e62 chore(release): v1.9.24
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 23:37:59 -05:00
ScreenTinker 3b593e2a1c Merge branch 'feat/ota-managed-override' 2026-07-28 23:34:39 -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 0bd9cde418 Merge branch 'fix/self-ota-stand-down-requires-device-owner' 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 bcb1b5c7a3 chore(release): v1.9.23 2026-07-28 20:43:34 -05:00
ScreenTinker 0df7f58b26 Parse MAX_FILE_SIZE, and document what else caps an upload
Follow-up to #233, which made the upload ceiling configurable — the right call,
500MB is genuinely too low for video.

An environment variable is a string, so the value reached multer's
limits.fileSize as text where a number is expected. That survives some
comparisons through coercion and misbehaves in others, which is the worst kind
of bug to find later; the line directly above it already used parseInt for the
same reason. It is parsed properly now, and a suffix is accepted — someone
raising a limit for video is choosing "about 2GB", and 2147483648 is easy to
mistype by a factor of ten.

An unparseable value falls back to the default rather than becoming NaN or
zero. Either would reject every upload on the instance, from a typo in an env
file, with nothing on screen to explain it.

The documentation matters as much as the code here. MAX_FILE_SIZE is the LAST
limit in the chain: nginx caps the request body with client_max_body_size and
returns 413 before the app is reached — our own deployment sets 500M — and
Cloudflare caps uploads per plan at the edge. Raising the variable alone often
changes nothing, so the README now says so, with the nginx directive and a note
that an upload failing with nothing in the server log never reached the server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 20:40:14 -05:00
a10kiloham 991c0da25a
Use environment variable for maxFileSize (#233)
Thanks — the hard-coded 500MB cap was genuinely too low for video, and making it configurable is the right call.

Merging as-is for the credit; a follow-up commit fixes two things this needs to actually work:

1. `process.env.MAX_FILE_SIZE` is a string, so the value reached multer as text rather than a number — the line directly above uses `parseInt()` for the same reason.
2. Raising it alone is not enough behind a reverse proxy. nginx caps request bodies at `client_max_body_size` (500M on our own deployment) and returns 413 before the app sees the upload, and Cloudflare's own cap applies too. That is now documented in the README alongside the variable.

The follow-up also accepts a suffix (`MAX_FILE_SIZE=2GB`) since typing the byte count is easy to get wrong.
2026-07-28 20:36:14 -05:00
ScreenTinker 3e3d0081fe Keep the smoke test out of npm test, and update the lockfile
Two mistakes in the previous commit, both of which broke CI.

The lockfile was not regenerated after adding puppeteer-core to
devDependencies, and `npm ci` requires the two to agree — so every job that
installs dependencies failed before running anything.

The smoke test was also placed in test/, which I described as keeping it out of
`npm test`. It does not: `node --test` globs that directory, so the runner
picked it up regardless of intent, tried to drive a browser as a unit test, and
failed. It now lives beside the server as smoke-ui.js, with a note saying why,
so the next person does not put it back.

Verified the way it should have been the first time: npm ci succeeds, native
modules still load, npm test is 807/807 with no browser involved, and
`npm run smoke` is 32/32 on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 20:34:34 -05:00
ScreenTinker 29ae184b14 Add an opt-in browser smoke test
A whole class of defect found today was invisible to the unit suite, to a
syntax check and to review, and appeared only in front of a browser: a context
menu whose only item read "schedule.ctx_new", pointer handlers stacking on every
calendar render so one drop fired five PUTs, and a week grid that scrolled
sideways on a phone. Nothing in the repo could have caught any of them.

This keeps the checks that earned their place and throws away the scratch
scripts around them. It boots a server, drives every view, and asserts each view
renders, none raises an uncaught error, no untranslated key reaches the screen,
the calendar binds its handlers once however many times it re-renders, and
nothing overflows horizontally at phone width.

Deliberately NOT part of `npm test`. It needs a real browser, which CI does not
have, so it is `npm run smoke` and exits 0 with an explanation when puppeteer or
Chrome is missing — a test that fails for want of tooling teaches people to
ignore failures. puppeteer-core rather than puppeteer, so installing it does not
pull down a private copy of Chrome; it drives whichever one is already there.

Verified both ways: 32/32 against current main, and it fails on the listener
stacking when that fix is reverted. The missing-key case is covered by the unit
guard instead, since a context menu only exists once it has been opened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 20:17:59 -05:00
ScreenTinker 5c95070d3a Bind the calendar's pointer handlers once, not once per render
A QA pass over my own changes found a real defect. attachGridInteractions ran
on every calendar render, but #calendar is the same element throughout — only
its children are replaced — so each render stacked another full set of pointer
handlers on it. Five weeks of navigation left five, which meant five ghost
blocks during a drag, five context menus on a right-click, and five PUT
requests on a single drop. Verified by counting listeners through the debugger:
five sets after five renders, one after this change.

Also guards the drag-to-create path. It reuses the Add Schedule button's own
handler so the dialog resets exactly as it does for a normal create, but it
called .onclick() unguarded — and a drag is a user gesture that must never
throw. A missing button now quietly does nothing instead of raising an uncaught
error in the middle of an interaction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 20:08:31 -05:00
ScreenTinker 7747d7e051 Put Members in the nav, reveal titles on touch, and stop a stale heartbeat killing a socket
Three loose ends from the interface review.

Inviting a colleague is a core action and had no entry in the navigation at
all. The only route was an unlabelled icon beside the workspace name, or typing
the URL. There is now a Members item, translated, which resolves to the active
workspace so the static link needs no id. The Teams entry it sits near stays
hidden, since that feature is still switched off.

A native title= is hover-only, so the icon-only buttons — rename a wall, remove
a device from one, manage members — explained themselves on a desktop and said
nothing on a touchscreen. Long-pressing one now shows its label. The text was
already there and already translated; it simply had no way to reach a finger.

The last one is the bug that took a real screen dark. A device row can vanish
while its socket is still heartbeating, and the telemetry insert then failed a
foreign key. That throw was fatal in a way that is hard to guess: the
safe-socket wrapper reads a throwing handler as a broken one and disconnects
the socket server-side, and socket.io deliberately does not retry that kind of
disconnect — so the player sat doing nothing until a person reloaded it. A
heartbeat for a device that no longer exists is an ordinary race, not a fault
worth ending a connection over; the write is skipped and the register path
answers unpaired, which is the reply that actually helps the client recover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 20:00:35 -05:00