mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
97 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
ed22693a6e | Merge branch 'fix/transition-overlay-handoff' | ||
|
|
0b6907704e | Merge branch 'fix/resume-self-advance-on-follower-exit' | ||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
59c536c923 |
Keep a solo widget mounted, and size its keyboard to the viewport
Two problems on a panel showing one fullscreen widget, both visible as flashing. The player re-navigated the WebView every duration_sec. PlaylistController.next() requests a playlist refresh between plays and playCurrentItem() re-issues the item unconditionally, so a one-item playlist reloaded the same URL forever. The existing dedupe guard only covers the playlist-update path, so it logged "not restarting" AFTER the reload had already happened. On an interactive widget that also discarded whatever the viewer had typed. showWidget() is now idempotent: same URL with the widget already on screen returns without re-navigating, and the cached URL is cleared at every media-type transition so switching away and back still reloads. The refresh itself is untouched — schedule re-evaluation and dayparting still run on the timer, and widgets keep refreshing their own data client-side (directory-search polls its board every 30s and preserves the current query). The web player already behaved this way via reevaluateHeldWidget; this brings the Android player to parity. Separately, the directory-search keyboard was laid out in fixed pixels for a 1920-wide viewport. A panel's CSS viewport is its resolution over its density, so a 1080p screen at 240dpi presents 1280x720 — where four rows of 56px keys took ~37% of the height instead of ~24%, and the lone max-width:700px breakpoint never fired to correct it. Key metrics are now clamped against vh. The clamp maxima are the previous fixed values and both vh terms exceed them at 1080 tall, so a 1080 viewport renders pixel-identically; shorter viewports scale down. The breakpoint no longer re-pins .key, which would have undone the clamp. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
11c2890433
|
fix(android): visible D-pad focus stroke on setup buttons (#218)
The setup screen buttons used a bare <ripple> which only reacts to state_pressed (touch). D-pad navigation triggers state_focused, which the ripple ignored, leaving the focused button nearly invisible against the dark background from TV viewing distance. Wrap the button shape in a <selector> (kept inside the ripple so touch still gets the press ripple) that adds a bright 3dp white stroke on state_focused. Closes #209 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
07419fee1f |
feat(players): proof-of-play on Android+Tizen; close Tizen parity gaps
Android and Tizen never emitted device:play-event, so Reports showed Total Plays / Hours / proof-of-play as all zero for those devices (only the web player logged plays). Both now emit play_start on show and play_end on advance/teardown, mirroring the web player's contract (leader-gated for walls, widget-id fallback so durations close). Server side unchanged — play_logs and the reports queries were already waiting for the events. Tizen parity with the web player (from the parity audit): - audio: landscape <video> honors item.muted (warm-muted for autoplay, then applies the real state) instead of force-muting; wall followers stay silent - device:mute-changed: real-time per-item mute of the on-screen video - device:remote-key: D-pad/volume/mute/home, BACK=info overlay, POWER=screen-off - device:remote-touch: normalized-coordinate touch injection - buffered widget swap: reveal the new iframe on load then clear (no black flash) - diagnostic info overlay toggled by the dashboard BACK key Still muted on Tizen: the portrait AVPlay video path and transition-composited video (would need webapis.avplay volume APIs / renderVideoBuffered work). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ba00dd2811
|
fix(transition-engine): Android supersede wedge/leak + web/Tizen stale-video guard (#205)
Pre-release review follow-up to #204: fixes the Android superseded-wipe playlist wedge + GL leak, and adds the stale-item guard to web/Tizen renderVideoBuffered. |
||
|
|
96b71a0d56
|
feat: transition engine — GL wipes across web, Tizen & Android (+ image↔video) (#204)
Client-side GL Transitions v1 across all three players with never-blank degradation: shader lib + generated manifest + real-WebGL CI, transition-as-widget normalization, persistent WebGL/GLES2 compositors (web/Tizen/native Android), image↔video wipes, SSRF-hardened media proxy, decode-gated image preload, dashboard picker. Fixes the playlist change-fingerprint that dropped transition edits. Alpha + on-device soak validated. |
||
|
|
335681b907
|
feat(directory-board): panel-ring scroll + in-place refresh + per-device frame diagnostic (#203)
Compositor panel-ring board scroll (smooth on Blink+Gecko, no blank-on-refresh), a per-device frame-rate diagnostic widget + dashboard card, and web/Android/Tizen device-id passthrough to widget render URLs. |
||
|
|
af286826dc
|
fix(android): stop zero-duration widget self-loop pegging the main thread (#198)
A solo fullscreen widget (or image) with duration_sec=0 hit an unclamped scheduleAdvance(item.durationSec * 1000L) in PlaylistController.playCurrentItem, scheduling a 0ms auto-advance. For a single-item playlist next() re-selects the same item, so it re-played every looper tick (~20x/sec) — black-screening the TV and locking the UI (couldn't even reach home). Triggered when a schedule collapses the playlist to a single always-on duration-0 widget. Use slotMs() (the max(1, duration||10) contract shared with the web/Tizen players) so a zero/negative duration floors to 10s. Also floor scheduleAdvance() itself to MIN_ADVANCE_MS (500ms) as a backstop so no future path can busy-loop the main thread. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
77322c631a
|
fix(android): show soft keyboard for PIN/URL dialogs over immersive fullscreen (#191)
The hidden-settings PIN box (2x back) and the change-server URL dialog couldn't be typed into on kiosk devices: a plain AlertDialog shown over the player's SYSTEM_UI_FLAG_IMMERSIVE_STICKY activity never gains window focus, so the soft keyboard doesn't attach and the EditText gets no cursor. On a panel/Fire TV with no hardware keyboard that means the PIN can't be entered at all. Pre-existing (showPinDialog unchanged since it was added; immersive flags since the initial release) — surfaces on tier-0 kiosk devices where immersive is active. Fix: shared showImeDialog() applies the standard immersive-dialog IME workaround — mark the dialog NOT_FOCUSABLE before show() (so it doesn't steal focus and reset the activity's immersive flags), mirror the immersive systemUiVisibility onto the dialog, then clear NOT_FOCUSABLE after show() so the IME can attach, force SOFT_INPUT_STATE_ALWAYS_VISIBLE, and requestFocus the field. Routed both the PIN and change-server dialogs through it. Compiles (:app:compileDebugKotlin). IME behavior needs a real tier-0 device to confirm (can't be exercised headless). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
fa31eb9cc3
|
fix(android): reset stuck download backoff on content change + network reconnect (#170) (#190)
Symptom 1's 'stuck on first load, fixed by toggling the playlist' is stale download backoff. On a fresh device the first downloads fail while the link is settling; DownloadCoordinator escalates an exponential backoff (15s..5min cap), and ensure() then SKIPS those items. The 60s playlist refresh re-fires onPlaylistUpdate but ensure() still skips them, and backoff was only ever cleared by forget() (content-delete) — never by a re-assignment. So the item stays stuck until the 5-min window happens to lapse; toggling the playlist is just a manual way to wait it out. Fix (storm-safe — neither reset fires on the routine same-playlist 60s refresh): 1. DownloadCoordinator.resetBackoff(id) / resetAllBackoff() — clear attempts + nextAttemptAt but KEEP inFlight (single-flight preserved, no duplicate .part). 2. onPlaylistUpdate resets backoff for each item ONLY when the content-id signature changed (first load, reassignment, toggle-back), then ensures — so a genuine (re)assignment retries immediately. Same-signature 60s refresh -> no reset -> the retry-storm guard stays intact. 3. Network onAvailable (was onLost-only) -> resetAllBackoff() + requestPlaylistRefresh, so content that failed while the link was settling retries the moment real connectivity arrives. Tests: DownloadCoordinatorTest — resetBackoff/resetAllBackoff re-attempt a backed-off item before the clock advances; existing backoff/single-flight tests unchanged. :app:testDebugUnitTest green. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
bc9e72ec0b
|
fix(content): render YouTube Shorts in 9:16 instead of a landscape frame (#184) (#189)
Vertical Shorts were played in a player forced to 100%x100% on a landscape frame, so they looked wrong (pillarboxed/small). Option A: detect vertical at ingest, persist it, and have every player honor it. - Ingest (routes/content.js): detect a Short from the /shorts/ URL form OR portrait oEmbed dims (oEmbed now queried with the ORIGINAL url so /shorts/ reports its true dimensions), and persist it as st_aspect=vertical on the stored embed URL. That's the only signal players get (remote_url), so it must be captured at ingest, not re-derived per loop. YouTube ignores the unknown param; players read the video id, not the full URL, to build the embed. - Players read st_aspect=vertical and center a 9:16 box (fills a portrait screen, pillarboxes cleanly on landscape) instead of 100%x100%: web (player/index.html), Android (WebViewSupport.youtubeEmbedHtml), Tizen (player.js single-zone + zone paths). Dashboard library uses a static thumbnail, so it's unaffected. Not doing Option B (yt-dlp): runtime dep + storage/bandwidth + maintenance + YouTube ToS; embed-disabled Shorts already skip gracefully. Tests: youtube-shorts.test.js (4) — /shorts/ and portrait-dims tag vertical, landscape stays untagged, /shorts/ tags even if oEmbed fails. Android compiles; web player inline JS + Tizen player.js parse. Note: pre-existing Shorts added before this aren't retagged (would need an oEmbed backfill) — re-add to fix, or a follow-up migration. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
a15086540f
|
feat(widgets): directory-search widget (interactive search of a directory board, live-sync) (#188)
* feat(widgets): add directory-search widget
An interactive, walk-up search view of an existing directory-board. It
references a source board by id (no data copy), so a venue can run the
scrolling board on a main screen and a search view on a tablet, letting
people find an entry instantly.
Server (routes/widgets.js):
- register 'directory-search' + renderDirectorySearch(): resolves the source
board, inlines its categories as one \u003c-guarded JSON blob, renders all
text via textContent (XSS-safe), live case-insensitive filter over
identifier/name/subtitle (debounced), grouped results, available styling,
optional touch on-screen QWERTY keyboard that drives the same filter path.
- missing / non-directory-board source -> friendly full-page fallback, not a 500.
- live-sync while open is out of scope; left a // TODO for a poll hook.
Frontend editor (views/widgets.js): type + magnifier icon, source-board
dropdown (from loaded widgets, filtered to directory-board), title, logo
(reuses the board's picker), placeholder text, theme, on-screen-keyboard toggle;
getConfigFromForm case. i18n: widget.dirsearch.* + type keys in en/es/it/de/pt/fr.
docs: openapi widget_type enum. Tests: server/test/directory-search.test.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(widgets): live-sync for directory-search (poll source board, no reload)
Reflect directory-board edits on an open directory-search page without a reload.
- New public GET /api/widgets/:id/data.json returns { categories } for a
directory-board (404 for missing/wrong-type so the page keeps last-good data
on a transient miss). CORS-open (ACAO:*) + no-store so a null-origin sandboxed
widget iframe can read it; exposes only data already public via /render.
Exempted from CSP + auth in server.js alongside /render.
- directory-search page inlines its source_widget_id and polls the board's
data.json every 30s via a relative URL (works behind a proxy/base path and
from a null-origin iframe). Only rebuilds + rerenders when the data actually
changed, so a mid-search view isn't disturbed; skips while document.hidden;
keeps last-good data on any fetch error. Flatten logic factored into
buildFlat() and reused by the poll.
Tests: data.json feed (categories, CORS header, 404s) + poll wiring in
directory-search.test.js (13/13). Verified live in a browser (page.clock
fast-forward): editing the board updates the search page with no reload.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(android): let player WebViews take touch focus for interactive widgets
directory-search is served through the existing generic widget path
(loadUrl <server>/api/widgets/:id/render), so it already renders on Android
with JS + DOM storage + mixed-content enabled, same-origin (so its live-sync
fetch of the source board's data.json works), and no touch blocking.
Add isFocusable/isFocusableInTouchMode to the shared WebView config so the
search field reliably takes a tap/cursor inside the kiosk lock-task WebView.
Harmless for passive widgets (board/YouTube have no focusable inputs); the
widget's own on-screen keyboard still drives the filter when the system IME
is suppressed. Verified with :app:compileDebugKotlin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
9c70fcc790
|
feat(diagnostics): device incident log — offline cause, network-vs-reboot, display-sleep (#175)
* feat(diagnostics): device incident log — why a screen went offline/black, with device-attested cause
Field request (Bold/s_t_r_o_b_e): "screens go offline randomly — let us see the cause." Answers it
across the fleet with a unified incident log, and — the key insight — lets the DEVICE disambiguate
the cause the server can't: if the app process survived the gap it was a NETWORK problem (not a
reboot), and it can even tell a dropped Wi-Fi/Ethernet link from a link-up-but-server-unreachable
(router/upstream) failure.
Schema:
- device_status_log gains reason + detail (WHY each offline transition happened).
- NEW device_events table (unified incident feed): type (offline/online/display_off/display_on/
crash/reboot/network/app_error) + reason + detail, indexed, age-pruned + per-device capped.
Server:
- Capture the socket.io disconnect REASON (transport_close/ping_timeout/transport_error) instead of
discarding it — recorded in the offline-cause log. devices.offline_reason stays on the EXIT-SIGNAL
contract (crashed/clean_exit/silent) — a separate axis, preserved (violent death = 'silent').
- device:event handler (typed incidents) + device:connectivity-report handler (device-attested).
lib/incident-classify.js (pure, unit-tested) composes reason+detail: cold_start->reboot;
link_lost->network "Wi-Fi/Ethernet link lost"; else network "LAN up, server unreachable
(router/upstream)"; appends SSID / weak-signal (rssi<-75) / IP-changed. On a report it upgrades the
most-recent offline row from the server's guess to the device's ground truth.
- heartbeat timeout -> 'heartbeat_timeout'; retention/cap for device_events.
- Device-detail API returns statusLog.reason/detail + the last 50 device_events.
Device (Android WebSocketService): ConnectivityManager default-network callback (link-lost during a
gap) + Wi-Fi SSID/RSSI + IP snapshot -> device:connectivity-report on reconnect (app survived =>
network); ACTION_SCREEN_ON/OFF receiver -> device:event display_on/off ("screen went black"). All
guarded/feature-detected; no manifest change; compiles clean.
Web + Tizen players: reconnect connectivity-report (link_lost from navigator.onLine during the gap)
+ visibilitychange -> display_off/on. Best-effort (no wifi detail in a browser). Tizen exit-signal
marker slice untouched.
CMS (device-detail): the offline cause on the uptime-timeline hover + a new "Recent incidents" panel
(merged offline periods + typed events, friendly labels, detail, relative time + down-duration).
Built as a 4-way parallel agent fan-out over disjoint domains against a locked contract, then
integrated. Verified: full server suite 443/443 (incl. the seam fix keeping the exit-signal contract
intact), Android compileDebugKotlin clean, all players + CMS node -c clean.
Refs #170.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(diagnostics): internet-reachability probe — split "our server down" from "no internet" (#170)
Follow-up to the incident log: when a device's link is UP but it's offline, "router/upstream" was a
catch-all. The device now probes a public host (1.1.1.1 / 8.8.8.8 :443) DURING the gap, so the cause
pinpoints blame:
- link_lost=true -> Wi‑Fi/Ethernet link lost (device's own link)
- link up, internet_ok=true -> server_down: internet reachable, OUR server was unreachable
- link up, internet_ok=false -> no_internet: router/ISP down
- link up, no probe result -> generic router/upstream (unchanged fallback)
- Android WebSocketService: fire a short daemon-thread TCP probe (443, either host) at disconnect;
the result rides the connectivity-report as internet_ok (omitted if the gap ends before it finishes).
- lib/incident-classify.js: 3-way split on internet_ok; new reasons server_down / no_internet.
- Frontend i18n: device.event.server_down / .no_internet labels.
- Tests: +3 classify cases (server_down, no_internet, link_lost wins over internet_ok). 12/12.
Verified: classify 12/12, Android compileDebugKotlin clean, node -c clean. Refs #170.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(diagnostics): log an 'upgrade' incident (old → new app_version) — server-side (#170)
When a device reports an app_version different from the stored one, applyDeviceInfo logs an
'upgrade' device_events row (detail 'old → new'). Server-side, so it covers Android/Tizen/web with
no client change; a fresh pair (no prior version) isn't counted. Adds device.event.upgrade label.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
837f65e634
|
fix(content+android): rotation-aware media — portrait upright on dashboard AND player (#170) (#172)
* fix(content): rotation-aware media dimensions — portrait no longer stored landscape (#170) Ingest recorded CODED width/height and ignored rotation, so a portrait phone video (coded 1920x1080 + 90° Display-Matrix) or a portrait photo (EXIF orientation 6) was stored LANDSCAPE. The player then rendered it wrong-aspect and letterboxed — the "portrait content degraded + blue bar at the bottom" symptom in #170. The reporter's workaround (pre-rotate + mark Landscape) is exactly what this bug forces. - lib/media-orientation.js (new): pure, unit-tested display-dimension helpers = single source of truth for ingest AND the backfill. videoDisplayDims() reads the modern Display-Matrix side_data rotation (falls back to the legacy tags.rotate, sign-normalized); imageDisplayDims() honors EXIF orientation 5..8. Odd quarter-turns swap W/H. - lib/content-ingest.js: use the helpers for stored dims; add sharp .rotate() so image THUMBNAILS are auto-oriented too (video thumbs were already auto-rotated by ffmpeg). - scripts/backfill-rotation-dims.js (new): idempotent, dry-run-by-default maintenance to correct already-uploaded portrait media (re-probe -> fix dims -> regenerate image thumbs). - test/media-orientation.test.js: 5 bites (tag + Display-Matrix, sign/normalize, EXIF 5..8, the blue-bar landscape->portrait case, null-safety). Scopes #170 to its residual-on-1.9.4 issues; the 1.9.3 "never displays" slice was #162 + the remote_url-null download fix, already shipped in 1.9.4. The slow low-res/orientation- cycling first load is tracked separately in #170 pending repro data. Refs #170. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(android): honor EXIF orientation in ImageLoader so portrait photos render upright (#170) Completes the rotation-aware media fix on the PLAYER side. The server ingest fix (this branch) corrects stored dimensions + auto-orients the thumbnail, but the panel draws the full-res original via BitmapFactory, which ignores EXIF — so a portrait photo (landscape pixels tagged "rotate 90") still rendered sideways on the screen. QA root-cause pass on #170 caught this gap: the Android player reads no stored dims and applied no EXIF. ImageLoader now reads the EXIF orientation (from the file for cached content, from the byte stream for remote_url images — ExifInterface(stream) is API 24+, minSdk is 24) and rotates/ flips the decoded bitmap via a Matrix (all 8 orientations). NORMAL/UNDEFINED is a no-op (no extra allocation); a transformed copy recycles the source; OOM falls back to the source rather than crashing. Videos were already correct (ExoPlayer honors the rotation matrix). Verified: :app:compileDebugKotlin clean. Refs #170. Rides with the server rotation-dims fix on this branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
ef91f644a7
|
feat(system-control): Tier 0/1 controls with no device-owner dependency (#160) (#169)
* feat(system-control): Tier 0/1 controls with no device-owner dependency [#160] Track A of the system-control split (Track B = device owner, shipped in #168). Ships the capabilities that need NO device owner, with graceful per-tier degradation. Capability reporting (keystone): - DeviceInfo now reports can_write_settings / accessibility_enabled / overlay_granted alongside the existing tier/device_owner flags; server persists them (3 additive device columns, older APKs default to 0); dashboard gates controls + shows what's grantable. Android SystemControl (new, all best-effort / no-op when unsupported): - Tier 0 (no permission): media volume (AudioManager STREAM_MUSIC), per-window brightness (WindowManager.LayoutParams.screenBrightness — dims our window only). - Tier 1 (WRITE_SETTINGS): system-wide brightness + screen-off timeout (Settings.System). - Commands set_volume / set_brightness / set_system_brightness / set_screen_timeout wired in MainActivity.onCommand; ALLOWED_COMMANDS extended for the group path. - SetupActivity gains a one-time WRITE_SETTINGS grant row (mirrors the overlay/accessibility grants); manifest declares WRITE_SETTINGS. Dashboard: - device-detail "System control" section (any Android panel): volume + this-app brightness sliders always; system brightness + sleep-timeout only when the panel reports can_write_settings, else a "grant on the panel" hint. Sends on release (not drag). Validated live on a non-owner tier-0 panel: dashboard → set_volume 0.75/0.15 → the panel's STREAM_MUSIC volume moved to 11/2 (of 15). 423 server tests green. Closes #160. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(system-control): volume slider reflects real volume + device-owner brightness/timeout [#160] Two fixes from live testing: 1. Volume "doesn't remember" — the slider hardcoded 50 because the panel never reported its current volume. Now DeviceInfo reports media_volume (0..1); a new lightweight device:info socket event lets the panel re-report right after a set_volume (no full re-register / playlist re-push); server stores devices.media_volume; the dashboard inits the slider from it. Validated: dashboard set_volume 0.60 -> panel STREAM_MUSIC 2->9 (of 15) -> stored 0.60. 2. System brightness/timeout on a DEVICE OWNER — was gated only on WRITE_SETTINGS, which an owner doesn't have. A device owner can set those via DevicePolicyManager.setSystemSetting with no grant, so SystemControl now takes that path when isDeviceOwner(), and the dashboard enables the Tier-1 controls when can_write_settings OR tier===2. STPolicy.setSystemSetting added. deviceSocket device_info UPDATE extracted into applyDeviceInfo(), shared by device:register and device:info. Migration: devices.media_volume REAL (additive). 423 server tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(system-control): brightness/timeout remember + move controls into a tab [#160] Same "remember what it's set to" treatment as volume, now for brightness + sleep timeout, and the System control section moves off the top into its own "Controls" tab. Reporting (DeviceInfo -> device:info re-report -> devices columns -> dashboard slider init): - system_brightness (read from Settings.System, no permission) + screen_off_timeout_ms. - window_brightness: persisted in ServerConfig (survives relaunch, re-applied on MainActivity launch) so the per-window slider reflects it too. - reportInfoNow() now also fires after set_brightness / set_screen_timeout. Dashboard: new "Controls" tab (any Android panel) holding the volume/brightness/timeout controls; every control inits from the reported value; sleep dropdown preselects the current timeout. Server: +3 additive columns (system_brightness, window_brightness, screen_off_timeout_ms); the device_info UPDATE stores them. Migrations all additive/re-runnable. 423 server tests green. Validated live: set_brightness 0.40 -> stored window_brightness 0.40; volume 0.30 -> 0.33. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
501ffb11c1
|
feat(device-owner): tier foundation + QR provisioning + content-expiry & device enhancements (#168)
Device-owner tier substrate + silent install, end-to-end QR provisioning (Android 12+ compliance, APK-derived checksum, URL pre-seed, zero-touch onboarding, guided a11y screen), content-expiry (#157) with player no-restart deferral, and the device-enhancement batch (#10/#12/#13/#14). Backward-compatible with 1.9.3 clients; all autonomous behaviors opt-in. QA + security review green. Closes #161, #157, #159. |
||
|
|
938a43a466
|
Group sync: clock/schedule synchronized playback (offline-native) + double-buffer (#167)
* feat(group-sync): synchronized playback per group (server + Android) [stage 1]
Play a group's shared playlist in lockstep across its displays — start/end items
together — by reusing the video-wall sync primitive with the spatial transform
removed and keyed on group_id instead of wall_id.
Server:
- device_groups += sync_enabled + leader_device_id (optional pin).
- buildPlaylistPayload emits a group_sync:{group_id, is_leader} block ONLY for a
member whose playlist matches the group's shared playlist (playlist-match guard —
a mismatched member is ignored, never synced). Membership via device_group_members.
- Leader auto-election: pinned-if-online -> first online matching member -> stable
fallback; computed (never persisted, so the operator's pin is preserved).
- group:sync / group:sync-request relay among eligible members (mirrors wall:sync,
guarded on membership + playlist match).
- Self-heal: re-push payloads to group members on (re)connect so is_leader refreshes.
- PUT /api/device-groups/:id accepts sync_enabled + leader_device_id and re-pushes.
Android:
- WallController is now mode-aware. WALL = transform + object-fit:fill + forced
follower-mute (UNCHANGED — every wall branch is byte-equivalent when isGroup=false).
GROUP = same leader/follower timing incl. the full video drift controller, but
full-screen (no transform), normal fit, and per-item mute honored (no forced mute).
- WebSocketService: emitGroupSync/emitGroupSyncRequest + onGroupSync/onGroupSyncRequest.
- MainActivity: dispatch emit by mode, parseGroupConfig, onPlaylistUpdate group hook.
Wall path is provably unchanged (additive mode, defaults to WALL). Kotlin compiles;
server suite 407/407.
Stage 2 (follow-up): web + Tizen parity, dashboard sync toggle + leader picker,
offline-sweep leader promotion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): web + Tizen player parity [stage 2]
Mirror the Android group-sync generalization in the two web-based players so a group
with a shared playlist syncs across mixed player types, not just Android.
Web player (server/player/index.html):
- group:sync / group:sync-request handlers (index jump + latency-compensated video
drift, same maths as wall:sync) — no audio policy here, per-item mute honored.
- emitGroupSync() + applyGroupSync() (4Hz leader broadcast; follower sync-request).
NO CSS transform, NO forced mute (unlike applyWallMode).
- isFollower now also true for a group follower (suppresses self-advance).
- handlePlaylistUpdate handles data.group_sync (mutually exclusive with wall).
Tizen (tizen/js/player.js WallController + app.js):
- WallController is mode-aware: WALL styles the slice + forced follower semantics
(UNCHANGED when mode !== 'group'); GROUP clears any wall styling (full-screen) and
drives leader/follower timing only. syncId()/clearStageStyle() helpers; emitSync/
onSync/onSyncRequest key the event + id off the mode.
- app.js: group:sync/request socket handlers; onPlaylist enters group sync on a
group_sync block, else exits — content renders through the normal single-zone path.
Realigned the v4-exit-signal-phase3 TIZEN slice (682->696) shifted by the app.js edits.
Wall path is provably unchanged in both (additive group mode). Server suite 407/407;
both players' JS parse.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): dashboard UI — sync toggle + leader picker [stage 3]
On each group's header row:
- "Sync" checkbox -> PUT /api/groups/:id { sync_enabled } enables synchronized
playback; server re-pushes to members so they enter/exit sync mode. A hint notes
it needs a group playlist and that a display on a different playlist is ignored.
- When on, a "Leader: Auto / <display>" picker -> { leader_device_id } (null = auto-
elect, which self-heals; or pin a specific member to always lead when online).
Adds api.updateGroup(id, data) and the en i18n strings (en-only, mirroring the
existing per-group UI keys; the i18n parity test is apitoken-scoped).
Frontend parses (ESM); server suite 407/407.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): rework to clock/schedule sync + double-buffer + polish
Replace the leader/follower relay model with clock/schedule sync. Every
same-playlist member derives the identical (index, position) locally from a
server-disciplined clock + the deterministic playlist schedule, so sync:
- needs no server at play-time (offline-native), and
- has no leader role to double-elect (kills the split-brain class the leaked
WallController tick produced).
Server
- heartbeat-ack now carries server_ms + echoes client_ms for NTP-style clock
discipline; the client caches the offset (survives an outage).
- POST /groups/:id/resync -> group:resync (manual "Resync now").
- (kept: group_sync payload; leader machinery is now vestigial/ignored.)
Clients (web / Tizen / Android)
- Clock offset disciplined over the heartbeat, cached (localStorage / prefs).
- Schedule engine: pos = (syncedNow mod Sum(duration_sec)) with a CANONICAL
slot formula identical across platforms so mixed-platform groups can't drift.
- Snap-on-load: a fresh clip hard-seeks ONCE to the exact position (was ~5s of
gentle nudge to eat a ~0.3s load offset); steady-state keeps the gentle nudge.
- Double buffer: warm the next clip a few s before the boundary -> instant
switch, no black hold. Android pre-decodes on a throwaway surface so the swap
doesn't flash one wrong-aspect (landscape-stretched) frame.
- In-place duration edits: duration_sec dropped from the change signature and
applied in place, so a duration edit re-anchors the schedule WITHOUT a restart.
- Live-log shows discrete corrections (jump/align/seek) immediately; only the
steady-state line is throttled.
Android
- Fix leaked WallController leader tick: onDestroy() now stops it (Handler on the
main looper outlived the Activity -> zombie broadcaster / split-brain).
Dashboard
- Group leader picker -> "Resync now" button.
Tests: heartbeat-ack clock fields + resync route; exit-signal .wgt slice realigned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|