Added
-
๐ถ A Wi-Fi network can be pushed from FreeKiosk Cloud and saved for later, without the tablet switching to it. For kiosks spread over remote sites, one router whose password changes means a drive to every tablet behind it. The new
add_wifi_networkcloud command stages a network while the tablets are still online, and each tablet falls over to it on its own when its current network goes away.The tablet only ever adds a network under a name it does not already know, and refuses the rest. Android keeps one saved configuration per network name, so a new password for the network in use cannot be staged ahead of time: saving it would replace the credentials the tablet is connected with, and it would drop off at once, before the router had even changed, with a typo making that permanent. So a name that is the current network, or already saved, is refused with an explanation, and so is any case where the saved networks cannot be read, because an empty list and a withheld one look the same. The network is enabled for auto-join without a connection attempt, so the tablet stays where it is. In practice this means a site changing its password should also give the new network a new name, or keep the old one running for a while.
Device Owner only. The command is handled outside the action table the local REST API and MQTT share, so it cannot be triggered from the local network, and the password is never included in the result sent back. This is a separate method from the existing Wi-Fi picker's
connectToNetwork(), which disconnects and forces the switch on purpose: that is right for someone standing at the tablet, and wrong from a dashboard. Asked for by a beta tester running kiosks at several remote sites. -
๐ถ The status bar can show the mobile connection and airplane mode, and a Device Owner can block airplane mode outright (#130, #131). A tablet on a SIM had no way to tell an operator whether it was on mobile data or on nothing at all, and a kiosk that someone had switched to airplane mode looked identical to one with a dead connection. The status bar now carries a cellular item next to WiFi, and the heartbeat reports five fields so the cloud device page shows the same thing next to the WiFi tile.
setAirplaneModeBlocked()applies theDISALLOW_AIRPLANE_MODEuser restriction, which is Device Owner only and Android 9+; it is a restriction rather than a switch flipped at boot, so a user cannot turn airplane mode on for the seconds before the app notices and turns it back off.Three things this deliberately does not do, because the request asked for them and they are not possible or not done. There is no dedicated toggle for the cellular item, unlike battery, WiFi, bluetooth and volume: it appears when the device reports a mobile connection and is otherwise absent, so "configurable status bar" is not satisfied. There is no remote control of the mobile radio and there cannot be:
MODIFY_PHONE_STATEis a system permission that no Device Owner can hold, and Android exposes no scan-and-connect equivalent to the WiFi APIs. And 5G non-standalone reports as 4G, because the anchor really is LTE andTelephonyManagerreports the anchor.READ_PHONE_STATEis declared for the generation and signal strength only, and is stripped from Play Store builds through thesrc/playstoreoverlay: a phone-state permission on a kiosk browser is exactly the kind of thing that costs a review. Connected/disconnected and airplane mode need no permission, so a Play build keeps those. Requested by @krheinwald.
Changed
-
๐๏ธ A release now produces two APKs:
assembleReleasefor the GitHub release, andassembleRelease -Pcloudprovifor the one uploaded to FreeKiosk Cloud. Google Play Protect blocks a sideloaded install outright when the app declares any of four permissions, one of which is an accessibility service, with "App blocked to protect your device / This app can request access to sensitive data". The APK the cloud serves is fetched from a download URL by the Android setup wizard during QR Device Owner provisioning, so it is exactly the case Google blocks: a beta tester hit it mid-wizard on a factory-reset tablet, with no way past it. The new overlay strips the accessibility service and nothing else, unlike-Pplaystore, because a provisioning APK still needsREQUEST_INSTALL_PACKAGESto self-update and to install managed apps silently. Setting both flags fails the build, since they select different overlays.What it costs, on cloud-provisioned devices only: screenshots of an external app in the foreground, and remote key injection into one. Everything else keeps working, since
PixelCopycaptures FreeKiosk's own window anddpm.lockNow()turns the screen off for a Device Owner. The cost is small in practice because a QR-provisioned tablet cannot enable the service anyway: writingSettings.SecureneedsWRITE_SECURE_SETTINGS, which is not grantable to a Device Owner and only ever arrives throughadb shell pm grant(#99), and there is no ADB in a QR flow. The GitHub APK keeps the service for ADB provisioning, where it does work. โ ๏ธ Removing the accessibility service does not guarantee that provisioning succeeds: Play Protect also keeps an allowlist of approved DPCs, a separate axis on which overlays and silent installs also weigh. Reported by a beta tester whose QR provisioning was blocked mid-wizard.
Fixed
-
๐ "Auto Wake on Screen Off" undid every screen-off command, so the screen popped straight back on (#232). Auto-wake exists to bring a tablet back when Android sleeps it. It acted on
ACTION_SCREEN_OFFwith no idea what had caused the screen to go off, andturnScreenOff()calledlockNow()leaving no trace that the screen-off had been asked for. With auto-wake enabled, a screen-off sent over MQTT, REST or the cloud was therefore undone within a second: the two features fought each other, and from the outside the screen simply came back on with nothing in the logs to say why. It also explains why the screensaver was the only thing that appeared to work, since the screensaver is an app overlay that dims the display and never triggers a real screen-off at all.Both turn-off paths now mark the request in the SharedPreferences the receiver already reads, and the receiver skips auto-wake for a screen-off landing within a 5 second grace window, then clears the marker so a genuine system screen-off still wakes as configured.
lockNow()is asynchronous and the broadcast arrives a beat later, so an exact match is not possible; the window is wide enough for a slow device and short enough to leave real auto-wake alone. The marker is set before the branches, since the accessibilityGLOBAL_ACTION_LOCK_SCREENpath locks the screen too; the last-resort path only dims to near-zero brightness and never emitted the broadcast in the first place. Not confirmed to be the reporter's cause, which needs to know whether auto-wake is enabled on his tablet. The behaviour is wrong either way. Reported by @howels. -
๐ A zoom, pinch-to-zoom or keyboard-mode change pushed from the cloud did not reach the page until the next reload. Applying a kiosk profile to a second tablet left the screen zoom and the numeric keyboard exactly as they were, while everything else in the same profile applied. The three of them live in the WebView's
injectedJavaScript, which React Native runs once per page load and never re-runs when the prop changes: the pushed value was written to storage and into React state, the component re-rendered with a new script string, and the mounted page carried on with the old one. Every setting handled natively or through a plain React prop applied immediately, which is exactly why only some of them looked ignored.A signature of the four affected settings (zoom level, zoom mode, disable-user-zoom, keyboard mode) is now kept in a ref, and a config push remounts the WebView only when one of them actually changed. That condition is the point: remounting on every push would reload the page of every kiosk on every cloud sync, and the heartbeat runs every 30 seconds. The signature is seeded on mount so the first push compares against what is on screen rather than against an empty value. โ ๏ธ Remounting reloads the page on the configured URL, because re-running the injected script is the only way to apply these, so changing the zoom from the cloud is visible to whoever is in front of the tablet. Reported by a beta tester who applied one profile to two tablets.
-
๐จ๏ธ The print dialog could be dismissed by the kiosk itself, a second or two after opening (#249). Nothing gets a callback when Android's print dialog closes, so FreeKiosk tracked it with a flag set before opening the dialog and cleared by
MainActivitythe moment the activity regained window focus. Regaining focus is not the dialog closing: on a device that bounces focus while the spooler is opening, the flag was cleared with the dialog still on screen, and the next focus loss then re-entered lock task, which dismisses a system activity.MainActivityalready carried a comment about devices "where onWindowFocusChanged fires rapidly", so the guard rested on a signal known to be unreliable.PrintManager.print()hands back aPrintJob, and the job knows: it stays live while the dialog is up and turns completed, cancelled or failed once the user is done, whatever the window focus did in between.isPrintActiveis now derived from it rather than from focus, with a short "starting" window so the moment between asking to print and holding the job is covered too, and the previous two-minute timeout kept as an upper bound rather than as the mechanism. The focus handler still defers immersive mode, it just no longer clears the flag.This is not confirmed to be the cause of #249, which was reported on a Galaxy Tab A11 running Android 15 and has not been reproduced here: the test that would settle it, printing with Lock Mode off, has not come back yet. The mechanism it replaces was fragile either way, and a dialog protected by the job's own state cannot be dismissed by a focus quirk on any device. Reported by email rather than on GitHub, with a video showing the preview appearing for one to two seconds on every page, which is what ruled out the popup theory the report had started from.
-
๐ The REST API server could serve every endpoint unauthenticated on the local network, with no way of knowing.
KioskHttpServerskips its auth check entirely when no API key is set, which is the right behaviour for someone who deliberately runs without one. ButgetSecureApiKey()caught every failure and returned an empty string, so a key that could not be read was indistinguishable from a key that was never set. On a device whose Android Keystore is broken at the vendor HAL level, every Keychain call throws, and a kiosk whose owner had deliberately set a key came up serving screenshots, navigation, screen control and TTS to anyone on the LAN. Nothing in the app, the settings screen or the logs said so.Reading a secret now reports which of the two happened, and
ApiService.autoStart()fails closed: a key that cannot be read refuses to start the server and says why, instead of starting it open. A deliberately keyless setup is untouched and still starts. Found inside the #258 report by @flxb8, who noticed it while diagnosing something else and had spent time in that state without realising. -
๐ Secure-storage failures were reported as successes, and threw away the value that failed. On firmwares whose Keystore is broken (the same class as #200),
saveSecureMqttPassword()andsaveSecureBasicAuthPassword()returnfalse. Every caller discarded that return value and logged success anyway: the MQTT settings field kept showing the password that had just been typed, the log said it was saved to the Keychain, and only the broker disagreed withNOT_AUTHORIZED. The reporter's words for what that cost: "the app said it worked, the settings screen showed the password, and only the broker log disagreed."Every caller now checks. A failed save is logged as an error and, on the settings screens, raised to the operator instead of leaving a password on screen that was never stored.
clearPendingAdbConfig()is no longer called when a secret in that config failed to save: it used to run unconditionally, so a failed write also discarded the pending value and re-running the same ADB command reproduced the failure with nothing left to retry from.โ ๏ธ The read-back fallback is not extended.
saveSecurePin()recovers from this by storing a hash and salt in AsyncStorage, which works precisely because a PIN is only ever compared, never read back. An MQTT password has to be sent to the broker, so the same fallback would mean writing a recoverable password in clear outside the Keystore, on the one class of device where the secure store is known to be broken. That is a trade worth making deliberately or not at all, so for now the failure is made visible rather than papered over. Reported, diagnosed down to the function and offered a hardware test, by @flxb8. The #200 entry's claim that the PIN is "the only setting stored in the Android Keystore" was wrong:secureStorage.tsdeclares five services, and the same failure reaches all of them. -
๐ท The screensaver dismissed itself seconds after activating, in an empty room (#262). With motion detection on, the screen went dark and woke ~9.2 s later, every cycle, so the device never actually slept. The motion detector takes its reference frame from the first image the camera hands over after starting, and on an external UVC camera that frame is whatever the sensor produced before stabilising: black, green, or half a picture. Comparing a real scene against it exceeds any threshold, every time, whatever the room contains. The reporter established it fired on the first comparison after warm-up regardless of image content, on two FreeKiosk versions.
The first comparison after a reset is now discarded and its frame becomes the new reference, so the comparison that matters is between two stable frames. That costs one extra frame of latency when detection starts and nothing afterwards, which is why it was preferred to requiring two consecutive detections: that would have doubled the latency of every real detection for ever, on a feature whose job is to wake the screen as someone approaches. Reported, with the ruled-out list that made it diagnosable, by @WHISTLER-Arc.
-
๐งท One refused heartbeat erased a tablet's whole configuration, PIN and cloud enrolment, for good. A 401 or 403 on the heartbeat was taken to mean "this device was removed from the cloud" and wiped everything on the spot: every setting, every secret including the PIN, and the credentials themselves. The app cannot tell that case apart from any other refusal, and the wipe is irreversible, so the tablet then held no credentials, could never reconnect, and stayed that way through a reboot. Recovering it took someone standing next to it with a fresh token. The beta server's logs show four devices whose last request ever was a single 401, one of them after more than 5,700 heartbeats.
A refusal now has to persist, across at least five attempts and thirty minutes, before the device gives up; until then it keeps its credentials and keeps trying, so a transient refusal recovers on its own with nothing lost. A device genuinely deleted from the dashboard is refused on every beat and unenrols once the window elapses. The explicit path,
force_unenrollin an accepted heartbeat, still unenrols immediately. The window is persisted, so the headless heartbeat that runs behind an external app, which can start in a fresh JS context, counts toward the same one. Reported by a beta tester running kiosks at remote sites, for whom every occurrence meant a drive. -
๐ก A QR-provisioned tablet could become Device Owner and never enrol, with nothing saying so. Zero-touch provisioning has two halves: Android makes FreeKiosk the Device Owner, then FreeKiosk enrols with the cloud using the token the QR carried. The second half was attempted once per app launch. A network error kept the token for "the next launch", but a Device Owner kiosk locked in lock task is never relaunched, so one attempt made before Wi-Fi was usable left the tablet on the welcome screen for good, with a live connection to the server and an unused token on the dashboard. A network error now retries every 30 seconds until the token is consumed or refused, and the welcome screen shows where enrolment stands: connecting, retrying and why, or refused with the server's reason and what to do instead. Reported by a beta tester whose tablet sat in that state until he entered a token by hand.
-
๐ "Please enter a password" named neither the password nor where it was. Save can be pressed from any settings tab and the PIN lives on General, so an operator saving from Display got a dialog with no field and no location. It now says General > Password and offers to go there. The empty PIN field no longer shows
1234as its placeholder either, which, greyed, read as a PIN already set. Reported by a beta tester. -
๐ The settings screen kept showing old values after a config push from the cloud. It loads once when opened, so a push arriving while it was open looked like a push that had failed. It now says a new configuration arrived and offers to reload. It asks rather than reloading on its own, because the screen does not track unsaved edits and reloading would discard whatever the operator was typing. Reported by a beta tester.
Documentation
- ๐
The screen scheduler rule format is documented, in
docs/adb-configuration.md.screen_scheduler_ruleswas listed as "a JSON array" and nothing else, so a beta tester had to build a rule in the app and read back what the tablet reported to find out what one looks like. The page now gives every field, the day numbering (0= Sunday), the time format, and the rule nobody could guess: whensleepTimeis later thanwakeTimethe window runs overnight anddaysnames the evening it starts, so[1,2,3,4,5]leaves the screen on all weekend.