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.
This commit is contained in:
ScreenTinker 2026-07-29 21:57:59 -05:00
parent 54f1f62762
commit 275e1683b8
14 changed files with 177 additions and 10 deletions

View file

@ -9,6 +9,14 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- OPTIONAL, and never requested at startup. Android 8.1+ hides the connected Wi-Fi network
name from apps without location permission, so the device page can only show "unavailable"
without it. A signage player should not demand location to display a network name, so this
is opt-in from the setup screen and nothing else depends on it: not granting it changes
only that one field. Coarse is enough below Android 10; fine is required from 10 onwards. -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

View file

@ -160,6 +160,18 @@ class SetupActivity : AppCompatActivity() {
// Default launcher / HOME: a kiosk MUST be the default launcher, else Android returns to the
// stock launcher and tears down + recreates the player on a loop (it never renders). Request
// the HOME role (clean system dialog on API 29+); fall back to the Home-app picker in Settings.
// OPTIONAL: location, solely so the device page can show the Wi-Fi network name. Requested
// only when someone taps this row — never at startup, and nothing else in the player depends
// on it. Once granted (or permanently denied) requestPermissions() stops prompting, so an
// already-answered row sends you to app settings where it can be changed either way.
findViewById<Button>(R.id.enableLocationBtn).setOnClickListener {
if (hasLocationPermission()) openAppSettings()
else ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
101
)
}
findViewById<Button>(R.id.enableLauncherBtn).setOnClickListener { promptSetDefaultLauncher() }
// Launch-on-boot needs USE_FULL_SCREEN_INTENT, which Android 14+ auto-revokes
@ -306,6 +318,13 @@ class SetupActivity : AppCompatActivity() {
writeSettingsStatus.setTextColor(if (canWrite) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
bindPermissionButton(enableWriteSettingsBtn, canWrite, "Enable")
// Optional Wi-Fi-name permission
val hasLoc = hasLocationPermission()
val locationStatus = findViewById<TextView>(R.id.locationStatus)
locationStatus.text = if (hasLoc) "ON" else "OFF"
locationStatus.setTextColor(if (hasLoc) 0xFF22C55E.toInt() else 0xFF64748B.toInt())
bindPermissionButton(findViewById(R.id.enableLocationBtn), hasLoc, "Enable")
// Default launcher (HOME): kiosk foreground stability requires being the default launcher.
val isDefaultHome = isDefaultLauncher()
val launcherStatus = findViewById<TextView>(R.id.launcherStatus)
@ -318,6 +337,11 @@ class SetupActivity : AppCompatActivity() {
continueBtn.text = if (allGood) "Continue to Setup" else "Continue Anyway"
}
/** Either location permission is enough for the SSID; coarse suffices below Android 10. */
private fun hasLocationPermission(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
private fun isDefaultLauncher(): Boolean {
// Ask the SAME authority the action uses. This used to read resolveActivity(MATCH_DEFAULT_ONLY),
// which can name us when we are merely a HOME candidate rather than the chosen home app — so

View file

@ -30,6 +30,11 @@ class DeviceInfo(private val context: Context) {
put("ram_total_mb", getRamTotalMB())
put("cpu_usage", getCpuUsage())
put("wifi_ssid", getWifiSSID())
// The screen's OWN address on the network. The server separately records the PUBLIC
// address it sees the connection from; showing only that had customers reading their
// ISP's address as their screen's IP. Needs no permission — read straight off the
// interfaces, so it works on Ethernet panels too, not just Wi-Fi.
put("local_ip", getLocalIp() ?: JSONObject.NULL)
put("wifi_rssi", getWifiRSSI())
put("uptime_seconds", getUptimeSeconds())
// #74/#75: OS timezone + UTC clock (effective-tz resolution + dashboard skew indicator)
@ -168,17 +173,50 @@ class DeviceInfo(private val context: Context) {
}
}
/**
* The connected Wi-Fi network name, or a value saying WHY we do not have it.
*
* Android 8.1+ hides the SSID from apps without location permission, returning the literal
* "<unknown ssid>". We report "Unknown" for that, which reads as a fault in the player a
* customer reasonably assumed it needed device-owner access. It needs LOCATION, which this app
* deliberately does not require: a signage player asking for location to display a network name
* is a poor trade. It can be granted from the setup screen if someone wants the field filled in.
*
* So: "permission" when we are not allowed to know, null when there is genuinely no Wi-Fi (an
* Ethernet panel), and the name otherwise. The dashboard can then say something true.
*/
@Suppress("DEPRECATION")
private fun getWifiSSID(): String {
private fun getWifiSSID(): String? {
return try {
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
val info = wm.connectionInfo
info.ssid?.replace("\"", "") ?: "Unknown"
val raw = wm.connectionInfo?.ssid?.replace("\"", "")
when {
raw.isNullOrEmpty() -> null
// What the platform hands back when location is missing or switched off.
raw.equals("<unknown ssid>", ignoreCase = true) || raw == "0x" -> "permission"
else -> raw
}
} catch (e: Exception) {
"Unknown"
null
}
}
/** First non-loopback IPv4 on any up interface (Wi-Fi or Ethernet). No permission needed. */
private fun getLocalIp(): String? = try {
var found: String? = null
val ifaces = java.net.NetworkInterface.getNetworkInterfaces()
while (ifaces != null && ifaces.hasMoreElements() && found == null) {
val iface = ifaces.nextElement()
if (!iface.isUp || iface.isLoopback) continue
val addrs = iface.inetAddresses
while (addrs.hasMoreElements()) {
val addr = addrs.nextElement()
if (!addr.isLoopbackAddress && addr is java.net.Inet4Address) { found = addr.hostAddress; break }
}
}
found
} catch (e: Throwable) { null }
@Suppress("DEPRECATION")
private fun getWifiRSSI(): Int {
return try {

View file

@ -446,6 +446,66 @@
android:paddingBottom="4dp" />
</LinearLayout>
<!-- OPTIONAL: Wi-Fi network name. Android 8.1+ will not tell an app the connected SSID
without location permission, so the device page shows "unavailable" without this.
Nothing else in the player uses location, and nothing else changes if it is refused —
this row exists so it is a choice rather than a silent blank field. -->
<LinearLayout
android:id="@+id/locationRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="5dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Wi-Fi Name (optional)"
android:textColor="#F1F5F9"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Android needs location permission to reveal the network name. Nothing else uses it."
android:textColor="#64748B"
android:textSize="8sp" />
</LinearLayout>
<TextView
android:id="@+id/locationStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="OFF"
android:textColor="#EF4444"
android:textSize="9sp"
android:textStyle="bold"
android:layout_marginEnd="12dp" />
<Button
android:id="@+id/enableLocationBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="Enable"
android:textColor="#FFFFFF"
android:textSize="9sp"
android:background="@drawable/button_primary"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="4dp"
android:paddingBottom="4dp" />
</LinearLayout>
<!-- Default launcher / HOME. A signage kiosk MUST be the device's default launcher, or Android
keeps returning to the stock launcher and the player is torn down + recreated on a loop
(never renders). Not applicable where you can't set a launcher (e.g. some Android TV). -->

View file

@ -259,6 +259,8 @@ export default {
'device.playlist_picker.with_auto': '{name} (auto) — {n} Elemente',
'device.info.status': 'Status',
'device.info.ip_address': 'IP-Adresse',
'device.info.local_ip': 'Lokale IP',
'device.info.wifi_needs_location': 'Standortberechtigung erforderlich',
'device.info.battery': 'Akku',
'device.info.storage': 'Speicher',
'device.info.size_free': '{size} frei',

View file

@ -465,6 +465,8 @@ export default {
// Info cards
'device.info.status': 'Status',
'device.info.ip_address': 'IP Address',
'device.info.local_ip': 'Local IP',
'device.info.wifi_needs_location': 'Needs location permission',
'device.info.battery': 'Battery',
'device.info.storage': 'Storage',
'device.info.size_free': '{size} free',

View file

@ -289,6 +289,8 @@ export default {
'device.playlist_picker.with_auto': '{name} (auto) — {n} elementos',
'device.info.status': 'Estado',
'device.info.ip_address': 'Dirección IP',
'device.info.local_ip': 'IP local',
'device.info.wifi_needs_location': 'Requiere permiso de ubicación',
'device.info.battery': 'Batería',
'device.info.storage': 'Almacenamiento',
'device.info.size_free': '{size} libres',

View file

@ -259,6 +259,8 @@ export default {
'device.playlist_picker.with_auto': '{name} (auto) — {n} éléments',
'device.info.status': 'Statut',
'device.info.ip_address': 'Adresse IP',
'device.info.local_ip': 'IP locale',
'device.info.wifi_needs_location': 'Autorisation de localisation requise',
'device.info.battery': 'Batterie',
'device.info.storage': 'Stockage',
'device.info.size_free': '{size} libres',

View file

@ -275,6 +275,8 @@ export default {
// Info cards
'device.info.status': 'Stato',
'device.info.ip_address': 'Indirizzo IP',
'device.info.local_ip': 'IP locale',
'device.info.wifi_needs_location': 'Richiede permesso di posizione',
'device.info.battery': 'Batteria',
'device.info.storage': 'Archiviazione',
'device.info.size_free': '{size} liberi',

View file

@ -259,6 +259,8 @@ export default {
'device.playlist_picker.with_auto': '{name} (auto) — {n} itens',
'device.info.status': 'Status',
'device.info.ip_address': 'Endereço IP',
'device.info.local_ip': 'IP local',
'device.info.wifi_needs_location': 'Requer permissão de localização',
'device.info.battery': 'Bateria',
'device.info.storage': 'Armazenamento',
'device.info.size_free': '{size} livres',

View file

@ -5,6 +5,16 @@ import { esc, livenessBadge, hydrateAuthImages } from '../utils.js';
import { t, tn } from '../i18n.js';
import { showDeviceOwnerQRModal } from '../components/device-owner-qr-modal.js';
// The player distinguishes three cases for the Wi-Fi name, because "--" was hiding a real
// answer: Android 8.1+ refuses to reveal the SSID to an app without location permission, and a
// customer reasonably read the blank as a bug in the player. "permission" means we are not
// allowed to know; empty means there is genuinely no Wi-Fi (an Ethernet panel).
function ssidLabel(ssid) {
if (ssid === 'permission') return esc(t('device.info.wifi_needs_location'));
if (!ssid) return '--';
return esc(ssid);
}
let currentDevice = null;
let statusHandler = null;
let screenshotHandler = null;
@ -302,6 +312,13 @@ async function loadDevice(deviceId, activeTab = null) {
<div class="info-card-label">${t('device.info.ip_address')}</div>
<div class="info-card-value small">${device.ip_address || '--'}</div>
</div>
<div class="info-card">
<!-- Two different addresses, and conflating them confused a customer into reading their
ISP's address as the screen's. Above is where the connection comes FROM (public);
this is what the screen calls itself on its own network. -->
<div class="info-card-label">${t('device.info.local_ip')}</div>
<div class="info-card-value small" id="telLocalIp">${device.local_ip || '--'}</div>
</div>
${device.android_version && !device.android_version.startsWith('Web/') ? `
<div class="info-card">
<div class="info-card-label">${t('device.info.battery')}</div>
@ -330,7 +347,7 @@ async function loadDevice(deviceId, activeTab = null) {
${device.android_version && !device.android_version.startsWith('Web/') ? `
<div class="info-card">
<div class="info-card-label">${t('device.info.wifi')}</div>
<div class="info-card-value small" id="telWifi">${latestTelemetry.wifi_ssid || '--'}</div>
<div class="info-card-value small" id="telWifi">${ssidLabel(latestTelemetry.wifi_ssid)}</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:2px" id="telRssi">${latestTelemetry.wifi_rssi ? latestTelemetry.wifi_rssi + ' dBm' : ''}</div>
</div>
` : ''}
@ -1868,7 +1885,8 @@ function updateTelemetryDisplay(telemetry) {
};
if (telemetry.battery_level != null) update('telBattery', telemetry.battery_level + '%');
if (telemetry.storage_free_mb) update('telStorage', t('device.info.size_free', { size: formatBytes(telemetry.storage_free_mb) }));
if (telemetry.wifi_ssid) update('telWifi', telemetry.wifi_ssid);
if (telemetry.wifi_ssid !== undefined) update('telWifi', ssidLabel(telemetry.wifi_ssid));
if (telemetry.local_ip) update('telLocalIp', telemetry.local_ip);
if (telemetry.wifi_rssi) update('telRssi', telemetry.wifi_rssi + ' dBm');
if (telemetry.uptime_seconds) update('telUptime', formatUptime(telemetry.uptime_seconds));
if (telemetry.ram_free_mb) update('telRam', t('device.info.size_free', { size: formatBytes(telemetry.ram_free_mb) }));

View file

@ -348,6 +348,10 @@ const migrations = [
// boot, so an `IS NULL` backfill would silently swallow the first alert of any outage
// that began since the last restart. The one-time backfill is below, in schema_migrations.
"ALTER TABLE devices ADD COLUMN offline_alert_heartbeat INTEGER",
// The device's OWN address on the local network, reported by the player. devices.ip_address is
// the PUBLIC address the server sees the connection arrive from — both are useful and they are
// not the same thing. A customer reading the public IP as "my screen's IP" prompted this.
"ALTER TABLE device_telemetry ADD COLUMN local_ip TEXT",
// Backfill a unique 6-digit PIN for already-paired devices that predate the
// settings_pin column (their next reconnect re-sends device:paired with it, so
// the existing fleet isn't locked out of the on-device menu). Idempotent: the

View file

@ -22,7 +22,7 @@ router.get('/', (req, res) => {
const devices = db.prepare(`
SELECT d.*,
t.battery_level, t.battery_charging, t.storage_free_mb, t.storage_total_mb,
t.ram_free_mb, t.ram_total_mb, t.wifi_ssid, t.wifi_rssi, t.uptime_seconds,
t.ram_free_mb, t.ram_total_mb, t.wifi_ssid, t.wifi_rssi, t.uptime_seconds, t.local_ip,
t.cpu_usage,
s.filepath as screenshot_path, s.captured_at as screenshot_at,
u.email as owner_email, u.name as owner_name

View file

@ -985,8 +985,8 @@ module.exports = function setupDeviceSocket(io) {
if (telemetry && deviceExists(device_id)) {
db.prepare(`
INSERT INTO device_telemetry (device_id, battery_level, battery_charging, storage_free_mb, storage_total_mb,
ram_free_mb, ram_total_mb, cpu_usage, wifi_ssid, wifi_rssi, uptime_seconds)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ram_free_mb, ram_total_mb, cpu_usage, wifi_ssid, wifi_rssi, uptime_seconds, local_ip)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
device_id,
telemetry.battery_level ?? null,
@ -998,7 +998,10 @@ module.exports = function setupDeviceSocket(io) {
telemetry.cpu_usage ?? null,
telemetry.wifi_ssid ?? null,
telemetry.wifi_rssi ?? null,
telemetry.uptime_seconds ?? null
telemetry.uptime_seconds ?? null,
// Device-supplied text headed for a column the dashboard renders: trim and cap it.
// 45 chars is the longest legitimate value (a full IPv6 address).
typeof telemetry.local_ip === 'string' ? telemetry.local_ip.trim().slice(0, 45) || null : null
);
pruneTelemetry(device_id);