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
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.
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.
* 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>
Client-side fixes to the Android signage player, all validated end-to-end on a
Pixel-10 emulator (Android 16) against the alpha server.
- content download: a local item with "remote_url": null was mis-tagged as a
remote stream (org.json optString returns the STRING "null" for a JSON null),
so it was ack'd "ready" and NEVER downloaded — stranding the screen on
"waiting for content" and only ever playing 1 of N files. Guard with isNull().
- playback (#162): PlaylistController trusted isRunning+currentIndex as "already
playing" and never re-called playItem, permanently stranding a panel on
"waiting for content" after a restart/OTA/content-not-ready-at-first-start.
Guards now require hasContentOnScreen (a genuine render) before short-circuiting.
- provisioning: revert to the URL-entry screen if a connect attempt hangs >60s
(wrong/unreachable URL) instead of an endless "Connecting to server…".
- re-pair: a server rejection (device:unpaired / auth-error) left the device
connected-but-unregistered with no pairing code (stuck); a naive re-register
then stormed the #150 reclaim guard ~20x/s. Now: re-register once, debounced +
backed off; honor the reclaim-settle window with a stable "re-pairing available
in Xs" countdown; show the code only once the server accepts it (isPairingCodeLive).
- status: a fully-online device could sit on a stale "Connecting to server…" when
MainActivity was relaunched (CLEAR_TASK) after the service already registered —
it now pulls a fresh playlist on bind so the real state renders.
- setup: add a Default Launcher (HOME role) step so a kiosk can be set as the
default launcher without adb (prevents ~45s activity-recreate churn).
- debug: new DebugLog.v() streams the deep download/playback trace only while
live dashboard debug is enabled; silent in production.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The player has a launcher (category.HOME) + a boot receiver, but auto-start was
unreliable where you can't set a home launcher (Android TV) and on Android 14+,
where USE_FULL_SCREEN_INTENT is auto-revoked for non-calling apps so the boot
full-screen launcher silently no-ops.
Boot launch:
- BootReceiver now does a direct background startActivity when 'display over other
apps' (SYSTEM_ALERT_WINDOW) is granted — a real exception to the bg-activity-launch
restriction, and the one path that works on Android TV. Full-screen-intent
notification kept as a fallback (locked screen / no overlay).
- Boot notification moved to a dedicated HIGH-importance channel (full-screen
intents are only honored from one), and it auto-dismisses once the UI is up.
Setup screen — new permission rows so operators can grant what boot-launch needs:
- Launch on Boot (USE_FULL_SCREEN_INTENT, shown on Android 14+)
- Background Activity (battery-optimization exemption)
- Display Over Apps (SYSTEM_ALERT_WINDOW)
Made the screen scrollable and ~50% smaller text/buttons so all rows + Continue
fit on one screen (incl. landscape signage). Install-Unknown-Apps subtitle now
states updates are signature-verified, so it doesn't read as 'install anything'.
Verified end-to-end on an Android 16 emulator: after reboot the app auto-launched
(Direct launch via overlay) and the boot notice cleared itself; all rows toggle.
ScreenTinker - open source digital signage management software.
MIT License, all features included, no license gates.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>