mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Merge branch 'feat/report-lan-ip-and-optional-ssid'
This commit is contained in:
commit
40035533e5
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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). -->
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) }));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue