Commit graph

182 commits

Author SHA1 Message Date
ScreenTinker 66df64a798 chore(release): v1.9.34-alpha12 2026-08-13 20:20:21 -05:00
ScreenTinker 8b0601b7bc chore(release): v1.9.34-alpha11 2026-08-13 19:57:50 -05:00
screentinker feda25943c
OTA: say which failure happened, and stop refusing readable APKs on API 28/29 (#269)
* OTA: say which failure happened, and stop refusing readable APKs on API 28/29

Two changes, both aimed at the same dead end: a panel that will not update and a
message that cannot tell you why.

NAME THE FAILURE. "failed to download or failed signature verification" covers
SEVEN distinct branches — three of them download failures where verification never
runs at all. Every specific reason went to logcat, and an unprivileged app UID
cannot read logcat on Android 9, so in the field the message was unactionable: it
named a symptom shared by unrelated causes and pointed at the wrong half of the
code as often as the right one. Each branch now records what actually happened and
the operator sees it:

    "not installed — server returned HTTP 416 for the APK"
    "not installed — could not read signing certificates (archive=0, installed=1) on API 28"
    "not installed — APK is signed by a different key than the installed app"
    "not installed — download/install threw IOException: ENOSPC (downloaded 41232 bytes)"

The byte count rides along on verification failures so a truncated download is
distinguishable from a genuine key mismatch — the two look identical today.

FALLBACK ARCHIVE CERT READ. On API 28/29 the archive's signer comes from the
legacy GET_SIGNATURES path (#139: signingInfo is null for ARCHIVES below API 30).
When PackageManager returns nothing there, we refused a possibly-fine APK with no
way to tell that apart from a real mismatch. It now reads the v1 signature itself
via JarFile before giving up.

This does NOT weaken the check. JarFile with verify=true only populates
JarEntry.certificates after the covered bytes have been read and verified — the
read IS the verification — and the extracted cert is still compared against the
installed app's. An unsigned, tampered or differently-signed APK still fails, and
any error in the fallback returns empty, which still refuses the install.

Deliberately NOT done: disabling signature verification on Android 9. It would mean
those panels silently installing whatever the server hands them — on device-owner
hardware that is remote code execution, and the cheap Android 9 boxes are the ones
most likely on a customer's flat network. It also might not fix anything, since
three of the seven branches never reach verification.

Context: a panel on alpha failed four forced updates this evening while succeeding
at an unattended one, and four separate theories for it died on contact with
evidence. The reason this took an evening is that the device could not say what
went wrong. That is the actual bug being fixed here.

* OTA: prove the destination is writable before downloading

Every theory this evening turned on whether the app could actually put a file
somewhere, and nothing in the code ever checked. A returned directory path is not
the same as a usable one: it can be missing, unwritable, on a volume that has gone
away, or simply full — and all four surfaced as the same opaque "failed to
download" as a genuine network fault.

apkDir() now proves the external directory before choosing it (exists-or-created,
and canWrite) rather than trusting a non-null path, and falls back to internal
storage when it does not hold up.

apkDirProblem() runs BEFORE the network call on both download paths and names the
real condition:

    "cannot stage the update — no write permission on /storage/…/Download"
    "cannot stage the update — only 6MB free on /data/…/Download, need ~18MB"
    "cannot stage the update — write test failed in /storage/…: IOException EROFS"

It does not infer from canWrite(), which returns true on volumes that then refuse
the write; it writes a probe byte and deletes it. Free space is checked against
DOUBLE the APK, because the installer stages its own copy — a volume with exactly
the download's worth free still fails later, at install time, where the message is
even further from the cause.

The pushed-APK path gets the same preflight; it shared every one of these failure
modes and reported none of them.
2026-08-13 19:57:27 -05:00
ScreenTinker 0b10776701 chore(release): v1.9.34-alpha10 2026-08-13 19:00:53 -05:00
ScreenTinker 13534d9b61 chore(release): v1.9.34-alpha9 2026-08-13 17:27:29 -05:00
ScreenTinker 8b162ecce2 chore(release): v1.9.34-alpha8
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-08-13 14:32:54 -05:00
screentinker 3234c923a3
An APK download with no external storage went nowhere, silently (#266)
getExternalFilesDir() returns null whenever external storage is unavailable, and
on a signage panel that is not exotic: no emulated volume, a vendor ROM that never
mounts one, an ejected card, storage still unmounted early in boot.

Both APK download paths did:

    File(context.getExternalFilesDir(DIRECTORY_DOWNLOADS), name)

Java's File(File, String) treats a null parent as "no parent" and silently yields
a RELATIVE path, so the download targeted `ScreenTinker-x.y.z.apk` in the process
working directory — `/` — which is not writable. The write threw, the generic
catch swallowed it, and the caller reported only "failed to download or failed
signature verification".

That message is why this was expensive to find. The HTTP request SUCCEEDS (the
server logs a served download at the exact moment of each failure), so it does not
look like a download problem; the signing key is fine, so it does not look like a
verification problem; and because nothing is ever written there is no partial file
to find. It also never recovers — every attempt fails the same way, forever.

installFromUrl (the dashboard "Push an APK" button) carried the identical line, so
the obvious workaround for a panel in this state was broken by the same bug.

Both now use apkDir(), which falls back to internal storage. filesDir cannot be
unmounted: if it is gone the app is not running.

file_paths.xml gains a <files-path> for the same directory. The silent
PackageInstaller path streams the file itself and needs nothing there, but the
intent-based install FALLBACK resolves it through FileProvider and would throw
"Failed to find configured root" — turning an already-degraded panel into one that
cannot install at all.

⚠️ This cannot reach an affected panel over the air: the broken download path IS
the delivery mechanism, and Push APK shares the bug. A panel already in this state
needs one manual install to escape it.

Root cause is inferred from converging evidence on an Android 9 panel (HTTP served
at each failure, no APK anywhere on the device, the app's own external files dir
denied to its own UID, both paths failing identically, signing verified at parity
against the release it already installed). Handling a null return is correct
regardless — it must never become a relative path.
2026-08-13 14:32:34 -05:00
ScreenTinker 1fc50ec263 chore(release): v1.9.34-alpha7 2026-08-13 13:41:46 -05:00
ScreenTinker 2ed87f5cfb The stage kept the size of a window that no longer existed
A display could come up with a border on one edge the exact size of a system
bar that was set to hide -- sometimes. Reboot and it might fill the screen
correctly. It has been "sometimes doesn't fill" for a long time, across
devices, which is what a race looks like from the outside.

applyOrientation() read `resources.displayMetrics`, which reports the app
WINDOW rather than the panel. Immersive mode is a request: the bars hide and
the window grows several frames later. A playlist arriving before that finished
measured a bar-sized window and wrote it straight into rootView's layoutParams
-- and it stayed there, because the guard compared only the orientation STRING,
which never changes on a display that has always been landscape. The window
then expanded and the stage did not, leaving dead space the exact size of a bar
that was no longer on screen. onWindowFocusChanged re-asserted the immersive
flags but never re-measured, so nothing repaired it.

Rendering the cached playlist immediately at boot made losing that race the
common case rather than a rare one.

Three changes, each of which alone would help and which together make it
unwinnable:

  - re-measure on onWindowFocusChanged, so a stage sized during any transient
    window state corrects itself instead of being permanent;
  - the guard compares the measured SIZE as well as the orientation, so
    "landscape -> landscape" can repair a bad measurement;
  - ask for full-bleed through WindowCompat/WindowInsetsControllerCompat as
    well as the deprecated systemUiVisibility flags, because some OEM builds
    honour only the modern route.

Deliberately measures the WINDOW, not the display. On one RK356x box
`dumpsys window` reports `init=1920x1080 app=1920x1024`: the firmware reserves
56px for a hidden bar, and those pixels are not the app's to paint. Sizing the
stage to the display there would not fill the gap, it would push the bottom of
every asset outside the window and crop it silently -- worse than a border. If
`app=` still differs from `init=` after this, the reservation is firmware
behaviour and has to be turned off on the device.

Also shipped as a 1.9.24-based build for the reporting customer, so the change
could be tested in isolation against ten releases of drift.
2026-08-13 00:03:03 -05:00
ScreenTinker 72fd2314b5 chore(release): v1.9.34-alpha6 2026-08-12 15:15:43 -05:00
ScreenTinker 350ca58f22 chore(release): v1.9.34-alpha5 2026-08-12 14:49:13 -05:00
ScreenTinker ffceaf2c1f chore(release): v1.9.34-alpha4
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-08-12 11:53:35 -05:00
ScreenTinker 6cd697c3fc chore(release): v1.9.34-alpha3
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-08-11 23:23:31 -05:00
ScreenTinker 77d41ae73e chore(release): v1.9.34-alpha2 2026-08-11 22:34:53 -05:00
ScreenTinker 6830fe58ea chore(release): v1.9.34-alpha1
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-08-11 21:54:55 -05:00
ScreenTinker 3333d5e968 Stop Android panels losing controls when they update
A declared capability set REPLACES the per-platform baseline rather than
merging with it, so anything the baseline grants and the player omits is a
control the operator loses by updating. Three were being lost.

- display.brightness: the per-window dim (setWindowBrightness) is Tier 0 —
  no permission, no owner, no WRITE_SETTINGS — and MainActivity applies it
  unconditionally. It was simply never declared.

- remote.screenshot / remote.stream: gated on the accessibility service,
  while captureScreen() falls through to ScreenshotCapture.captureView,
  a plain view draw with no permission check. A Tier-0 panel lost live view
  and screenshots by updating, and a GRANTED MediaProjection never became a
  capability either — consent given, capture working, server still refusing,
  because nothing re-declared.

- system.device_owner: no player declared it, so the server accepted
  system.kiosk as a stand-in for every Tier-2 command. Declaring the
  canonical name makes refusals say what they mean; the stand-in can retire
  one release after this reaches displays.

display.power stays conditional on purpose: screen_on works anywhere via a
wake lock but screen_off needs owner/admin/accessibility, and a control that
sleeps a panel it cannot wake is worse than no control. It is the sole entry
in the DELIBERATE set in player-parity-baselines.test.js.

Also fixes the capture-bootstrap gate in device-detail.js. It hung off
can('remote.screenshot'), which hid the button from exactly the panels that
need it. The gate is now Android-and-nothing-else, NOT "Android that lacks
capture": /api/devices/:id ships capabilitiesFor(), which flattens declared
and baseline into one array, and the android baseline contains
remote.screenshot — so a "lacks capture" test hides the button from all ~440
undeclared panels in the field. isAndroidDevice() mirrors platformFamily()
with all four signals in order; an Android-test-only helper classified every
Tizen TV as Android, since Tizen registers android_version 'Tizen 6.5'.

Tests: the suite could not see any of this. Mutation testing showed deleting
either capability line, or reverting isAndroidDevice to its buggy form, left
all tests green. Added an update-invariant test (declared set vs baseline,
with an argued exception list), a test that executes the shipped helper
rather than the harness stub, and a legacy-panel test using the shape the API
actually returns instead of one it never produces. All four mutations now
fail.

Verified on a real Android 16 device across all three tiers: Tier 0 captures
live video (no accessibility, no MediaProjection, no owner), Tier 1 gains
display.power via accessibility, Tier 2 declares system.device_owner and every
Tier-2 command delivers. An in-place upgrade from the pre-change build lost
nothing and gained exactly these three.

Baselines deliberately NOT moved — a baseline entry moves in the release
AFTER the one carrying the player fix, once it has reached displays.

Parity gaps 3 and 4 were implemented, audited and reverted; docs/player-parity.md
records why so the next attempt starts from the traps. Gap 3 (wiring "Force
update") meets an unbounded synchronous download against a 120s watchdog and a
3-attempt counter with no version binding, so three presses refuse a panel every
future version. Gap 4 (deferring to BS.capabilities()) removes working
screenshot/stream from diskless BrightSigns that capture to RAM, over-declares
transitions, and rides a probe timeout that discards a late answer permanently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 10:31:33 -05:00
ScreenTinker f58c537d15 chore(release): v1.9.33
Some checks failed
CI / Unit tests (node --test) (push) Has been cancelled
CI / OpenAPI spec lint (push) Has been cancelled
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Has been cancelled
CI / Boot smoke + version check (push) Has been cancelled
2026-08-07 20:47:58 -05:00
ScreenTinker b9b1870472 chore(release): v1.9.32 2026-08-07 17:36:46 -05:00
ScreenTinker 9face2fdd4 Show a panel's IPv6, and size the pairing code to the screen it is on
Two field-reported gaps, unrelated except that both are about being able to
read something off a screen.

A PANEL'S IPv6 WAS NEVER COLLECTED, LET ALONE SHOWN.

DeviceInfo.getLocalIp() filters to Inet4Address, so a v6-only panel reported no
address at all and the dashboard rendered a dash for a screen that was perfectly
reachable. It now reports both stacks in their own fields: a dual-stack panel
genuinely has two addresses and either may be the one you need, so collapsing
them into one column would make it mean "whichever interface enumerated first".

Link-local (fe80::/10) is deliberately excluded. Every interface has one, they
tend to enumerate first, and none can be dialled without also knowing the zone
index — so admitting them would fill the field with a string nobody can paste
anywhere and hide the address that works. Any %iface suffix is trimmed for the
same reason. The 45-char cap the writer already applied is exactly the longest
legitimate IPv6 text form, so it needed no change.

The dashboard card renders only when a panel actually has a v6 address, rather
than showing an empty row to the overwhelmingly v4 fleet.

THE PAIRING CODE DID NOT SCALE, WHICH IS WORST WHERE IT MATTERS MOST.

Every size on the pre-playback screens was a hard-coded pixel value. A CSS pixel
covers a quarter of the screen area on a 4K panel that it does on 1080p, and a
sixteenth on 8K — so the 72px code that fills a 1080p screen is a smudge on the
4K wall it was installed on, which is where signage actually goes.

What has to stay constant is ANGULAR size, so the root font size is now
viewport-proportional and everything on those screens is a rem against it. The
code holds 6.67% of screen height at every resolution: 72px at 1080p — bit for
bit what it renders today, so nothing changes for the existing fleet — 144px at
4K, 288px at 8K. Verified in a browser rather than by arithmetic: at a 1409px
viewport the root computes to 13.0473px, which is 0.926vmin to four decimals.

vmin, not vw, because portrait-mounted panels are common here and vw would
render a 1080x1920 screen at half size. Clamped at both ends so the dashboard's
preview iframe stays legible instead of microscopic and an ultrawide does not
get silly. Applied to the web player (which BrightSign also runs) and to Tizen,
where a 1920x1080 logical viewport makes it arithmetically identical to the
values it replaces — the point being the panels where it is not.

A test asserts the scaling cannot reach playback content: the whole safety
argument is that only the chrome uses rem, and a stage or zone rule adopting it
would start resizing CONTENT, which is a worse bug than the one being fixed.
Android is untouched — its pairing code already autosizes within a dp-scaled
layout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-07 08:06:45 -05:00
ScreenTinker 70b2227fa8 chore(release): v1.9.31 2026-08-06 21:08:14 -05:00
ScreenTinker 9a630087ff Show the video in a rotated wall panel's screenshot, not a black rectangle
#236 gave each video-wall panel a mounting rotation, which for the first time
puts a real rotation on an ancestor of the ExoPlayer TextureView. The screenshot
compositor could not express that: it pasted the video frame with an axis-aligned
Rect built from getLocationInWindow(), so on a rotated panel the frame landed
outside the capture bitmap entirely. What reached the dashboard was the plain
black that view.draw() leaves wherever a TextureView is — a panel that looks dead
while it is playing perfectly, which is the worst thing a diagnostic can say.

The frame is now placed through the same transform chain the hierarchy was drawn
with, accumulated up the parent chain the way the framework does when it draws a
child, so any ancestor rotation/translation is honoured. The bitmap is also
scaled from the surface's own dimensions rather than assumed to match the view.

Measured on the emulator, a wall panel playing video, remote screenshot vs the
adb framebuffer at the same moment (standard deviation — 0 means a flat frame):

                    before                  after
  rotation 0    sd 0.439 / truth 0.430   sd 0.443 / truth 0.435   (unchanged)
  rotation 90   sd 0     / truth 0.461   sd 0.448 / truth 0.448
  rotation 90   sd 0     / truth 0.467   sd 0.460 / truth 0.457
  rotation 90   sd 0     / truth 0.408   sd 0.463 / truth 0.466

Every rotated capture was #010101 with zero variance before; each now tracks the
real framebuffer. Rotation 0 is unchanged, and so is the ordinary fullscreen
(non-wall) path, re-measured across images and video.

No unit test: this is android.graphics.Matrix semantics against a live view
hierarchy, which the JVM test source set cannot exercise — the evidence is the
before/after measurement above. Android 151/151, server 1298/1298.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 16:40:48 -05:00
ScreenTinker 5d56e538af Play the first item of a playlist on a fresh panel, instead of skipping it
A newly paired panel always learns its playlist BEFORE the media arrives, so
start() finds nothing playable and the 3-second content re-check is what really
begins playback. updatePlaylist() has already seeded currentIndex = 0 for a
playlist that has not started, but the re-check advanced PAST that index — so
the first pass ran 1,2,3,0 and item 1 only appeared after the list wrapped.

On the emulator, a fresh pair with a 4-item playlist reproduced it every time:

  Starting playback
  Playing: red.png (index 1)      <- clip32.mp4 (index 0) never got its turn
  Playing: clip7.mp4 (index 2)
  Playing: blue.png (index 3)
  Playing: clip32.mp4 (index 0)   <- 54s late, on the second pass

On a two-item playlist that is indistinguishable from "only one of the two ever
plays", which is how it was reported.

The distinction the re-check was missing is hasContentOnScreen. With content up,
currentIndex is a real position that has had its turn and the scan must move past
it. With nothing up, currentIndex is only where playback INTENDED to start, so
skipping it drops that item. PlaylistSelection.recheckIndex now makes that choice
explicitly, and playableFromIndex treats a negative index as "no position yet"
rather than wrapping onto the last item.

Verified on the emulator against the same cold start: the first pass is now
0,1,2,3,4 in order. Playback resume (#234) is untouched — it never reaches the
re-check when its target is cached, confirmed by an Activity relaunch resuming
mid-playlist as before.

Tests: 6 new cases in PlaylistSelectionTest covering both sides of the rule, the
still-downloading item, the no-position-yet start, and the empty case.
Android 151/151, server 1298/1298.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 16:26:34 -05:00
Claude e4c25c39df Describe a portrait video wall as portrait, and stop a wall hiding its screens
#236: the wall canvas was secretly framebuffer space rather than the wall as
the audience sees it. Invisible while every panel is the normal way up, and
actively misleading the moment one isn't — two portrait-mounted panels standing
side by side had to be STACKED VERTICALLY in the editor, with a pre-rotated copy
of every video, before the output came out right. It worked, but only after
trial and error, and it meant a portrait wall could never reuse content as-is.

Each panel now carries a mounting rotation (0/90/180/270 clockwise, the same
convention as the per-device orientation setting), the canvas means the physical
wall, and the player works out the mapping. The geometry lives in one place,
server/lib/wall-geometry.js, because four players have to agree on it to the
pixel across a seam.

Existing walls need no migration and do not move. Every wall in the field is
rotation 0, and that case takes the original expression verbatim on all three
players rather than the algebraically-equal centre-based one — the two differ in
the last float bit, and a float's worth of disagreement between two panels is a
hairline seam down a wall that was aligned yesterday. Pinned by the first test
in wall-geometry.test.js and by wall-payload.test.js.

While a display is in a wall its panel rotation replaces its own orientation:
both describe the same physical fact, so honouring both turned the content twice.

#235: a wall replaced its members' cards, so one dead panel of a four-panel wall
was invisible from the dashboard, and inspecting a single screen meant pulling it
out of the live wall and putting it back. The wall screen now lists its panels
with live online state, a per-panel screenshot request, and a link to each
device's page; the dashboard wall card carries per-member status chips that track
socket updates.

Tests: wall-geometry.test.js re-simulates the CSS box independently and asserts
each panel's viewport maps onto exactly its own rect of wall space, for every
rotation, plus a mixed wall and the Tizen player's hand-ported copy executed
against the canonical rule. Full server suite green (1260).

Not verified here: the Android and Tizen renders on real hardware. Kotlin
compiles clean; the maths is shared/tested, the view plumbing is not.
2026-08-06 09:46:31 -05:00
ScreenTinker 3b9ad08454 chore(release): v1.9.29 2026-08-06 08:39:26 -05:00
ScreenTinker a85e067260 chore(release): v1.9.29-rc5 2026-08-05 23:00:59 -05:00
ScreenTinker ba45c2d60c chore(release): v1.9.29-rc4
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-08-05 16:32:56 -05:00
ScreenTinker 684e60fc55 Offline media on every player, and a revision so the cache can still be updated
Two halves of the same problem. A screen has to keep playing when the link is
gone, and it must not keep playing the wrong thing once the link is back.

CACHING FOR OFFLINE, on the players that could not:

- Tizen cached nothing but the playlist, so a panel came back from a reboot
  knowing exactly what to show and fetched every frame of it from a server that
  was not there. tizen/js/media-cache.js caches the media itself to wgt-private
  (the store Tizen documents as surviving reboots), resumable via Range and
  If-Range, with the transfer async so a stalled chunk cannot freeze the player.
  offline.cache moves from "absent" to a runtime claim: a build with no writable
  private storage still says nothing.

- The web player's worker stored only what a single fetch() happened to
  complete, which on a marginal link is nothing at all — a 200MB asset never
  finishes in one go and every retry starts from zero. It now accumulates in
  resumable chunks, driven by the player's playlist rather than by playback, so
  the prefetch is not competing with the video that is currently on screen for
  the same scarce bandwidth. BrightSign inherits this.

STILL UPDATING, which caching quietly breaks:

PUT /api/content/:id/replace changes an asset's bytes under a stable id. Every
cache keys on that id, so before this the new bytes could not reach a panel that
already held the old ones — not until the next refresh, but never. Content now
carries a revision, stamped onto each item at send time like widget revs, and
every player keys its cache on it. The same send-time refresh fixes a second
bug: a replace writes a new randomly-named file and unlinks the old one, so the
filepath in a published snapshot pointed at a deleted file and web panels 404'd
on the item until somebody republished the playlist. The route now also pushes
to affected devices, which it never did.

Bytes are kept only where they can be built upon: no validator means no safe
resume, so the partial is discarded and the attempt backs off as the failure it
is rather than re-fetching the same prefix forever.

Server needed no new transfer support — res.sendFile already does Range,
If-Range and 416. The Tizen cache and the service worker are both driven in Node
against fakes, because neither can be exercised without hardware and "the chunks
assemble correctly" is not something to discover from a panel showing a corrupt
video.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 15:27:36 -05:00
ScreenTinker cf3b2e62af Resume interrupted content downloads instead of restarting from zero
A site on a marginal link (the report came from a one-bar 5G install) could
never fill its cache. Every attempt started at byte 0 and the .part was deleted
on any interruption, so an asset larger than one call's worth of transfer was
discarded and re-fetched forever — five minutes of progress thrown away, back
off, five more minutes, thrown away. With nothing cached, the player showed the
waiting state, which is what got reported as "the screens go black instead of
playing cached content". The offline playback path was never the problem; the
cache simply could not be filled.

An interrupted download now keeps its .part and the next attempt asks for the
rest with Range. Two ways that could corrupt the cache, both closed: If-Range
with a stored validator makes a changed asset come back as a full 200 (restart)
rather than a spliceable tail, and a partial longer than the asset gets a 416
and is discarded. Bytes are kept only when they can be built upon — with no
validator there is no safe resume, so the partial is dropped and the attempt
backs off as the failure it is, rather than re-fetching the same prefix forever.

DownloadCoordinator now distinguishes progress from failure: attempts chain
while bytes are landing (bounded, single-flight held throughout) and only a
no-progress attempt escalates the exponential backoff or acks "failed" — an
advancing download is not a failed one and should not be shown as such.

Server side is unchanged; res.sendFile already serves Range/If-Range, and
content-range-resume.test.js pins that since it is load-bearing and a future
middleware could silently remove it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 14:54:09 -05:00
ScreenTinker 812e89f28f Android declares what it can actually do, and can wake a panel it slept
Two halves of platform-native parity.

THE DECLARATION. The player now sends a `capabilities` array on every register,
using the vocabulary in server/lib/player-capabilities.js so the dashboard can
stop offering controls that cannot work on a given panel.

Computed at registration, never cached, because almost everything interesting is
runtime state an APK cannot know about itself: accessibility gets switched on
months after install, device owner arrives through a provisioning flow, and
WRITE_SETTINGS is a grant an operator can revoke. A value captured once would be
wrong on the same hardware from one boot to the next.

The rule when uncertain is to UNDER-claim. A missing control is a support
question; a control that looks like it works and does nothing is a bug report,
and on a panel nobody can reach it is an expensive one. So:

  system.reboot / kiosk / time   owner only. Off-owner, reboot degrades to an
                                 accessibility power DIALOG and kiosk to screen
                                 pinning — both need someone at the screen, which
                                 is not a remote capability.
  system.install_apk             owner or a delegated install scope.
  system.brightness / timeout    WRITE_SETTINGS or owner. Per-window dimming
                                 works at any tier but is not what an operator
                                 means by "brightness".
  remote.screenshot / stream     accessibility only. Without it capture falls
                                 back to the app's own view.
  display.power                  see below.
  system.shell                   ALWAYS. It is app-UID `sh -c` and runs at any
                                 tier; the directive grouped it with Tier-2, but
                                 the code is not owner-gated and under-claiming
                                 would hide a working diagnostic.

Never declared, so the dashboard stops offering them: display.resolution (needs
system/root — an app cannot change the negotiated output mode) and sync.native
(frame-accurate hardware sync is a BrightSign SyncManager feature; Android has
the clock-derived group sync, which IS declared).

THE WAKE PATH. display.power was asymmetric: screen_off worked via owner, admin
FORCE_LOCK or accessibility, while screen_on was a logged no-op. The retired
attempt was `input keyevent 224`, which exec denies to an app UID, and that one
failure had been read as "no wake path exists". A wake LOCK is a different
mechanism needing only WAKE_LOCK — a normal permission already in the manifest.

That asymmetry is expensive on a fleet: an operator sleeps a panel overnight and
cannot wake it remotely, so someone drives to the site. Losing the screen is the
wrong direction to fail in. Handled in the service as well as the Activity, since
the service is the only thing guaranteed alive, and paired with a keyguard
dismiss because waking to a lock screen is half a fix. The lock is held briefly
and self-expires, so a missed release cannot pin a panel on.

display.power is therefore declared on the OFF path (owner/admin/accessibility),
which is now the binding constraint — offering a control that sleeps a panel it
cannot wake would be the worst version of this feature.

DeviceInfo.isAccessibilityEnabled is internal rather than private so the
declaration asks the same question as the telemetry shown beside it, instead of
a second copy that drifts.

Verified: APK compiles (9,023,669 bytes); all 25 declared strings are known to
the server vocabulary, with zero unknown; and they survive R8 into classes4.dex
along with the `capabilities` payload key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 14:08:52 -05:00
ScreenTinker d205a49dfa The settings PIN can be rotated and set from the dashboard
It was generated once at pairing and never changed. On a fleet that makes it a
shared secret with no expiry: anyone who watches it typed once — an installer, a
contractor, someone filming a screen — keeps it for the life of the panel, and
the only way to take it back was to unpair and re-pair every affected display. A
customer asked whether it rotates, which was the right question.

POST /api/devices/:id/settings-pin takes { rotate: true } or { pin: "123456" },
and pushes the result to the panel over its socket immediately. The live push is
the part that matters: without it a new PIN would only take effect at the next
pairing, so an operator revoking a leaked PIN would believe access was closed
while the old one still opened the menu. The response reports whether the panel
actually took it, so an offline display is stated rather than assumed.

Validation is the security-relevant half and is pure and tested: six digits,
digits only, and a blocklist of the PINs people actually pick (repeats and
sequences) refused on explicit set and never produced by the generator. A PIN
that can be set to "0000" or left empty is a gate that is not there.

Generation uses crypto.randomInt rather than Math.random — this is a credential,
and a rotation requested BECAUSE a PIN leaked must not be predictable from
anything else. Leading zeros are padded, or roughly one PIN in ten would be five
digits and rejected by the on-device prompt.

Android applies it live via device:settings-pin instead of only at pairing. The
PIN is never written to a log on either side, and it stays out of device list
responses as before.

1084 pass; Android compiles.

Asked for by chris@chris-pc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 13:44:15 -05:00
ScreenTinker b419830629 Android: the boot notice now clears, and kiosk survives a reboot
Two field reports from a customer running the player on Android x86.

THE "STARTING DISPLAY…" BANNER NEVER CLEARED. Relauncher launches the activity
directly when the overlay permission is granted — the normal kiosk setup — and
THEN posts the notification, deliberately, so a device that could not auto-launch
still has a tappable way back. On a device where the launch DID work, that
ordering posts the prompt after onCreate has already cancelled it, and nothing
cancels it again: a permanent banner over content that is already playing. They
sent a photo of exactly that.

Cancelling in onCreate only ever closed half the race. It now also clears on
every foreground: if the player is on screen, a "Starting display…" prompt is
stale by definition, whoever posted it and whenever.

KIOSK MODE DID NOT SURVIVE A REBOOT. startLockTask() is a runtime call on the
Activity, and nothing persisted the operator's intent — so a locked panel came
back up unlocked, silently, and the only symptom is that someone can suddenly
leave the app. The flag is now written BEFORE the lock is attempted, so a device
that reboots mid-call still comes back in the state that was asked for, and a
lock that fails is retried on the next start rather than forgotten. Restored in
onStart rather than onCreate because lock-task can be dropped on some
transitions.

Also theirs: an "Exit kiosk mode" entry in the PIN menu, shown ONLY when locked.
With kiosk on and no other input, that menu is the only way out of a panel, and
a menu entry that does nothing is worse than no entry.

Builds clean: versionCode 100, v1 JAR signature intact.

Reported by chris@chris-pc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 13:30:48 -05:00
ScreenTinker c170861124 chore(release): v1.9.29-rc3 2026-08-05 11:37:53 -05:00
ScreenTinker 5ce094b1f8 chore(release): v1.9.29-rc2 2026-08-05 00:06:20 -05:00
ScreenTinker b15b17f5dd chore(release): v1.9.29-rc1 2026-08-04 23:29:37 -05:00
Claude fca36c242a Open our own permissions screen from the in-service Settings menu
The Permissions entry showed a ✓/✗ read-out and then handed off to Android's App Info page. The
screen we actually built for this — a row per permission with its live state and a Manage button
that stays visible once granted — was only reachable during first-run setup, so an installer who
wanted to review or revoke something on a running panel had to re-pair to see it.

Manage Permissions is now the primary action and opens SetupActivity in review mode. Android's App
Info page stays as the secondary, because notification access and some OEM toggles are only
reachable there.

Review mode exists because three things in SetupActivity assume first-run, and every one of them
had to be exempted or this silently did nothing:

  - proceedToNext() goes unconditionally to ProvisioningActivity. Without the exemption the button
    an installer was told to press would send a paired, playing screen to the pairing page.
  - onCreate returns early when setup_complete is set — and every device that can reach this menu
    has it set, so the screen closed before it drew and the menu entry looked broken.
  - updateStatuses() re-labels the continue button on every refresh, silently overwriting the label
    set in onCreate. The label had to move to where it actually sticks.

Review mode also hides the first-run skip hint, does not re-stamp setup_complete, and returns to
playback rather than continuing anywhere.

Verified on an Android 12 emulator, both directions:
  in service  BACK x2 -> PIN -> Settings -> Permissions -> MANAGE PERMISSIONS -> our screen with
              every row and its state -> DONE -> back to playback, no ProvisioningActivity launch,
              widget rendering resumed
  first run   full uninstall + fresh install -> SetupActivity, button reads CONTINUE ANYWAY, skip
              hint present, no DONE label, continue lands on ProvisioningActivity, pairing completes
              and playback starts

That second run is the one that mattered: both early-exit guards are inverted conditions, and a
mistake in either would have broken onboarding for every new install.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-01 14:31:15 -05:00
ScreenTinker ff7bfb2ded chore(release): v1.9.28
Some checks failed
CI / Unit tests (node --test) (push) Has been cancelled
CI / OpenAPI spec lint (push) Has been cancelled
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Has been cancelled
CI / Boot smoke + version check (push) Has been cancelled
2026-07-30 23:02:18 -05:00
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 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 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 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 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
ScreenTinker d70764991e chore(release): v1.9.26 2026-07-30 18:43:35 -05:00
Claude 9034478c28 Android: a YouTube item must end on its duration, and clearing a playlist must apply at once
A screen kept showing a YouTube video after its playlist was reassigned, and kept showing it after
"no playlist" was selected. Restarting the app showed the new content immediately, which ruled out
the network, the download and the server payload.

Two faults met:

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

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

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

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

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

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

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

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

Also caught by the test suite, and worth recording: the first version of this dropped the
comma in the device SELECT list ("t.uptime_seconds t.local_ip"), which 500'd the endpoint
and failed seven tests that never mention telemetry. Verified end to end afterwards —
public and local addresses both returned, distinct, from a real request.
2026-07-29 21:57:59 -05:00