Added
-
🖧
--es cloud_tokennow enrols a device from the ADB command line. The cloud's "Headless install (ADB)" snippet on the Add Device page has advertised--es cloud_token "TOKEN"since the cloud shipped, but no build ever read the extra: the intent only stored the PIN, so a scripted enrolment silently did nothing and the operator had no way to tell. The extra is now recognised and written to the same store the setup-wizard QR feeds (FreeKioskCloudEnrollment), so it is consumed by the existingCloudSyncService.consumePendingProvisioningEnrollmenton first launch with nothing added on the JS side.--es cloud_urlcomes with it, optional, defaulting tohttps://cloud.freekiosk.app. That default is not cosmetic: the consumer returns early when the stored URL is empty, so a token on its own would have been stored and then ignored, which is the same silent failure in a new place. Passing the URL explicitly is what a self-hosted instance will need. The token joinspinand the MQTT credentials inADB_SENSITIVE_KEYS, so it is masked in the ADB logging rather than printed to logcat. Not verified on a device: it compiles, and the store and its consumer are the ones the QR path already exercises. What needs checking is a realam startwith a fresh token ending on an enrolled device. Reported by a beta tester who found the page advertising a flag that did not exist. -
🔄 A long-press button can now restart the WebView from the kiosk itself. Nothing on a locked device could recover a hung or stale page: the person standing in front of the screen has no access to Settings, so a dashboard that lost its WebSocket and stopped updating meant restarting FreeKiosk or rebooting the tablet, neither of which is practical on an unattended kiosk. A grey circular button in the top-right corner of WebView mode now fills a progress ring while it is held and restarts the WebView once the hold completes. A short tap does nothing, so a stray touch or a passer-by cannot trigger it. The hold duration is configurable from 1 to 10 seconds (5 by default) under
Settings > General, and the ring is drawn with two clipped, rotated half-discs rather than by adding an SVG dependency. The button is hidden on the dashboard grid, where no WebView is mounted. Contributed by @paolo-lussignoli in #224, who also opened the request as #223. -
📸 Screenshots and camera snapshots are published over MQTT, and auto-discovered by Home Assistant (#182). Both were reachable only through the REST API, which meant hand-written
camera:YAML with astill_image_urland the embedded HTTP server exposed on the network just to see a tablet's screen. Each enabled stream now publishes a JPEG on its own topic ({base}/{topicId}/image/screenshot,.../image/camera_front,.../image/camera_back) and advertises animageentity, acameraentity fed by the same topic (the first reads well in Markdown cards and notifications, the second inpicture-glance), a capture button, an auto-publish switch and an interval number. One set per camera actually present on the device, so a tablet with no front sensor advertises no front entities. Seven new commands come with it, fromscreenshot_capturetocamera_interval, and disabling a stream clears both its discovery config and its retained image so nothing stale is left on the broker.Everything is off by default and nothing is captured until it is switched on in
Settings > MQTT. Captures and publishing are fully native, on a dedicated capture thread: that is deliberate, because bothScreenCaptureandCameraPhotoModuleblock on latches while MQTT commands arrive on the main thread, and because it keeps working while the JS thread is suspended, the same reasoning as the existingscreenOnandttshandling. ⚠️ Worth reading before enabling it: a screenshot publishes whatever the kiosk displays, a camera snapshot publishes whoever is in front of it, images are retained on the broker, and TLS is only used when the broker port is 8883. The MQTT documentation now says so and suggests the broker ACL to match. Contributed, and tested end to end against Mosquitto and Home Assistant on a Xiaomi tablet under Android 16, by @cocoke in #225.Four defects were fixed on top of the contribution during review, none of which surfaced in that run.
stop()interrupted the capture thread withshutdownNow(), andCameraPhotoModulecloses itsCameraDeviceonly after the latch it waits on, so an MQTT disconnect during a camera capture left the camera held by the process, breaking motion detection and every later snapshot until a restart.restartTimers()was not synchronized while its runnables re-post themselves, so two interleaved calls could leave two timer chains publishing the same stream for ever. An unparseable interval payload was clamped up to the 5-second minimum instead of being ignored, putting the device on the fastest capture loop on a typo. And the nine new settings keys were missing fromclearAll(), so camera publishing survived a full settings reset. Not verified on hardware past what the contributor measured: the screen-off case in particular is expected rather than tested, and the periodic camera path has not been left running for long on a battery-powered device.Two decisions worth naming, both taken after the merge. The button is off by default: it draws a visible control over the kiosk page and lets anyone in front of the screen restart it, so it is opt-in like every other WebView overlay. And it remounts the WebView rather than calling
reload()on it, so it restarts on the configured URL instead of reloading whichever page the user had navigated to. That is deliberate rather than an oversight: the case this was asked for is a page that has stopped responding, where a remount also recovers a dead render process and areload()can go unanswered. The settings text says so instead of promising a reload of the current page. ⚠️ Two bounds are known and not yet addressed. The position is fixed at the top-right corner, so the 50x50 dp square it occupies is no longer clickable on the page underneath, which matters for a web app whose own controls sit there (the WebView back button, by contrast, is positionable in percent). And the setting has no ADB key, so a fleet provisioned by script cannot switch it on. Not verified on hardware here:tscis clean,npm testpasses and ESLint reports no new finding, but nothing was run on a device on our side. The contributor reports testing the interaction on a Samsung Galaxy Active 5 running Android 16.
Fixed
-
🛡️ The permission wizard told a Device Owner that everything was granted automatically, which is not true of two of them: the banner read "Managed mode (Device Owner). Permissions are granted automatically; you can finish right away.", so an operator provisioning a fleet closed it without granting anything. Display over other apps and Usage access are appops rather than runtime permissions, and Android exposes no way for a Device Owner to grant them to itself (
setPermissionGrantStatecovers dangerous runtime permissions only). Without the overlay the blocking overlay and the secret tap zone never appear, so on a QR-provisioned device there was no way out of an external app; without usage access, external-app mode cannot detect and relaunch the foreground app. The banner now names those two and says why they still need a tap each. The wizard already listed both with a working Grant button that deep-links to the right system screen, so nothing else had to change.This does not make the rollout hands-off, it makes the wizard honest: those two taps per tablet remain. An exit path that does not depend on the overlay while an external app is in front would remove them, and that is design work rather than a fix. Reported by a beta tester provisioning tablets for external-app kiosks.
-
📌 A QR-provisioned device landed on the stock launcher, so FreeKiosk never started: the wizard completed and the enrollment token was stored, but Home pinning threw and the tablet booted to the system launcher with FreeKiosk installed and idle, which costs the zero-touch enrollment its trigger.
DeviceAdminReceiver.ktcarriedimport android.app.admin.DeviceAdminReceiveralongside its ownclass DeviceAdminReceiver. In Kotlin an explicit import outranks a same-package declaration, so everyDeviceAdminReceiver::class.javain that one file resolved to the framework class, andpinHomeLauncherhandedDevicePolicyManagera component namedcom.freekiosk/android.app.admin.DeviceAdminReceiver, which it rejected:SecurityException: Admin ComponentInfo{...} does not exist or is not owned by uid.KioskModulebuilds the same component and always worked, because no other file in the project carries that import, which is why pinning succeeded as soon as the app was opened by hand. The supertype is now spelled out inline instead of imported, so the name resolves locally; verified in the bytecode, where the class literal iscom/freekiosk/DeviceAdminReceiverrather than the framework one.This defect is older than the 2.0.0-beta.2 provisioning fix and survived it untouched. It only became visible because beta.2 replaced an empty
catch {}with a real log line, which is the whole argument for not swallowing exceptions. Diagnosed down to the shadowed import, with the log line and the fix, by a beta tester provisioning a fleet of Galaxy Tab S10 FE.