Merge branch 'feat/report-lan-ip-and-optional-ssid'
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run

This commit is contained in:
ScreenTinker 2026-07-29 21:57:59 -05:00
commit 40035533e5
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);