From 58f27d56e8879a0a0b3eccc33f4989d86d8c3982 Mon Sep 17 00:00:00 2001 From: BlazzzPlay Date: Thu, 9 Jul 2026 19:05:24 -0400 Subject: [PATCH] fix(android): server-provisioned settings PIN replaces hardcoded 0000 - Remove stray brace that broke compilation (MainActivity line 985) - Server generates unique 6-digit PIN per device during pairing - PIN stored in encrypted SharedPreferences (ServerConfig.settingsPin) - Fallback: generate random PIN locally if server doesn't send one - Include settings_pin in device:paired on pair + reconnect - DB migration: settings_pin column on devices table - Hint changed from hardcoded 0000 to generic 'PIN' string --- .../java/com/remotedisplay/player/MainActivity.kt | 10 ++-------- .../com/remotedisplay/player/data/ServerConfig.kt | 14 ++++++++++++++ .../player/service/WebSocketService.kt | 7 +++++++ android/app/src/main/res/values/strings.xml | 1 + server/db/database.js | 3 +++ server/db/schema.sql | 1 + server/server.js | 9 ++++++--- server/ws/deviceSocket.js | 8 ++++++-- 8 files changed, 40 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt index 361e42b..819c09a 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -77,8 +77,6 @@ class MainActivity : AppCompatActivity() { private val backTapTimes = mutableListOf() private var backTapRunnable: Runnable? = null private val TAP_WINDOW_MS = 1800L - private val DEFAULT_SETTINGS_PIN = "0000" - private val PREF_SETTINGS_PIN = "settings_pin" // Connection-failure auto-prompt threshold. private var failureBannerShown = false @@ -852,12 +850,9 @@ class MainActivity : AppCompatActivity() { } private fun showPinDialog() { - val prefs = getSharedPreferences("remote_display", MODE_PRIVATE) - val storedPin = prefs.getString(PREF_SETTINGS_PIN, DEFAULT_SETTINGS_PIN) ?: DEFAULT_SETTINGS_PIN - val input = EditText(this).apply { inputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD - hint = DEFAULT_SETTINGS_PIN + hint = getString(R.string.settings_pin_hint) setSingleLine() } val container = FrameLayout(this).apply { @@ -870,7 +865,7 @@ class MainActivity : AppCompatActivity() { .setTitle(getString(R.string.settings_pin_title)) .setView(container) .setPositiveButton(android.R.string.ok) { _, _ -> - if (input.text.toString() == storedPin) { + if (input.text.toString() == config.settingsPin) { showSettingsDialog() } else { Toast.makeText(this, getString(R.string.settings_pin_wrong), Toast.LENGTH_SHORT).show() @@ -982,7 +977,6 @@ class MainActivity : AppCompatActivity() { .setNegativeButton(android.R.string.cancel, null) .show() } - } private fun navigateToProvisioning(url: String? = null) { try { wsService?.disconnect() } catch (_: Exception) {} diff --git a/android/app/src/main/java/com/remotedisplay/player/data/ServerConfig.kt b/android/app/src/main/java/com/remotedisplay/player/data/ServerConfig.kt index 58fb9c0..e70b2e3 100644 --- a/android/app/src/main/java/com/remotedisplay/player/data/ServerConfig.kt +++ b/android/app/src/main/java/com/remotedisplay/player/data/ServerConfig.kt @@ -41,6 +41,20 @@ class ServerConfig(context: Context) { get() = prefs.getString("device_name", "Unnamed Display") ?: "Unnamed Display" set(value) = prefs.edit().putString("device_name", value).apply() + // Provisioned by the server during pairing — a unique 6-digit PIN for the hidden + // settings menu on each device. If the server doesn't send one (backward compat), + // generate a random PIN locally on first access so every device still has a unique gate. + var settingsPin: String + get() { + val stored = prefs.getString("settings_pin", null) + if (stored != null) return stored + // First access, no server-provided PIN — generate one locally + val generated = (100000..999999).random().toString() + prefs.edit().putString("settings_pin", generated).apply() + return generated + } + set(value) = prefs.edit().putString("settings_pin", value).apply() + val isProvisioned: Boolean get() = deviceId.isNotEmpty() && serverUrl.isNotEmpty() diff --git a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt index 879a320..cb20727 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt @@ -215,6 +215,13 @@ class WebSocketService : Service() { val name = data.optString("name", "Display") config.setPaired(true) config.deviceName = name + // Server-provisioned settings PIN — unique per device, stored encrypted. + // If the server doesn't send one (old server), ServerConfig generates a + // random PIN on first access so the gate is never left with a hardcoded default. + val pin = data.optString("settings_pin", "") + if (pin.isNotEmpty()) { + config.settingsPin = pin + } Log.i("WebSocketService", "Paired as: $name") handler.post { try { onPaired?.invoke(id, name) } catch (e: Throwable) { Log.e("WebSocketService", "onPaired cb: ${e.message}") } } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 61cbcc4..9aae0ff 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -8,6 +8,7 @@ Enter PIN Wrong PIN + PIN Settings Change server URL Reconfigure device (re-pair) diff --git a/server/db/database.js b/server/db/database.js index 7f0ffaa..99c9954 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -251,6 +251,9 @@ const migrations = [ // register gate on its next reconnect (no restart). Hand-settable by direct SQLite: // UPDATE devices SET blocked = 1 WHERE id = ''; (0 to unblock) "ALTER TABLE devices ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0", + // settings_pin: 6-digit PIN for the in-app hidden settings menu, provisioned by + // the server during pairing so each device gets a unique PIN (never a hardcoded default). + "ALTER TABLE devices ADD COLUMN settings_pin TEXT", ]; // Apply each ALTER idempotently. A "duplicate column name" / "already exists" // error means the column is already present (expected on a migrated DB) - benign. diff --git a/server/db/schema.sql b/server/db/schema.sql index 34c3ece..513758d 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -63,6 +63,7 @@ CREATE TABLE IF NOT EXISTS devices ( user_id TEXT REFERENCES users(id), name TEXT NOT NULL DEFAULT 'Unnamed Display', pairing_code TEXT UNIQUE, + settings_pin TEXT, status TEXT NOT NULL DEFAULT 'offline', blocked INTEGER NOT NULL DEFAULT 0, last_heartbeat INTEGER, diff --git a/server/server.js b/server/server.js index d186afb..28bfc91 100644 --- a/server/server.js +++ b/server/server.js @@ -745,15 +745,18 @@ app.post('/api/provision/pair', requireAuth, resolveTenancy, checkDeviceLimit, ( pairLockout.reset(ip); // a valid claim forgives prior failed attempts from this IP const deviceName = name || 'Display ' + (db.prepare('SELECT COUNT(*) as count FROM devices WHERE user_id = ?').get(req.user.id).count + 1); - db.prepare("UPDATE devices SET pairing_code = NULL, name = ?, user_id = ?, workspace_id = ?, status = 'online', updated_at = strftime('%s','now') WHERE id = ?") - .run(deviceName, req.user.id, req.workspaceId, device.id); + // Generate a random 6-digit PIN for the hidden settings menu — each device gets a + // unique PIN provisioned by the server (never a hardcoded default). + const settingsPin = String(Math.floor(100000 + Math.random() * 900000)); + db.prepare("UPDATE devices SET pairing_code = NULL, name = ?, user_id = ?, workspace_id = ?, status = 'online', settings_pin = ?, updated_at = strftime('%s','now') WHERE id = ?") + .run(deviceName, req.user.id, req.workspaceId, settingsPin, device.id); // Link fingerprint to user db.prepare("UPDATE device_fingerprints SET user_id = ?, device_id = ? WHERE device_id = ?") .run(req.user.id, device.id, device.id); // Notify the device via WebSocket - deviceNs.to(device.id).emit('device:paired', { device_id: device.id, name: deviceName }); + deviceNs.to(device.id).emit('device:paired', { device_id: device.id, name: deviceName, settings_pin: settingsPin }); const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(device.id); require('./lib/device-sanitize').stripDeviceSecrets(updated); // never leak device_token to clients diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index f7733dc..af52710 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -401,7 +401,11 @@ module.exports = function setupDeviceSocket(io) { socket.emit('device:registered', { device_id: existing.device_id, device_token: newToken, status: 'online' }); // If device was already claimed by a user, tell the player it's paired if (oldDevice.user_id) { - socket.emit('device:paired', { name: oldDevice.name || 'Display' }); + socket.emit('device:paired', { + device_id: oldDevice.id, + name: oldDevice.name || 'Display', + settings_pin: oldDevice.settings_pin || undefined + }); } currentDeviceId = existing.device_id; heartbeat.registerConnection(existing.device_id, socket.id); @@ -532,7 +536,7 @@ module.exports = function setupDeviceSocket(io) { // — never received it and sat on the Connect page forever showing the URL (Bold #143). // Re-send the exact event the client already listens for; no client change needed. if (device.user_id) { - socket.emit('device:paired', { device_id, name: device.name || 'Display' }); + socket.emit('device:paired', { device_id, name: device.name || 'Display', settings_pin: device.settings_pin || undefined }); } logDeviceStatus(device_id, 'online'); // Flush any commands/playlist-updates queued while this device was offline.